diff --git a/.dockerignore b/.dockerignore index d482e263b..dccf71b4f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,11 @@ .git* **/target/ +.gitignore +cartesi-rollups-authority-claimer +cartesi-rollups-cli +cartesi-rollups-evm-reader +cartesi-rollups-node +cartesi-rollups-advancer +cartesi-rollups-validator +/rollups-contracts +/rollups-prt-contracts diff --git a/.github/license-check/config.json b/.github/license-check/config.json deleted file mode 100644 index 92a3bf073..000000000 --- a/.github/license-check/config.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "include": ["**/*.go", "**/*.rs"], - "exclude": [ - "offchain/grpc-interfaces/**", - "offchain/target/**", - "offchain/data/src/schema.rs", - "pkg/contracts/*.go", - "pkg/readerclient/generated.go", - "pkg/inspectclient/generated.go", - "rollups-contracts/**" - ], - "license": ".github/license-check/header.txt" - } -] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ddc074b05..ca89336fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,177 +1,386 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Build and test -on: - pull_request: - push: - tags: - - v* - branches: - - main +on: [push] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - packages: write - id-token: write - contents: write +env: + GHCR_REGISTRY: ghcr.io + CI_TAG: ci-${{ github.sha }} jobs: - test: - runs-on: ubuntu-22.04 - env: - RUSTFLAGS: -D warnings -C debuginfo=0 - defaults: - run: - working-directory: offchain + basic-checks: + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive + fetch-depth: 0 - - uses: depot/setup-action@v1 - - name: Build dependency images - uses: depot/bake-action@v1 + - name: Check conventional commit + uses: cocogitto/cocogitto-action@c7a74f5406bab86da17da0f0e460a69f8219a68c # v3 + id: conventional_commit_check with: - files: | - ./docker-bake.hcl - ./docker-bake.override.hcl - ./docker-bake.platforms.hcl - targets: | - rollups-node-devnet - rollups-node-snapshot - project: ${{ vars.DEPOT_PROJECT }} - workdir: build - load: true + check-latest-tag-only: true - - name: 📦 Install protoc - run: sudo apt update && sudo apt install -y protobuf-compiler libprotobuf-dev + - name: Check license header + run: make check-license - - uses: actions/cache@v4 + - name: Setup Go + uses: actions/setup-go@v6 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - offchain/target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + go-version-file: go.mod - - name: Install cargo sweep - run: cargo install cargo-sweep - continue-on-error: true + - name: Install go-licenses + run: go install github.com/google/go-licenses@v1.6.0 - - name: Install cargo cache - run: cargo install cargo-cache - continue-on-error: true + # LGPL-3.0 is classified as restricted, and go-licenses reads go-ethereum's + # root COPYING file, so it reports the whole module as GPL-3.0. Only library + # packages outside cmd/ are linked, which are LGPL-3.0, and that combination + # is allowed here deliberately. See dev/licenses-overrides.tsv. + # The targets and cgo modes match dev/licenses-generate.sh, because the + # linked packages differ between them. + - name: Check third party licenses + run: | + for target in linux/amd64 linux/arm64; do + for cgo in 1 0; do + GOOS="${target%/*}" GOARCH="${target#*/}" CGO_ENABLED=$cgo \ + go-licenses check ./... \ + --disallowed_types=forbidden,restricted,unknown \ + --ignore github.com/ethereum/go-ethereum + done + done - - name: Set sweep timestamp - run: cargo sweep -s + # The go-ethereum exception above holds only while no package under its + # GPL-3.0 cmd/ directory is linked. + - name: Check that no go-ethereum command is linked + run: | + for target in linux/amd64 linux/arm64; do + for cgo in 1 0; do + deps=$(GOOS="${target%/*}" GOARCH="${target#*/}" CGO_ENABLED=$cgo go list -deps ./...) + if grep '^github.com/ethereum/go-ethereum/cmd/' <<<"$deps"; then + echo "a go-ethereum cmd/ package is linked for $target with CGO_ENABLED=$cgo" >&2 + exit 1 + fi + done + done - - name: Build binaries and tests - run: cargo build --all-targets + - name: Check third party notices are current + run: | + dev/licenses-generate.sh > THIRD_PARTY_LICENSES.md + git diff --exit-code THIRD_PARTY_LICENSES.md - - name: Clean old build files - run: cargo sweep -f + - name: Lint Markdown docs + uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23 + with: + globs: | + *.md + docs/*.md + + build: + runs-on: ubuntu-24.04 + permissions: + id-token: write + contents: read + packages: write + steps: + - name: Checkout source code + uses: actions/checkout@v6 + with: + submodules: recursive - - name: Clean dependencies source files - run: cargo cache --autoclean + - name: Setup variables + run: echo ROLLUPS_NODE_VERSION=`make version` >> $GITHUB_ENV - - name: Run tests - run: cargo test + - name: Login to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + registry: ${{ env.GHCR_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Generate GraphQL schema - run: ./target/debug/generate-schema + - name: Set up Depot CLI + uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1 - - name: Upload GraphQL schema - uses: actions/upload-artifact@v4 + - name: Build rollups-node image (amd64) + uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1 with: - name: graphql-schema - path: offchain/schema.graphql - if-no-files-found: error + file: Dockerfile + context: . + platforms: linux/amd64 + tags: ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:devel-amd64-${{ env.CI_TAG }} + push: true + project: ${{ vars.DEPOT_PROJECT }} + token: ${{ secrets.DEPOT_TOKEN }} - - name: Install Go - uses: actions/setup-go@v5 + - name: Build rollups-node image (arm64) + uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1 with: - go-version-file: 'go.mod' + file: Dockerfile + context: . + platforms: linux/arm64 + tags: ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:devel-arm64-${{ env.CI_TAG }} + push: true + project: ${{ vars.DEPOT_PROJECT }} + token: ${{ secrets.DEPOT_TOKEN }} - - name: Run Go tests - working-directory: ${{ github.workspace }} - run: go test ./... + - name: Build tester image + uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1 + with: + file: Dockerfile + context: . + target: tester + platforms: linux/amd64 + tags: ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:tester-${{ env.CI_TAG }} + push: true + project: ${{ vars.DEPOT_PROJECT }} + token: ${{ secrets.DEPOT_TOKEN }} - build_docker: - runs-on: ubuntu-22.04 - needs: - - test - steps: - - uses: actions/checkout@v4 + - name: Build devnet image + uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1 with: - submodules: recursive + file: test/devnet/Dockerfile + context: . + platforms: linux/amd64 + tags: ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node-devnet:${{ env.CI_TAG }} + push: true + project: ${{ vars.DEPOT_PROJECT }} + token: ${{ secrets.DEPOT_TOKEN }} - - name: Docker meta - id: docker_meta - uses: docker/metadata-action@v5 + - name: Build debian package (amd64) + uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1 with: - images: | - name=ghcr.io/cartesi/rollups-node - name=docker.io/cartesi/rollups-node,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - tags: | - type=semver,pattern={{version}} - type=ref,event=branch - type=ref,event=pr + file: Dockerfile + context: . + target: debian-packager + platforms: linux/amd64 + tags: ${{ github.repository_owner }}/rollups-node:debian-packager-amd64 + push: false + load: true + project: ${{ vars.DEPOT_PROJECT }} + token: ${{ secrets.DEPOT_TOKEN }} + + - name: Export deb package artifact (amd64) + run: make copy-debian-package BUILD_PLATFORM=linux/amd64 DEB_ARCH=amd64 DEB_PACKAGER_IMG=${{ github.repository_owner }}/rollups-node:debian-packager-amd64 + + - name: Build debian package (arm64) + uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1 + with: + file: Dockerfile + context: . + target: debian-packager + platforms: linux/arm64 + tags: ${{ github.repository_owner }}/rollups-node:debian-packager-arm64 + push: false + load: true + project: ${{ vars.DEPOT_PROJECT }} + token: ${{ secrets.DEPOT_TOKEN }} + + - name: Export deb package artifact (arm64) + run: make copy-debian-package BUILD_PLATFORM=linux/arm64 DEB_ARCH=arm64 DEB_PACKAGER_IMG=${{ github.repository_owner }}/rollups-node:debian-packager-arm64 + + - name: Upload deb artifacts + uses: actions/upload-artifact@v7 + with: + name: artifacts + path: | + cartesi-rollups-node-v${{ env.ROLLUPS_NODE_VERSION }}_amd64.deb + cartesi-rollups-node-v${{ env.ROLLUPS_NODE_VERSION }}_arm64.deb - - name: Login to Docker Hub - uses: docker/login-action@v3 - if: ${{ startsWith(github.ref, 'refs/tags/v') }} + lint: + # disabled for now + if: false + runs-on: ubuntu-24.04 + needs: [build] + permissions: + contents: read + packages: read + steps: + - name: Checkout source code + uses: actions/checkout@v6 + + - name: Login to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + registry: ${{ env.GHCR_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull and tag tester image + run: | + docker pull ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:tester-${{ env.CI_TAG }} + docker tag ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:tester-${{ env.CI_TAG }} \ + cartesi/rollups-node:tester - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + - name: Run linting + run: make lint-with-docker + + unit-test: + runs-on: ubuntu-24.04 + needs: [build] + permissions: + contents: read + packages: read + steps: + - name: Checkout source code + uses: actions/checkout@v6 + + - name: Login to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: - registry: ghcr.io + registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: depot/setup-action@v1 - - name: Build and push docker image - id: docker_build - uses: depot/bake-action@v1 + - name: Pull and tag CI images + run: | + docker pull ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:tester-${{ env.CI_TAG }} + docker pull ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:devel-amd64-${{ env.CI_TAG }} + docker pull ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node-devnet:${{ env.CI_TAG }} + docker tag ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:tester-${{ env.CI_TAG }} \ + cartesi/rollups-node:tester + docker tag ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:devel-amd64-${{ env.CI_TAG }} \ + cartesi/rollups-node:devel + docker tag ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node-devnet:${{ env.CI_TAG }} \ + cartesi/rollups-node-devnet:devel + + - name: Check endpoint access restrictions + run: make lint-endpoints-with-docker + + - name: Cache test machine images + uses: actions/cache@v5 with: - files: | - ./docker-bake.hcl - ${{ steps.docker_meta.outputs.bake-file }} - ./docker-bake.platforms.hcl - targets: rollups-node - set: rollups-node.args.ROLLUPS_NODE_VERSION=${{ steps.docker_meta.outputs.version }} - push: true - project: ${{ vars.DEPOT_PROJECT }} - workdir: build + path: test/downloads + key: test-deps-${{ hashFiles('test/dependencies.sha256') }} + + - name: Download test dependencies + run: make download-test-dependencies - release: - needs: [test, build_docker] - runs-on: ubuntu-22.04 - if: startsWith(github.ref, 'refs/tags/v') + - name: Run unit tests + run: make unit-test-with-compose + + # Runs the shard coverage guard and derives the integration matrix from the + # Makefile's shard × topology cells, so adding a shard or topology never + # requires a workflow change and an unassigned test fails fast here. + integration-test-setup: + runs-on: ubuntu-24.04 + outputs: + cells: ${{ steps.matrix.outputs.cells }} steps: - - uses: actions/checkout@v4 + - name: Checkout source code + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 with: - submodules: recursive + go-version-file: go.mod + + - name: Check shard coverage + run: make integration-test-shard-check - - name: Trim CHANGELOG.md - run: sed -e '0,/^##[^#]/d' -e '/^##[^#]/,$d' -i CHANGELOG.md + - name: List shard x topology cells + id: matrix + run: | + cells=$(make -s list-integration-cells | jq -c '.') + echo "cells=$cells" >> "$GITHUB_OUTPUT" - - name: Download GraphQL schema - uses: actions/download-artifact@v4 + integration-test: + runs-on: ubuntu-24.04 + needs: [build, integration-test-setup] + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.integration-test-setup.outputs.cells) }} + env: + COMPOSE_PROJECT: rollups-it-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard }}-${{ matrix.topology }} + permissions: + contents: read + packages: read + steps: + - name: Checkout source code + uses: actions/checkout@v6 + + - name: Login to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: - name: graphql-schema + registry: ${{ env.GHCR_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Publish Github release - uses: softprops/action-gh-release@v2 + - name: Pull and tag CI images + run: | + docker pull ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:tester-${{ env.CI_TAG }} + docker pull ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:devel-amd64-${{ env.CI_TAG }} + docker pull ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node-devnet:${{ env.CI_TAG }} + docker tag ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:tester-${{ env.CI_TAG }} \ + cartesi/rollups-node:tester + docker tag ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node:devel-amd64-${{ env.CI_TAG }} \ + cartesi/rollups-node:devel + docker tag ${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/rollups-node-devnet:${{ env.CI_TAG }} \ + cartesi/rollups-node-devnet:devel + + - name: Cache test machine images + uses: actions/cache@v5 with: - prerelease: true - body_path: CHANGELOG.md - files: schema.graphql + path: test/downloads + key: test-deps-${{ hashFiles('test/dependencies.sha256') }} + + - name: Download test dependencies + run: make download-test-dependencies + + - name: Run integration shard ${{ matrix.shard }} (${{ matrix.topology }}) + run: | + make integration-test-with-compose SHARD=${{ matrix.shard }} \ + NODE_TOPOLOGY=${{ matrix.topology }} DEFAULT_BLOCK=${{ matrix.default_block }} \ + COMPOSE_PROJECT="$COMPOSE_PROJECT" + + - name: Upload integration test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-test-logs-${{ matrix.shard }}-${{ matrix.topology }} + path: integration-logs-${{ matrix.shard }}-${{ matrix.topology }}.txt + retention-days: 3 + + # Redundant with the trap in compose-integration-run.sh; this is the + # safety net for when concurrency cancel-in-progress SIGKILLs the make + # process before its trap can fire, which would otherwise leak the + # project's containers and volumes. + - name: Clean up compose project + if: always() + run: | + docker rm -f "$COMPOSE_PROJECT-integration-test-run" >/dev/null 2>&1 || true + ids="$(docker ps -aq \ + --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \ + --filter "label=com.docker.compose.service=integration-test" \ + 2>/dev/null || true)" + if [ -n "$ids" ]; then + docker rm -f $ids >/dev/null 2>&1 || true + fi + docker compose -p "$COMPOSE_PROJECT" \ + -f test/compose/compose.integration.yaml down -v --remove-orphans || true + + publish_artifacts: + name: Publish artifacts + needs: [basic-checks, build, unit-test, integration-test] + runs-on: ubuntu-24.04 + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + permissions: + contents: write + steps: + - name: Checkout emulator source code + uses: actions/checkout@v6 + + - name: Download artifacts + uses: actions/download-artifact@v8 + + - name: Upload products to GitHub Release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 + with: + draft: true + files: | + artifacts/cartesi-rollups-node-v*.deb diff --git a/.github/workflows/clean-up-images.yml b/.github/workflows/clean-up-images.yml index d0bed5d4e..d806eb60e 100644 --- a/.github/workflows/clean-up-images.yml +++ b/.github/workflows/clean-up-images.yml @@ -1,15 +1,20 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Clean-up Docker images +# build.yml pushes ci-tagged images to GHCR on every push; this periodic sweep +# is what keeps them from accumulating without bound. on: - pull_request: - branches: - - main - types: [closed] + schedule: + # Approximately every 5 days. cron has no true "every N days", so this + # fires on days-of-month 1,6,11,16,21,26,31 and resets at each month + # start (one short gap at the boundary) — fine for a cleanup with a + # 7-day age floor. + - cron: "0 3 */5 * *" + workflow_dispatch: jobs: cleanup: - name: Cleanup ghcr.io/cartesi/${{ matrix.image }}:pr-${{ github.event.number }} image + name: Cleanup ghcr.io/cartesi/${{ matrix.image }} CI images runs-on: ubuntu-latest permissions: packages: write @@ -17,13 +22,25 @@ jobs: matrix: image: - rollups-node + - rollups-node-devnet steps: - - uses: vlaurin/action-ghcr-prune@v0.6.0 + # Prune stale CI images older than 7 days to avoid deleting + # images needed by concurrently running workflows. + - name: Prune stale CI tags + # Pinned to a commit: third-party action holding a packages:write + # token. Still node20: no node24 release exists upstream (checked + # 2026-06); works until the runner removes node20 (announced + # 2026-09-16). + uses: vlaurin/action-ghcr-prune@0cf7d39f88546edd31965acba78cdcb0be14d641 # v0.6.0 with: organization: cartesi container: ${{ matrix.image }} token: ${{ secrets.GITHUB_TOKEN }} prune-untagged: false keep-last: 0 + older-than: 7 days prune-tags-regexes: | - ^pr-${{ github.event.number }}$ + ^ci- + ^devel-amd64-ci- + ^devel-arm64-ci- + ^tester-ci- diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml deleted file mode 100644 index 44552e9ec..000000000 --- a/.github/workflows/code-quality.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Assess code quality - -on: push - -jobs: - assess-code-quality: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 0 - - - name: Check conventional commit - uses: cocogitto/cocogitto-action@v3 - id: conventional_commit_check - with: - check-latest-tag-only: true - - - name: Check license header - uses: viperproject/check-license-header@v2 - with: - path: ./ - config: .github/license-check/config.json - - - uses: actions/setup-go@v5 - with: - go-version-file: "go.mod" - - - name: Run Go Linter - uses: golangci/golangci-lint-action@v6 - with: - version: v1.58.2 - - - name: Lint Markdown docs - uses: DavidAnson/markdownlint-cli2-action@v16 - with: - globs: | - *.md - docs/*.md - - - name: Check auto generated files - run: make check-generate diff --git a/.github/workflows/rust-code-quality.yml b/.github/workflows/rust-code-quality.yml deleted file mode 100644 index c992877b9..000000000 --- a/.github/workflows/rust-code-quality.yml +++ /dev/null @@ -1,46 +0,0 @@ -# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -name: Assess Rust code quality - -on: push - -jobs: - assess-rust-code-quality: - runs-on: ubuntu-latest - defaults: - run: - working-directory: offchain - - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - offchain/target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- - - - name: Install protoc - run: sudo apt update && sudo apt install -y protobuf-compiler libprotobuf-dev - - - name: Update rust - run: rustup update - - - name: Install cargo-machete - run: cargo install cargo-machete - continue-on-error: true - - - name: Analyze dependencies - run: cargo machete . - - - name: Check code format - run: cargo fmt --all -- --check - - - name: Run linter - run: cargo clippy -- -A clippy::module_inception diff --git a/.gitignore b/.gitignore index 72a35910f..ac2440d0c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,21 @@ build/deployments **/.idea/ machine-snapshot/** -deployment.json -anvil_state.json +/deployment.json +/anvil_state.json +/cartesi-rollups-authority-claimer +/cartesi-rollups-cli +/cartesi-rollups-evm-reader +/cartesi-rollups-node* +/cartesi-rollups-advancer +/cartesi-rollups-validator +/cartesi-rollups-claimer +/cartesi-rollups-jsonrpc-api +/cartesi-rollups-prt +/cartesi-rollups-machine-tool +/rollups-contracts +/rollups-prt-contracts +/applications +/test/downloads +/snapshots +/integration-logs*.txt diff --git a/.gitmodules b/.gitmodules index 3940b309f..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +0,0 @@ -[submodule "grpc-interfaces"] - path = offchain/grpc-interfaces/grpc-interfaces - url = ../grpc-interfaces.git -[submodule "rollups-contracts"] - path = rollups-contracts - url = https://github.com/cartesi/rollups-contracts - branch = v1.2.0 diff --git a/.golangci.yml b/.golangci.yml index 07203d296..7949eda2e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,16 +1,93 @@ +version: "2" +run: + build-tags: + - endtoendtests linters: enable: - exhaustive - goconst - - godox - - gofmt - lll - - mnd - misspell -linters-settings: - lll: - line-length: 100 - tab-width: 4 - mnd: - ignored-functions: - - "^make" + - mnd + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - revive + - gocritic + - copyloopvar + - gosec + - nolintlint + - prealloc + - forbidigo +# - depguard + settings: + lll: + line-length: 140 + tab-width: 4 + mnd: + ignored-functions: + - ^make + forbidigo: + analyze-types: true + forbid: + - pattern: '^config\.(SafeURL|URL)\.Raw$' + pkg: '^github\.com/cartesi/rollups-node/internal/config$' + msg: 'Use typed CLI connection helpers or reviewed service transports for raw URLs.' + - pattern: '^rpc\.Dial.*$' + pkg: '^github\.com/ethereum/go-ethereum/rpc$' + msg: 'Use ethutil protected HTTP constructors.' + - pattern: '^ethclient\.Dial.*$' + pkg: '^github\.com/ethereum/go-ethereum/ethclient$' + msg: 'Use ethutil protected HTTP constructors.' + - pattern: '^factory\.NewRepositoryFromConnectionString$' + pkg: '^github\.com/cartesi/rollups-node/internal/repository/factory$' + msg: 'Use cli.OpenRepository for typed database connections.' + - pattern: '^.*\.(SubmitClaim|AcceptClaim|JoinTournament|StageTournamentResult|AcceptStagedTournamentResult|TryRecoveringBond|Transact|RawTransact|Transfer)$' + msg: 'Service delivery must use txsender for every publication.' + - pattern: '^ethclient\.Client\.SendTransaction$' + pkg: '^github\.com/ethereum/go-ethereum/ethclient$' + msg: 'Service delivery must use txsender for every publication.' + - pattern: '^rpc\.Client\.Call(Context)?$' + pkg: '^github\.com/ethereum/go-ethereum/rpc$' + msg: 'Service delivery must use txsender for every publication.' + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + # CLI publication and low-level transport have separate ownership rules. + # These delivery rules cover the two service packages and their adapters. + - path-except: '^internal/(claimer|prt)/' + text: 'Service delivery must use txsender for every publication.' + linters: + - forbidigo + # Tests use synthetic URLs and in-process RPC connections to exercise the boundaries. + - path: '_test\.go$' + linters: + - forbidigo + - path: internal/jsonrpc/api/.*\.go + text: avoid meaningless package names + linters: + - revive + - path: internal/jsonrpc/.*\.go + text: avoid package names that conflict with Go standard library package names + linters: + - revive + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c10656f..d6bfd69c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Added PostGraphile service +- Added Cartesi Machine C API wrapper + +### Changed + +- Bumped Rollups Contracts to 2.0 +- Normalized boolean configuration parameters (`CARTESI_LEGACY_BLOCKCHAIN_ENABLED`, `CARTESI_FEATURE_CLAIMER_ENABLED`, `CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED`, `CARTESI_EXPERIMENTAL_SERVER_MANAGER_LOG_BYPASS_ENABLED` and `CARTESI_LOG_PRETTY_ENABLED`) and adjusted their logic accordingly + +### Removed + +- Removed `advance-runner`, `dispatcher`, `graphql-server`, `host-runner`, `indexer`, `inspect-server`, and `state-server` Rust services +- Removed `server-manager` from rollups-node Docker image +- Removed support to host mode + ## [1.5.1] 2024-08-26 ### Added @@ -46,13 +64,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added verification to ensure `CARTESI_BLOCKCHAIN_ID` matches the id returned from the Ethereum node - Added verification to ensure the Cartesi Machine snapshot hash matches the template hash from the CartesiDApp contract - Added support for `CARTESI_AUTH_PRIVATE_KEY` and `CARTESI_AUTH_PRIVATE_KEY_FILE` -- Added `CARTESI_AUTH_KIND` environment variable to select the blockchain authetication method +- Added `CARTESI_AUTH_KIND` environment variable to select the blockchain authentication method - Added structured logging with slog. Colored logs can now be enabled with `CARTESI_LOG_PRETTY` environment variable ### Changed - Changed `CARTESI_BLOCKCHAIN_ID` type from int to uint64 -- Changed `CARTESI_CONTRACTS_APPLICATION_DEPLOYMENT_BLOCK_NUMBER` type from string to int64. +- Changed `CARTESI_CONTRACTS_APPLICATION_DEPLOYMENT_BLOCK_NUMBER` type from string to int64 - Changed `CARTESI_LOG_LEVEL` option `warning` to `warn` - Bumped Cartesi Emulator SDK to 0.17.1 - Bumped Server Manager to 0.9.1 @@ -400,7 +418,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deprecated mock contracts -[Unreleased]: https://github.com/cartesi/rollups-node/releases/tag/v1.5.0...HEAD +[Unreleased]: https://github.com/cartesi/rollups-node/releases/tag/v1.5.1...HEAD +[1.5.1]: https://github.com/cartesi/rollups-node/releases/tag/v1.5.1 [1.5.0]: https://github.com/cartesi/rollups-node/releases/tag/v1.5.0 [1.4.0]: https://github.com/cartesi/rollups-node/releases/tag/v1.4.0 [1.3.1]: https://github.com/cartesi/rollups-node/releases/tag/v1.3.1 diff --git a/docs/code_of_conduct.md b/CODE_OF_CONDUCT.md similarity index 100% rename from docs/code_of_conduct.md rename to CODE_OF_CONDUCT.md diff --git a/docs/contributing.md b/CONTRIBUTING.md similarity index 100% rename from docs/contributing.md rename to CONTRIBUTING.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..0e611f37b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,194 @@ +# (c) Cartesi and individual authors (see AUTHORS) +# SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +# syntax=docker.io/docker/dockerfile:1 + +ARG EMULATOR_VERSION=0.22.0 + +# Build directories. +ARG GO_BUILD_PATH=/build/cartesi/go + +FROM debian:trixie-20250811 AS common-env + +USER root + +# Re-declare ARGs so they can be used in the RUN block +ARG EMULATOR_VERSION +ARG GO_BUILD_PATH + +# Install ca-certificates and curl (setup). +RUN </dev/null || echo amd64) +DEB_FILENAME= cartesi-rollups-node-v$(ROLLUPS_NODE_VERSION)_$(DEB_ARCH).deb +DEB_PACKAGER_IMG ?= cartesi/rollups-node:debian-packager + +# Docker image platform +BUILD_PLATFORM ?= + +ifneq ($(BUILD_PLATFORM),) +DOCKER_PLATFORM=--platform $(BUILD_PLATFORM) +endif + +# Go artifacts +GO_ARTIFACTS := $(addprefix cartesi-rollups-,node cli evm-reader advancer validator claimer jsonrpc-api prt machine-tool) + +# These artifacts embed the machine runtime and therefore require libcartesi. +# Keep this list explicit: every other artifact is built with CGO_ENABLED=0, so +# the normal build fails if a C dependency leaks into a pure service or tool. +MACHINE_GO_ARTIFACTS := cartesi-rollups-node cartesi-rollups-advancer +PURE_GO_ARTIFACTS := $(filter-out $(MACHINE_GO_ARTIFACTS),$(GO_ARTIFACTS)) + +# fixme(vfusco): path on all oses +CGO_CFLAGS:= -I$(PREFIX)/include +CGO_LDFLAGS:= -L$(PREFIX)/lib +export CGO_CFLAGS +export CGO_LDFLAGS + +CARTESI_TEST_MACHINE_IMAGES_PATH:= $(PREFIX)/share/cartesi-machine/images/ +export CARTESI_TEST_MACHINE_IMAGES_PATH + +GO_VERSION_LDFLAGS := -s -w -X 'github.com/cartesi/rollups-node/internal/version.BuildVersion=$(ROLLUPS_NODE_VERSION)' +PURE_GO_BUILD_PARAMS := -ldflags "$(GO_VERSION_LDFLAGS)" +MACHINE_GO_BUILD_PARAMS := -ldflags "$(GO_VERSION_LDFLAGS) -r $(PREFIX)/lib" +ifeq ($(BUILD_TYPE),debug) + PURE_GO_BUILD_PARAMS += -gcflags "all=-N -l" + MACHINE_GO_BUILD_PARAMS += -gcflags "all=-N -l" +endif + +# Tests and Go development tools cover machine packages, so retain the +# machine-capable parameters for those existing recipes. +GO_BUILD_PARAMS = $(MACHINE_GO_BUILD_PARAMS) + +GO_TEST_PACKAGES ?= ./... +GO_TEST_FLAGS ?= + +# Short selector for make env and integration runs. An explicit selector takes +# precedence over the node setting inherited from make env. +DEFAULT_BLOCK ?= $(if $(CARTESI_BLOCKCHAIN_DEFAULT_BLOCK),$(CARTESI_BLOCKCHAIN_DEFAULT_BLOCK),latest) + +ifneq ($(filter env integration-test integration-test-local integration-test-with-compose \ + _compose-topology-% _compose-cell-% _local-topology-%,$(MAKECMDGOALS)),) +override CARTESI_BLOCKCHAIN_DEFAULT_BLOCK := $(DEFAULT_BLOCK) +export CARTESI_BLOCKCHAIN_DEFAULT_BLOCK +$(if $(filter latest safe finalized,$(DEFAULT_BLOCK)),,$(error DEFAULT_BLOCK must be latest, safe or finalized)) +$(if $(filter-out 1,$(words $(DEFAULT_BLOCK))),$(error DEFAULT_BLOCK must select one block policy)) +endif + +VERBOSE ?= +ifeq ($(VERBOSE),true) + PURE_GO_BUILD_PARAMS += -v + MACHINE_GO_BUILD_PARAMS += -v + GO_TEST_FLAGS += -v +endif + +TEST_PATTERN ?= +ifneq ($(TEST_PATTERN),) + # Single-quote the pattern: a shard union like ^Test(A|B)$ contains shell + # metacharacters that would otherwise break the recipe's `go test` line. + # $(value ...) keeps the raw text so the trailing `$` anchor is not eaten by + # make's re-expansion when TEST_PATTERN arrives via a sub-make. + GO_TEST_FLAGS += -run '$(value TEST_PATTERN)' +endif -.PHONY: md-lint -md-lint: ## Lint Markdown docs. Each dir has its own .markdownlint.yaml. - @echo "Running markdownlint-cli" - @docker run --rm -v $$PWD:/workdir ghcr.io/igorshubovych/markdownlint-cli:latest "*.md" - @docker run --rm -v $$PWD/docs:/workdir ghcr.io/igorshubovych/markdownlint-cli:latest "*.md" +TEST_PACKAGES ?= +ifneq ($(TEST_PACKAGES),) + GO_TEST_PACKAGES := $(addprefix ./, $(addsuffix /..., $(subst :, ,$(TEST_PACKAGES)))) +endif -.PHONY: generate -generate: ## Generate the file that are commited to the repo - @echo "Generating Go files" +COVERAGE_DIR := coverage +COVER ?= +ifeq ($(COVER),true) + GO_TEST_FLAGS += -coverprofile=$(COVERAGE_DIR)/coverage.out -covermode=atomic -coverpkg=./... + COVER_DEPS := $(COVERAGE_DIR) + COVER_REPORT := coverage-report +endif + +# The pure Go artifacts are built without cgo, which changes what they link: +# go-ethereum, for one, uses a pure Go secp256k1 instead of libsecp256k1. The +# unit tests of the packages they link also run that way, without coverage. +PURE_GO_TEST_PACKAGES = $(filter $(shell go list $(GO_TEST_PACKAGES)), \ + $(shell CGO_ENABLED=0 go list -deps $(addprefix ./cmd/,$(PURE_GO_ARTIFACTS)))) +PURE_GO_TEST_FLAGS = $(filter-out -coverprofile=% -covermode=% -coverpkg=%,$(GO_TEST_FLAGS)) + + +ROLLUPS_CONTRACTS_ABI_BASEDIR:= rollups-contracts/ +ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR:= rollups-prt-contracts/ +ROLLUPS_CONTRACTS_ABI_STAMP:= $(ROLLUPS_CONTRACTS_ABI_BASEDIR).$(ROLLUPS_CONTRACTS_VERSION)-$(ROLLUPS_CONTRACTS_SHA256).stamp +ROLLUPS_PRT_CONTRACTS_ABI_STAMP:= $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR).$(ROLLUPS_PRT_CONTRACTS_VERSION)-$(ROLLUPS_PRT_CONTRACTS_SHA256).stamp + +all: build + +# ============================================================================= +# Build +# ============================================================================= +build: build-go ## Build all artifacts + +build-go: $(GO_ARTIFACTS) ## Build Go artifacts (node, cli, evm-reader) + +env: + @echo export CGO_CFLAGS=\"$(CGO_CFLAGS)\" + @echo export CGO_LDFLAGS=\"$(CGO_LDFLAGS)\" + @echo export CARTESI_LOG_LEVEL="info" + @echo export CARTESI_BLOCKCHAIN_DEFAULT_BLOCK="$(DEFAULT_BLOCK)" + @echo export CARTESI_BLOCKCHAIN_HTTP_ENDPOINT="http://localhost:8545" + @echo export CARTESI_BLOCKCHAIN_ID="31337" + @echo export CARTESI_EVM_READER_POLLING_INTERVAL="1" + @echo export CARTESI_CONTRACTS_INPUT_BOX_ADDRESS="0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + @echo export CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS="0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + @echo export CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS="0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + @echo export CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS="0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + @echo export CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS="0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + @echo export CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS="0xF8f6dFA3A3e40C10926839FFd8a221C87C8b2ccD" + @echo export CARTESI_DEVNET_ERC20_PORTAL_ADDRESS="0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419" + @echo export CARTESI_DEVNET_TEST_USDC_ADDRESS="0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81" + @echo export CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS="0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" + @echo export CARTESI_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\" + @echo export CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX="0" + @echo export CARTESI_PRT_AUTH_MNEMONIC=\"test test test test test test test test test test test junk\" + @echo export CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX="6" + @echo export CARTESI_DATABASE_CONNECTION="postgres://postgres:password@localhost:5432/rollupsdb?sslmode=disable" + @echo export CARTESI_SNAPSHOTS_DIR="snapshots" + @echo export CARTESI_TEST_DATABASE_CONNECTION="postgres://test_user:password@localhost:5432/test_rollupsdb?sslmode=disable" + @echo export CARTESI_TEST_MACHINE_IMAGES_PATH=\"$(CARTESI_TEST_MACHINE_IMAGES_PATH)\" + @echo export PATH=\"$(CURDIR):$$PATH\" + @$(if $(MACOSX_DEPLOYMENT_TARGET),echo export MACOSX_DEPLOYMENT_TARGET=\"$(MACOSX_DEPLOYMENT_TARGET)\") + +# ============================================================================= +# Artifacts +# ============================================================================= +$(PURE_GO_ARTIFACTS): + @echo "Building pure Go artifact $@" + CGO_ENABLED=0 go build $(PURE_GO_BUILD_PARAMS) ./cmd/$@ + +$(MACHINE_GO_ARTIFACTS): + @echo "Building Go artifact $@" + CGO_ENABLED=1 go build $(MACHINE_GO_BUILD_PARAMS) ./cmd/$@ + +tidy-go: @go mod tidy - @go generate -v ./... -.PHONY: graphql-schema -graphql-schema: ## Generate the graphql schema file - @cd offchain; cargo run --bin generate-schema - @mv offchain/schema.graphql api/graphql/reader.graphql +generate: generate-contracts generate-config generate-inspect ## Generate all code files committed to the repo -.PHONY: check-generate -check-generate: generate graphql-schema ## Check whether the generated files are in sync +generate-contracts: contracts ## Generate contract ABI bindings + @echo "Generating contract bindings" + @go generate ./pkg/contracts/... + +generate-config: ## Generate config code from Config.toml + @echo "Generating config code" + @go generate ./internal/config/... + +generate-inspect: ## Generate inspect API client + @echo "Generating inspect client" + @go generate ./pkg/inspectclient/... + +check-generate: generate ## Check whether the generated files are in sync @echo "Checking differences on the repository..." @if git diff --exit-code; then \ echo "No differences found."; \ @@ -42,58 +219,745 @@ check-generate: generate graphql-schema ## Check whether the generated files are exit 1; \ fi -.PHONY: docker-build-deps -docker-build-deps: ## Build the dependencies images using bake - @cd build && docker buildx bake --load rollups-node-devnet rollups-node-snapshot - -.PHONY: docker-build -docker-build: submodules ## Build the docker images using bake - @cd build && docker buildx bake --load - -.PHONY: docker-run -docker-run: docker-clean ## Run the node with the anvil devnet - @docker compose \ - -f ./build/compose-database.yaml \ - -f ./build/compose-devnet.yaml \ - -f ./build/compose-snapshot.yaml \ - -f ./build/compose-node.yaml \ - up - -.PHONY: docker-run-host -docker-run-host: docker-clean ## Run the node in host mode - @docker compose \ - -f ./build/compose-database.yaml \ - -f ./build/compose-devnet.yaml \ - -f ./build/compose-node.yaml \ - -f ./build/compose-host.yaml \ - up - -.PHONY: docker-run-sepolia -docker-run-sepolia: docker-clean ## Run the node with the sepolia testnet - @if [ ! -n "$$RPC_HTTP_URL" ]; then \ - echo "RPC_HTTP_URL was not set"; \ - exit 1; \ - fi - @if [ ! -n "$$RPC_WS_URL" ]; then \ - echo "RPC_WS_URL was not set"; \ - exit 1; \ +contracts: $(ROLLUPS_CONTRACTS_ABI_STAMP) $(ROLLUPS_PRT_CONTRACTS_ABI_STAMP) ## Export the contract artifacts + +$(ROLLUPS_CONTRACTS_ABI_STAMP): + @echo "Downloading rollups-contracts artifacts" + @rm -rf $(ROLLUPS_CONTRACTS_ABI_BASEDIR) + @mkdir -p $(ROLLUPS_CONTRACTS_ABI_BASEDIR) + @curl -sSL $(ROLLUPS_CONTRACTS_URL)/v$(ROLLUPS_CONTRACTS_VERSION)/$(ROLLUPS_CONTRACTS_ARTIFACT) -o $(ROLLUPS_CONTRACTS_ARTIFACT) + @echo "$(ROLLUPS_CONTRACTS_SHA256) $(ROLLUPS_CONTRACTS_ARTIFACT)" | shasum -a 256 --check > /dev/null + @tar -zxf $(ROLLUPS_CONTRACTS_ARTIFACT) -C $(ROLLUPS_CONTRACTS_ABI_BASEDIR) + @touch $@ + @rm -f $(ROLLUPS_CONTRACTS_ARTIFACT) + +$(ROLLUPS_PRT_CONTRACTS_ABI_STAMP): + @echo "Downloading rollups-prt-contracts artifacts" + @rm -rf $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) + @mkdir -p $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) + @curl -sSL $(ROLLUPS_PRT_CONTRACTS_URL)/v$(ROLLUPS_PRT_CONTRACTS_VERSION)/$(ROLLUPS_PRT_CONTRACTS_ARTIFACT) -o $(ROLLUPS_PRT_CONTRACTS_ARTIFACT) + @echo "$(ROLLUPS_PRT_CONTRACTS_SHA256) $(ROLLUPS_PRT_CONTRACTS_ARTIFACT)" | shasum -a 256 --check > /dev/null + @tar -zxf $(ROLLUPS_PRT_CONTRACTS_ARTIFACT) -C $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) + @touch $@ + @rm $(ROLLUPS_PRT_CONTRACTS_ARTIFACT) + +migrate: ## Run migration on development database + @echo "Running PostgreSQL migration" + @go run $(GO_BUILD_PARAMS) dev/migrate/main.go + +generate-db: ## Generate repository/db with Jet + @echo "Generating internal/repository/postgres/db with jet" + @rm -rf internal/repository/postgres/db + @go run github.com/go-jet/jet/v2/cmd/jet -dsn=$$CARTESI_DATABASE_CONNECTION -schema=public -path=./internal/repository/postgres/db + @rm -rf internal/repository/postgres/db/rollupsdb/public/model + @chmod -R g+rwX internal/repository/postgres/db + +# ============================================================================= +# Clean +# ============================================================================= + +clean: clean-go clean-contracts clean-docs clean-devnet-files clean-dapps clean-test-dependencies clean-test-logs clean-debian-packages ## Clean all artifacts + +clean-go: ## Clean Go artifacts + @echo "Cleaning Go artifacts" + @go clean -i -r -cache + @rm -f $(GO_ARTIFACTS) + @rm -rf $(COVERAGE_DIR) + +clean-contracts: ## Clean contract artifacts + @echo "Cleaning contract artifacts" + @rm -rf $(ROLLUPS_CONTRACTS_ABI_BASEDIR) $(ROLLUPS_PRT_CONTRACTS_ABI_BASEDIR) + @rm -f $(ROLLUPS_CONTRACTS_ARTIFACT) $(ROLLUPS_PRT_CONTRACTS_ARTIFACT) + +clean-docs: ## Clean the documentation + @echo "Cleaning the documentation" + @rm -rf docs/cli docs/node docs/evm-reader docs/advancer docs/validator docs/config.md + +clean-devnet-files: ## Clean the devnet files + @echo "Cleaning devnet files" + @rm -f deployment.json anvil_state.json + +clean-debian-packages: + @echo "Cleaning debian package" + @rm -f cartesi-rollups-node-v*.deb + +clean-dapps: ## Clean the dapps + @echo "Cleaning dapps" + @rm -rf applications snapshots + +clean-test-dependencies: ## Clean the test dependencies + @echo "Cleaning test dependencies" + @rm -rf $(DOWNLOADS_DIR) + +clean-test-logs: ## Clean integration test log files + @echo "Cleaning integration test logs" + @rm -f integration-logs.txt integration-logs-*.txt + +# Reaps the deterministically-named local shard projects (and the default +# single-project name). A SIGKILL/OOM during a local sharded run skips the +# cleanup trap and leaks each project's anonymous Postgres volume; this is the +# manual recovery. CI uses ephemeral runners and does not need it. +clean-integration-compose: ## Tear down leftover integration shard×topology compose projects and volumes + @echo "Cleaning integration compose projects" + @cleanup_project() { \ + project="$$1"; \ + docker rm -f "$$project-integration-test-run" >/dev/null 2>&1 || true; \ + ids="$$(docker ps -aq --filter "label=com.docker.compose.project=$$project" --filter "label=com.docker.compose.service=integration-test" 2>/dev/null || true)"; \ + if [ -n "$$ids" ]; then docker rm -f $$ids >/dev/null 2>&1 || true; fi; \ + docker compose -p "$$project" -f test/compose/compose.integration.yaml down -v --remove-orphans 2>/dev/null || true; \ + }; \ + for t in $(INTEGRATION_TOPOLOGIES) ; do \ + cleanup_project rollups-node-integration-all-$$t ; \ + for s in $(INTEGRATION_SHARDS) delivery-finalized ; do \ + cleanup_project rollups-node-integration-$$s-$$t ; \ + done ; \ + done; \ + cleanup_project rollups-node-integration + +# ============================================================================= +# Tests +# ============================================================================= +test: unit-test ## Execute all tests + +$(COVERAGE_DIR): + @mkdir -p $@ + +coverage-report: + @go tool cover -func=$(COVERAGE_DIR)/coverage.out + @go tool cover -html=$(COVERAGE_DIR)/coverage.out -o $(COVERAGE_DIR)/coverage.html + @echo "Coverage report: $(COVERAGE_DIR)/coverage.html" + +unit-test: $(COVER_DEPS) ## Execute go unit tests + @echo "Running go unit tests" + @go clean -testcache + @go test -p 1 $(GO_BUILD_PARAMS) $(GO_TEST_FLAGS) $(GO_TEST_PACKAGES) + @echo "Running go unit tests of the pure Go artifacts without cgo" + @packages="$(PURE_GO_TEST_PACKAGES)"; [ -z "$$packages" ] || \ + CGO_ENABLED=0 go test -p 1 $(PURE_GO_BUILD_PARAMS) $(PURE_GO_TEST_FLAGS) $$packages + @$(if $(COVER_REPORT),$(MAKE) $(COVER_REPORT)) + +GOTESTSUM_FORMAT ?= testdox +ifeq ($(VERBOSE),true) + GOTESTSUM_FORMAT = standard-verbose +endif +COMPOSE_TOPOLOGY_GOTESTSUM_FORMAT = $(GOTESTSUM_FORMAT) +ifeq ($(strip $(SHARD)),) +ifeq ($(origin GOTESTSUM_FORMAT),file) + COMPOSE_TOPOLOGY_GOTESTSUM_FORMAT = standard-verbose +endif +endif + +integration-test: ## Execute e2e tests + @echo "Running end-to-end tests" + @if command -v gotestsum >/dev/null 2>&1; then \ + gotestsum --format $(GOTESTSUM_FORMAT) -- -count=1 -timeout 55m $(GO_BUILD_PARAMS) $(GO_TEST_FLAGS) -tags=endtoendtests ./test/integration/...; \ + else \ + go test -count=1 -timeout 55m $(GO_BUILD_PARAMS) $(GO_TEST_FLAGS) -tags=endtoendtests ./test/integration/...; \ fi - @docker compose \ - -f ./build/compose-database.yaml \ - -f ./build/compose-snapshot.yaml \ - -f ./build/compose-node.yaml \ - -f ./build/compose-sepolia.yaml \ - up - -.PHONY: docker-clean -docker-clean: ## Remove the containers and volumes from previous compose run - @docker compose \ - -f ./build/compose-database.yaml \ - -f ./build/compose-devnet.yaml \ - -f ./build/compose-snapshot.yaml \ - -f ./build/compose-node.yaml \ - down -v - -.PHONY: help + +delivery-test: ## Run delivery release fixtures against an isolated PostgreSQL/Anvil deployment + @test -n "$$CARTESI_CLAIMER_CAPACITY_ADMIN_CONNECTION" || \ + (echo 'Set CARTESI_CLAIMER_CAPACITY_ADMIN_CONNECTION to the isolated test database admin URL.' >&2; exit 1) + @CARTESI_TXSENDER_ANVIL_TEST=1 go test $(GO_BUILD_PARAMS) -tags=endtoendtests ./internal/txsender \ + -run '^TestProviderAnvilProtectedPublicationAndLatestConsumption$$' -count=1 -v + @CARTESI_CLAIMER_ANVIL_TEST=1 go test $(GO_BUILD_PARAMS) -tags=endtoendtests ./internal/claimer \ + -run '^TestAnvil(QuorumDecidingVoteGas|AuthorityAcceptanceRecoversFromOperationalFaults)$$' -count=1 -v + @CARTESI_PRT_ANVIL_TEST=1 go test $(GO_BUILD_PARAMS) -tags=endtoendtests ./internal/prt \ + -run '^TestAnvilJoinGasAfterPeerJoin$$' -count=1 -v + +echo-dapp: applications/echo-dapp ## Echo dapp + +reject-dapp: applications/reject-dapp ## Reject dapp + +exception-dapp: applications/exception-dapp ## Exception dapp + +applications/echo-dapp: ## Create echo-dapp test application + @echo "Creating echo-dapp test application" + @mkdir -p applications + @cartesi-machine --ram-length=128Mi --store=applications/echo-dapp --final-hash --assert-rolling-template -- ioctl-echo-loop --vouchers=1 --delegate-call-vouchers=1 --notices=1 --reports=1 --verbose=1 || { rm -rf "$@"; exit 1; } + +applications/reject-dapp: ## Create reject-dapp test application + @echo "Creating reject-dapp test application" + @mkdir -p applications + @cartesi-machine --ram-length=128Mi --store=applications/reject-dapp --final-hash --assert-rolling-template -- "rollup accept && echo '{\"payload\": \"0x726561736f6e20666f722072656a656374696e67\" }' | rollup report && rollup reject" || { rm -rf "$@"; exit 1; } + +applications/exception-dapp: ## Create exception-dapp test application + @echo "Creating exception-dapp test application" + @mkdir -p applications + @cartesi-machine --ram-length=128Mi --store=applications/exception-dapp --final-hash --assert-rolling-template -- "rollup accept && echo '{\"payload\": \"0x7468697320697320612064756d6d7920657863657074696f6e2074657874\"}' | rollup exception" || { rm -rf "$@"; exit 1; } + +reject-loop-dapp: applications/reject-loop-dapp ## Reject loop dapp + +exception-loop-dapp: applications/exception-loop-dapp ## Exception loop dapp + +halt-loop-dapp: applications/halt-loop-dapp ## Halt loop dapp + +mcycle-overflow-dapp: applications/mcycle-overflow-dapp ## MCYCLE overflow dapp + +unexpected-yield-dapp: applications/unexpected-yield-dapp ## Unexpected-yield dapp + +invalid-outputs-root-dapps: applications/invalid-outputs-root-dapp applications/invalid-outputs-root-length-dapp applications/invalid-template-outputs-root-dapp ## Invalid outputs-root test dapps + +erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## ERC-20 withdrawal test dapp + +erc20-withdrawal-nvram-dapp: applications/erc20-withdrawal-nvram-dapp ## ERC-20 withdrawal test dapp with NVRAM accounts + +applications/reject-loop-dapp: ## Create reject-loop-dapp test application + @echo "Creating reject-loop-dapp test application" + @mkdir -p applications + @cartesi-machine --ram-length=128Mi --store=applications/reject-loop-dapp --final-hash --assert-rolling-template -- ioctl-echo-loop --vouchers=1 --notices=1 --reports=1 --reject=1 --verbose=1 || { rm -rf "$@"; exit 1; } + +applications/exception-loop-dapp: ## Create exception-loop-dapp test application + @echo "Creating exception-loop-dapp test application" + @mkdir -p applications + @cartesi-machine --ram-length=128Mi --store=applications/exception-loop-dapp --final-hash --assert-rolling-template -- ioctl-echo-loop --vouchers=1 --notices=1 --reports=1 --exception=1 --verbose=1 || { rm -rf "$@"; exit 1; } + +applications/halt-loop-dapp: ## Create halt-loop-dapp test application + @echo "Creating halt-loop-dapp test application" + @mkdir -p applications + @cartesi-machine --ram-length=128Mi --store=applications/halt-loop-dapp --final-hash --assert-rolling-template -- "rollup accept && rollup accept" || { rm -rf "$@"; exit 1; } + +applications/mcycle-overflow-dapp: applications/echo-dapp ## Create MCYCLE overflow test application + @echo "Creating mcycle-overflow-dapp test application" + @go run $(GO_BUILD_PARAMS) ./test/tooling/terminalmachine mcycle-overflow --source=$< --output=$@ + +applications/unexpected-yield-dapp: ## Create unexpected-yield test application + @echo "Creating unexpected-yield-dapp test application" + @go run $(GO_BUILD_PARAMS) ./test/tooling/terminalmachine unexpected-yield --output=$@ + +applications/invalid-outputs-root-dapp: ## Create an accepted guest with a wrong outputs root + @go run $(GO_BUILD_PARAMS) ./test/tooling/terminalmachine invalid-outputs-root --kind=value --output=$@ + +applications/invalid-outputs-root-length-dapp: ## Create an accepted guest with a malformed outputs-root length + @go run $(GO_BUILD_PARAMS) ./test/tooling/terminalmachine invalid-outputs-root --kind=length --output=$@ + +applications/invalid-template-outputs-root-dapp: ## Create a template with a zero outputs root + @go run $(GO_BUILD_PARAMS) ./test/tooling/terminalmachine invalid-outputs-root --kind=template --output=$@ + +applications/erc20-withdrawal-dapp: test/dapps/erc20-withdrawal/install.sh ## Create ERC-20 withdrawal test application + @echo "Creating ERC-20 withdrawal test application" + @mkdir -p applications + @PORTAL=$${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419}; \ + TOKEN=$${CARTESI_DEVNET_TEST_USDC_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}; \ + cartesi-machine --ram-length=128Mi \ + --flash-drive=label:accounts,length:4Mi,mke2fs:false,mount:false,user:dapp \ + --env=TRUSTED_ERC20_PORTAL=$$PORTAL \ + --env=TRUSTED_ERC20_TOKEN=$$TOKEN \ + --append-init-file=test/dapps/erc20-withdrawal/install.sh \ + --store=applications/erc20-withdrawal-dapp --final-hash --assert-rolling-template -- /usr/local/bin/erc20-withdrawal-dapp || { rm -rf "$@"; exit 1; } + +applications/erc20-withdrawal-nvram-dapp: test/dapps/erc20-withdrawal/install.sh ## Create ERC-20 withdrawal NVRAM test application + @echo "Creating ERC-20 withdrawal NVRAM test application" + @mkdir -p applications + @PORTAL=$${CARTESI_DEVNET_ERC20_PORTAL_ADDRESS:-0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419}; \ + TOKEN=$${CARTESI_DEVNET_TEST_USDC_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}; \ + cartesi-machine --ram-length=128Mi \ + --nvram=label:accounts,length:4Mi,user:dapp \ + --env=ACCOUNTS_DRIVE_KIND=nvram \ + --env=TRUSTED_ERC20_PORTAL=$$PORTAL \ + --env=TRUSTED_ERC20_TOKEN=$$TOKEN \ + --append-init-file=test/dapps/erc20-withdrawal/install.sh \ + --store=applications/erc20-withdrawal-nvram-dapp --final-hash --assert-rolling-template -- /usr/local/bin/erc20-withdrawal-dapp || { rm -rf "$@"; exit 1; } + +deploy-echo-dapp: applications/echo-dapp ## Deploy echo-dapp test application + @echo "Deploying echo-dapp test application" + @bash scripts/dev-cli deploy application echo-dapp applications/echo-dapp/ + +deploy-reject-dapp: applications/reject-dapp ## Deploy reject-dapp test application + @echo "Deploying reject-dapp test application" + @bash scripts/dev-cli deploy application reject-dapp applications/reject-dapp/ + +deploy-exception-dapp: applications/exception-dapp ## Deploy exception-dapp test application + @echo "Deploying exception-dapp test application" + @bash scripts/dev-cli deploy application exception-dapp applications/exception-dapp/ + +deploy-prt-echo-dapp: applications/echo-dapp ## Deploy echo-dapp test application + @echo "Deploying echo-dapp test application" + @bash scripts/dev-cli deploy application prt-echo-dapp applications/echo-dapp/ --prt + +deploy-erc20-withdrawal-dapp: applications/erc20-withdrawal-dapp ## Deploy ERC-20 withdrawal test application + @set -e; \ + APP=$${APP:-erc20-withdrawal-dapp}; \ + GUARDIAN=$${GUARDIAN:-0x70997970C51812dc3A010C7d01b50e0d17dc79C8}; \ + BUILDER=$${CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS:-0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7}; \ + DRIVE_START_INDEX=$$(bash scripts/accounts-drive-start-index \ + applications/erc20-withdrawal-dapp/config.json "$${ACCOUNTS_DRIVE_START_INDEX:-}"); \ + WITHDRAWAL_CONFIG=$$(jq -cn \ + --arg guardian "$$GUARDIAN" \ + --arg builder "$$BUILDER" \ + --argjson drive "$$DRIVE_START_INDEX" \ + '{guardian:$$guardian,log2_leaves_per_account:0,log2_max_num_of_accounts:17,accounts_drive_start_index:$$drive,withdrawal_output_builder:$$builder}'); \ + echo "Deploying $$APP with accounts-drive start index $$DRIVE_START_INDEX"; \ + bash scripts/dev-cli deploy application "$$APP" applications/erc20-withdrawal-dapp \ + --salt "$$(openssl rand -hex 32)" \ + --withdrawal-config "$$WITHDRAWAL_CONFIG" \ + --enable=false; \ + bash scripts/dev-cli app execution-parameters set "$$APP" snapshot_policy EVERY_EPOCH; \ + bash scripts/dev-cli app status "$$APP" enabled --yes + +fund-wallet: ## Fund the default Anvil wallet with ETH and TestUsdc + @set -e; \ + RPC_URL=$${CARTESI_BLOCKCHAIN_HTTP_ENDPOINT:-http://localhost:8545}; \ + TOKEN=$${CARTESI_DEVNET_TEST_USDC_ADDRESS:-0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81}; \ + WALLET=$${WALLET:-$$(cast rpc --rpc-url "$$RPC_URL" eth_accounts | jq -r '.[0]')}; \ + ETH_WEI=$${ETH_WEI:-0x8ac7230489e80000}; \ + TOKEN_AMOUNT=$${TOKEN_AMOUNT:-1000000}; \ + echo "Funding $$WALLET with $$ETH_WEI wei"; \ + cast rpc --rpc-url "$$RPC_URL" anvil_setBalance "$$WALLET" "$$ETH_WEI" >/dev/null; \ + echo "Minting $$TOKEN_AMOUNT TestUsdc units to $$WALLET"; \ + cast send --rpc-url "$$RPC_URL" --from "$$WALLET" --unlocked "$$TOKEN" "mint(uint256)" "$$TOKEN_AMOUNT" >/dev/null + +withdraw-wallet: cartesi-rollups-cli ## Send a test withdrawal request from the current signer + @set -e; \ + APP=$${APP:-erc20-withdrawal-dapp}; \ + AMOUNT=$${AMOUNT:-25}; \ + PAYLOAD=$$(printf '0x01%016x' "$$AMOUNT"); \ + echo "Sending withdrawal request to $$APP: amount=$$AMOUNT payload=$$PAYLOAD"; \ + bash scripts/dev-cli send "$$APP" "$$PAYLOAD" --hex --yes --json + +# Temporary test dependencies target while we are not using distribution packages +DOWNLOADS_DIR = test/downloads +CARTESI_TEST_MACHINE_IMAGES = $(DOWNLOADS_DIR)/linux.bin $(DOWNLOADS_DIR)/rootfs.ext2 +$(CARTESI_TEST_MACHINE_IMAGES): + @mkdir -p $(DOWNLOADS_DIR) + @wget -nc -i test/dependencies -P $(DOWNLOADS_DIR) + @shasum -ca 256 test/dependencies.sha256 + @cd $(DOWNLOADS_DIR) && ln -s rootfs-tools.ext2 rootfs.ext2 + @cd $(DOWNLOADS_DIR) && ln -s linux-6.18.56-ctsi-1.bin linux.bin + +download-test-dependencies: | $(CARTESI_TEST_MACHINE_IMAGES) + +dependencies.sha256: + @shasum -a 256 $(DOWNLOADS_DIR)/rootfs-tools* $(DOWNLOADS_DIR)/linux-*.bin > $@ + +# ============================================================================= +# Static Analysis +# ============================================================================= +lint-endpoints: ## Check endpoint access restrictions and their fixtures + @golangci-lint run --enable-only forbidigo ./... + @bash dev/test-endpoint-lint.sh + +lint: lint-endpoints ## Run the linter + @echo "Running the linter" + @golangci-lint run ./... + +fmt: ## Run go fmt + @echo "Running go fmt" + @go fmt ./... + +fmt-check: ## Check go formatting (non-destructive) + @echo "Checking go formatting" + @test -z "$$(gofmt -l .)" || (echo "Unformatted files:" && gofmt -l . && exit 1) + +vet: ## Run go vet + @echo "Running go vet" + @go vet ./... + +escape: ## Run go escape analysis + @echo "Running go escape analysis" + go build -gcflags="-m -m" ./... + +# ============================================================================= +# Docs +# ============================================================================= + +docs: generate-cli-docs generate-config-docs ## Generate all documentation + +generate-cli-docs: ## Generate CLI documentation + @echo "Generating CLI documentation" + @go run $(GO_BUILD_PARAMS) dev/gen-docs/main.go + +generate-config-docs: ## Generate config documentation from Config.toml + @echo "Generating config documentation" + @cd internal/config/generate && go run . -mode=docs + +# ============================================================================= +# Docker +# ============================================================================= +devnet: clean-contracts ## Build docker devnet image + @docker build $(DOCKER_PLATFORM) -t cartesi/rollups-node-devnet:$(IMAGE_TAG) -f test/devnet/Dockerfile . + +image: ## Build the docker images using bake + @docker build $(DOCKER_PLATFORM) -t cartesi/rollups-node:$(IMAGE_TAG) . + +tester-image: ## Build the docker images using bake + @docker build $(DOCKER_PLATFORM) --target=tester -t cartesi/rollups-node:tester . + +debian-packager: ## Build debian packager image + @echo "Building debian packager image $(DEB_PACKAGER_IMG) $(BUILD_PLATFORM)" + @docker build $(DOCKER_PLATFORM) --target debian-packager -t $(DEB_PACKAGER_IMG) . + +run-with-compose: ## Run the node with the anvil devnet + @docker compose up + +start-devnet: ## Run the anvil devnet docker container + @echo "Starting devnet" + @docker run --rm --name devnet -p 8545:8545 -d cartesi/rollups-node-devnet:$(IMAGE_TAG) + @$(MAKE) copy-devnet-files + +copy-devnet-files deployment.json: ## Copy the devnet files to the host (it must be running) + @echo "Copying devnet files" + @docker cp devnet:/usr/share/devnet/deployment.json deployment.json + @docker cp devnet:/usr/share/devnet/anvil_state.json anvil_state.json + +start-postgres: ## Run the PostgreSQL 16 docker container + @echo "Starting portgres" + @docker run --rm --name postgres -p 5432:5432 -d -e POSTGRES_PASSWORD=password -e POSTGRES_DB=rollupsdb -v $(CURDIR)/test/postgres/init-test-db.sh:/docker-entrypoint-initdb.d/init-test-db.sh postgres:18-alpine + @$(MAKE) migrate + +start-awslocalstack: ## Run the AWS LocalStack docker container + @echo "Starting AWS localstack" + @docker run --rm --name awslocalstack -p 127.0.0.1:4566:4566 -d -e SERVICES=kms localstack/localstack:4.14.0 + +env-awslocalstack: + @echo export AWS_ACCESS_KEY_ID=test + @echo export AWS_SECRET_ACCESS_KEY=test + @echo export AWS_REGION=us-east-1 + @echo export AWS_ENDPOINT_URL_KMS=http://localhost:4566 + @echo export LOCALSTACK_KMS_ENDPOINT=http://localhost:4566 + @echo export LOCALSTACK_KMS_REQUIRED=true + +start: start-postgres start-devnet ## Start the anvil devnet and PostgreSQL 16 docker containers + +stop-devnet: ## Stop the anvil devnet docker container + @docker stop devnet || true + +stop-postgres: ## Stop the PostgreSQL 16 docker container + @docker stop postgres || true + +stop-awslocalstack: ## Stop the AWS LocalStack docker container + @docker stop awslocalstack || true + +stop: stop-devnet stop-postgres ## Stop the anvil devnet and PostgreSQL 16 docker containers + +restart-devnet: ## Restart the anvil devnet docker container + @$(MAKE) stop-devnet + @$(MAKE) start-devnet + +restart-postgres: ## Restart the PostgreSQL 16 docker container and migrate it + @$(MAKE) stop-postgres + @$(MAKE) start-postgres + +restart: ## Restart all running docker containers + @$(MAKE) stop-devnet + @$(MAKE) stop-postgres + @$(MAKE) start-devnet + @$(MAKE) start-postgres + +shutdown-compose: ## Remove the containers and volumes from previous compose run + @docker compose down -v + +unit-test-with-compose: $(CARTESI_TEST_MACHINE_IMAGES) ## Run unit tests using docker compose with auto-shutdown + @trap 'docker compose -f test/compose/compose.test.yaml down -v || true' EXIT && \ + docker compose -f test/compose/compose.test.yaml run --rm --remove-orphans unit-test + +lint-with-docker: ## Run linting inside Docker (no host Go needed) + @docker run --rm cartesi/rollups-node:tester sh -c 'make lint && make vet && make fmt-check' + +lint-endpoints-with-docker: ## Check endpoint access restrictions in the tester image + @docker run --rm cartesi/rollups-node:tester make lint-endpoints + +check-license: ## Verify license headers on Go source files + @scripts/check-license-header.sh + +# ============================================================================= +# Integration test sharding +# ============================================================================= +# Each shard runs as an isolated Docker Compose project (own Anvil, Postgres, +# and test-managed node); tests within a shard stay sequential. The anchors +# (^...$$) are load-bearing: go test -run matches unanchored, and several test +# names are prefixes of others (Foreclose / ForecloseReplay / ForeclosePrt). +# Every top-level test must match exactly one ordinary shard; this is enforced by +# `make integration-test-shard-check`. +# +# Shards are grouped by semantic family, not balanced by runtime: `withdrawal` +# covers withdrawals and deposit refunds; `restart` and `replay` are the heaviest. Each shard +# gets its own CI runner and the full per-job `go test -timeout 55m` +# (run-integration-tests.sh) budget; `restart` (multi-suite, ~25-min setup +# contexts) is the first to watch if a shard ever approaches that ceiling. +# Discovery (integration-test-shard-check) parses Go-selected test files with +# a plain Go toolchain; native fixture dependencies are not built on the +# CI setup runner. +INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal awskms + +INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|TerminalMachineStates|MultiApp|EchoAuthorityStaging|IntegrationCLI|IntegrationLogScanner|TransactionFeeReplacement)$$ +INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ +INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt|PrtPassiveDisputeObserver|PrtRecoveryFixtureConfig|ReaderConfigClearsSignerAndCanRestoreExactSubmittingRecord|RecoveryFixturePreservesFinalizedProfileAndParentLimits|SparseDisputeCommitment(MatchesDenseTrees|CanonicalGeometry|RejectsOutOfRangeRequests)|PassiveObserver(CleanupPreservesRestoreOrder|Config(PreservesOriginalAndUnrelatedFields|RejectsMissingFieldsAndInvalidJSON))|PrtCommitmentMatchesMachineCLI)$$ +INTEGRATION_SHARD_replay := ^Test(Foreclose|ForecloseReplay|DivergentClaim|IndependentClaimWatch|IndependentClaimObserverCorrectClaims)$$ +INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy|NodeSubprocess|ManagedNodeDeadlineStopsChildrenBeforeProcessExit)$$ +INTEGRATION_SHARD_withdrawal := ^Test(WithdrawalLifecycle|RefundLifecycle|AccountsDriveStartIndex_MemoryRanges_ReturnsStartIndex)$$ +INTEGRATION_SHARD_awskms := ^TestLocalStackAWSIntegration$$ + +# This profile exercises the finality-sensitive paths with a longer lag while +# keeping its full suites within the existing job budget. The complete ordinary +# matrix still covers every integration test. +# Keep its members here: +# the guard verifies every named suite exists, as well as the complete pattern. +INTEGRATION_DELIVERY_FINALIZED_TESTS := EchoAuthorityStaging EchoPrt DivergentClaim +INTEGRATION_SHARD_delivery-finalized = ^Test($(subst $(space),|,$(INTEGRATION_DELIVERY_FINALIZED_TESTS)))$$ + +# ----------------------------------------------------------------------------- +# Node topology axis — orthogonal to shards. +# ----------------------------------------------------------------------------- +# A shard selects WHICH tests run; a topology selects HOW the node is deployed. +# CI runs the (shard, topology) cells in parallel. Both topologies are +# test-managed (TestMain starts and can restart them), so both run every shard. +# standalone — the all-in-one cartesi-rollups-node process. +# multiprocess — one OS process per service (evm-reader, advancer, validator, +# claimer, prt, jsonrpc-api) sharing Postgres, started as +# subprocesses by TestMain (on the host, or inside the test +# container under compose). See test/integration/multinode_helpers_test.go. +# +# Applicability is per-topology data (INTEGRATION_SHARDS_). multiprocess +# runs the SAME shards as standalone — the node-lifecycle tests stop/start the +# whole service set via the topology-aware harness. Killing a single service +# (partial failure) is separate future fault-injection work. +INTEGRATION_TOPOLOGIES := standalone multiprocess +NODE_TOPOLOGY ?= standalone + +INTEGRATION_SHARDS_standalone := $(INTEGRATION_SHARDS) +INTEGRATION_SHARDS_multiprocess := $(filter-out awskms,$(INTEGRATION_SHARDS)) + +# The CI matrix is the set of (shard, topology) cells, encoded "shard:topology". +INTEGRATION_CELLS := $(foreach t,$(INTEGRATION_TOPOLOGIES),$(foreach s,$(INTEGRATION_SHARDS_$(t)),$(s):$(t))) +INTEGRATION_FINALIZED_CELLS := $(foreach t,$(INTEGRATION_TOPOLOGIES),delivery-finalized:$(t)) + +COMPOSE_PROJECT ?= rollups-node-integration +INTEGRATION_LOGS ?= integration-logs.txt +INTEGRATION_TEST_JOBS ?= 3 +CLEAN_STALE_LOCAL_NODE ?= false + +# String helpers for building run patterns / matrices. +comma := , +empty := +space := $(empty) $(empty) + +# --- Selection driven by NODE_TOPOLOGY and SHARD ---------------------------- +# Selected topologies: NODE_TOPOLOGY (default standalone), a space-separated +# list, or the sugar value `all`. +TOPOLOGIES_SELECTED = $(if $(filter all,$(NODE_TOPOLOGY)),$(INTEGRATION_TOPOLOGIES),$(NODE_TOPOLOGY)) +# The delivery profile is an explicit selection alias. Ordinary defaults and +# exact-one shard coverage exclude it because it overlaps three ordinary shards. +shards_for = $(filter $(if $(strip $(SHARD)),$(SHARD),$(INTEGRATION_SHARDS_$(1))),$(INTEGRATION_SHARDS_$(1)) delivery-finalized) +# run_pattern(topology): the selected shards' -run regexes as one alternation. +run_pattern = $(subst $(space),|,$(strip $(foreach s,$(call shards_for,$(1)),$(INTEGRATION_SHARD_$(s))))) +# compose_profiles(topology): activate optional infrastructure required by the +# selected shards for this topology. +compose_profiles = $(if $(filter awskms,$(call shards_for,$(1))),awskms,) +# Selected (shard:topology) cells, for PARALLEL fan-out. +SELECTED_CELLS = $(foreach t,$(TOPOLOGIES_SELECTED),$(foreach s,$(call shards_for,$(t)),$(s):$(t))) +# Label for project/log names: the SHARD filter joined by '-', or "all". +SUITE_LABEL = $(if $(strip $(SHARD)),$(subst $(space),-,$(strip $(SHARD))),all) + +# Validate NODE_TOPOLOGY / SHARD at parse time for integration entry points so a +# bad invocation fails before any prerequisite work (downloading images, etc). +ifneq ($(filter integration-test-with-compose integration-test-local,$(MAKECMDGOALS)),) +$(if $(strip $(TOPOLOGIES_SELECTED)),,$(error NODE_TOPOLOGY must select at least one topology. Known topologies: $(INTEGRATION_TOPOLOGIES))) +$(foreach t,$(TOPOLOGIES_SELECTED),$(if $(filter $(t),$(INTEGRATION_TOPOLOGIES)),,$(error unknown topology '$(t)'. Known topologies: $(INTEGRATION_TOPOLOGIES)))) +$(foreach s,$(SHARD),$(if $(filter $(s),$(INTEGRATION_SHARDS) delivery-finalized),,$(error unknown shard '$(s)'. Known shards: $(INTEGRATION_SHARDS) delivery-finalized))) +endif + +# PARALLEL is compose-only: the host node binds fixed ports. Fail at parse time, +# before the (slow) build prerequisites of integration-test-local. +ifneq ($(filter integration-test-local,$(MAKECMDGOALS)),) +ifeq ($(PARALLEL),true) +$(error PARALLEL is not supported for integration-test-local (the host node binds fixed ports 10000/10011/10012). Use 'integration-test-with-compose PARALLEL=true') +endif +endif + +# ============================================================================= +# Two entry points. Both honor: +# NODE_TOPOLOGY one topology (default standalone), a list, or `all` +# SHARD restrict to a subset of shards (default: all applicable) +# PARALLEL =true runs cells concurrently (compose only) +# DEFAULT_BLOCK latest (default), safe or finalized; local uses the running +# chain, while Compose uses a 64-block lag with finalized. +# A requested-but-inapplicable (shard, topology) is skipped with a message. +# ============================================================================= + +integration-test-with-compose: $(CARTESI_TEST_MACHINE_IMAGES) ## Run integration tests via docker compose (NODE_TOPOLOGY=, SHARD=, DEFAULT_BLOCK=, PARALLEL=true) +ifeq ($(PARALLEL),true) +ifeq ($(strip $(SELECTED_CELLS)),) + @echo "skip: no applicable integration cells selected" +else + @$(MAKE) -k -j $(INTEGRATION_TEST_JOBS) $(addprefix _compose-cell-,$(SELECTED_CELLS)) +endif +else + @set -e; for t in $(TOPOLOGIES_SELECTED); do $(MAKE) _compose-topology-$$t; done +endif + +# One topology: the union of selected shards in one compose project, sequential. +_compose-topology-%: + @pattern='$(call run_pattern,$*)'; \ + if [ -z "$$pattern" ]; then echo "skip: no applicable shards for topology '$*' (SHARD filter excludes all)"; exit 0; fi; \ + COMPOSE_PROJECT='$(if $(filter rollups-node-integration,$(COMPOSE_PROJECT)),rollups-node-integration-$(SUITE_LABEL)-$*,$(COMPOSE_PROJECT))' \ + INTEGRATION_LOGS='integration-logs-$(SUITE_LABEL)-$*.txt' \ + TEST_PATTERN="$$pattern" SHARD_NAME='$(SUITE_LABEL)' NODE_TOPOLOGY='$*' \ + COMPOSE_PROFILES='$(call compose_profiles,$*)' \ + GOTESTSUM_FORMAT='$(COMPOSE_TOPOLOGY_GOTESTSUM_FORMAT)' \ + scripts/compose-integration-run.sh + +# One (shard:topology) cell in its own project — PARALLEL fan-out target. +_compose-cell-%: + @COMPOSE_PROJECT='$(COMPOSE_PROJECT)-$(firstword $(subst :, ,$*))-$(lastword $(subst :, ,$*))' \ + INTEGRATION_LOGS='integration-logs-$(firstword $(subst :, ,$*))-$(lastword $(subst :, ,$*)).txt' \ + TEST_PATTERN='$(INTEGRATION_SHARD_$(firstword $(subst :, ,$*)))' \ + SHARD_NAME='$(firstword $(subst :, ,$*))' \ + NODE_TOPOLOGY='$(lastword $(subst :, ,$*))' \ + COMPOSE_PROFILES='$(if $(filter awskms,$(firstword $(subst :, ,$*))),awskms,)' \ + GOTESTSUM_FORMAT='$(GOTESTSUM_FORMAT)' \ + scripts/compose-integration-run.sh + +integration-test-shard-check: ## Verify every integration test belongs to exactly one shard + @scripts/check-integration-shards.sh \ + $(foreach s,$(INTEGRATION_SHARDS),'$(s)=$(INTEGRATION_SHARD_$(s))') \ + '--profile=delivery-finalized=$(INTEGRATION_SHARD_delivery-finalized)' \ + $(foreach t,$(INTEGRATION_DELIVERY_FINALIZED_TESTS),'--profile-test=delivery-finalized=Test$(t)') + +list-integration-shards: ## Print integration shard names as a JSON array + @echo '[$(subst $(space),$(comma),$(patsubst %,"%",$(INTEGRATION_SHARDS)))]' + +list-integration-cells: ## Print ordinary and finalized shard×topology cells for the CI matrix + @printf '['; sep=''; \ + for cell in $(INTEGRATION_CELLS); do \ + printf '%s{"shard":"%s","topology":"%s","default_block":"latest"}' "$$sep" "$${cell%%:*}" "$${cell##*:}"; \ + sep=','; \ + done; \ + for cell in $(INTEGRATION_FINALIZED_CELLS); do \ + printf '%s{"shard":"%s","topology":"%s","default_block":"finalized"}' "$$sep" "$${cell%%:*}" "$${cell##*:}"; \ + sep=','; \ + done; \ + printf ']\n' + +test-with-compose: ## Run all tests using docker compose with auto-shutdown + @$(MAKE) unit-test-with-compose + @$(MAKE) integration-test-with-compose + +integration-test-local: build cartesi-rollups-machine-tool echo-dapp reject-loop-dapp exception-loop-dapp halt-loop-dapp mcycle-overflow-dapp unexpected-yield-dapp invalid-outputs-root-dapps erc20-withdrawal-dapp erc20-withdrawal-nvram-dapp ## Run integration tests on the host (NODE_TOPOLOGY=, SHARD=, DEFAULT_BLOCK=; requires: make start && eval $$(make env); CLEAN_STALE_LOCAL_NODE=true to stop test-port listeners) + @set -e; first=1; for t in $(TOPOLOGIES_SELECTED); do \ + if [ "$$first" = 1 ]; then first=0; else echo "=== resetting dev DB + devnet between topologies ==="; $(MAKE) restart; fi; \ + $(MAKE) _local-topology-$$t; \ + done + +# One topology on the host: in-process node (standalone) or service subprocesses +# (multiprocess, via TestMain reading NODE_TOPOLOGY). +_local-topology-%: + @pattern='$(call run_pattern,$*)'; \ + if [ -z "$$pattern" ]; then echo "skip: no applicable shards for topology '$*' (SHARD filter excludes all)"; exit 0; fi; \ + if [ -n "$(filter awskms,$(SHARD))" ]; then \ + if [ -z "$$LOCALSTACK_KMS_ENDPOINT" ]; then \ + echo "ERROR: LOCALSTACK_KMS_ENDPOINT is required when SHARD includes awskms." >&2; \ + echo "Run 'make start-awslocalstack', then eval \"\$$(make env-awslocalstack)\"." >&2; \ + exit 1; \ + fi; \ + export LOCALSTACK_KMS_REQUIRED=true; \ + fi; \ + cartesi-rollups-cli db init; \ + test_ports="10000 10001 10002 10003 10004 10005 10006 10011 10012"; \ + busy_pids="$$(for p in $$test_ports; do lsof -tiTCP:$$p -sTCP:LISTEN 2>/dev/null || true; done | sort -u | tr '\n' ' ')"; \ + if [ -n "$$busy_pids" ]; then \ + if [ "$(CLEAN_STALE_LOCAL_NODE)" = "true" ]; then \ + echo "Stopping process(es) listening on integration test ports: $$busy_pids"; \ + kill $$busy_pids 2>/dev/null || true; \ + sleep 2; \ + else \ + echo "ERROR: integration test ports are already in use by PID(s): $$busy_pids" >&2; \ + echo "Stop those processes, or rerun with CLEAN_STALE_LOCAL_NODE=true to stop test-port listeners." >&2; \ + exit 1; \ + fi; \ + fi; \ + export CARTESI_TEST_DAPP_PATH=$(CURDIR)/applications/echo-dapp; \ + export CARTESI_TEST_REJECT_DAPP_PATH=$(CURDIR)/applications/reject-loop-dapp; \ + export CARTESI_TEST_EXCEPTION_DAPP_PATH=$(CURDIR)/applications/exception-loop-dapp; \ + export CARTESI_TEST_HALT_DAPP_PATH=$(CURDIR)/applications/halt-loop-dapp; \ + export CARTESI_TEST_MCYCLE_OVERFLOW_DAPP_PATH=$(CURDIR)/applications/mcycle-overflow-dapp; \ + export CARTESI_TEST_UNEXPECTED_YIELD_DAPP_PATH=$(CURDIR)/applications/unexpected-yield-dapp; \ + export CARTESI_TEST_INVALID_OUTPUTS_ROOT_DAPP_PATH=$(CURDIR)/applications/invalid-outputs-root-dapp; \ + export CARTESI_TEST_INVALID_OUTPUTS_ROOT_LENGTH_DAPP_PATH=$(CURDIR)/applications/invalid-outputs-root-length-dapp; \ + export CARTESI_TEST_INVALID_TEMPLATE_OUTPUTS_ROOT_DAPP_PATH=$(CURDIR)/applications/invalid-template-outputs-root-dapp; \ + export CARTESI_TEST_ERC20_WITHDRAWAL_DAPP_PATH=$(CURDIR)/applications/erc20-withdrawal-dapp; \ + export CARTESI_TEST_ERC20_WITHDRAWAL_NVRAM_DAPP_PATH=$(CURDIR)/applications/erc20-withdrawal-nvram-dapp; \ + NODE_TOPOLOGY='$*' TEST_PATTERN="$$pattern" $(MAKE) integration-test + +deploy-load-test-apps: applications/echo-dapp ## Deploy 3 echo-dapp instances for load testing + @echo "Deploying load-test apps (3 echo-dapps with different salts)..." + @bash scripts/dev-cli deploy application load-test-flood applications/echo-dapp/ \ + --salt=0000000000000000000000000000000000000000000000000000000000000A01 + @bash scripts/dev-cli deploy application load-test-trickle-1 applications/echo-dapp/ \ + --salt=0000000000000000000000000000000000000000000000000000000000000A02 + @bash scripts/dev-cli deploy application load-test-trickle-2 applications/echo-dapp/ \ + --salt=0000000000000000000000000000000000000000000000000000000000000A03 + @echo "Done. 3 apps deployed: load-test-flood, load-test-trickle-1, load-test-trickle-2" + +load-test: deploy-load-test-apps ## Deploy 3 apps and run advancer starvation load test + @echo "NOTE: Start the node (separate terminal) with: CARTESI_ADVANCER_INPUT_BATCH_SIZE=10 cartesi-rollups-node" + @scripts/load-test.sh + +ci-test: ## Run the CI test pipeline locally (unit + integration across all topologies) +# @$(MAKE) lint-with-docker + @$(MAKE) integration-test-shard-check + @$(MAKE) unit-test-with-compose + @$(MAKE) integration-test-with-compose NODE_TOPOLOGY=all + +clean-test-compose-resources: ## Clean up compose resources after some unexpected test failure + @echo "Cleaning up Docker Compose resources..." + @docker compose -f test/compose/compose.test.yaml down -v || true + help: ## Show help for each of the Makefile recipes @grep "##" $(MAKEFILE_LIST) | grep -v grep | sed -e 's/:.*##\(.*\)/:\n\t\1\n/' + +version: ## Show the current version + @echo $(ROLLUPS_NODE_VERSION) + +# Phony, because the notices depend on more than files make can see: go.sum, +# the imported packages, and licence URLs that go-licenses resolves online. +THIRD_PARTY_LICENSES.md: ## Update the THIRD_PARTY_LICENSES.md file + dev/licenses-generate.sh > $@.tmp || { rm -f $@.tmp; exit 1; } + mv $@.tmp $@ + +# ============================================================================= +# Install +# ============================================================================= +install: $(GO_ARTIFACTS) ## Install all Go artifacts + @echo "Installing Go artifacts to $(BIN_INSTALL_PATH)" + @mkdir -m 0755 -p $(BIN_INSTALL_PATH) + @for artifact in $(GO_ARTIFACTS); do \ + install -m0755 $$artifact $(BIN_INSTALL_PATH)/; \ + done + +copy-debian-package: ## Copy debian package from debian packager image + @echo "Copying debian package from image $(DEB_PACKAGER_IMG) $(BUILD_PLATFORM)" + @docker create --name debian-packager $(DOCKER_PLATFORM) $(DEB_PACKAGER_IMG) + @docker cp debian-packager:/build/cartesi/go/rollups-node/$(DEB_FILENAME) . + @docker rm debian-packager > /dev/null + +build-debian-package: install + mkdir -p $(DESTDIR)/DEBIAN $(DOC_INSTALL_PATH) + install -m0644 LICENSE $(DOC_INSTALL_PATH)/copyright + install -m0644 THIRD_PARTY_LICENSES.md $(DOC_INSTALL_PATH)/ + install -m0644 licenses/LGPL-3.0.txt $(DOC_INSTALL_PATH)/ + install -m0644 licenses/GPL-3.0.txt $(DOC_INSTALL_PATH)/ + { \ + echo ""; \ + echo "The binaries in this package statically link third party components."; \ + echo "They are listed with their licences in THIRD_PARTY_LICENSES.md, in this"; \ + echo "directory. Some are covered by the GNU LGPL-3.0, whose text is in"; \ + echo "LGPL-3.0.txt, alongside the GPL-3.0.txt it builds on."; \ + } >> $(DOC_INSTALL_PATH)/copyright + sed 's|ARG_VERSION|$(ROLLUPS_NODE_VERSION)|g;s|ARG_ARCH|$(DEB_ARCH)|g' control.template > $(DESTDIR)/DEBIAN/control + dpkg-deb -Zxz --root-owner-group --build $(DESTDIR) $(DEB_FILENAME) + +.PHONY: \ + build build-go $(GO_ARTIFACTS) cartesi-rollups-machine-tool \ + clean clean-go clean-contracts clean-docs clean-devnet-files clean-dapps clean-test-dependencies clean-test-logs clean-integration-compose clean-debian-packages \ + test unit-test unit-test-with-compose integration-test integration-test-with-compose integration-test-local test-with-compose ci-test coverage-report \ + integration-test-shard-check list-integration-shards list-integration-cells \ + generate generate-contracts generate-config generate-inspect check-generate generate-db THIRD_PARTY_LICENSES.md \ + docs generate-cli-docs generate-config-docs \ + lint lint-endpoints lint-endpoints-with-docker fmt fmt-check vet escape check-license \ + devnet image tester-image debian-packager run-with-compose shutdown-compose \ + start start-devnet start-postgres stop stop-devnet stop-postgres restart restart-devnet restart-postgres \ + install copy-debian-package build-debian-package \ + mcycle-overflow-dapp unexpected-yield-dapp invalid-outputs-root-dapps erc20-withdrawal-dapp erc20-withdrawal-nvram-dapp \ + deploy-erc20-withdrawal-dapp fund-wallet withdraw-wallet \ + env help version diff --git a/README.md b/README.md index 70a58d4de..fe7f99286 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,127 @@ # Cartesi Rollups Node -This page assumes the reader knows the concepts of Cartesi Rollups. -The Cartesi Rollups [documentation page][rollups-docs] is an excellent place to learn these concepts. +[![Latest Release](https://img.shields.io/github/v/release/cartesi/rollups-node?label=version)](https://github.com/cartesi/rollups-node/releases) +[![Build Status](https://img.shields.io/github/actions/workflow/status/cartesi/rollups-node/build.yml?branch=main)](https://github.com/cartesi/rollups-node/actions) +[![License](https://img.shields.io/github/license/cartesi/rollups-node)](LICENSE) -[rollups-docs]: https://docs.cartesi.io/cartesi-rollups/overview/ +The Cartesi Rollups Node is the middleware that connects the Rollups smart contracts, the application back-end running inside the Cartesi Machine, and the front-end. -## Introduction +## Getting Started -![Overview](docs/images/overview.svg) +### Installation -The diagram above presents the topology of the Cartesi Rollups framework. -In this diagram, the white box is an EVM-compatible blockchain, such as Ethereum. -The blue boxes are the major Cartesi components in the Cartesi Rollups framework. -The black boxes are the components of the application built on top of Cartesi Rollups. +We provide packages for debian (.deb) in **amd64** and **arm64** variants on the release page. Other systems can build from source. -The Cartesi Rollups Contracts run in the base layer and are responsible for settlement, consensus, and data availability. -The Cartesi Machine is a deterministic execution environment that runs off-chain. -These two components live in completely decoupled environments, so the Cartesi Rollups framework needs another component to handle the communication between them. +#### From Sources -The Cartesi Rollups Node is the middleware that connects the Rollups smart contracts, the application back-end running inside the Cartesi Machine, and the front-end. -As such, the Node reads the advance-state inputs from the smart contracts, sends those inputs to the Cartesi Machine, and stores the computation outputs in a database that the application front-end can later query. -The Node also provides a consensus mechanism so users can validate outputs on-chain. +##### System Requirements -## Features +- Cartesi Machine emulator == 0.22.x +- GNU Make >= 3.81 +- Go >= 1.27.3 -This section delves into the Node features in more detail. +Follow the [Cartesi Machine installation instructions](https://github.com/cartesi/machine-emulator?tab=readme-ov-file#installation). -### Advance State +##### Build -![Advance State](docs/images/advance.svg) +With the Cartesi machine emulator installed, build the rollups-node: -The diagram above presents the data flow for advancing the rollup state. -First, the application front-end adds an advance-state input to the `InputBox` smart contract in the base layer. -The Node watches the events emitted by this contract, reads the input, and sends it to the application back-end running inside the Cartesi Machine. -Once the application back-end processes the input, the Node obtains the outputs (vouchers, notices, and reports) and stores them in a database. -Finally, the application front-end reads these outputs from the database. +```sh +# clone a branch of the rollups-node +git clone --branch next/2.0 https://github.com/cartesi/rollups-node.git +cd rollups-node -The Node provides a GraphQL API to read the rollup state for the application front end. -This API contains the advance-state inputs, the corresponding outputs, and the proofs necessary to validate the outputs on-chain. -The [`reader.graphql`](api/graphql/reader.graphql) file contains the schema for this API. +# compile +make +``` -### Inspect State +### Install -![Inspect State](docs/images/inspect.svg) +Optionally setup `GO_INSTALL_PATH`, then: -Besides advance-state inputs, the Cartesi Machine can also process inspect-state inputs. -By definition, these inputs don't alter the machine's state; instead, they are a mechanism to query the application back-end directly. -The application front-end sends inspect-state inputs directly to the Rollups Node. -The Node sends the inputs to the Cartesi Machine, obtains the reports, and returns them to the application front-end (note the inspect-state inputs do not return vouchers and notices). +```sh -The Node provides a REST API for the application front-end to inspect the Cartesi Machine. -The [`inspect.yaml`](api/openapi/inspect.yaml) file contains the schema for this API. +# install +sudo make install +``` -It is worth noting that the current version of the Rollups Node can only process one input at a time, be it advance-state or inspect-state. -So, be cautious of using inspect-state inputs if your application has specific scalability requirements. -We recommend benchmarking the application back-end before committing to an architecture that relies on inspect-state inputs. +### Usage -### Validate +Once installed, the node requires configuration to run. +Via either environment variables or execution arguments. -![Validate](docs/images/validate.svg) +As an **example**, the development values can be found with `make env`. +Node operators should get familiar with those and customize to their specific setup. +For more on this topic, consult the [wiki](https://github.com/cartesi/rollups-node/wiki/Configuration). +See [Transaction delivery](docs/transaction-delivery.md) for signer configuration, limits, recovery and removal. -The Rollups Node bundles multiple advance-state inputs into an epoch. -Since the Node only supports the Authority consensus mechanism, it is up to the Node to decide when it should close an epoch. -Once an epoch is closed, the Node computes a claim for the epoch and submits it to the rollups smart contracts. -Then, the application front-end fetches the proof that a given output is within the closed epoch and uses this proof to validate the output. -For instance, the front end may verify whether a notice is valid or execute a voucher. +The node provides multiple binaries, one for each of its modules (advancer, claimer, evm-reader, validator, jsonrpc-api). +They follow the naming convention `cartesi-rollups-`. +In addition, there is the `cli` tool that provides functionality to help develop and debug the Cartesi Rollups node and +the `node` to run in standalone mode. -Only the validator can submit a claim to the Rollups smart contracts. -The application developer decides who is the validator when deploying the application contract to the base layer. -If you want to run the Node but aren't the application's validator, you may turn off the feature that submits the claims. -The [configuration](#configuration) section describes how to do so. +Each of these binaries has a help invokable with: ` --help` for discoverability. +For more on this topic, consult the [wiki](https://github.com/cartesi/rollups-node/wiki/Commands). -### Host Mode +## Use Cases -The host mode allows the developer to run the application back-end in the host machine instead of the Cartesi Machine. -[NoNodo][nonodo] replaces this feature, which will be deprecated in version 2.0 of the Rollups Node. +The following projects have been using the rollups-node: -[nonodo]: https://github.com/gligneul/nonodo#nonodo +- [Cartesi CLI](https://github.com/cartesi/cli) - Uses the emulator's CLI in TypeScript for DApp development. -## Running +## Related Projects -We recommend application developers use [Sunodo][sunodo-docs] to run the Node. -Advanced developers who want to modify the Node may check the [development](#development) section. +TODO -[sunodo-docs]: https://docs.sunodo.io/ +## Benchmarks -### Configuration +TODO -The Rollups Node should be configured exclusively with environment variables, which are described at [Node Configuration](docs/config.md) page. +## Documentation -## Dependencies +The Cartesi Rollups node documentation is undergoing a comprehensive update. +While the full documentation is being refreshed, you can find guides and tutorials in our [wiki](https://github.com/cartesi/rollups-node/wiki). -The Cartesi Rollups Node depends on the following Cartesi components: +## Change Log -| Component | Version | -|---|---| -| Cartesi Machine SDK | [v0.17.1](https://github.com/cartesi/machine-emulator-sdk/releases/tag/v0.17.1) | -| Cartesi OpenAPI Interfaces | [v0.7.1](https://github.com/cartesi/openapi-interfaces/releases/tag/v0.7.1) | -| Cartesi Rollups Contracts | [v1.2.0](https://github.com/cartesi/rollups-contracts/releases/tag/v1.2.0) | -| Cartesi Server Manager | [v0.9.1](https://github.com/cartesi/server-manager/releases/tag/v0.9.1) | +Changes between Cartesi Rollups node releases are documented in [CHANGELOG](CHANGELOG). -## Development +## Roadmap -Check the [development](docs/development.md) page for more information about developing the Rollups Node. +We are continually improving the Rollups node with new features and enhancements and ramping up the 2.0 version. +Check out our roadmap at [GitHub Projects](https://github.com/cartesi/rollups-node/projects) to see what's coming in the future. -### Internal architecture +## Community & Support -This document covered the features of the Node and its external APIs in a high-level manner. -Check the [architecture](docs/architecture.md) page for more details about the Node internal components. +- Join our [Discord](https://discord.gg/cartesi) `#node` channel to engage with users and developers. +- Report issues on our [GitHub Issues](https://github.com/cartesi/rollups-node/issues). -## Releasing +## Developing -Check the [release](docs/release.md) page for more information about the steps to release a version of the Rollups Node. +For information about developing the rollups node, including instructions for running tests, using the linter, and code formatting, please refer to our [development guide](https://github.com/cartesi/rollups-node/wiki/Development-Guide) in the wiki. ## Contributing -Thank you for your interest in Cartesi! -Head over to our [Contributing Guidelines](docs/contributing.md) for instructions on how to sign our Contributors Agreement and get started with Cartesi! +Please see our [contributing guidelines](CONTRIBUTING.md) for instructions on how to start contributing to the project. +Note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. + +## Authors -Please note we have a [Code of Conduct](docs/code_of_conduct.md); please follow it in all your interactions with the project. +The Cartesi Rollups Node is actively developed by [Cartesi](https://cartesi.io/)'s Node Reference Unit, with significant contributions from many open-source developers. +For a complete list of authors, see the [AUTHORS](AUTHORS) file. ## License -Note: This component currently has dependencies licensed under the GNU GPL, version 3, so you should treat this component as a whole as being under the GPL version 3. -But all Cartesi-written code in this component is licensed under the Apache License, version 2, or a compatible permissive license, and can be used independently under the Apache v2 license. -After we rewrite this component, we will release it under the Apache v2 license. +The project's own code, including all contributions to it, is available under the [Apache License, Version 2.0](LICENSE). +This code is also offered under the [GNU Lesser General Public License, Version 3](licenses/LGPL-3.0.txt), making it dual-licensed: you may use it independently in other applications under either license, at your option. + +The distributed node includes LGPL-licensed dependencies. +In particular, go-ethereum is statically linked into every binary, so the node as a whole is subject to LGPL-3.0 when distributed with it. +Only go-ethereum's library packages are used, that is, everything outside `cmd/`. + +Third-party components and code covered by separate LICENSE / COPYING files remain subject to their respective licenses. +See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) for details. + +The Cartesi Machine emulator is a dynamic library in its own package. A recipient can replace it without a new build of the node. +The complete source of this project is in this repository under Apache 2.0. To build the node against a different go-ethereum, add a `replace` directive for `github.com/ethereum/go-ethereum` to `go.mod` and run `make build`. +The full text of the LGPL-3.0 and the GPL-3.0 is in [licenses/](licenses). diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 000000000..7f30954c2 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,554 @@ +# Third Party Licenses + +This file lists the third party components distributed with the Cartesi Rollups Node. +Regenerate it with `make THIRD_PARTY_LICENSES.md` — never edit it directly. + +The entries immediately below cover components that are not Go modules and are therefore +invisible to `go-licenses`; they are maintained by hand in `dev/licenses-manual.md`. +Everything after them is generated from the module graph, with the corrections listed in +`dev/licenses-overrides.tsv` applied. + +The project's own code, including all contributions to it, is available under the [Apache License, Version 2.0](LICENSE). +This code is also offered under the [GNU Lesser General Public License, Version 3](licenses/LGPL-3.0.txt), making it dual-licensed: you may use it independently in other applications under either license, at your option. + +Two components reach users under the GNU Lesser General Public License v3.0: go-ethereum, +statically linked into every binary, and the Cartesi Machine emulator, loaded at run time. +The node as a whole is subject to LGPL-3.0 when distributed with go-ethereum. +The LGPL-3.0 is written as a set of additional permissions on top of the GPL-3.0. Both texts +are in [licenses/LGPL-3.0.txt](licenses/LGPL-3.0.txt) and +[licenses/GPL-3.0.txt](licenses/GPL-3.0.txt). + +The emulator is a dynamic library in its own package. A recipient can replace it without a new +build of the node. go-ethereum is statically linked. The complete source of this project is in +this repository under Apache-2.0. To build the node against a different go-ethereum, add a +`replace` directive for `github.com/ethereum/go-ethereum` to `go.mod` and run `make build`. + +The Go bindings under `pkg/contracts/` are generated from the release artifacts of +cartesi/rollups-contracts and cartesi/dave, pinned and checksum-verified by the Makefile. Both +repositories are Apache-2.0. They are listed below for attribution. + +## github.com/cartesi/machine-emulator + +* Name: github.com/cartesi/machine-emulator +* Version: 0.22.0 +* License: [LGPL-3.0](https://github.com/cartesi/machine-emulator/blob/v0.22.0/COPYING) + +## github.com/cartesi/rollups-contracts + +* Name: github.com/cartesi/rollups-contracts +* Version: 3.0.0-alpha.10 +* License: [Apache-2.0](https://github.com/cartesi/rollups-contracts/blob/v3.0.0-alpha.10/LICENSE) +* Note: source of the generated contract bindings under pkg/contracts/. + +## github.com/cartesi/dave + +* Name: github.com/cartesi/dave +* Version: 3.0.0-alpha.6 +* License: [Apache-2.0](https://github.com/cartesi/dave/blob/v3.0.0-alpha.6/LICENSE) +* Note: source of the generated PRT contract bindings under pkg/contracts/. + +## github.com/BurntSushi/toml + +* Name: github.com/BurntSushi/toml +* Version: v1.6.0 +* License: [MIT](https://github.com/BurntSushi/toml/blob/v1.6.0/COPYING) + +## github.com/apapsch/go-jsonmerge/v2 + +* Name: github.com/apapsch/go-jsonmerge/v2 +* Version: v2.0.0 +* License: [MIT](https://github.com/apapsch/go-jsonmerge/blob/v2.0.0/LICENSE) + +## github.com/aws/aws-sdk-go-v2 + +* Name: github.com/aws/aws-sdk-go-v2 +* Version: v1.47.5 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/v1.47.5/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/config + +* Name: github.com/aws/aws-sdk-go-v2/config +* Version: v1.33.12 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/config/v1.33.12/config/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/credentials + +* Name: github.com/aws/aws-sdk-go-v2/credentials +* Version: v1.20.12 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/credentials/v1.20.12/credentials/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/feature/ec2/imds + +* Name: github.com/aws/aws-sdk-go-v2/feature/ec2/imds +* Version: v1.20.5 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.20.5/feature/ec2/imds/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/internal/configsources + +* Name: github.com/aws/aws-sdk-go-v2/internal/configsources +* Version: v1.5.8 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/configsources/v1.5.8/internal/configsources/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 + +* Name: github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 +* Version: v2.8.8 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/v2.8.8/internal/endpoints/v2/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/internal/sync/singleflight + +* Name: github.com/aws/aws-sdk-go-v2/internal/sync/singleflight +* Version: v1.47.5 +* License: [BSD-3-Clause](https://github.com/aws/aws-sdk-go-v2/blob/v1.47.5/internal/sync/singleflight/LICENSE) + +## github.com/aws/aws-sdk-go-v2/internal/v4a + +* Name: github.com/aws/aws-sdk-go-v2/internal/v4a +* Version: v1.5.8 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/internal/v4a/v1.5.8/internal/v4a/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding + +* Name: github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding +* Version: v1.13.23 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/accept-encoding/v1.13.23/service/internal/accept-encoding/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/service/internal/presigned-url + +* Name: github.com/aws/aws-sdk-go-v2/service/internal/presigned-url +* Version: v1.14.8 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/internal/presigned-url/v1.14.8/service/internal/presigned-url/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/service/kms + +* Name: github.com/aws/aws-sdk-go-v2/service/kms +* Version: v1.62.1 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/kms/v1.62.1/service/kms/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/service/signin + +* Name: github.com/aws/aws-sdk-go-v2/service/signin +* Version: v1.12.0 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/signin/v1.12.0/service/signin/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/service/sso + +* Name: github.com/aws/aws-sdk-go-v2/service/sso +* Version: v1.39.0 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sso/v1.39.0/service/sso/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/service/ssooidc + +* Name: github.com/aws/aws-sdk-go-v2/service/ssooidc +* Version: v1.44.0 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/ssooidc/v1.44.0/service/ssooidc/LICENSE.txt) + +## github.com/aws/aws-sdk-go-v2/service/sts + +* Name: github.com/aws/aws-sdk-go-v2/service/sts +* Version: v1.51.6 +* License: [Apache-2.0](https://github.com/aws/aws-sdk-go-v2/blob/service/sts/v1.51.6/service/sts/LICENSE.txt) + +## github.com/aws/smithy-go + +* Name: github.com/aws/smithy-go +* Version: v1.28.7 +* License: [Apache-2.0](https://github.com/aws/smithy-go/blob/v1.28.7/LICENSE) + +## github.com/aws/smithy-go/internal/sync/singleflight + +* Name: github.com/aws/smithy-go/internal/sync/singleflight +* Version: v1.28.7 +* License: [BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.28.7/internal/sync/singleflight/LICENSE) + +## github.com/aws/smithy-go/transport/http/protocol/internal/json/internal/stdlib + +* Name: github.com/aws/smithy-go/transport/http/protocol/internal/json/internal/stdlib +* Version: v1.28.7 +* License: [BSD-3-Clause](https://github.com/aws/smithy-go/blob/v1.28.7/transport/http/protocol/internal/json/internal/stdlib/LICENSE) + +## github.com/bits-and-blooms/bitset + +* Name: github.com/bits-and-blooms/bitset +* Version: v1.27.0 +* License: [BSD-3-Clause](https://github.com/bits-and-blooms/bitset/blob/v1.27.0/LICENSE) + +## github.com/cespare/xxhash/v2 + +* Name: github.com/cespare/xxhash/v2 +* Version: v2.3.0 +* License: [MIT](https://github.com/cespare/xxhash/blob/v2.3.0/LICENSE.txt) + +## github.com/consensys/gnark-crypto + +* Name: github.com/consensys/gnark-crypto +* Version: v0.22.0 +* License: [Apache-2.0](https://github.com/consensys/gnark-crypto/blob/v0.22.0/LICENSE) + +## github.com/cpuguy83/go-md2man/v2/md2man + +* Name: github.com/cpuguy83/go-md2man/v2/md2man +* Version: v2.0.7 +* License: [MIT](https://github.com/cpuguy83/go-md2man/blob/v2.0.7/LICENSE.md) + +## github.com/crate-crypto/go-eth-kzg + +* Name: github.com/crate-crypto/go-eth-kzg +* Version: v1.5.0 +* License: [Apache-2.0](https://github.com/crate-crypto/go-eth-kzg/blob/v1.5.0/LICENSE) + +## github.com/deckarep/golang-set/v2 + +* Name: github.com/deckarep/golang-set/v2 +* Version: v2.9.0 +* License: [MIT](https://github.com/deckarep/golang-set/blob/v2.9.0/LICENSE) + +## github.com/decred/dcrd/dcrec/secp256k1/v4 + +* Name: github.com/decred/dcrd/dcrec/secp256k1/v4 +* Version: v4.4.1 +* License: [ISC](https://github.com/decred/dcrd/blob/dcrec/secp256k1/v4.4.1/LICENSE) + +## github.com/ethereum/go-ethereum + +* Name: github.com/ethereum/go-ethereum +* Version: v1.17.10 +* License: [LGPL-3.0](https://github.com/ethereum/go-ethereum/blob/v1.17.10/COPYING.LESSER) +* Note: only the library packages are used, that is, everything outside cmd/, and those are LGPL-3.0. The GPL-3.0 COPYING file at the module root covers go-ethereum's own binaries, which this project does not build or distribute. + +## github.com/ethereum/go-ethereum/crypto/keccak + +* Name: github.com/ethereum/go-ethereum/crypto/keccak +* Version: v1.17.10 +* License: [BSD-3-Clause](https://github.com/ethereum/go-ethereum/blob/v1.17.10/crypto/keccak/LICENSE) + +## github.com/ethereum/go-ethereum/crypto/secp256k1 + +* Name: github.com/ethereum/go-ethereum/crypto/secp256k1 +* Version: v1.17.10 +* License: [BSD-3-Clause](https://github.com/ethereum/go-ethereum/blob/v1.17.10/crypto/secp256k1/LICENSE) +* Note: built with cgo, this package also compiles the bundled libsecp256k1 C library, which is under the [MIT licence](https://github.com/ethereum/go-ethereum/blob/v1.17.10/crypto/secp256k1/libsecp256k1/COPYING) + +## github.com/ethereum/go-ethereum/metrics + +* Name: github.com/ethereum/go-ethereum/metrics +* Version: v1.17.10 +* License: [BSD-2-Clause-FreeBSD](https://github.com/ethereum/go-ethereum/blob/v1.17.10/metrics/LICENSE) + +## github.com/fjl/jsonw + +* Name: github.com/fjl/jsonw +* Version: v0.1.0 +* License: [MIT](https://github.com/fjl/jsonw/blob/v0.1.0/LICENSE) + +## github.com/fsnotify/fsnotify + +* Name: github.com/fsnotify/fsnotify +* Version: v1.10.1 +* License: [BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.10.1/LICENSE) + +## github.com/go-jet/jet/v2 + +* Name: github.com/go-jet/jet/v2 +* Version: v2.16.1 +* License: [Apache-2.0](https://github.com/go-jet/jet/blob/v2.16.1/LICENSE) + +## github.com/go-logr/logr + +* Name: github.com/go-logr/logr +* Version: v1.4.4 +* License: [Apache-2.0](https://github.com/go-logr/logr/blob/v1.4.4/LICENSE) + +## github.com/go-logr/stdr + +* Name: github.com/go-logr/stdr +* Version: v1.2.2 +* License: [Apache-2.0](https://github.com/go-logr/stdr/blob/v1.2.2/LICENSE) + +## github.com/go-viper/mapstructure/v2 + +* Name: github.com/go-viper/mapstructure/v2 +* Version: v2.5.0 +* License: [MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE) + +## github.com/golang-migrate/migrate/v4 + +* Name: github.com/golang-migrate/migrate/v4 +* Version: v4.20.1 +* License: [MIT](https://github.com/golang-migrate/migrate/blob/v4.20.1/LICENSE) + +## github.com/google/uuid + +* Name: github.com/google/uuid +* Version: v1.6.0 +* License: [BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE) + +## github.com/gorilla/websocket + +* Name: github.com/gorilla/websocket +* Version: v1.5.3 +* License: [BSD-2-Clause](https://github.com/gorilla/websocket/blob/v1.5.3/LICENSE) + +## github.com/hashicorp/go-cleanhttp + +* Name: github.com/hashicorp/go-cleanhttp +* Version: v0.5.2 +* License: [MPL-2.0](https://github.com/hashicorp/go-cleanhttp/blob/v0.5.2/LICENSE) + +## github.com/hashicorp/go-retryablehttp + +* Name: github.com/hashicorp/go-retryablehttp +* Version: v0.7.8 +* License: [MPL-2.0](https://github.com/hashicorp/go-retryablehttp/blob/v0.7.8/LICENSE) + +## github.com/holiman/uint256 + +* Name: github.com/holiman/uint256 +* Version: v1.3.2 +* License: [BSD-3-Clause](https://github.com/holiman/uint256/blob/v1.3.2/COPYING) + +## github.com/jackc/chunkreader/v2 + +* Name: github.com/jackc/chunkreader/v2 +* Version: v2.0.1 +* License: [MIT](https://github.com/jackc/chunkreader/blob/v2.0.1/LICENSE) + +## github.com/jackc/pgconn + +* Name: github.com/jackc/pgconn +* Version: v1.14.3 +* License: [MIT](https://github.com/jackc/pgconn/blob/v1.14.3/LICENSE) + +## github.com/jackc/pgerrcode + +* Name: github.com/jackc/pgerrcode +* Version: v0.0.0-20250907135507-afb5586c32a6 +* License: [MIT](https://github.com/jackc/pgerrcode/blob/afb5586c32a6/LICENSE) + +## github.com/jackc/pgio + +* Name: github.com/jackc/pgio +* Version: v1.0.0 +* License: [MIT](https://github.com/jackc/pgio/blob/v1.0.0/LICENSE) + +## github.com/jackc/pgpassfile + +* Name: github.com/jackc/pgpassfile +* Version: v1.0.0 +* License: [MIT](https://github.com/jackc/pgpassfile/blob/v1.0.0/LICENSE) + +## github.com/jackc/pgproto3/v2 + +* Name: github.com/jackc/pgproto3/v2 +* Version: v2.3.3 +* License: [MIT](https://github.com/jackc/pgproto3/blob/v2.3.3/LICENSE) + +## github.com/jackc/pgservicefile + +* Name: github.com/jackc/pgservicefile +* Version: v0.0.0-20240606120523-5a60cdf6a761 +* License: [MIT](https://github.com/jackc/pgservicefile/blob/5a60cdf6a761/LICENSE) + +## github.com/jackc/pgtype + +* Name: github.com/jackc/pgtype +* Version: v1.14.4 +* License: [MIT](https://github.com/jackc/pgtype/blob/v1.14.4/LICENSE) + +## github.com/jackc/pgx/v4 + +* Name: github.com/jackc/pgx/v4 +* Version: v4.18.3 +* License: [MIT](https://github.com/jackc/pgx/blob/v4.18.3/LICENSE) + +## github.com/jackc/pgx/v5 + +* Name: github.com/jackc/pgx/v5 +* Version: v5.11.0 +* License: [MIT](https://github.com/jackc/pgx/blob/v5.11.0/LICENSE) + +## github.com/jackc/puddle/v2 + +* Name: github.com/jackc/puddle/v2 +* Version: v2.2.3 +* License: [MIT](https://github.com/jackc/puddle/blob/v2.2.3/LICENSE) + +## github.com/lib/pq + +* Name: github.com/lib/pq +* Version: v1.12.3 +* License: [MIT](https://github.com/lib/pq/blob/v1.12.3/LICENSE) + +## github.com/lmittmann/tint + +* Name: github.com/lmittmann/tint +* Version: v1.2.1 +* License: [MIT](https://github.com/lmittmann/tint/blob/v1.2.1/LICENSE) + +## github.com/oapi-codegen/runtime + +* Name: github.com/oapi-codegen/runtime +* Version: v1.8.0 +* License: [Apache-2.0](https://github.com/oapi-codegen/runtime/blob/v1.8.0/LICENSE) + +## github.com/pelletier/go-toml/v2 + +* Name: github.com/pelletier/go-toml/v2 +* Version: v2.4.3 +* License: [MIT](https://github.com/pelletier/go-toml/blob/v2.4.3/LICENSE) + +## github.com/russross/blackfriday/v2 + +* Name: github.com/russross/blackfriday/v2 +* Version: v2.1.0 +* License: [BSD-2-Clause](https://github.com/russross/blackfriday/blob/v2.1.0/LICENSE.txt) + +## github.com/sagikazarmark/locafero + +* Name: github.com/sagikazarmark/locafero +* Version: v0.12.0 +* License: [MIT](https://github.com/sagikazarmark/locafero/blob/v0.12.0/LICENSE) + +## github.com/shirou/gopsutil + +* Name: github.com/shirou/gopsutil +* Version: v3.21.11 +* License: [BSD-3-Clause](https://github.com/shirou/gopsutil/blob/v3.21.11/LICENSE) + +## github.com/spf13/afero + +* Name: github.com/spf13/afero +* Version: v1.15.0 +* License: [Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt) + +## github.com/spf13/cast + +* Name: github.com/spf13/cast +* Version: v1.10.0 +* License: [MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE) + +## github.com/spf13/cobra + +* Name: github.com/spf13/cobra +* Version: v1.10.2 +* License: [Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt) + +## github.com/spf13/pflag + +* Name: github.com/spf13/pflag +* Version: v1.0.10 +* License: [BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE) + +## github.com/spf13/viper + +* Name: github.com/spf13/viper +* Version: v1.21.0 +* License: [MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE) + +## github.com/stretchr/testify + +* Name: github.com/stretchr/testify +* Version: v1.12.1 +* License: [MIT](https://github.com/stretchr/testify/blob/v1.12.1/LICENSE) + +## github.com/stretchr/testify/internal/difflib + +* Name: github.com/stretchr/testify/internal/difflib +* Version: v1.12.1 +* License: [BSD-3-Clause](https://github.com/stretchr/testify/blob/v1.12.1/internal/difflib/LICENSE) + +## github.com/stretchr/testify/internal/spew + +* Name: github.com/stretchr/testify/internal/spew +* Version: v1.12.1 +* License: [ISC](https://github.com/stretchr/testify/blob/v1.12.1/internal/spew/LICENSE) + +## github.com/subosito/gotenv + +* Name: github.com/subosito/gotenv +* Version: v1.6.0 +* License: [MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE) + +## github.com/tklauser/go-sysconf + +* Name: github.com/tklauser/go-sysconf +* Version: v0.4.0 +* License: [BSD-3-Clause](https://github.com/tklauser/go-sysconf/blob/v0.4.0/LICENSE) + +## github.com/tklauser/numcpus + +* Name: github.com/tklauser/numcpus +* Version: v0.12.0 +* License: [Apache-2.0](https://github.com/tklauser/numcpus/blob/v0.12.0/LICENSE) + +## github.com/tyler-smith/go-bip39 + +* Name: github.com/tyler-smith/go-bip39 +* Version: v1.1.0 +* License: [MIT](https://github.com/tyler-smith/go-bip39/blob/v1.1.0/LICENSE) + +## go.mongodb.org/mongo-driver + +* Name: go.mongodb.org/mongo-driver +* Version: v1.17.10 +* License: [Apache-2.0](https://github.com/mongodb/mongo-go-driver/blob/v1.17.10/LICENSE) + +## go.opentelemetry.io/auto/sdk + +* Name: go.opentelemetry.io/auto/sdk +* Version: v1.2.1 +* License: [Apache-2.0](https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/sdk/v1.2.1/sdk/LICENSE) + +## go.opentelemetry.io/otel + +* Name: go.opentelemetry.io/otel +* Version: v1.47.0 +* License: [Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/v1.47.0/LICENSE) + +## go.opentelemetry.io/otel/log + +* Name: go.opentelemetry.io/otel/log +* Version: v1.47.0 +* License: [Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/log/v1.47.0/log/LICENSE) + +## go.opentelemetry.io/otel/metric + +* Name: go.opentelemetry.io/otel/metric +* Version: v1.47.0 +* License: [Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/metric/v1.47.0/metric/LICENSE) + +## go.opentelemetry.io/otel/trace + +* Name: go.opentelemetry.io/otel/trace +* Version: v1.47.0 +* License: [Apache-2.0](https://github.com/open-telemetry/opentelemetry-go/blob/trace/v1.47.0/trace/LICENSE) + +## go.yaml.in/yaml/v3 + +* Name: go.yaml.in/yaml/v3 +* Version: v3.0.5 +* License: [MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE) + +## golang.org/x/crypto + +* Name: golang.org/x/crypto +* Version: v0.58.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.58.0:LICENSE) + +## golang.org/x/sync + +* Name: golang.org/x/sync +* Version: v0.24.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.24.0:LICENSE) + +## golang.org/x/sys + +* Name: golang.org/x/sys +* Version: v0.49.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.49.0:LICENSE) + +## golang.org/x/text + +* Name: golang.org/x/text +* Version: v0.43.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.43.0:LICENSE) diff --git a/api/graphql/reader.graphql b/api/graphql/reader.graphql deleted file mode 100644 index 26f72a9cf..000000000 --- a/api/graphql/reader.graphql +++ /dev/null @@ -1,218 +0,0 @@ -"Data that can be used as proof to validate notices and execute vouchers on the base layer blockchain" -type Proof { - "Validity proof for an output" - validity: OutputValidityProof! - "Data that allows the validity proof to be contextualized within submitted claims, given as a payload in Ethereum hex binary format, starting with '0x'" - context: String! -} - -enum CompletionStatus { - UNPROCESSED - ACCEPTED - REJECTED - EXCEPTION - MACHINE_HALTED - CYCLE_LIMIT_EXCEEDED - TIME_LIMIT_EXCEEDED - PAYLOAD_LENGTH_LIMIT_EXCEEDED -} - -"Request submitted to the application to advance its state" -type Input { - "Input index starting from genesis" - index: Int! - "Status of the input" - status: CompletionStatus! - "Address responsible for submitting the input" - msgSender: String! - "Timestamp associated with the input submission, as defined by the base layer's block in which it was recorded" - timestamp: BigInt! - "Number of the base layer block in which the input was recorded" - blockNumber: BigInt! - "Input payload in Ethereum hex binary format, starting with '0x'" - payload: String! - "Get voucher from this particular input given the voucher's index" - voucher(index: Int!): Voucher! - "Get notice from this particular input given the notice's index" - notice(index: Int!): Notice! - "Get report from this particular input given the report's index" - report(index: Int!): Report! - "Get vouchers from this particular input with support for pagination" - vouchers(first: Int, last: Int, after: String, before: String): VoucherConnection! - "Get notices from this particular input with support for pagination" - notices(first: Int, last: Int, after: String, before: String): NoticeConnection! - "Get reports from this particular input with support for pagination" - reports(first: Int, last: Int, after: String, before: String): ReportConnection! -} - -"Validity proof for an output" -type OutputValidityProof { - "Local input index within the context of the related epoch" - inputIndexWithinEpoch: Int! - "Output index within the context of the input that produced it" - outputIndexWithinInput: Int! - "Merkle root of all output hashes of the related input, given in Ethereum hex binary format (32 bytes), starting with '0x'" - outputHashesRootHash: String! - "Merkle root of all voucher hashes of the related epoch, given in Ethereum hex binary format (32 bytes), starting with '0x'" - vouchersEpochRootHash: String! - "Merkle root of all notice hashes of the related epoch, given in Ethereum hex binary format (32 bytes), starting with '0x'" - noticesEpochRootHash: String! - "Hash of the machine state claimed for the related epoch, given in Ethereum hex binary format (32 bytes), starting with '0x'" - machineStateHash: String! - "Proof that this output hash is in the output-hashes merkle tree. This array of siblings is bottom-up ordered (from the leaf to the root). Each hash is given in Ethereum hex binary format (32 bytes), starting with '0x'." - outputHashInOutputHashesSiblings: [String!]! - "Proof that this output-hashes root hash is in epoch's output merkle tree. This array of siblings is bottom-up ordered (from the leaf to the root). Each hash is given in Ethereum hex binary format (32 bytes), starting with '0x'." - outputHashesInEpochSiblings: [String!]! -} - -"Representation of a transaction that can be carried out on the base layer blockchain, such as a transfer of assets" -type Voucher { - "Voucher index within the context of the input that produced it" - index: Int! - "Input whose processing produced the voucher" - input: Input! - "Transaction destination address in Ethereum hex binary format (20 bytes), starting with '0x'" - destination: String! - "Transaction payload in Ethereum hex binary format, starting with '0x'" - payload: String! - "Proof object that allows this voucher to be validated and executed on the base layer blockchain" - proof: Proof -} - -"Top level queries" -type Query { - "Get input based on its identifier" - input(index: Int!): Input! - "Get voucher based on its index" - voucher(voucherIndex: Int!, inputIndex: Int!): Voucher! - "Get notice based on its index" - notice(noticeIndex: Int!, inputIndex: Int!): Notice! - "Get report based on its index" - report(reportIndex: Int!, inputIndex: Int!): Report! - "Get inputs with support for pagination" - inputs(first: Int, last: Int, after: String, before: String, where: InputFilter): InputConnection! - "Get vouchers with support for pagination" - vouchers(first: Int, last: Int, after: String, before: String): VoucherConnection! - "Get notices with support for pagination" - notices(first: Int, last: Int, after: String, before: String): NoticeConnection! - "Get reports with support for pagination" - reports(first: Int, last: Int, after: String, before: String): ReportConnection! -} - -"Pagination entry" -type NoticeEdge { - "Node instance" - node: Notice! - "Pagination cursor" - cursor: String! -} - -"Pagination result" -type InputConnection { - "Total number of entries that match the query" - totalCount: Int! - "Pagination entries returned for the current page" - edges: [InputEdge!]! - "Pagination metadata" - pageInfo: PageInfo! -} - -"Pagination result" -type VoucherConnection { - "Total number of entries that match the query" - totalCount: Int! - "Pagination entries returned for the current page" - edges: [VoucherEdge!]! - "Pagination metadata" - pageInfo: PageInfo! -} - -"Informational statement that can be validated in the base layer blockchain" -type Notice { - "Notice index within the context of the input that produced it" - index: Int! - "Input whose processing produced the notice" - input: Input! - "Notice data as a payload in Ethereum hex binary format, starting with '0x'" - payload: String! - "Proof object that allows this notice to be validated by the base layer blockchain" - proof: Proof -} - -"Pagination entry" -type ReportEdge { - "Node instance" - node: Report! - "Pagination cursor" - cursor: String! -} - -"Pagination result" -type ReportConnection { - "Total number of entries that match the query" - totalCount: Int! - "Pagination entries returned for the current page" - edges: [ReportEdge!]! - "Pagination metadata" - pageInfo: PageInfo! -} - -"Filter object to restrict results depending on input properties" -input InputFilter { - "Filter only inputs with index lower than a given value" indexLowerThan: Int - "Filter only inputs with index greater than a given value" indexGreaterThan: Int -} - -scalar BigInt - -"Pagination result" -type NoticeConnection { - "Total number of entries that match the query" - totalCount: Int! - "Pagination entries returned for the current page" - edges: [NoticeEdge!]! - "Pagination metadata" - pageInfo: PageInfo! -} - -"Pagination entry" -type InputEdge { - "Node instance" - node: Input! - "Pagination cursor" - cursor: String! -} - -"Page metadata for the cursor-based Connection pagination pattern" -type PageInfo { - "Cursor pointing to the first entry of the page" - startCursor: String - "Cursor pointing to the last entry of the page" - endCursor: String - "Indicates if there are additional entries after the end curs" - hasNextPage: Boolean! - "Indicates if there are additional entries before the start curs" - hasPreviousPage: Boolean! -} - -"Application log or diagnostic information" -type Report { - "Report index within the context of the input that produced it" - index: Int! - "Input whose processing produced the report" - input: Input! - "Report data as a payload in Ethereum hex binary format, starting with '0x'" - payload: String! -} - -"Pagination entry" -type VoucherEdge { - "Node instance" - node: Voucher! - "Pagination cursor" - cursor: String! -} - -schema { - query: Query -} diff --git a/api/openapi/inspect.yaml b/api/openapi/inspect.yaml index d5b4e3213..07c83e201 100644 --- a/api/openapi/inspect.yaml +++ b/api/openapi/inspect.yaml @@ -2,7 +2,7 @@ openapi: 3.0.0 info: title: Inspect-state HTTP API for Cartesi Rollups - version: 0.6.0 + version: 0.7.0 license: name: Apache-2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html @@ -11,31 +11,33 @@ info: API that allows the DApp frontend to make inspect-state requests to the DApp backend. paths: - inspect/{payload}: - get: - operationId: inspect - summary: Inspect DApp state via GET + inspect/{dapp}: + post: + operationId: inspect_post + summary: Inspect DApp state via POST description: | - This method sends an inspect-state request to the DApp backend, passing the payload string in the URL. - The payload string should be URL-encoded; the inspect server will decode the string to UTF-8. - If the DApp frontend needs to pass a binary string to the backend then it is advised to use the POST method. + This POST method sends an inspect-state request to the DApp backend, using the body contents as a binary payload for the inspect method. - The response contains a status string and the reports generated by the DApp backend. - The status string can be either 'accept', 'reject', or 'exception'. - In case of exception, the field exception_payload will contain the exception payload; - Otherwise, this field will be null. + The response includes the inspection completion status, reports generated by the DApp backend, and the number of previously processed inputs. The optional `error` field contains a sanitized description when the status is `Exception` or `Failed`. For `Exception`, `exception_data` contains the raw CMIO payload supplied by the guest. - When running on machine mode, the whole Cartesi machine is rolled back after processing the inspect-state request. - On host mode, it is advised against changing the DApp backend state when processing an inspect-state request. - Notice that this method is synchronous, so it is not advised to be used for performing resource-intensive operations. + The inspect operation is executed on a temporary fork of the machine created upon request arrival, which is discarded afterward. Note that this method is synchronous and not recommended for resource-intensive operations. parameters: - in: path - name: payload + name: dapp + description: dapp name or address required: true schema: type: string + requestBody: + description: Binary payload + content: + application/octet-stream: + schema: + type: string + format: binary + responses: "200": description: Inspect state response. @@ -44,36 +46,36 @@ paths: schema: $ref: "#/components/schemas/InspectResult" - default: - description: Error response. + "404": + description: Application not found. content: text/plain: schema: $ref: "#/components/schemas/Error" - inspect: - post: - operationId: inspect_post - summary: Inspect DApp state via POST - description: | - Differently from the GET method, the POST method sends an inspect-state request to the DApp backend by passing its payload in the request body. - The payload should be a binary. - Other than that, it behaves the same way as described in the GET method. - - requestBody: - content: - application/octet-stream: - schema: - type: string - format: binary + "503": + description: | + The inspect request cannot be served right now. - responses: - "200": - description: Inspect state response. + This can happen when the application is registered but its + Cartesi Machine instance has not been initialized yet, when the + application has been foreclosed and its machine is no longer + available for live inspect requests, when application execution + has terminated, or when the application's inspect capacity is + exhausted. content: - application/json: + text/plain: schema: - $ref: "#/components/schemas/InspectResult" + $ref: "#/components/schemas/Error" + examples: + machineNotReady: + value: Machine not ready + foreclosed: + value: Application was foreclosed; machine unavailable + terminal: + value: Application is terminal; inspect unavailable + atCapacity: + value: Application inspect at capacity default: description: Error response. @@ -89,10 +91,17 @@ components: properties: status: $ref: "#/components/schemas/CompletionStatus" - exception_payload: - $ref: "#/components/schemas/Payload" + error: + type: string + description: Sanitized error description, present only when status is Exception or Failed + example: "The node could not complete the inspection" + exception_data: + allOf: + - $ref: "#/components/schemas/Payload" + description: Raw guest-provided CMIO exception payload, present only when status is Exception reports: type: array + description: Reports emitted before completion; for Failed, this may be only a partial prefix items: $ref: "#/components/schemas/Report" processed_input_count: @@ -101,22 +110,18 @@ components: example: 0 required: - status - - exception_payload - reports - processed_input_count CompletionStatus: type: string - description: Whether inspection completed or not (and why not) - enum: - [ - Accepted, - Rejected, - Exception, - MachineHalted, - CycleLimitExceeded, - TimeLimitExceeded, - ] + description: | + How inspection completed. MachineHalted means the temporary inspect + execution halted; the canonical application machine is unchanged. + Failed means the node could not complete inspection because of an + operational limit, cancellation, timeout, protocol error, or internal + failure. Reports returned with Failed may be partial. + enum: [Accepted, Rejected, Exception, MachineHalted, Failed] example: "Accepted" Payload: diff --git a/build/Dockerfile b/build/Dockerfile deleted file mode 100644 index c9a66c6bb..000000000 --- a/build/Dockerfile +++ /dev/null @@ -1,352 +0,0 @@ -# (c) Cartesi and individual authors (see AUTHORS) -# SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -# syntax=docker.io/docker/dockerfile:1 - -# This dockerfile contains multiple stages to build three final targets. -# The file was split in a section for each final target. - -# Version config that should be set in the bake file. -ARG BASE_IMAGE -ARG RUST_VERSION -ARG GO_VERSION -ARG FOUNDRY_NIGHTLY_VERSION -ARG SERVER_MANAGER_VERSION -ARG MACHINE_EMULATOR_VERSION -ARG ROOTFS_VERSION -ARG LINUX_VERSION -ARG LINUX_KERNEL_VERSION -ARG ROM_VERSION -ARG ROLLUPS_NODE_VERSION - -# Build directories. -ARG SNAPSHOT_BUILD_PATH=/build/snapshot -ARG DEVNET_BUILD_PATH=/build/devnet -ARG RUST_BUILD_PATH=/build/rollups-node/rust -ARG GO_BUILD_PATH=/build/rollups-node/go - -# Runtime dir for the cartesi-machine snapshot. -ARG SNAPSHOT_RUNTIME_PATH=/usr/share/cartesi/snapshot - -#################################################################################################### -# STAGE: emulator-base -# -# This stage creates a base-image with the Cartesi machine emulator. -# The result is used as the base for the snapshot and the node targets. -# We do this instead of using the cartesi/machine-emulator image to have control over the distro -# used by the base image. -FROM ${BASE_IMAGE} AS emulator-base - -# Install machine-emulator -ARG MACHINE_EMULATOR_VERSION -ARG DEBIAN_FRONTEND=noninteractive -RUN < echo-dapp-params.json +# Load +cartesi-rollups-cli app execution-parameters load echo-dapp < echo-dapp-params.json + +Note: Duration values can be set using time suffixes (e.g., "11s", "1m", "1h", or "1h20m0.5s"). + When using 'dump' and 'load', durations are represented in nanoseconds. + + Snapshot policy is one of: NONE, EVERY_INPUT, EVERY_EPOCH` + +const maxJSONSize = 1 << 20 // 1MB limit +const maxParamLength = 100 +const maxValueLength = 100 + +func setHelpFunc(cmd *cobra.Command) { + origHelpFunc := cmd.HelpFunc() + newHelpFunc := func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + } + cmd.SetHelpFunc(newHelpFunc) +} + +func init() { + // Add subcommands + Cmd.AddCommand(getCmd) + Cmd.AddCommand(setCmd) + Cmd.AddCommand(listCmd) + Cmd.AddCommand(dumpCmd) + Cmd.AddCommand(loadCmd) + + setHelpFunc(getCmd) + setHelpFunc(setCmd) + setHelpFunc(listCmd) + setHelpFunc(dumpCmd) + setHelpFunc(loadCmd) + +} + +func run(cmd *cobra.Command, _ []string) { + // If no subcommand is provided, show help + err := cmd.Help() + cobra.CheckErr(err) +} + +// getCmd represents the get command +var getCmd = &cobra.Command{ + Use: "get [application] [parameter]", + Short: "Get a specific configuration parameter", + Args: cobra.ExactArgs(2), //nolint:mnd + Run: runGet, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +// setCmd represents the set command +var setCmd = &cobra.Command{ + Use: "set [application] [parameter] [value]", + Short: "Set a specific configuration parameter", + Args: cobra.ExactArgs(3), //nolint:mnd + Run: runSet, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +// listCmd represents the list command +var listCmd = &cobra.Command{ + Use: "list [application]", + Short: "List all configuration parameters", + Args: cobra.ExactArgs(1), + Run: runList, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +// dumpCmd represents the list command +var dumpCmd = &cobra.Command{ + Use: "dump [application]", + Short: "Dump execution parameters as a JSON object", + Args: cobra.ExactArgs(1), + Run: runDump, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +// loadCmd represents the load command +var loadCmd = &cobra.Command{ + Use: "load [application]", + Short: "Load configuration from stdin", + Args: cobra.ExactArgs(1), + Run: runLoad, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +func runGet(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + parameter := strings.TrimSpace(args[1]) + + // Validate parameter name length + if len(parameter) > maxParamLength { + cobra.CheckErr(fmt.Errorf("parameter name exceeds maximum allowed length of %d characters", maxParamLength)) + } + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + app, err := repo.GetApplication(ctx, nameOrAddress) + cobra.CheckErr(err) + if app == nil { + fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + } + + params, err := repo.GetExecutionParameters(ctx, app.ID) + cobra.CheckErr(err) + + value, err := getParameterValue(params, parameter) + cobra.CheckErr(err) + + fmt.Println(value) +} + +func runSet(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + parameter := strings.TrimSpace(args[1]) + value := strings.TrimSpace(args[2]) + + // Validate parameter name length + if len(parameter) > maxParamLength { + cobra.CheckErr(fmt.Errorf("parameter name exceeds maximum allowed length of %d characters", maxParamLength)) + } + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + app, err := repo.GetApplication(ctx, nameOrAddress) + cobra.CheckErr(err) + if app == nil { + fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + } + + params, err := repo.GetExecutionParameters(ctx, app.ID) + cobra.CheckErr(err) + + err = setParameterValue(params, parameter, value) + cobra.CheckErr(err) + + err = params.Validate() + cobra.CheckErr(err) + + params.UpdatedAt = time.Now() + err = repo.UpdateExecutionParameters(ctx, params) + cobra.CheckErr(err) + + fmt.Printf("Parameter %s updated successfully for %s\n", parameter, app.Name) +} + +func runList(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + app, err := repo.GetApplication(ctx, nameOrAddress) + cobra.CheckErr(err) + if app == nil { + fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + } + + params, err := repo.GetExecutionParameters(ctx, app.ID) + cobra.CheckErr(err) + + printParameters(params) +} + +func runDump(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + app, err := repo.GetApplication(ctx, nameOrAddress) + cobra.CheckErr(err) + if app == nil { + fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + } + + params, err := repo.GetExecutionParameters(ctx, app.ID) + cobra.CheckErr(err) + + jsonData, err := json.MarshalIndent(params, "", " ") + cobra.CheckErr(err) + fmt.Println(string(jsonData)) +} + +func runLoad(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + app, err := repo.GetApplication(ctx, nameOrAddress) + cobra.CheckErr(err) + if app == nil { + fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + } + + // Read JSON from stdin with size limit to prevent memory exhaustion + lr := &io.LimitedReader{ + R: os.Stdin, + N: maxJSONSize, + } + data, err := io.ReadAll(lr) + if err != nil { + cobra.CheckErr(err) + } + if lr.N == 0 { + cobra.CheckErr(fmt.Errorf("input exceeds maximum allowed size of %d bytes", maxJSONSize)) + } + + var params model.ExecutionParameters + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() // Prevent unexpected fields + err = decoder.Decode(¶ms) + cobra.CheckErr(err) + + // Validate the loaded parameters + if err := params.Validate(); err != nil { + cobra.CheckErr(err) + } + + // Set the application ID + params.ApplicationID = app.ID + + err = repo.UpdateExecutionParameters(ctx, ¶ms) + cobra.CheckErr(err) + + fmt.Printf("Configuration loaded successfully for %s\n", app.Name) +} + +func getParameterValue(params *model.ExecutionParameters, parameter string) (string, error) { + parameter = strings.ToLower(parameter) + switch parameter { + case "snapshot_policy": + return string(params.SnapshotPolicy), nil + case "advance_inc_cycles": + return fmt.Sprintf("%d", params.AdvanceIncCycles), nil + case "advance_max_cycles": + return fmt.Sprintf("%d", params.AdvanceMaxCycles), nil + case "inspect_inc_cycles": + return fmt.Sprintf("%d", params.InspectIncCycles), nil + case "inspect_max_cycles": + return fmt.Sprintf("%d", params.InspectMaxCycles), nil + case "advance_inc_deadline": + return params.AdvanceIncDeadline.String(), nil + case "advance_max_deadline": + return params.AdvanceMaxDeadline.String(), nil + case "inspect_inc_deadline": + return params.InspectIncDeadline.String(), nil + case "inspect_max_deadline": + return params.InspectMaxDeadline.String(), nil + case "load_deadline": + return params.LoadDeadline.String(), nil + case "store_deadline": + return params.StoreDeadline.String(), nil + case "fast_deadline": + return params.FastDeadline.String(), nil + case "max_concurrent_inspects": + return fmt.Sprintf("%d", params.MaxConcurrentInspects), nil + default: + return "", fmt.Errorf("unknown parameter: %s", parameter) + } +} + +func setParameterValue(params *model.ExecutionParameters, parameter, value string) error { + // Limit input size to prevent potential DoS + if len(value) > maxValueLength { + return fmt.Errorf("parameter value exceeds maximum allowed length of %d characters", maxValueLength) + } + + parameter = strings.ToLower(parameter) + switch parameter { + case "snapshot_policy": + value = strings.ToUpper(value) + switch model.SnapshotPolicy(value) { + case model.SnapshotPolicy_None, model.SnapshotPolicy_EveryInput, model.SnapshotPolicy_EveryEpoch: + params.SnapshotPolicy = model.SnapshotPolicy(value) + default: + return fmt.Errorf("invalid snapshot policy: %s. Valid values are: NONE, EVERY_INPUT, EVERY_EPOCH", value) + } + case "advance_inc_cycles": + val, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for advance_inc_cycles: %w", err) + } + params.AdvanceIncCycles = val + case "advance_max_cycles": + val, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for advance_max_cycles: %w", err) + } + params.AdvanceMaxCycles = val + case "inspect_inc_cycles": + val, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for inspect_inc_cycles: %w", err) + } + params.InspectIncCycles = val + case "inspect_max_cycles": + val, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for inspect_max_cycles: %w", err) + } + params.InspectMaxCycles = val + case "advance_inc_deadline": + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid value for advance_inc_deadline: %w", err) + } + params.AdvanceIncDeadline = duration + case "advance_max_deadline": + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid value for advance_max_deadline: %w", err) + } + params.AdvanceMaxDeadline = duration + case "inspect_inc_deadline": + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid value for inspect_inc_deadline: %w", err) + } + params.InspectIncDeadline = duration + case "inspect_max_deadline": + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid value for inspect_max_deadline: %w", err) + } + params.InspectMaxDeadline = duration + case "load_deadline": + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid value for load_deadline: %w", err) + } + params.LoadDeadline = duration + case "store_deadline": + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid value for store_deadline: %w", err) + } + params.StoreDeadline = duration + case "fast_deadline": + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid value for fast_deadline: %w", err) + } + params.FastDeadline = duration + case "max_concurrent_inspects": + val, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return fmt.Errorf("invalid value for max_concurrent_inspects: %w", err) + } + params.MaxConcurrentInspects = uint32(val) + default: + return fmt.Errorf("unknown parameter: %s", parameter) + } + return nil +} + +func printParameters(params *model.ExecutionParameters) { + writeParameters(os.Stdout, params) +} + +func writeParameters(w io.Writer, params *model.ExecutionParameters) { + fmt.Fprintf(w, "snapshot_policy: %s\n", params.SnapshotPolicy) + fmt.Fprintf(w, "advance_inc_cycles: %d\n", params.AdvanceIncCycles) + fmt.Fprintf(w, "advance_max_cycles: %d\n", params.AdvanceMaxCycles) + fmt.Fprintf(w, "inspect_inc_cycles: %d\n", params.InspectIncCycles) + fmt.Fprintf(w, "inspect_max_cycles: %d\n", params.InspectMaxCycles) + fmt.Fprintf(w, "advance_inc_deadline: %s\n", params.AdvanceIncDeadline) + fmt.Fprintf(w, "advance_max_deadline: %s\n", params.AdvanceMaxDeadline) + fmt.Fprintf(w, "inspect_inc_deadline: %s\n", params.InspectIncDeadline) + fmt.Fprintf(w, "inspect_max_deadline: %s\n", params.InspectMaxDeadline) + fmt.Fprintf(w, "load_deadline: %s\n", params.LoadDeadline) + fmt.Fprintf(w, "store_deadline: %s\n", params.StoreDeadline) + fmt.Fprintf(w, "fast_deadline: %s\n", params.FastDeadline) + fmt.Fprintf(w, "max_concurrent_inspects: %d\n", params.MaxConcurrentInspects) +} diff --git a/cmd/cartesi-rollups-cli/root/app/execution-parameters/execution_parameters_test.go b/cmd/cartesi-rollups-cli/root/app/execution-parameters/execution_parameters_test.go new file mode 100644 index 000000000..b261e81a1 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/execution-parameters/execution_parameters_test.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package execution + +import ( + "bytes" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +func TestCycleMaximumParameters(t *testing.T) { + params := &model.ExecutionParameters{} + require.NoError(t, setParameterValue(params, "advance_max_cycles", "123")) + require.NoError(t, setParameterValue(params, "inspect_max_cycles", "456")) + value, err := getParameterValue(params, "advance_max_cycles") + require.NoError(t, err) + require.Equal(t, "123", value) + value, err = getParameterValue(params, "inspect_max_cycles") + require.NoError(t, err) + require.Equal(t, "456", value) +} + +func TestWriteParametersIncludesCycleMaximums(t *testing.T) { + params := &model.ExecutionParameters{ + AdvanceIncCycles: 11, AdvanceMaxCycles: 12, + InspectIncCycles: 21, InspectMaxCycles: 22, + } + var output bytes.Buffer + writeParameters(&output, params) + + require.Contains(t, output.String(), "advance_inc_cycles: 11") + require.Contains(t, output.String(), "advance_max_cycles: 12") + require.Contains(t, output.String(), "inspect_inc_cycles: 21") + require.Contains(t, output.String(), "inspect_max_cycles: 22") +} diff --git a/cmd/cartesi-rollups-cli/root/app/list/list.go b/cmd/cartesi-rollups-cli/root/app/list/list.go new file mode 100644 index 000000000..9614362dc --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/list/list.go @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package list //nolint:revive // The package implements the CLI's "list" subcommand. + +import ( + "encoding/json" + "fmt" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "list", + Short: "Lists all applications", + Example: examples, + Run: run, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +const examples = `# List all registered applications: +cartesi-rollups-cli app list` + +func init() { + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + applications, _, err := repo.ListApplications(ctx, repository.ApplicationFilter{}, repository.Pagination{}, false) + cobra.CheckErr(err) + + if applications == nil { + applications = []*model.Application{} + } + + result, err := json.MarshalIndent(applications, "", " ") + cobra.CheckErr(err) + + fmt.Println(string(result)) +} diff --git a/cmd/cartesi-rollups-cli/root/app/register/register.go b/cmd/cartesi-rollups-cli/root/app/register/register.go new file mode 100644 index 000000000..d15266d43 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/register/register.go @@ -0,0 +1,448 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package register + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "strings" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var Cmd = &cobra.Command{ + Use: "register", + Short: "Register an existing application on the node", + Example: examples, + Run: run, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED Enable machine hash check`, +} + +const examples = `# Adds an application to Rollups Node: +cartesi-rollups-cli app register -n echo-dapp -a 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF -t applications/echo-dapp` + +var ( + name string + applicationAddress string + consensusAddress string + templatePath string + templateHash string + epochLength uint64 + claimStagingPeriod uint64 + enableMachineHashCheck bool + applicationTypePRT bool + disabled bool + printAsJSON bool + executionParametersFileParam string +) + +func init() { + Cmd.Flags().StringVarP(&name, "name", "n", "", "Application name") + cobra.CheckErr(Cmd.MarkFlagRequired("name")) + + Cmd.Flags().StringVarP(&applicationAddress, "address", "a", "", "Application contract address") + cobra.CheckErr(Cmd.MarkFlagRequired("address")) + + Cmd.Flags().StringVarP(&consensusAddress, "consensus", "c", "", + "Application IConsensus Address. (DO NOT USE IN PRODUCTION)\nThis value is retrieved from the application contract", + ) + + Cmd.Flags().StringVarP(&templatePath, "template-path", "t", "", "Application template URI") + cobra.CheckErr(Cmd.MarkFlagRequired("template-path")) + + Cmd.Flags().StringVarP(&templateHash, "template-hash", "H", "", + "Application template hash. (DO NOT USE IN PRODUCTION)\nThis value is retrieved from the application contract", + ) + + Cmd.Flags().Uint64VarP(&epochLength, "epoch-length", "e", 0, + "Consensus Epoch length. (DO NOT USE IN PRODUCTION)\nThis value is retrieved from the consensus contract", + ) + + Cmd.Flags().Uint64Var(&claimStagingPeriod, "claim-staging-period", 0, + "Consensus claim staging period in blocks. "+ + "(DO NOT USE IN PRODUCTION)\nThis value is retrieved from the consensus contract", + ) + + Cmd.Flags().BoolVarP(&disabled, "disabled", "d", false, "Registers the application with enabled=false") + + Cmd.Flags().BoolVarP(&printAsJSON, "print-json", "j", false, "Prints the application data as JSON") + + Cmd.Flags().StringVarP(&executionParametersFileParam, "execution-parameters-file", "", "", + "JSON encoded execution parameters of the application. Default values will be used if not defined.") + + Cmd.Flags().BoolVar(&enableMachineHashCheck, "machine-hash-check", true, + "Enable or disable machine hash check (DO NOT DISABLE IN PRODUCTION)") + cobra.CheckErr(viper.BindPFlag(config.FEATURE_MACHINE_HASH_CHECK_ENABLED, Cmd.Flags().Lookup("machine-hash-check"))) + + Cmd.Flags().BoolVarP(&applicationTypePRT, "prt", "", false, "Register as PRT application.") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + command.Flags().Lookup("blockchain-http-endpoint").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + + validName, err := config.ToApplicationNameFromString(name) + cobra.CheckErr(err) + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + applicationEnabled := !disabled + + address := common.HexToAddress(applicationAddress) + + var parsedTemplateHash common.Hash + if cmd.Flags().Changed("template-hash") { + parsedTemplateHash = common.HexToHash(templateHash) + } else { + contractTemplateHash, err := getTemplateHash(ctx, address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get template hash from application: %v\n", err) + os.Exit(1) + } + parsedTemplateHash = *contractTemplateHash + checkEnabled, err := config.GetFeatureMachineHashCheckEnabled() + cobra.CheckErr(err) + if checkEnabled { + snapshotTemplateHash, err := util.ReadRootHash(templatePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Read machine template hash failed: %v\n", err) + os.Exit(1) + } + if parsedTemplateHash != snapshotTemplateHash { + fmt.Fprintf(os.Stderr, "Template hash mismatch: contract has %s but machine has %s\n", + parsedTemplateHash.Hex(), snapshotTemplateHash.Hex()) + os.Exit(1) + } + } + } + + var consensus common.Address + if cmd.Flags().Changed("consensus") { + consensus = common.HexToAddress(consensusAddress) + } else { + consensus, err = getConsensus(ctx, address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get consensus address from application: %v\n", + cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + os.Exit(1) + } + } + + if !cmd.Flags().Changed("epoch-length") && !applicationTypePRT { + epochLength, err = getEpochLength(ctx, consensus) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get epoch length from consensus: %v\n", + cli.DecorateRevert(err, iconsensus.IConsensusMetaData)) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + } + } + + if !cmd.Flags().Changed("claim-staging-period") { + claimStagingPeriod, err = getClaimStagingPeriod(ctx, consensus) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get claim staging period from consensus: %v\n", + cli.DecorateRevert(err, iconsensus.IConsensusMetaData)) + os.Exit(1) + } + } + + withdrawalConfig, err := readApplicationWithdrawalConfig(ctx, address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read withdrawal config from application: %v\n", + cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + os.Exit(1) + } + + inputBoxAddress, err := getInputBox(ctx, address) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get input box address from application: %v\n", + cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + os.Exit(1) + } + + block, err := getInputBoxDeploymentBlock(ctx, inputBoxAddress) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get deployment block number: %v\n", err) + os.Exit(1) + } + if !block.IsUint64() { + fmt.Fprintf(os.Stderr, "Input box deployment block does not fit uint64: %v\n", block) + os.Exit(1) + } + inputBoxBlockNumber := block.Uint64() + + // ensure there is a contract deployed at the input box address + hasCode, err := hasCodeAt(ctx, inputBoxAddress) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to probe input box address for contract: %v\n", err) + os.Exit(1) + } + if !hasCode { + fmt.Fprintf(os.Stderr, "input box address has no code: %v\n", inputBoxAddress) + os.Exit(1) + } + + consensusType, err := getConsensusType(ctx, consensus, applicationTypePRT) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to detect consensus type: %v\n", err) + os.Exit(1) + } + + application := model.Application{ + Name: validName, + IApplicationAddress: address, + IConsensusAddress: consensus, + IInputBoxAddress: inputBoxAddress, + TemplateURI: templatePath, + TemplateHash: parsedTemplateHash, + EpochLength: epochLength, + ClaimStagingPeriod: claimStagingPeriod, + WithdrawalConfig: withdrawalConfig, + ConsensusType: consensusType, + Enabled: applicationEnabled, + Status: model.ApplicationStatus_OK, + IInputBoxBlock: inputBoxBlockNumber, + LastEpochCheckBlock: 0, + LastInputCheckBlock: 0, + LastOutputCheckBlock: 0, + LastTournamentCheckBlock: 0, + LastForecloseCheckBlock: 0, + LastAccountsDriveProvedCheckBlock: 0, + LastWithdrawalCheckBlock: 0, + } + + // load execution parameters from a file? + withExecutionParameters := cmd.Flags().Changed("execution-parameters-file") + if withExecutionParameters { + filePath := executionParametersFileParam + if executionParametersFileParam == "-" { + filePath = os.Stdin.Name() + } + contents, err := os.ReadFile(filePath) //nolint:gosec // The CLI user explicitly supplies this path. + cobra.CheckErr(err) + + decoder := json.NewDecoder(strings.NewReader(string(contents))) + decoder.DisallowUnknownFields() // Prevent unexpected fields + err = decoder.Decode(&application.ExecutionParameters) + cobra.CheckErr(err) + cobra.CheckErr(application.ExecutionParameters.Validate()) + } + + _, err = repo.CreateApplication(ctx, &application, withExecutionParameters) + cobra.CheckErr(err) + + if printAsJSON { + jsonData, err := json.Marshal(application) + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshalling application to JSON: %v\n", err) + os.Exit(1) + } + fmt.Println(string(jsonData)) + } else { + fmt.Printf("Application %v successfully registered\n", application.IApplicationAddress) + } +} + +func getTemplateHash( + ctx context.Context, + appAddress common.Address, +) (*common.Hash, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetTemplateHash(ctx, client, appAddress) +} + +func hasCodeAt( + ctx context.Context, + consensusAddress common.Address, +) (bool, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return false, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return false, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + bytes, err := client.CodeAt(ctx, consensusAddress, nil) + if err != nil { + return false, fmt.Errorf("failed to retrieve code at consensus address: %s", consensusAddress) + } + return len(bytes) != 0, nil +} + +func getConsensus( + ctx context.Context, + appAddress common.Address, +) (common.Address, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return common.Address{}, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return common.Address{}, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetConsensus(ctx, client, appAddress) +} + +func getInputBox( + ctx context.Context, + appAddress common.Address, +) (common.Address, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return common.Address{}, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return common.Address{}, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetInputBox(ctx, client, appAddress) +} + +func getEpochLength( + ctx context.Context, + consensusAddr common.Address, +) (uint64, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return 0, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return 0, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetEpochLength(ctx, client, consensusAddr) +} + +func getClaimStagingPeriod( + ctx context.Context, + consensusAddr common.Address, +) (uint64, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return 0, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return 0, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetClaimStagingPeriod(ctx, client, consensusAddr) +} + +type quorumConsensusProbe interface { + NumOfValidators(opts *bind.CallOpts) (*big.Int, error) +} + +func getConsensusType( + ctx context.Context, + consensusAddr common.Address, + applicationTypePRT bool, +) (model.Consensus, error) { + if applicationTypePRT { + return model.Consensus_PRT, nil + } + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return "", fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return "", fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + quorum, err := iquorum.NewIQuorum(consensusAddr, client) + if err != nil { + return "", err + } + return consensusTypeFromQuorumProbe(applicationTypePRT, quorum) +} + +func consensusTypeFromQuorumProbe( + applicationTypePRT bool, + probe quorumConsensusProbe, +) (model.Consensus, error) { + if applicationTypePRT { + return model.Consensus_PRT, nil + } + numOfValidators, err := probe.NumOfValidators(nil) + if err != nil { + return model.Consensus_Authority, nil + } + if numOfValidators == nil || numOfValidators.Sign() == 0 { + return "", fmt.Errorf("quorum consensus reports zero validators") + } + return model.Consensus_Quorum, nil +} + +func readApplicationWithdrawalConfig( + ctx context.Context, + appAddr common.Address, +) (model.WithdrawalConfig, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return model.WithdrawalConfig{}, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return model.WithdrawalConfig{}, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + wc, err := ethutil.GetApplicationWithdrawalConfig(ctx, client, appAddr) + if err != nil { + return model.WithdrawalConfig{}, err + } + return model.WithdrawalConfig(wc), nil +} + +func getInputBoxDeploymentBlock( + ctx context.Context, + inputBoxAddress common.Address, +) (*big.Int, error) { + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return nil, fmt.Errorf("failed to get blockchain http endpoint address: %w", err) + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to connect to the blockchain http endpoint: %s", ethEndpoint) + } + return ethutil.GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) +} diff --git a/cmd/cartesi-rollups-cli/root/app/register/register_test.go b/cmd/cartesi-rollups-cli/root/app/register/register_test.go new file mode 100644 index 000000000..5720161cd --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/register/register_test.go @@ -0,0 +1,78 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package register + +import ( + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/require" +) + +type quorumConsensusProbeStub struct { + numOfValidators *big.Int + err error + called bool +} + +func (p *quorumConsensusProbeStub) NumOfValidators(_ *bind.CallOpts) (*big.Int, error) { + p.called = true + return p.numOfValidators, p.err +} + +func TestConsensusTypeFromQuorumProbe_PRTSkipsProbe(t *testing.T) { + probe := &quorumConsensusProbeStub{ + numOfValidators: big.NewInt(3), + } + + consensusType, err := consensusTypeFromQuorumProbe(true, probe) + + require.NoError(t, err) + require.Equal(t, model.Consensus_PRT, consensusType) + require.False(t, probe.called) +} + +func TestConsensusTypeFromQuorumProbe_AuthorityWhenProbeFails(t *testing.T) { + probe := &quorumConsensusProbeStub{ + err: errors.New("execution reverted"), + } + + consensusType, err := consensusTypeFromQuorumProbe(false, probe) + + require.NoError(t, err) + require.Equal(t, model.Consensus_Authority, consensusType) + require.True(t, probe.called) +} + +func TestConsensusTypeFromQuorumProbe_QuorumWhenValidatorsExist(t *testing.T) { + probe := &quorumConsensusProbeStub{ + numOfValidators: big.NewInt(3), + } + + consensusType, err := consensusTypeFromQuorumProbe(false, probe) + + require.NoError(t, err) + require.Equal(t, model.Consensus_Quorum, consensusType) + require.True(t, probe.called) +} + +func TestConsensusTypeFromQuorumProbe_RejectsZeroValidatorQuorum(t *testing.T) { + probe := &quorumConsensusProbeStub{ + numOfValidators: big.NewInt(0), + } + + _, err := consensusTypeFromQuorumProbe(false, probe) + + require.ErrorContains(t, err, "zero validators") + require.True(t, probe.called) +} + +func TestRegisterCommandDoesNotExposeLegacyInputBoxFlags(t *testing.T) { + require.Nil(t, Cmd.Flags().Lookup("data-availability")) + require.Nil(t, Cmd.Flags().Lookup("inputbox-from-env")) + require.Nil(t, Cmd.Flags().Lookup("inputbox-block-number")) +} diff --git a/cmd/cartesi-rollups-cli/root/app/remove/remove.go b/cmd/cartesi-rollups-cli/root/app/remove/remove.go new file mode 100644 index 000000000..4a9fc0d39 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/remove/remove.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package remove + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "remove [app-name-or-address]", Aliases: []string{"rm"}, Args: cobra.ExactArgs(1), + Short: "Remove registered applications", RunE: run, + Long: `Remove a disabled application and its stored data. +Use --allow-abandonment to acknowledge that removal stops protection and alerts +and abandons outstanding work. Transactions already sent or in progress may still +execute. --yes skips only the prompt. The command does not inspect the blockchain +for remaining obligations. The database connection uses CARTESI_DATABASE_CONNECTION.`, + Example: `cartesi-rollups-cli app remove echo-dapp --allow-abandonment +cartesi-rollups-cli app remove echo-dapp --allow-abandonment --yes --timeout 10m`, +} + +const defaultRemovalTimeout = 10 * time.Minute + +var yesFlag, allowAbandonment bool +var removalTimeout time.Duration + +func init() { + Cmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Skip the confirmation prompt") + Cmd.Flags().BoolVar(&allowAbandonment, "allow-abandonment", false, "Acknowledge abandoned work, protection and alerts") + Cmd.Flags().DurationVar(&removalTimeout, "timeout", defaultRemovalTimeout, "Timeout for database access and deletion") + origHelp := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelp(command, args) + }) +} + +func checkAcknowledgment() error { + if !allowAbandonment { + return fmt.Errorf("use --allow-abandonment to acknowledge abandoned work, protection and alerts") + } + return nil +} + +func run(cmd *cobra.Command, args []string) error { + if removalTimeout <= 0 { + return fmt.Errorf("--timeout must be greater than zero") + } + if err := checkAcknowledgment(); err != nil { + return err + } + name, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return err + } + // Bound startup and lookup independently of operator time at the prompt. + ctx, cancel := context.WithTimeout(cmd.Context(), removalTimeout) + repo, err := cli.OpenRepository(ctx, dsn) + if err != nil { + cancel() + return err + } + defer repo.Close() + app, err := repo.GetApplication(ctx, name) + cancel() + if err != nil { + return err + } + if app == nil { + return repository.ErrNotFound + } + if app.Enabled { + return fmt.Errorf("application %s must be disabled before removal", app.Name) + } + fmt.Fprintln(cmd.OutOrStdout(), "Removal cannot be undone. The node stops protection and alerts for this application "+ + "and abandons outstanding work. Transactions already sent or in progress may still execute.") + if !yesFlag { + confirmed, promptErr := cli.ConfirmPrompt(fmt.Sprintf("Remove application %s (%s)?", app.Name, app.IApplicationAddress)) + if promptErr != nil { + return promptErr + } + if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Operation cancelled") + return nil + } + } + slog.Info("Application removal requested with abandonment acknowledged", "application", app.Name, + "address", app.IApplicationAddress) + deleteCtx, deleteDone := context.WithTimeout(cmd.Context(), removalTimeout) + defer deleteDone() + if err = repo.DeleteApplication(deleteCtx, app.ID); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Application %s (%s) successfully removed\n", app.Name, app.IApplicationAddress) + return nil +} diff --git a/cmd/cartesi-rollups-cli/root/app/remove/remove_test.go b/cmd/cartesi-rollups-cli/root/app/remove/remove_test.go new file mode 100644 index 000000000..f995e761b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/remove/remove_test.go @@ -0,0 +1,28 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package remove + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRemovalPromptSkipDoesNotAcknowledgeAbandonment(t *testing.T) { + oldYes, oldAllow := yesFlag, allowAbandonment + t.Cleanup(func() { yesFlag, allowAbandonment = oldYes, oldAllow }) + for _, skipPrompt := range []bool{false, true} { + yesFlag, allowAbandonment = skipPrompt, false + require.ErrorContains(t, checkAcknowledgment(), "--allow-abandonment") + allowAbandonment = true + require.NoError(t, checkAcknowledgment()) + } +} + +func TestRemovalRequiresAcknowledgmentBeforeOpeningDatabase(t *testing.T) { + oldAllow, oldTimeout := allowAbandonment, removalTimeout + t.Cleanup(func() { allowAbandonment, removalTimeout = oldAllow, oldTimeout }) + allowAbandonment, removalTimeout = false, defaultRemovalTimeout + require.ErrorContains(t, run(Cmd, []string{"echo-dapp"}), "--allow-abandonment") +} diff --git a/cmd/cartesi-rollups-cli/root/app/remove/run_test.go b/cmd/cartesi-rollups-cli/root/app/remove/run_test.go new file mode 100644 index 000000000..45176fd6a --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/remove/run_test.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package remove + +import ( + "bytes" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func removalRunRepository(t *testing.T) repository.Repository { + t.Helper() + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + unlock, err := db.LockTestPostgres(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(unlock) + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := postgres.NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + + // Removal must not need an RPC endpoint or submission registration. + for key, value := range map[string]string{ + config.DATABASE_CONNECTION: endpoint, + config.BLOCKCHAIN_HTTP_ENDPOINT: "", + config.BLOCKCHAIN_HTTP_ENDPOINT_FILE: "", + } { + previous := viper.Get(key) + t.Cleanup(func() { viper.Set(key, previous) }) + viper.Set(key, value) + } + for name, value := range map[string]string{"yes": "true", "allow-abandonment": "false", "timeout": "5s"} { + flag := Cmd.Flags().Lookup(name) + previous, changed := flag.Value.String(), flag.Changed + t.Cleanup(func() { + require.NoError(t, flag.Value.Set(previous)) + flag.Changed = changed + }) + require.NoError(t, Cmd.Flags().Set(name, value)) + } + return repo +} + +func TestRunRemovalWithYesRequiresRiskAcknowledgment(t *testing.T) { + // These tests mutate the command flags and Viper configuration, so they must + // stay sequential even though each repository connection is independent. + repo := removalRunRepository(t) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT). + WithEnabled(false).Create(t.Context(), t, repo) + before, err := repo.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.NotNil(t, before) + var output bytes.Buffer + command := &cobra.Command{} + command.SetContext(t.Context()) + command.SetOut(&output) + + err = run(command, []string{app.Name}) + require.ErrorContains(t, err, "--allow-abandonment") + require.Empty(t, output.String()) + after, err := repo.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, before, after, "--yes must leave the risky application intact") + + require.NoError(t, Cmd.Flags().Set("allow-abandonment", "true")) + output.Reset() + require.NoError(t, run(command, []string{app.Name})) + require.Contains(t, output.String(), "Transactions already sent or in progress may still execute") + require.Contains(t, output.String(), "successfully removed") + after, err = repo.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Nil(t, after, "explicit abandonment must reach the actual database deletion") +} + +func TestRunRemovalWithAbandonmentStillRejectsEnabledApplication(t *testing.T) { + repo := removalRunRepository(t) + app := repotest.NewApplicationBuilder().WithEnabled(true).Create(t.Context(), t, repo) + before, err := repo.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.NotNil(t, before) + require.NoError(t, Cmd.Flags().Set("allow-abandonment", "true")) + var output bytes.Buffer + command := &cobra.Command{} + command.SetContext(t.Context()) + command.SetOut(&output) + + require.ErrorContains(t, run(command, []string{app.Name}), "must be disabled before removal") + require.Empty(t, output.String()) + after, err := repo.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, before, after, "neither confirmation flag can remove an enabled application") +} diff --git a/cmd/cartesi-rollups-cli/root/app/status/status.go b/cmd/cartesi-rollups-cli/root/app/status/status.go new file mode 100644 index 000000000..072c335f9 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/status/status.go @@ -0,0 +1,164 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package status + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" +) + +var yesFlag bool + +var Cmd = &cobra.Command{ + Use: "status [app-name-or-address] [new-status]", + Short: "Display application status or set the enabled flag", + Example: examples, + Args: cobra.RangeArgs(1, 2), //nolint:mnd + Run: run, + Long: ` +FAILED stops local processing and foreclosure work. Repair the failure cause +and verify snapshot/database consistency before you enable the application. +Enabling clears FAILED. It does not repair state or cancel foreclosure. + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +const examples = `# Get application status: +cartesi-rollups-cli app status echo-dapp + +# Set application status: +cartesi-rollups-cli app status echo-dapp enabled +cartesi-rollups-cli app status echo-dapp disabled + +# After repairing the cause, re-enable a FAILED application without a prompt: +cartesi-rollups-cli app status echo-dapp enabled --yes` + +func init() { + Cmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Skip confirmation prompts") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + app, err := repo.GetApplication(ctx, nameOrAddress) + cobra.CheckErr(err) + if app == nil { + fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. + } + + // If no new status is provided, display the current status, operator + // enabled flag, and reason. + // Foreclose / drive-prove markers (zero == not observed) are surfaced + // so operators and integration tests can detect post-foreclosure + // progress without going through the JSON-RPC API. + if len(args) == 1 { + fmt.Println(app.Status) + fmt.Printf("Enabled: %t\n", app.Enabled) + if app.Reason != nil && *app.Reason != "" { + fmt.Printf("Reason: %s\n", *app.Reason) + } + if app.ForecloseBlock != 0 { + fmt.Printf("Foreclose block: 0x%x\n", app.ForecloseBlock) + if app.Status == model.ApplicationStatus_Failed { + fmt.Println("Foreclosure work is blocked by FAILED. Repair the cause before enabling the application.") + } + if app.ForecloseTransaction != nil { + fmt.Printf("Foreclose transaction: %s\n", app.ForecloseTransaction.Hex()) + } + } + if app.AccountsDriveProvedBlock != 0 { + fmt.Printf("Accounts drive proved block: 0x%x\n", app.AccountsDriveProvedBlock) + if app.AccountsDriveMerkleRoot != nil { + fmt.Printf("Accounts drive merkle root: %s\n", app.AccountsDriveMerkleRoot.Hex()) + } + } + repo.Close() + os.Exit(0) + } + + // Handle status change + newStatus := strings.ToLower(args[1]) + + var targetEnabled bool + switch newStatus { + case "enabled", "enable": + targetEnabled = true + case "disabled", "disable": + targetEnabled = false + default: + fmt.Fprintf(os.Stderr, "Error: Invalid status %q. Valid values are 'enabled' or 'disabled'\n", newStatus) + repo.Close() + os.Exit(1) + } + + if !targetEnabled && !app.Enabled { + fmt.Printf("Application %s enabled flag is already %t\n", app.Name, app.Enabled) + repo.Close() + os.Exit(0) + } + + // Re-enabling a FAILED application clears the failure status and requires + // confirmation because processing may restart from the last snapshot. + clearFailed := app.Status == model.ApplicationStatus_Failed && targetEnabled + if clearFailed && !yesFlag { + fmt.Printf("Application %q has FAILED status.\n", app.Name) + if app.Reason != nil { + fmt.Printf("Reason: %s\n", *app.Reason) + } + fmt.Println("Re-enabling will attempt to restart processing from the last snapshot.") + fmt.Println("Repair the failure cause and verify snapshot/database consistency before you proceed.") + confirmed, err := cli.ConfirmPrompt("Proceed?") + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) + repo.Close() + os.Exit(1) + } + if !confirmed { + fmt.Println("Aborted.") + repo.Close() + os.Exit(0) + } + } + + // Show failure reason when changing status away from FAILED. + if app.Status == model.ApplicationStatus_Failed && app.Reason != nil && *app.Reason != "" { + fmt.Printf("Previous failure reason: %s\n", *app.Reason) + } + + if targetEnabled { + err = repo.EnableApplicationAndResetDeployment(ctx, app.ID, clearFailed) + } else { + err = repo.UpdateApplicationEnabled(ctx, app.ID, targetEnabled) + } + cobra.CheckErr(err) + + fmt.Printf("Application %s enabled flag updated to %t\n", app.Name, targetEnabled) +} diff --git a/cmd/cartesi-rollups-cli/root/app/status/status_test.go b/cmd/cartesi-rollups-cli/root/app/status/status_test.go new file mode 100644 index 000000000..952bcc545 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/app/status/status_test.go @@ -0,0 +1,169 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package status + +import ( + "context" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +const enabledStatus = "enabled" + +func statusRepository(t *testing.T) (repository.Repository, string) { + t.Helper() + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + unlock, err := db.LockTestPostgres(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(unlock) + require.NoError(t, db.SetupTestPostgres(endpoint)) + r, err := postgres.NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(r.Close) + previous := viper.Get(config.DATABASE_CONNECTION) + viper.Set(config.DATABASE_CONNECTION, endpoint) + t.Cleanup(func() { viper.Set(config.DATABASE_CONNECTION, previous) }) + originalContext, originalYes := Cmd.Context(), yesFlag + yesFlag = false + t.Cleanup(func() { Cmd.SetContext(originalContext); Cmd.SetArgs(nil); yesFlag = originalYes }) + return r, endpoint +} + +func TestExplicitEnableResetsAlreadyEnabledHealthyApplication(t *testing.T) { + r, _ := statusRepository(t) + for _, spelling := range []string{enabledStatus, "enable"} { + t.Run(spelling, func(t *testing.T) { + app := repotest.NewApplicationBuilder().WithEnabled(true).Create(t.Context(), t, r) + key := repository.DeploymentIdentity{ApplicationID: app.ID, ApplicationAddress: app.IApplicationAddress, + ConsensusAddress: app.IConsensusAddress} + _, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key, + ConfirmedTier: new(model.DeploymentTierTable), Reason: "old confirmed table"}) + require.NoError(t, err) + // Execute the actual Cobra dispatch. No replacement repository or command seam is used. + Cmd.SetArgs([]string{app.Name, spelling}) + require.NoError(t, Cmd.ExecuteContext(t.Context())) + state, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 1, state.ResetGeneration) + require.Nil(t, state.ConfirmedTier) + got, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.True(t, got.Enabled) + require.Equal(t, model.ApplicationStatus_OK, got.Status) + Cmd.SetArgs([]string{app.Name, enabledStatus}) + require.NoError(t, Cmd.ExecuteContext(t.Context())) + state, err = r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 2, state.ResetGeneration) + _, err = r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key, + ResetGeneration: state.ResetGeneration, ConfirmedTier: new(model.DeploymentTierGeneration), + Reason: "preserve across disable"}) + require.NoError(t, err) + before, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + Cmd.SetArgs([]string{app.Name, "disabled"}) + require.NoError(t, Cmd.ExecuteContext(t.Context())) + after, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } +} + +func TestExplicitEnablePreservesFailureNotObservedByCLI(t *testing.T) { + for _, skipPrompt := range []bool{false, true} { + name := "without yes" + if skipPrompt { + name = "with yes" + } + t.Run(name, func(t *testing.T) { + r, endpoint := statusRepository(t) + app := repotest.NewApplicationBuilder().WithEnabled(false).Create(t.Context(), t, r) + key := repository.DeploymentIdentity{ApplicationID: app.ID, ApplicationAddress: app.IApplicationAddress, + ConsensusAddress: app.IConsensusAddress} + _, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key, + ConfirmedTier: new(model.DeploymentTierTable), Reason: "old table"}) + require.NoError(t, err) + control, err := pgx.Connect(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, control.Close(context.Background())) }) + failure, err := control.Begin(t.Context()) + require.NoError(t, err) + t.Cleanup(func() { _ = failure.Rollback(context.Background()) }) + _, err = failure.Exec(t.Context(), "UPDATE application SET status='FAILED',reason=$2 WHERE id=$1", app.ID, "new failure") + require.NoError(t, err) + args := []string{app.Name, enabledStatus} + if skipPrompt { + args = append(args, "--yes") + } + Cmd.SetArgs(args) + done := make(chan error, 1) + go func() { done <- Cmd.ExecuteContext(t.Context()) }() + // The uncommitted failure is invisible to the CLI's read. Its enable then waits for this transaction. + var waiting bool + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); { + _, err := failure.Exec(t.Context(), "SELECT pg_stat_clear_snapshot()") + require.NoError(t, err) + err = failure.QueryRow(t.Context(), `SELECT EXISTS(SELECT 1 FROM pg_stat_activity + WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)) + AND query LIKE '%UPDATE public.application%')`, control.PgConn().PID()).Scan(&waiting) + require.NoError(t, err) + if waiting { + break + } + time.Sleep(10 * time.Millisecond) + } + require.True(t, waiting, "CLI enable must wait after reading the old healthy status") + require.NoError(t, failure.Commit(t.Context())) + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("CLI enable did not finish after the failure committed") + } + got, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.True(t, got.Enabled) + require.Equal(t, model.ApplicationStatus_Failed, got.Status) + require.Equal(t, new("new failure"), got.Reason) + state, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 1, state.ResetGeneration) + require.Nil(t, state.ConfirmedTier) + require.Nil(t, state.DeploymentReason) + }) + } +} + +func TestExplicitEnableClearsObservedFailureWithYes(t *testing.T) { + r, _ := statusRepository(t) + for _, enabled := range []bool{false, true} { + app := repotest.NewApplicationBuilder().WithEnabled(enabled).Create(t.Context(), t, r) + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Failed, new("repaired failure"))) + Cmd.SetArgs([]string{app.Name, "enable", "--yes"}) + require.NoError(t, Cmd.ExecuteContext(t.Context())) + got, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.True(t, got.Enabled) + require.Equal(t, model.ApplicationStatus_OK, got.Status) + require.Nil(t, got.Reason) + state, err := r.GetDeploymentAdmissionState(t.Context(), repository.DeploymentIdentity{ApplicationID: app.ID, + ApplicationAddress: app.IApplicationAddress, ConsensusAddress: app.IConsensusAddress}) + require.NoError(t, err) + require.EqualValues(t, 1, state.ResetGeneration) + } +} diff --git a/cmd/cartesi-rollups-cli/root/contract/app.go b/cmd/cartesi-rollups-cli/root/contract/app.go new file mode 100644 index 000000000..23b4536e2 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/app.go @@ -0,0 +1,196 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" +) + +var appCmd = &cobra.Command{ + Use: "app ", + Short: "Query application contract state (IApplication)", + Args: cobra.ExactArgs(1), + RunE: runApp, +} + +func runApp(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + result, err := cc.queryApp() + if err != nil { + return err + } + + if jsonParam { + return outputJSON(result) + } + printApp(result, cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} + +// queryApp reads all IApplication view functions and returns an AppResult. +func (c *chainClient) queryApp() (*AppResult, error) { + if err := c.ensureContract(c.appAddr, "application"); err != nil { + return nil, err + } + + app, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IApplication: %w", err) + } + + templateHash, err := app.GetTemplateHash(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetTemplateHash: %w", err) + } + + owner, err := app.Owner(c.callOpts) + if err != nil { + return nil, fmt.Errorf("owner: %w", err) + } + + deploymentBlockRaw, err := app.GetDeploymentBlockNumber(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetDeploymentBlockNumber: %w", err) + } + deploymentBlock, err := safeUint64(deploymentBlockRaw, "deployment block") + if err != nil { + return nil, err + } + + executedOutputsRaw, err := app.GetNumberOfExecutedOutputs(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetNumberOfExecutedOutputs: %w", err) + } + executedOutputs, err := safeUint64(executedOutputsRaw, "executed outputs") + if err != nil { + return nil, err + } + + consensusAddr, err := app.GetOutputsMerkleRootValidator(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetOutputsMerkleRootValidator: %w", err) + } + + inputBox, err := app.GetInputBox(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetInputBox: %w", err) + } + + isForeclosed, err := app.IsForeclosed(c.callOpts) + if err != nil { + return nil, fmt.Errorf("IsForeclosed: %w", err) + } + + wc, err := ethutil.GetApplicationWithdrawalConfig(c.callOpts.Context, c.eth, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetApplicationWithdrawalConfig: %w", err) + } + + // Detect consensus type for display. + consensusLabel := consensusUnknown.String() + if err := c.ensureContract(consensusAddr, "consensus"); err == nil { + var detectErr error + var cType consensusType + var contractVersion string + cType, contractVersion, detectErr = c.detectConsensus(consensusAddr) + if detectErr != nil { + slog.Warn("failed to detect consensus type", "error", detectErr) + } + consensusLabel = cType.String() + if contractVersion != "" { + consensusLabel += fmt.Sprintf(" (IConsensus %s)", contractVersion) + } + } + + return &AppResult{ + Address: formatAddr(c.appAddr), + Owner: formatAddr(owner), + TemplateHash: formatHash(templateHash), + DeploymentBlock: deploymentBlock, + ExecutedOutputs: executedOutputs, + ConsensusAddress: formatAddr(consensusAddr), + ConsensusType: consensusLabel, + InputBox: formatAddr(inputBox), + IsForeclosed: isForeclosed, + Guardian: formatAddr(wc.Guardian), + WithdrawalOutputBuilder: formatAddr(wc.WithdrawalOutputBuilder), + Log2LeavesPerAccount: wc.Log2LeavesPerAccount, + Log2MaxNumOfAccounts: wc.Log2MaxNumOfAccounts, + AccountsDriveStartIndex: wc.AccountsDriveStartIndex, + }, nil +} + +func printApp(r *AppResult, blockNum, chainID, blockTime uint64) { + p := &printer{w: os.Stdout} + p.withSection(fmt.Sprintf("Application %s", r.Address), func() { + printAppFields(p, r) + }) + p.footer(blockNum, chainID, blockTime) +} + +// printAppFields renders the body of the Application section. Shared by the +// standalone "contract app" command and "contract summary". +func printAppFields(p *printer, r *AppResult) { + p.field("Template Hash", r.TemplateHash) + p.field("Owner", r.Owner) + p.field("Deployment Block", fmt.Sprintf("%d", r.DeploymentBlock)) + p.field("Executed Outputs", fmt.Sprintf("%d", r.ExecutedOutputs)) + p.field("Consensus", fmt.Sprintf("%s (%s)", r.ConsensusAddress, r.ConsensusType)) + p.field("InputBox", r.InputBox) + p.field("Foreclosed", formatBool(r.IsForeclosed)) + // WithdrawalConfig is logically grouped — a zero guardian means + // no foreclosure was configured on deploy, so other fields are + // meaningless and we condense the display. + if r.Guardian == formatAddr(common.Address{}) { + p.field("WithdrawalConfig", "(disabled — no foreclosure)") + } else { + p.field("Guardian", r.Guardian) + p.field("Withdrawal Output Builder", r.WithdrawalOutputBuilder) + p.field("Log2 Leaves Per Account", fmt.Sprintf("%d", r.Log2LeavesPerAccount)) + p.field("Log2 Max Num of Accounts", fmt.Sprintf("%d", r.Log2MaxNumOfAccounts)) + p.field("Accounts Drive Start Index", + fmt.Sprintf("%d", r.AccountsDriveStartIndex)) + } +} + +// formatBool renders a bool as a short human-readable string. +func formatBool(b bool) string { + if b { + return "yes" + } + return "no" +} + +func outputJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// getConsensusAddress is a helper that reads the consensus address from the app contract. +func (c *chainClient) getConsensusAddress() (common.Address, error) { + app, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return common.Address{}, fmt.Errorf("bind IApplication: %w", err) + } + addr, err := app.GetOutputsMerkleRootValidator(c.callOpts) + if err != nil { + return common.Address{}, fmt.Errorf("GetOutputsMerkleRootValidator: %w", err) + } + return addr, nil +} diff --git a/cmd/cartesi-rollups-cli/root/contract/commitment.go b/cmd/cartesi-rollups-cli/root/contract/commitment.go new file mode 100644 index 000000000..19c97413f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/commitment.go @@ -0,0 +1,115 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "fmt" + "os" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" +) + +var commitmentCmd = &cobra.Command{ + Use: "commitment ", + Short: "Read a commitment's on-chain state from the current tournament", + Args: cobra.ExactArgs(2), //nolint:mnd + RunE: runCommitment, +} + +func runCommitment(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + if err := validateHash(args[1], "commitment hash"); err != nil { + return err + } + commitmentHash := common.HexToHash(args[1]) + + // Resolve tournament address using the same logic as the tournament subcommand. + res, err := cc.resolveTournamentAddress(args[:1]) // only pass app addr + if err != nil { + return fmt.Errorf("resolve tournament: %w", err) + } + tournamentAddr := res.addr + + if err := cc.ensureContract(tournamentAddr, "tournament"); err != nil { + return err + } + + caller, err := itournament.NewITournamentCaller(tournamentAddr, cc.eth) + if err != nil { + return fmt.Errorf("bind ITournament: %w", err) + } + + commitment, err := caller.CommitmentStanding(cc.callOpts, [32]byte(commitmentHash)) + if err != nil { + return fmt.Errorf("CommitmentStanding: %w", err) + } + + descriptor, err := caller.TournamentDescriptor(cc.callOpts) + if err != nil { + return fmt.Errorf("TournamentDescriptor: %w", err) + } + maxLevel, err := cc.tournamentMaxLevel() + if err != nil { + return err + } + if descriptor.Level > maxLevel { + return fmt.Errorf("tournament level %d exceeds maximum level %d", descriptor.Level, maxLevel) + } + + blocksRemaining := uint64(0) + if commitment.ClockRunning && commitment.ClockDeadline > cc.blockNum { + blocksRemaining = commitment.ClockDeadline - cc.blockNum + } + claimer := "" + if commitment.Claimer != (common.Address{}) { + claimer = formatAddr(commitment.Claimer) + } + + result := &CommitmentResult{ + Commitment: formatHash([32]byte(commitmentHash)), + Tournament: formatAddr(tournamentAddr), + TournamentLevel: fmt.Sprintf("%d/%d (%s)", descriptor.Level, maxLevel, tournamentLevelName(descriptor.Level, descriptor.Kind)), + Joined: commitment.Joined, + Claimer: claimer, + ClockRunning: commitment.ClockRunning, + ClockAllowance: commitment.ClockAllowance, + ClockDeadline: commitment.ClockDeadline, + BlocksRemaining: blocksRemaining, + FinalMachineHash: formatHash(commitment.FinalState), + } + + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + p.withSection(fmt.Sprintf("Commitment %s", result.Commitment), func() { + p.field("Tournament", + fmt.Sprintf("%s (level %s)", result.Tournament, result.TournamentLevel)) + p.field("Joined", formatBool(result.Joined)) + if result.Claimer != "" { + p.field("Claimer", result.Claimer) + } + p.field("Clock Running", formatBool(result.ClockRunning)) + p.field("Clock Allowance", + fmt.Sprintf("%d blocks (raw allowance)", result.ClockAllowance)) + if result.ClockRunning { + p.field("Clock Deadline", fmt.Sprintf("block %d (inclusive)", result.ClockDeadline)) + p.field("Blocks Remaining", fmt.Sprintf("%d", result.BlocksRemaining)) + } else { + p.field("Clock Deadline", "paused") + } + p.field("Final Machine Hash", result.FinalMachineHash) + }) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} diff --git a/cmd/cartesi-rollups-cli/root/contract/consensus.go b/cmd/cartesi-rollups-cli/root/contract/consensus.go new file mode 100644 index 000000000..dea3c849d --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/consensus.go @@ -0,0 +1,183 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "fmt" + "os" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" +) + +var consensusCmd = &cobra.Command{ + Use: "consensus ", + Short: "Query consensus contract state (auto-detects Authority/Quorum/DaveConsensus)", + Args: cobra.ExactArgs(1), + RunE: runConsensus, +} + +func runConsensus(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + consensusAddr, err := cc.getConsensusAddress() + if err != nil { + return err + } + + if err := cc.ensureContract(consensusAddr, "consensus"); err != nil { + return err + } + + cType, contractVersion, err := cc.detectConsensus(consensusAddr) + if err != nil { + return err + } + + switch cType { + case consensusAuthority: + return cc.printAuthority(consensusAddr, contractVersion) + case consensusQuorum: + return cc.printQuorum(consensusAddr, contractVersion) + case consensusDave: + return cc.printDave(consensusAddr) + case consensusUnknown: + return fmt.Errorf("unknown consensus type at %s", consensusAddr) + } + return fmt.Errorf("unknown consensus type at %s", consensusAddr) +} + +func (c *chainClient) printAuthority(addr common.Address, contractVersion string) error { + result, err := c.queryAuthority(addr, contractVersion) + if err != nil { + return err + } + + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + p.withSection(fmt.Sprintf("Authority %s", result.Address), func() { + p.field("Owner (Validator)", result.Owner) + p.field("Epoch Length", fmt.Sprintf("%d blocks", result.EpochLength)) + p.field("Claim Staging Period", fmt.Sprintf("%d blocks", result.ClaimStagingPeriod)) + p.field("Accepted Claims", fmt.Sprintf("%d", result.AcceptedClaims)) + p.field("Staged Claims", fmt.Sprintf("%d", result.StagedClaims)) + p.field("IConsensus Version", result.ContractVersion) + }) + p.footer(c.blockNum, c.chainID, c.resolveTimestamp(c.blockNum)) + return nil +} + +func (c *chainClient) printQuorum(addr common.Address, contractVersion string) error { + result, err := c.queryQuorum(addr, contractVersion) + if err != nil { + return err + } + + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + p.withSection(fmt.Sprintf("Quorum %s", result.Address), func() { + p.field("Validators", fmt.Sprintf("%d", result.NumValidators)) + p.field("Quorum Threshold", + fmt.Sprintf("%d (computed: strict majority)", result.QuorumThreshold)) + p.field("Epoch Length", fmt.Sprintf("%d blocks", result.EpochLength)) + p.field("Claim Staging Period", fmt.Sprintf("%d blocks", result.ClaimStagingPeriod)) + p.field("Accepted Claims", fmt.Sprintf("%d", result.AcceptedClaims)) + p.field("Staged Claims", fmt.Sprintf("%d", result.StagedClaims)) + if result.ContractVersion != "" { + p.field("IConsensus Version", result.ContractVersion) + } + for i, v := range result.Validators { + p.field(fmt.Sprintf(" Validator #%d", i+1), v) + } + }) + p.footer(c.blockNum, c.chainID, c.resolveTimestamp(c.blockNum)) + return nil +} + +func (c *chainClient) printDave(addr common.Address) error { + result, err := c.queryDave(addr) + if err != nil { + return err + } + + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + p.withSection(fmt.Sprintf("DaveConsensus %s", result.Address), func() { + p.field("Deployment Block", fmt.Sprintf("%d", result.DeploymentBlock)) + p.field("InputBox", result.InputBox) + p.field("Tournament Factory", result.Factory) + printTournamentFinished(p, result) + p.field("Current Sealed Epoch", fmt.Sprintf("%d", result.CurrentEpochNumber)) + p.field("Input Range", + fmt.Sprintf("[%d, %d)", result.InputLowerBound, result.InputUpperBound)) + p.field("Root Tournament", result.RootTournament) + printDaveSentries(p, result) + printDaveStaging(p, result) + }) + p.footer(c.blockNum, c.chainID, c.resolveTimestamp(c.blockNum)) + return nil +} + +func printDaveSentries(p *printer, r *DaveConsensusResult) { + p.field("Sentry Manager", r.SentryManager) + p.field("Sentries", fmt.Sprintf("%d", r.NumSentries)) + for _, sentry := range r.Sentries { + p.field(fmt.Sprintf(" Sentry #%d", sentry.ID), sentry.Address) + } +} + +// printTournamentFinished renders the IsFinished field and distinguishes a +// winner from a failed tournament. A failed tournament has no result to stage. +func printTournamentFinished(p *printer, r *DaveConsensusResult) { + if !r.IsFinished { + p.field("Tournament Finished", "no") + return + } + if r.IsTournamentFailed { + p.field("Tournament Finished", "yes (NO WINNER — all commitments eliminated)") + return + } + if r.WinnerCommitment != "" { + p.field("Tournament Finished", + fmt.Sprintf("yes (winner: %s)", r.WinnerCommitment)) + if r.WinnerPostEpochMachineHash != "" { + p.field("Winner Machine Hash", r.WinnerPostEpochMachineHash) + } + return + } + p.field("Tournament Finished", "yes") +} + +func printDaveStaging(p *printer, r *DaveConsensusResult) { + p.field("Claim Staging Period", fmt.Sprintf("%d blocks", r.ClaimStagingPeriod)) + p.field("Tournament Result Staged", formatBool(r.IsTournamentResultStaged)) + if !r.IsTournamentResultStaged { + return + } + if r.StagingBlock != nil { + p.field("Staging Block", fmt.Sprintf("%d", *r.StagingBlock)) + } + p.field("Staged Machine Hash", r.StagedMachineHash) + p.field("Staged Outputs Root", r.StagedOutputsMerkleRoot) + if r.AllSentriesAgree != nil { + p.field("All Sentries Agree", formatBool(*r.AllSentriesAgree)) + } + if r.ClaimStagingPeriodOver != nil { + p.field("Staging Period Over", formatBool(*r.ClaimStagingPeriodOver)) + } +} diff --git a/cmd/cartesi-rollups-cli/root/contract/contract.go b/cmd/cartesi-rollups-cli/root/contract/contract.go new file mode 100644 index 000000000..5b8acd526 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/contract.go @@ -0,0 +1,440 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "context" + "encoding/hex" + "fmt" + "log/slog" + "math/big" + "strings" + "sync" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/cobra" +) + +var ( + blockParam string + jsonParam bool + timeoutParam string + timestampsParam bool +) + +// Cmd is the parent `contract` cobra command. +var Cmd = &cobra.Command{ + Use: "contract", + Short: "[Experimental] Read on-chain contract state directly via eth_call and eth_getLogs", + Long: ` +Read-only diagnostic tool that queries Cartesi Ethereum smart contracts directly. +No database required — only an RPC endpoint and an application address. + +Experimental: +This tool is experimental and may produce inaccurate results. Values are read +directly from on-chain contracts and may not reflect finalized state. Verify +critical information independently before acting on it. + +Supported Environment Variables: + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum RPC endpoint URL`, + Run: runContract, +} + +func init() { + Cmd.PersistentFlags().StringVar(&blockParam, "block", "latest", + "Query view functions at this block number (default: latest, pinned at startup)") + Cmd.PersistentFlags().BoolVar(&jsonParam, "json", false, + "Machine-readable JSON output") + Cmd.PersistentFlags().StringVar(&timeoutParam, "timeout", "30s", + "Context deadline for the entire command execution") + Cmd.PersistentFlags().BoolVar(×tampsParam, "timestamps", false, + "Resolve block numbers to wall-clock times (extra RPC calls)") + + // Unhide root persistent flags in the contract command's help. + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + if f := command.Flags().Lookup("blockchain-http-endpoint"); f != nil { + f.Hidden = false + } + origHelpFunc(command, args) + }) + + Cmd.AddCommand(appCmd) + Cmd.AddCommand(consensusCmd) + Cmd.AddCommand(inputboxCmd) + Cmd.AddCommand(tournamentCmd) + Cmd.AddCommand(epochCmd) + Cmd.AddCommand(summaryCmd) + Cmd.AddCommand(outputCmd) + Cmd.AddCommand(commitmentCmd) + Cmd.AddCommand(matchCmd) +} + +func runContract(cmd *cobra.Command, _ []string) { + err := cmd.Help() + cobra.CheckErr(err) +} + +// consensusType represents the detected on-chain consensus mechanism. +type consensusType int + +const ( + consensusUnknown consensusType = iota + consensusAuthority // IAuthority (single validator, Ownable) + consensusQuorum // IQuorum (multi-validator, majority threshold) + consensusDave // IDaveConsensus (PRT) +) + +func (t consensusType) String() string { + switch t { + case consensusAuthority: + return "Authority" + case consensusQuorum: + return "Quorum" + case consensusDave: + return "DaveConsensus (PRT)" + case consensusUnknown: + return "Unknown" + } + return "Unknown" +} + +// ERC-165 interface IDs for consensus type detection. +// Solidity's type(I).interfaceId XORs only functions DEFINED in I, excluding inherited ones. +var ( + // IDataProvider: provideMerkleRootOfInput(uint256,bytes) + iDataProviderInterfaceID = [4]byte{0x7a, 0x96, 0xf4, 0x80} + // IConsensus interface IDs by version (own functions only, excluding inherited + // isOutputsMerkleRootValid). Checked in order; first match wins. + // v3.0.0-alpha: computed at init from the binding's ABI to stay in lockstep + // with the contract — see computeIConsensusV3InterfaceID. + iConsensusInterfaceIDv30 = computeIConsensusV3InterfaceID() + // v2.2.0: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims ^ getNumberOfSubmittedClaims + iConsensusInterfaceIDv220 = [4]byte{0x90, 0xb2, 0xf3, 0x46} + // v2.1.x: submitClaim ^ getEpochLength ^ getNumberOfAcceptedClaims (no getNumberOfSubmittedClaims) + iConsensusInterfaceIDv21x = [4]byte{0x7e, 0xec, 0xfc, 0xec} + // IQuorum: own 7 functions (excluding inherited IConsensus). Signatures (types) + // are identical across v2.1.x / v2.2.0 / v3 — only Solidity param NAMES changed, + // which do not affect the selector. + iQuorumInterfaceID = [4]byte{0x3c, 0x92, 0x5a, 0x62} +) + +// computeIConsensusV3InterfaceID returns the ERC-165 interface ID of v3 IConsensus. +// Per Solidity's `type(I).interfaceId`, the ID is the XOR of selectors of the +// functions DECLARED in IConsensus (8 of them); inherited functions +// (`isOutputsMerkleRootValid` from IOutputsMerkleRootValidator, `version` from +// IVersionGetter, IApplicationChecker which has no functions) are excluded. +// Computed at package init from the binding's embedded ABI so a contract-level +// rename or signature change is automatically reflected. +func computeIConsensusV3InterfaceID() [4]byte { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(fmt.Errorf("computeIConsensusV3InterfaceID: parse ABI: %w", err)) + } + // Methods declared in IConsensus.sol (v3), excluding inherited functions. + methodNames := []string{ + "submitClaim", + "acceptClaim", + "getEpochLength", + "getClaimStagingPeriod", + "getNumberOfAcceptedClaims", + "getNumberOfStagedClaims", + "getNumberOfSubmittedClaims", + "getClaim", + } + var id [4]byte + for _, name := range methodNames { + m, ok := parsed.Methods[name] + if !ok { + panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q not found in IConsensus ABI", name)) + } + if len(m.ID) != 4 { //nolint:mnd // ABI method selectors are exactly four bytes. + panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q selector is %d bytes, expected 4", name, len(m.ID))) + } + for i := range 4 { + id[i] ^= m.ID[i] + } + } + return id +} + +// chainClient holds the shared Ethereum client and call options for all subcommands. +// All view functions are called through this client to ensure consistent block-number +// queries. The block number is ALWAYS pinned to a concrete value (never nil/latest). +type chainClient struct { + eth *ethclient.Client + callOpts *bind.CallOpts + filter ethutil.Filter + appAddr common.Address + chainID uint64 + blockNum uint64 + + // tsCache caches block number → timestamp lookups for --timestamps. + tsMu sync.RWMutex + tsCache map[uint64]uint64 +} + +func newChainClient( + ctx context.Context, + endpoint string, + appAddr common.Address, + block *big.Int, +) (*chainClient, error) { + client, err := ethutil.DialEthClient(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("connect to RPC endpoint: %w", err) + } + success := false + defer func() { + if !success { + client.Close() + } + }() + + chainIDRaw, err := client.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("get chain ID: %w", err) + } + chainID, err := safeUint64(chainIDRaw, "chain ID") + if err != nil { + return nil, err + } + + var blockNum uint64 + if block != nil && block.Sign() >= 0 { + blockNum, err = safeUint64(block, "block number") + if err != nil { + return nil, err + } + } else { + // block is nil ("latest") or negative ("safe", "finalized"). + // Resolve to a concrete block number via the RPC. + header, hErr := client.HeaderByNumber(ctx, block) + if hErr != nil { + return nil, fmt.Errorf("resolve block tag: %w", hErr) + } + blockNum = header.Number.Uint64() + } + pinnedBlock := new(big.Int).SetUint64(blockNum) + + success = true + return &chainClient{ + eth: client, + callOpts: &bind.CallOpts{ + Context: ctx, + BlockNumber: pinnedBlock, + }, + filter: ethutil.Filter{ + Logger: slog.Default(), + }, + appAddr: appAddr, + chainID: chainID, + blockNum: blockNum, + tsCache: make(map[uint64]uint64), + }, nil +} + +// resolveTimestamp returns the timestamp for a block number, using a cache. +// Returns 0 if --timestamps is not enabled or the lookup fails. +func (c *chainClient) resolveTimestamp(blockNum uint64) uint64 { + if !timestampsParam { + return 0 + } + // Check cache under read lock first to avoid holding the write lock + // during a blocking RPC call. + c.tsMu.RLock() + if ts, ok := c.tsCache[blockNum]; ok { + c.tsMu.RUnlock() + return ts + } + c.tsMu.RUnlock() + + header, err := c.eth.HeaderByNumber( + c.callOpts.Context, new(big.Int).SetUint64(blockNum)) + if err != nil { + slog.Warn("failed to resolve timestamp", "block", blockNum, "error", err) + return 0 + } + + c.tsMu.Lock() + c.tsCache[blockNum] = header.Time + c.tsMu.Unlock() + return header.Time +} + +// ensureContract verifies the address has deployed code at the pinned block. +func (c *chainClient) ensureContract(addr common.Address, label string) error { + code, err := c.eth.CodeAt(c.callOpts.Context, addr, c.callOpts.BlockNumber) + if err != nil { + return fmt.Errorf("check contract code at %s: %w", addr, err) + } + if len(code) == 0 { + return fmt.Errorf( + "no contract deployed at %s (%s) — wrong address or wrong chain?", + addr, label) + } + return nil +} + +// iConsensusVersion pairs an ERC-165 interface ID with a human-readable contract version label. +type iConsensusVersion struct { + id [4]byte + version string +} + +var iConsensusVersions = []iConsensusVersion{ + {iConsensusInterfaceIDv30, "v3.0.0-alpha"}, + {iConsensusInterfaceIDv220, "v2.2.0"}, + {iConsensusInterfaceIDv21x, "v2.1.x"}, +} + +// detectConsensus uses ERC-165 supportsInterface to determine the consensus type. +// Checks IDataProvider → Dave, IQuorum → Quorum, IConsensus → Authority. +// The returned contractVersion is non-empty only for Authority/Quorum, indicating which +// IConsensus interface version was matched. +func (c *chainClient) detectConsensus( + consensusAddr common.Address, +) (consensusType, string, error) { + caller, err := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) + if err != nil { + return consensusUnknown, "", fmt.Errorf("bind consensus contract: %w", err) + } + + isDave, err := caller.SupportsInterface(c.callOpts, iDataProviderInterfaceID) + if err != nil { + return consensusUnknown, "", fmt.Errorf("supportsInterface(IDataProvider): %w", err) + } + if isDave { + return consensusDave, "", nil + } + + isQuorum, err := caller.SupportsInterface(c.callOpts, iQuorumInterfaceID) + if err != nil { + return consensusUnknown, "", fmt.Errorf("supportsInterface(IQuorum): %w", err) + } + if isQuorum { + // Quorum is also an IConsensus; probe the current IConsensus interface + // to surface the contract version. Older Quorum versions (pre-v3) report + // empty and the caller renders the label without the version suffix. + isCurrent, err := caller.SupportsInterface(c.callOpts, iConsensusInterfaceIDv30) + if err != nil { + return consensusUnknown, "", fmt.Errorf( + "supportsInterface(IConsensus v3.0.0-alpha) for Quorum: %w", err) + } + if isCurrent { + return consensusQuorum, "v3.0.0-alpha", nil + } + return consensusQuorum, "", nil + } + + for _, cv := range iConsensusVersions { + isConsensus, err := caller.SupportsInterface(c.callOpts, cv.id) + if err != nil { + return consensusUnknown, "", fmt.Errorf("supportsInterface(IConsensus): %w", err) + } + if isConsensus { + return consensusAuthority, cv.version, nil + } + } + + return consensusUnknown, "", fmt.Errorf( + "consensus at %s does not implement IDataProvider, IQuorum, or IConsensus"+ + " — wrong address or unsupported consensus type", consensusAddr) +} + +// safeUint64 converts a *big.Int to uint64, returning an error if the value is nil or overflows. +func safeUint64(v *big.Int, field string) (uint64, error) { + if v == nil { + return 0, fmt.Errorf("%s: nil value", field) + } + if !v.IsUint64() { + return 0, fmt.Errorf("%s value %s exceeds uint64 range", field, v.String()) + } + return v.Uint64(), nil +} + +// parseBlockFlag parses the --block flag value into a *big.Int. +// Returns nil for "latest". Returns negative big.Int for named tags +// ("safe", "finalized") using go-ethereum's rpc.BlockNumber constants. +// The caller resolves all non-positive values to a concrete block number. +func parseBlockFlag(s string) (*big.Int, error) { + switch s { + case "", "latest": + return nil, nil + case "safe": + return big.NewInt(int64(rpc.SafeBlockNumber)), nil + case "finalized": + return big.NewInt(int64(rpc.FinalizedBlockNumber)), nil + } + n, ok := new(big.Int).SetString(s, 0) + if !ok || n.Sign() < 0 { + return nil, fmt.Errorf("invalid block number: %q (use a number, \"latest\", \"safe\", or \"finalized\")", s) + } + return n, nil +} + +// validateHash checks that s is a valid 32-byte hex hash (with or without 0x prefix). +func validateHash(s, label string) error { + h := strings.TrimPrefix(s, "0x") + if len(h) != 64 { //nolint:mnd + return fmt.Errorf("invalid %s: expected 32-byte hex (66 chars with 0x prefix), got %q", label, s) + } + if _, err := hex.DecodeString(h); err != nil { + return fmt.Errorf("invalid %s: %w", label, err) + } + return nil +} + +// initChainClient creates a chainClient from the command flags and arguments. +func initChainClient(cmd *cobra.Command, args []string) (*chainClient, context.CancelFunc, error) { + if len(args) < 1 { + return nil, nil, fmt.Errorf("application address argument is required") + } + + appAddrStr := args[0] + if !common.IsHexAddress(appAddrStr) { + return nil, nil, fmt.Errorf("invalid application address: %q", appAddrStr) + } + appAddr := common.HexToAddress(appAddrStr) + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return nil, nil, fmt.Errorf("blockchain endpoint: %w", err) + } + + block, err := parseBlockFlag(blockParam) + if err != nil { + return nil, nil, err + } + + ctx := cmd.Context() + + var cancel context.CancelFunc + if timeoutParam != "" { + dur, parseErr := time.ParseDuration(timeoutParam) + if parseErr != nil { + return nil, nil, fmt.Errorf("invalid timeout: %w", parseErr) + } + ctx, cancel = context.WithTimeout(ctx, dur) + } else { + cancel = func() {} + } + + //nolint:forbidigo // Protected Ethereum connection. + cc, err := newChainClient(ctx, ethEndpoint.Raw(), appAddr, block) + if err != nil { + cancel() + return nil, nil, err + } + + return cc, cancel, nil +} diff --git a/cmd/cartesi-rollups-cli/root/contract/contract_test.go b/cmd/cartesi-rollups-cli/root/contract/contract_test.go new file mode 100644 index 000000000..35c6a411c --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/contract_test.go @@ -0,0 +1,183 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "math" + "math/big" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSafeUint64(t *testing.T) { + tests := []struct { + name string + value *big.Int + field string + want uint64 + wantErr bool + }{ + { + name: "zero", + value: big.NewInt(0), + field: "test", + want: 0, + }, + { + name: "normal value", + value: big.NewInt(12345), + field: "block", + want: 12345, + }, + { + name: "max uint64", + value: new(big.Int).SetUint64(math.MaxUint64), + field: "max", + want: math.MaxUint64, + }, + { + name: "overflow", + value: new(big.Int).Add(new(big.Int).SetUint64(math.MaxUint64), big.NewInt(1)), + field: "overflow", + wantErr: true, + }, + { + name: "negative", + value: big.NewInt(-1), + field: "negative", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := safeUint64(tt.value, tt.field) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.field) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestParseBlockFlag(t *testing.T) { + tests := []struct { + name string + input string + want *big.Int + wantErr bool + }{ + { + name: "empty string returns nil", + input: "", + want: nil, + }, + { + name: "latest returns nil", + input: "latest", + want: nil, + }, + { + name: "decimal number", + input: "12345", + want: big.NewInt(12345), + }, + { + name: "hex number", + input: "0xFF", + want: big.NewInt(255), + }, + { + name: "zero", + input: "0", + want: big.NewInt(0), + }, + { + name: "negative number", + input: "-1", + wantErr: true, + }, + { + name: "invalid string", + input: "abc", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseBlockFlag(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + if tt.want == nil { + assert.Nil(t, got) + } else { + require.NotNil(t, got) + assert.Equal(t, 0, tt.want.Cmp(got)) + } + } + }) + } +} + +func TestSafeUint64Nil(t *testing.T) { + _, err := safeUint64(nil, "test") + require.Error(t, err) + assert.Contains(t, err.Error(), "nil") +} + +func TestValidateHash(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid with prefix", "0x" + strings.Repeat("ab", 32), false}, + {"valid uppercase", "0x" + strings.Repeat("AB", 32), false}, + {"no prefix accepted", strings.Repeat("ab", 32), false}, + {"too short", "0xabcd", true}, + {"too long", "0x" + strings.Repeat("ab", 33), true}, + {"invalid hex", "0x" + strings.Repeat("gg", 32), true}, + {"empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateHash(tt.input, "test hash") + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestConsensusTypeString(t *testing.T) { + assert.Equal(t, "Authority", consensusAuthority.String()) + assert.Equal(t, "Quorum", consensusQuorum.String()) + assert.Equal(t, "DaveConsensus (PRT)", consensusDave.String()) + assert.Equal(t, "Unknown", consensusUnknown.String()) +} + +// TestIConsensusV3InterfaceID locks down the v3 interface ID computation. +// If a method is renamed in the binding or this list drifts from the +// IConsensus.sol interface, this test surfaces the change as a value mismatch. +func TestIConsensusV3InterfaceID(t *testing.T) { + // Non-zero, distinct from the v2 IDs. + assert.NotEqual(t, [4]byte{}, iConsensusInterfaceIDv30, + "v3 interface ID should be non-zero") + assert.NotEqual(t, iConsensusInterfaceIDv220, iConsensusInterfaceIDv30, + "v3 interface ID should differ from v2.2.0") + assert.NotEqual(t, iConsensusInterfaceIDv21x, iConsensusInterfaceIDv30, + "v3 interface ID should differ from v2.1.x") +} diff --git a/cmd/cartesi-rollups-cli/root/contract/epoch.go b/cmd/cartesi-rollups-cli/root/contract/epoch.go new file mode 100644 index 000000000..719023f97 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/epoch.go @@ -0,0 +1,676 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "os" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" +) + +var ( + epochFromBlock int64 + epochToBlock int64 + epochLimitFlag int +) + +var epochCmd = &cobra.Command{ + Use: "epoch ", + Short: "Show epoch history (claims, sealed epochs, events)", + Args: cobra.ExactArgs(1), + RunE: runEpoch, +} + +func init() { + epochCmd.Flags().Int64Var(&epochFromBlock, "from-block", -1, + "Start block for event scan (default: deployment block)") + epochCmd.Flags().Int64Var(&epochToBlock, "to-block", -1, + "End block for event scan (default: pinned block)") + epochCmd.Flags().IntVar(&epochLimitFlag, "limit", 50, //nolint:mnd + "Maximum number of events to display") +} + +func runEpoch(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + consensusAddr, err := cc.getConsensusAddress() + if err != nil { + return err + } + + if err := cc.ensureContract(consensusAddr, "consensus"); err != nil { + return err + } + + cType, _, err := cc.detectConsensus(consensusAddr) + if err != nil { + return err + } + + var history *EpochHistory + switch cType { + case consensusDave: + history, err = cc.epochHistoryDave(consensusAddr) + case consensusAuthority: + history, err = cc.epochHistoryAuthority(consensusAddr) + case consensusQuorum: + history, err = cc.epochHistoryQuorum(consensusAddr) + case consensusUnknown: + return fmt.Errorf("unknown consensus type at %s", consensusAddr) + } + if err != nil { + return err + } + + if jsonParam { + return outputJSON(history) + } + + cc.printEpochHistory(history) + return nil +} + +// epochHistoryDave uses FindTransitions on GetCurrentSealedEpoch().EpochNumber +// to discover EpochSealed events. +func (c *chainClient) epochHistoryDave(consensusAddr common.Address) (*EpochHistory, error) { + daveCaller, err := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IDaveConsensus: %w", err) + } + daveFilterer, err := idaveconsensus.NewIDaveConsensusFilterer(consensusAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IDaveConsensus filterer: %w", err) + } + + deployBlockRaw, err := daveCaller.GetDeploymentBlockNumber(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetDeploymentBlockNumber: %w", err) + } + deployBlock, err := safeUint64(deployBlockRaw, "deployment block") + if err != nil { + return nil, err + } + + // Get template hash for the machine hash chain. + appCaller, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IApplication: %w", err) + } + templateHash, err := appCaller.GetTemplateHash(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetTemplateHash: %w", err) + } + + fromBlock, toBlock, err := c.resolveBlockRange(deployBlock) + if err != nil { + return nil, err + } + + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + sealed, sErr := daveCaller.GetCurrentSealedEpoch(opts) + if sErr != nil { + return nil, sErr + } + return sealed.EpochNumber, nil + } + + var epochs []EpochEvent + limit := epochLimitFlag + + onHit := func(block uint64) error { + if limit > 0 && len(epochs) >= limit { + return errLimitReached + } + q, qErr := buildEventFilterQuery( + consensusAddr, "EpochSealed", + idaveconsensus.IDaveConsensusMetaData, block, block, + ) + if qErr != nil { + return qErr + } + itr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, q) + if fErr != nil { + return fErr + } + for log, logErr := range itr { + if logErr != nil { + return logErr + } + ev, pErr := daveFilterer.ParseEpochSealed(*log) + if pErr != nil { + return pErr + } + epochNum, sErr := safeUint64(ev.EpochNumber, "epoch number") + if sErr != nil { + return sErr + } + inputLower, sErr := safeUint64(ev.InputIndexLowerBound, "input lower bound") + if sErr != nil { + return sErr + } + inputUpper, sErr := safeUint64(ev.InputIndexUpperBound, "input upper bound") + if sErr != nil { + return sErr + } + + // Check if outputs root is valid on-chain. + var rootValid *bool + if rv, rvErr := daveCaller.IsOutputsMerkleRootValid( + c.callOpts, c.appAddr, ev.OutputsMerkleRoot); rvErr == nil { + rootValid = &rv + } else { + slog.Debug("IsOutputsMerkleRootValid failed", "error", rvErr) + } + + epochs = append(epochs, EpochEvent{ + EpochNumber: epochNum, + BlockNumber: log.BlockNumber, + TxHash: log.TxHash.Hex(), + InputLowerBound: inputLower, + InputUpperBound: inputUpper, + InitialMachineHash: formatHash(ev.InitialMachineStateHash), + OutputsMerkleRoot: formatHash(ev.OutputsMerkleRoot), + OutputsRootValid: rootValid, + Tournament: formatAddr(ev.Tournament), + }) + } + return nil + } + + // Use -1 as prevValue so the first sealed epoch (epoch 0) is detected as a + // transition from -1→0. Without this, epoch 0 would be invisible (0→0 = no change). + prevValue := big.NewInt(-1) + if fromBlock > deployBlock && fromBlock > 0 { + val, pErr := oracle(c.callOpts.Context, fromBlock-1) + if pErr != nil { + return nil, fmt.Errorf("get prevValue at block %d: %w", fromBlock-1, pErr) + } + prevValue = val + } + + _, err = ethutil.FindTransitions( + c.callOpts.Context, fromBlock, toBlock, prevValue, oracle, onHit, + ) + if err != nil && !errors.Is(err, errLimitReached) { + return nil, fmt.Errorf("find sealed epoch transitions: %w", err) + } + + return &EpochHistory{ + ConsensusType: "DaveConsensus", + TemplateHash: formatHash(templateHash), + Epochs: epochs, + }, nil +} + +// epochHistoryAuthority uses FindTransitions on GetNumberOfAcceptedClaims +// to discover ClaimSubmitted and ClaimAccepted events. +func (c *chainClient) epochHistoryAuthority( + consensusAddr common.Address, +) (*EpochHistory, error) { + consensusCaller, err := iconsensus.NewIConsensusCaller(consensusAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IConsensus: %w", err) + } + consensusFilterer, err := iconsensus.NewIConsensusFilterer(consensusAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IConsensus filterer: %w", err) + } + + appCaller, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IApplication: %w", err) + } + deployBlockRaw, err := appCaller.GetDeploymentBlockNumber(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetDeploymentBlockNumber: %w", err) + } + deployBlock, err := safeUint64(deployBlockRaw, "deployment block") + if err != nil { + return nil, err + } + + fromBlock, toBlock, err := c.resolveBlockRange(deployBlock) + if err != nil { + return nil, err + } + + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return consensusCaller.GetNumberOfAcceptedClaims(opts, c.appAddr) + } + + var claims []ClaimEvent + limit := epochLimitFlag + + onHit := func(block uint64) error { + if limit > 0 && len(claims) >= limit { + return errLimitReached + } + // Filter by app address as indexed topic to avoid fetching events + // for other applications on shared consensus contracts. + appTopic := common.BytesToHash(c.appAddr.Bytes()) + + // Retrieve ClaimSubmitted events at this block. + // ClaimSubmitted has indexed topics (submitter, appContract). + // Use nil wildcard for topic1 (submitter) and appTopic for topic2 (appContract). + subQ, qErr := buildEventFilterQuery( + consensusAddr, "ClaimSubmitted", + iconsensus.IConsensusMetaData, block, block, nil, &appTopic, + ) + if qErr != nil { + return qErr + } + subItr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, subQ) + if fErr != nil { + return fErr + } + for log, logErr := range subItr { + if logErr != nil { + return logErr + } + ev, pErr := consensusFilterer.ParseClaimSubmitted(*log) + if pErr != nil { + return pErr + } + lastBlock, sErr := safeUint64(ev.LastProcessedBlockNumber, "last processed block") + if sErr != nil { + return sErr + } + claims = append(claims, ClaimEvent{ + EventType: "ClaimSubmitted", + BlockNumber: log.BlockNumber, + TxHash: log.TxHash.Hex(), + Submitter: formatAddr(ev.Submitter), + LastProcessedBlock: lastBlock, + OutputsMerkleRoot: formatHash(ev.OutputsMerkleRoot), + }) + } + + // Retrieve ClaimAccepted events at this block. + accQ, qErr := buildEventFilterQuery( + consensusAddr, "ClaimAccepted", + iconsensus.IConsensusMetaData, block, block, &appTopic, + ) + if qErr != nil { + return qErr + } + accItr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, accQ) + if fErr != nil { + return fErr + } + for log, logErr := range accItr { + if logErr != nil { + return logErr + } + ev, pErr := consensusFilterer.ParseClaimAccepted(*log) + if pErr != nil { + return pErr + } + lastBlock, sErr := safeUint64(ev.LastProcessedBlockNumber, "last processed block") + if sErr != nil { + return sErr + } + + var rootValid *bool + if rv, rvErr := consensusCaller.IsOutputsMerkleRootValid( + c.callOpts, c.appAddr, ev.OutputsMerkleRoot); rvErr == nil { + rootValid = &rv + } else { + slog.Debug("IsOutputsMerkleRootValid failed", "error", rvErr) + } + + ce := ClaimEvent{ + EventType: "ClaimAccepted", + BlockNumber: log.BlockNumber, + TxHash: log.TxHash.Hex(), + LastProcessedBlock: lastBlock, + OutputsMerkleRoot: formatHash(ev.OutputsMerkleRoot), + OutputsRootValid: rootValid, + } + claims = append(claims, ce) + } + return nil + } + + prevValue := big.NewInt(0) + if fromBlock > deployBlock && fromBlock > 0 { + val, pErr := oracle(c.callOpts.Context, fromBlock-1) + if pErr != nil { + return nil, fmt.Errorf("get prevValue at block %d: %w", fromBlock-1, pErr) + } + prevValue = val + } + + _, err = ethutil.FindTransitions( + c.callOpts.Context, fromBlock, toBlock, prevValue, oracle, onHit, + ) + if err != nil && !errors.Is(err, errLimitReached) { + return nil, fmt.Errorf("find claim transitions: %w", err) + } + + return &EpochHistory{ + ConsensusType: "Authority", + Claims: claims, + }, nil +} + +// epochHistoryQuorum uses two-pass: FindTransitions for ClaimAccepted, +// then ChunkedFilterLogs for ClaimSubmitted over the full range. +func (c *chainClient) epochHistoryQuorum( + consensusAddr common.Address, +) (*EpochHistory, error) { + consensusCaller, err := iconsensus.NewIConsensusCaller(consensusAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IConsensus: %w", err) + } + consensusFilterer, err := iconsensus.NewIConsensusFilterer(consensusAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IConsensus filterer: %w", err) + } + quorumCaller, err := iquorum.NewIQuorumCaller(consensusAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IQuorum: %w", err) + } + + appCaller, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IApplication: %w", err) + } + deployBlockRaw, err := appCaller.GetDeploymentBlockNumber(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetDeploymentBlockNumber: %w", err) + } + deployBlock, err := safeUint64(deployBlockRaw, "deployment block") + if err != nil { + return nil, err + } + + numValRaw, err := quorumCaller.NumOfValidators(c.callOpts) + if err != nil { + return nil, fmt.Errorf("NumOfValidators: %w", err) + } + numVal, err := safeUint64(numValRaw, "num validators") + if err != nil { + return nil, err + } + threshold := 1 + numVal/2 //nolint:mnd + + fromBlock, toBlock, err := c.resolveBlockRange(deployBlock) + if err != nil { + return nil, err + } + + var claims []ClaimEvent + limit := epochLimitFlag + + // Pass 1: FindTransitions for ClaimAccepted. + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return consensusCaller.GetNumberOfAcceptedClaims(opts, c.appAddr) + } + + onHit := func(block uint64) error { + if limit > 0 && len(claims) >= limit { + return errLimitReached + } + // Filter by app address as indexed topic to avoid fetching events + // for other applications on shared Quorum contracts. + appTopic := common.BytesToHash(c.appAddr.Bytes()) + accQ, qErr := buildEventFilterQuery( + consensusAddr, "ClaimAccepted", + iconsensus.IConsensusMetaData, block, block, &appTopic, + ) + if qErr != nil { + return qErr + } + accItr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, accQ) + if fErr != nil { + return fErr + } + for log, logErr := range accItr { + if logErr != nil { + return logErr + } + ev, pErr := consensusFilterer.ParseClaimAccepted(*log) + if pErr != nil { + return pErr + } + if ev.AppContract != c.appAddr { + continue + } + lastBlock, sErr := safeUint64(ev.LastProcessedBlockNumber, "last processed block") + if sErr != nil { + return sErr + } + var rootValid *bool + if rv, rvErr := consensusCaller.IsOutputsMerkleRootValid( + c.callOpts, c.appAddr, ev.OutputsMerkleRoot); rvErr == nil { + rootValid = &rv + } else { + slog.Debug("IsOutputsMerkleRootValid failed", "error", rvErr) + } + claims = append(claims, ClaimEvent{ + EventType: "ClaimAccepted", + BlockNumber: log.BlockNumber, + TxHash: log.TxHash.Hex(), + LastProcessedBlock: lastBlock, + OutputsMerkleRoot: formatHash(ev.OutputsMerkleRoot), + OutputsRootValid: rootValid, + }) + } + return nil + } + + prevValue := big.NewInt(0) + if fromBlock > deployBlock && fromBlock > 0 { + val, pErr := oracle(c.callOpts.Context, fromBlock-1) + if pErr != nil { + return nil, fmt.Errorf("get prevValue at block %d: %w", fromBlock-1, pErr) + } + prevValue = val + } + + _, err = ethutil.FindTransitions( + c.callOpts.Context, fromBlock, toBlock, prevValue, oracle, onHit, + ) + if err != nil && !errors.Is(err, errLimitReached) { + return nil, fmt.Errorf("find claim accepted transitions: %w", err) + } + + // Pass 2: ChunkedFilterLogs for ClaimSubmitted over full range. + // ClaimSubmitted has indexed topics (submitter, appContract). + // Use nil wildcard for topic1 (submitter) and appTopic for topic2 (appContract). + appTopic := common.BytesToHash(c.appAddr.Bytes()) + subQ, err := buildEventFilterQuery( + consensusAddr, "ClaimSubmitted", + iconsensus.IConsensusMetaData, fromBlock, toBlock, nil, &appTopic, + ) + if err != nil { + return nil, fmt.Errorf("build ClaimSubmitted query: %w", err) + } + subItr, err := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, subQ) + if err != nil { + return nil, fmt.Errorf("filter ClaimSubmitted: %w", err) + } + + var submissions []ClaimEvent + for log, logErr := range subItr { + if logErr != nil { + return nil, fmt.Errorf("iterate ClaimSubmitted: %w", logErr) + } + if limit > 0 && len(submissions) >= limit { + break + } + ev, pErr := consensusFilterer.ParseClaimSubmitted(*log) + if pErr != nil { + return nil, fmt.Errorf("parse ClaimSubmitted: %w", pErr) + } + lastBlock, sErr := safeUint64(ev.LastProcessedBlockNumber, "last processed block") + if sErr != nil { + return nil, sErr + } + + // Get vote count for this specific claim. + votesRaw, vErr := quorumCaller.NumOfValidatorsInFavorOf( + c.callOpts, c.appAddr, ev.LastProcessedBlockNumber, ev.OutputsMerkleRoot) + var votes *uint64 + if vErr == nil { + v, sErr := safeUint64(votesRaw, "votes") + if sErr == nil { + votes = &v + } + } + + ce := ClaimEvent{ + EventType: "ClaimSubmitted", + BlockNumber: log.BlockNumber, + TxHash: log.TxHash.Hex(), + Submitter: formatAddr(ev.Submitter), + LastProcessedBlock: lastBlock, + OutputsMerkleRoot: formatHash(ev.OutputsMerkleRoot), + VotesForClaim: votes, + VotesNeeded: &threshold, + } + submissions = append(submissions, ce) + } + + // Merge submissions and acceptances, sorted by block number. + allClaims := mergeClaimEvents(submissions, claims) + + return &EpochHistory{ + ConsensusType: "Quorum", + Claims: allClaims, + }, nil +} + +// mergeClaimEvents merges two sorted-by-block slices of ClaimEvents. +func mergeClaimEvents(a, b []ClaimEvent) []ClaimEvent { + result := make([]ClaimEvent, 0, len(a)+len(b)) + i, j := 0, 0 + for i < len(a) && j < len(b) { + if a[i].BlockNumber <= b[j].BlockNumber { + result = append(result, a[i]) + i++ + } else { + result = append(result, b[j]) + j++ + } + } + result = append(result, a[i:]...) + result = append(result, b[j:]...) + return result +} + +// resolveBlockRange computes the from/to block range for event scanning. +// Returns an error if --from-block or --to-block exceeds the pinned block, or from > to. +func (c *chainClient) resolveBlockRange(deployBlock uint64) (uint64, uint64, error) { + from := deployBlock + if epochFromBlock >= 0 { + from = uint64(epochFromBlock) + } + to := c.blockNum + if epochToBlock >= 0 { + to = uint64(epochToBlock) + } + if from > c.blockNum { + return 0, 0, fmt.Errorf( + "--from-block %d exceeds pinned block %d", from, c.blockNum) + } + if to > c.blockNum { + return 0, 0, fmt.Errorf( + "--to-block %d exceeds pinned block %d", to, c.blockNum) + } + if from > to { + return 0, 0, fmt.Errorf( + "--from-block %d exceeds --to-block %d", from, to) + } + return from, to, nil +} + +// printEpochHistory renders the epoch history as text. +func (c *chainClient) printEpochHistory(h *EpochHistory) { + p := &printer{w: os.Stdout} + + switch h.ConsensusType { + case "DaveConsensus": + c.printDaveEpochHistory(p, h) + case "Authority": + c.printClaimHistory(p, h, "Authority") + case "Quorum": + c.printClaimHistory(p, h, "Quorum") + } + + p.footer(c.blockNum, c.chainID, c.resolveTimestamp(c.blockNum)) +} + +func (c *chainClient) printDaveEpochHistory(p *printer, h *EpochHistory) { + p.withSection(fmt.Sprintf("Epoch History (DaveConsensus, %d epochs)", len(h.Epochs)), func() { + if h.TemplateHash != "" { + p.field("Template Hash", h.TemplateHash+" (epoch 0 initial state)") + } + }) + + for _, ep := range h.Epochs { + ts := formatBlockTime(c.resolveTimestamp(ep.BlockNumber)) + p.withSection( + fmt.Sprintf("Epoch %d (sealed at block %d%s, tx %s)", + ep.EpochNumber, ep.BlockNumber, ts, ep.TxHash), func() { + p.field("Input Range", + fmt.Sprintf("[%d, %d)", ep.InputLowerBound, ep.InputUpperBound)) + p.field("Initial Machine Hash", ep.InitialMachineHash) + rootStatus := "unknown" + if ep.OutputsRootValid != nil { + rootStatus = "no" + if *ep.OutputsRootValid { + rootStatus = "yes" + } + } + p.field("Outputs Merkle Root", + fmt.Sprintf("%s (valid: %s)", ep.OutputsMerkleRoot, rootStatus)) + p.field("Tournament", ep.Tournament) + }) + } +} + +func (c *chainClient) printClaimHistory(p *printer, h *EpochHistory, cType string) { + p.withSection(fmt.Sprintf("Epoch History (%s, %d events)", cType, len(h.Claims)), func() {}) + + for _, cl := range h.Claims { + label := cl.EventType + ts := formatBlockTime(c.resolveTimestamp(cl.BlockNumber)) + p.withSection( + fmt.Sprintf("%s at block %d%s tx %s", label, cl.BlockNumber, ts, cl.TxHash), func() { + if cl.Submitter != "" { + p.field("Submitter", cl.Submitter) + } + p.field("Last Processed Block", fmt.Sprintf("%d", cl.LastProcessedBlock)) + p.field("Outputs Merkle Root", cl.OutputsMerkleRoot) + if cl.OutputsRootValid != nil { + p.field("Root Valid on-chain", fmt.Sprintf("%t", *cl.OutputsRootValid)) + } + if cl.VotesForClaim != nil && cl.VotesNeeded != nil { + p.field("Votes", + fmt.Sprintf("%d / %d needed", *cl.VotesForClaim, *cl.VotesNeeded)) + } + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/contract/epoch_test.go b/cmd/cartesi-rollups-cli/root/contract/epoch_test.go new file mode 100644 index 000000000..0b373bf0e --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/epoch_test.go @@ -0,0 +1,119 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveBlockRange(t *testing.T) { + cc := &chainClient{blockNum: 1000} + + // Ensure globals are reset even if a subtest panics. + t.Cleanup(func() { + epochFromBlock = -1 + epochToBlock = -1 + }) + + t.Run("defaults to deploy and pinned block", func(t *testing.T) { + epochFromBlock = -1 + epochToBlock = -1 + from, to, err := cc.resolveBlockRange(500) + require.NoError(t, err) + assert.Equal(t, uint64(500), from) + assert.Equal(t, uint64(1000), to) + }) + + t.Run("respects from-block and to-block flags", func(t *testing.T) { + epochFromBlock = 600 + epochToBlock = 800 + from, to, err := cc.resolveBlockRange(500) + require.NoError(t, err) + assert.Equal(t, uint64(600), from) + assert.Equal(t, uint64(800), to) + }) + + t.Run("from-block exceeds pinned block", func(t *testing.T) { + epochFromBlock = 2000 + epochToBlock = -1 + _, _, err := cc.resolveBlockRange(500) + require.Error(t, err) + assert.Contains(t, err.Error(), "--from-block 2000 exceeds pinned block 1000") + }) + + t.Run("from-block exceeds to-block", func(t *testing.T) { + epochFromBlock = 800 + epochToBlock = 700 + _, _, err := cc.resolveBlockRange(500) + require.Error(t, err) + assert.Contains(t, err.Error(), "--from-block 800 exceeds --to-block 700") + }) +} + +func TestMergeClaimEvents(t *testing.T) { + t.Run("both empty", func(t *testing.T) { + result := mergeClaimEvents(nil, nil) + assert.Empty(t, result) + }) + + t.Run("a empty", func(t *testing.T) { + b := []ClaimEvent{ + {EventType: "ClaimAccepted", BlockNumber: 100}, + {EventType: "ClaimAccepted", BlockNumber: 200}, + } + result := mergeClaimEvents(nil, b) + assert.Equal(t, b, result) + }) + + t.Run("b empty", func(t *testing.T) { + a := []ClaimEvent{ + {EventType: "ClaimSubmitted", BlockNumber: 50}, + } + result := mergeClaimEvents(a, nil) + assert.Equal(t, a, result) + }) + + t.Run("interleaved", func(t *testing.T) { + a := []ClaimEvent{ + {EventType: "ClaimSubmitted", BlockNumber: 10}, + {EventType: "ClaimSubmitted", BlockNumber: 30}, + {EventType: "ClaimSubmitted", BlockNumber: 50}, + } + b := []ClaimEvent{ + {EventType: "ClaimAccepted", BlockNumber: 20}, + {EventType: "ClaimAccepted", BlockNumber: 40}, + } + result := mergeClaimEvents(a, b) + assert.Len(t, result, 5) + + // Verify sorted order. + for i := 1; i < len(result); i++ { + assert.LessOrEqual(t, result[i-1].BlockNumber, result[i].BlockNumber) + } + + // Verify types in expected order. + assert.Equal(t, "ClaimSubmitted", result[0].EventType) + assert.Equal(t, "ClaimAccepted", result[1].EventType) + assert.Equal(t, "ClaimSubmitted", result[2].EventType) + assert.Equal(t, "ClaimAccepted", result[3].EventType) + assert.Equal(t, "ClaimSubmitted", result[4].EventType) + }) + + t.Run("same block number preserves order", func(t *testing.T) { + a := []ClaimEvent{ + {EventType: "ClaimSubmitted", BlockNumber: 100}, + } + b := []ClaimEvent{ + {EventType: "ClaimAccepted", BlockNumber: 100}, + } + result := mergeClaimEvents(a, b) + assert.Len(t, result, 2) + // a comes first when blocks are equal (a[i].BlockNumber <= b[j].BlockNumber). + assert.Equal(t, "ClaimSubmitted", result[0].EventType) + assert.Equal(t, "ClaimAccepted", result[1].EventType) + }) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/format.go b/cmd/cartesi-rollups-cli/root/contract/format.go new file mode 100644 index 000000000..0927dec06 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/format.go @@ -0,0 +1,153 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "fmt" + "io" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +type printer struct { + w io.Writer + indent int + started bool +} + +// withSection runs fn within an indented section, guaranteeing endSection is always called. +func (p *printer) withSection(title string, fn func()) { + if p.started { + fmt.Fprintf(p.w, "\n") + } + p.started = true + fmt.Fprintf(p.w, "%s%s\n", strings.Repeat("\t", p.indent), title) + p.indent++ + fn() + p.indent-- +} + +func (p *printer) field(name, value string) { + padding := strings.Repeat(" ", max(1, 24-len(name))) //nolint:mnd + fmt.Fprintf(p.w, "%s%s%s%s\n", strings.Repeat("\t", p.indent), name, padding, value) +} + +func (p *printer) fieldErr(label string, err error) { + p.field("ERROR", fmt.Sprintf("%s: %v", label, err)) +} + +// footer prints the block, chain ID, and a disclaimer at the end of every output. +func (p *printer) footer(blockNum, chainID uint64, blockTime ...uint64) { + ts := uint64(0) + if len(blockTime) > 0 { + ts = blockTime[0] + } + fmt.Fprintf(p.w, "\nState at block %d (chain ID: %d)%s\n", + blockNum, chainID, formatBlockTime(ts)) + fmt.Fprintf(p.w, "\nNote: this is an experimental diagnostic tool. "+ + "Values are read directly from on-chain contracts\n"+ + "and may not reflect finalized state. Verify critical information independently.\n") +} + +func formatAddr(addr common.Address) string { + return addr.Hex() // always full: 0x + 40 hex chars +} + +func formatHash(hash [32]byte) string { + h := common.BytesToHash(hash[:]) + return h.Hex() // always full: 0x + 64 hex chars +} + +var weiDivisor = new(big.Float).SetInt( + new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), //nolint:mnd +) + +func weiToETH(wei *big.Int) string { + eth := new(big.Float).SetInt(wei) + eth.Quo(eth, weiDivisor) + return fmt.Sprintf("%s ETH", eth.Text('f', 6)) //nolint:mnd +} + +func tournamentStatus(closed, finished bool) string { + switch { + case finished: + return "FINISHED" + case closed: + return "CLOSED (matches still running)" + default: + return "OPEN" + } +} + +func matchDeletionReason(reason uint8) string { + switch reason { + case 0: + return "STEP (on-chain proof)" + case 1: + return "TIMEOUT" + case 2: //nolint:mnd + return "CHILD_TOURNAMENT" + default: + return fmt.Sprintf("UNKNOWN(%d)", reason) + } +} + +func matchWinner(winner uint8) string { + switch winner { + case 0: + return "NONE (both eliminated)" + case 1: + return "ONE" + case 2: //nolint:mnd + return "TWO" + default: + return fmt.Sprintf("UNKNOWN(%d)", winner) + } +} + +const ( + matchPhaseUninitialized uint8 = iota + matchPhaseBisecting + matchPhaseReadyToSeal + matchPhaseSealed +) + +func matchPhaseName(phase uint8) string { + names := [...]string{"UNINITIALIZED", "BISECTING", "READY_TO_SEAL", "SEALED"} + if int(phase) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", phase) + } + return names[phase] +} + +func matchTimeoutOutcomeName(outcome uint8) string { + names := [...]string{"NONE", "ONE_WINS", "TWO_WINS", "ELIMINATE_BOTH"} + if int(outcome) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", outcome) + } + return names[outcome] +} + +func commitmentSideName(side uint8) string { + switch side { + case 0: + return "ONE" + case 1: + return "TWO" + default: + return fmt.Sprintf("UNKNOWN(%d)", side) + } +} + +// formatBlockTime returns " (2006-01-02 15:04:05 UTC)" if ts > 0, else "". +func formatBlockTime(ts uint64) string { + if ts == 0 { + return "" + } + t := time.Unix(int64(ts), 0).UTC() //nolint:gosec // timestamp won't overflow int64 + return fmt.Sprintf(" (%s)", t.Format("2006-01-02 15:04:05 UTC")) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/format_test.go b/cmd/cartesi-rollups-cli/root/contract/format_test.go new file mode 100644 index 000000000..1b753cf74 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/format_test.go @@ -0,0 +1,234 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatAddr(t *testing.T) { + tests := []struct { + name string + addr common.Address + want string + }{ + { + name: "zero address", + addr: common.Address{}, + want: "0x0000000000000000000000000000000000000000", + }, + { + name: "normal address uses EIP-55 checksum", + addr: common.HexToAddress("0xDEADBEEF11112222333344445555666677778888"), + want: "0xdeadbeEF11112222333344445555666677778888", + }, + { + name: "lowercase input normalizes to checksum", + addr: common.HexToAddress("0xdeadbeef11112222333344445555666677778888"), + want: "0xdeadbeEF11112222333344445555666677778888", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatAddr(tt.addr) + assert.Equal(t, tt.want, got) + // Always 42 characters: 0x + 40 hex chars + assert.Len(t, got, 42) + }) + } +} + +func TestFormatHash(t *testing.T) { + tests := []struct { + name string + hash [32]byte + want string + }{ + { + name: "zero hash", + hash: [32]byte{}, + want: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "normal hash", + hash: common.HexToHash( + "0xABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"), + want: "0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatHash(tt.hash) + // Always 66 characters: 0x + 64 hex chars + assert.Len(t, got, 66) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestWeiToETH(t *testing.T) { + tests := []struct { + name string + wei *big.Int + want string + }{ + { + name: "zero", + wei: big.NewInt(0), + want: "0.000000 ETH", + }, + { + name: "one wei", + wei: big.NewInt(1), + want: "0.000000 ETH", // too small to show at 6 decimal places + }, + { + name: "one ETH", + wei: new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), + want: "1.000000 ETH", + }, + { + name: "0.05 ETH", + wei: new(big.Int).Mul(big.NewInt(5), new(big.Int).Exp(big.NewInt(10), big.NewInt(16), nil)), + want: "0.050000 ETH", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := weiToETH(tt.wei) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTournamentStatus(t *testing.T) { + assert.Equal(t, "OPEN", tournamentStatus(false, false)) + assert.Equal(t, "CLOSED (matches still running)", tournamentStatus(true, false)) + assert.Equal(t, "FINISHED", tournamentStatus(false, true)) + assert.Equal(t, "FINISHED", tournamentStatus(true, true)) +} + +func TestMatchDeletionReason(t *testing.T) { + assert.Equal(t, "STEP (on-chain proof)", matchDeletionReason(0)) + assert.Equal(t, "TIMEOUT", matchDeletionReason(1)) + assert.Equal(t, "CHILD_TOURNAMENT", matchDeletionReason(2)) + assert.Equal(t, "UNKNOWN(42)", matchDeletionReason(42)) +} + +func TestMatchWinner(t *testing.T) { + assert.Equal(t, "NONE (both eliminated)", matchWinner(0)) + assert.Equal(t, "ONE", matchWinner(1)) + assert.Equal(t, "TWO", matchWinner(2)) + assert.Equal(t, "UNKNOWN(99)", matchWinner(99)) +} + +func TestMatchEnumNamesMatchSolidityOrder(t *testing.T) { + assert.Equal(t, "UNINITIALIZED", matchPhaseName(0)) + assert.Equal(t, "BISECTING", matchPhaseName(1)) + assert.Equal(t, "READY_TO_SEAL", matchPhaseName(2)) + assert.Equal(t, "SEALED", matchPhaseName(3)) + assert.Equal(t, "UNKNOWN(4)", matchPhaseName(4)) + + assert.Equal(t, "NONE", matchTimeoutOutcomeName(0)) + assert.Equal(t, "ONE_WINS", matchTimeoutOutcomeName(1)) + assert.Equal(t, "TWO_WINS", matchTimeoutOutcomeName(2)) + assert.Equal(t, "ELIMINATE_BOTH", matchTimeoutOutcomeName(3)) + assert.Equal(t, "UNKNOWN(4)", matchTimeoutOutcomeName(4)) + + assert.Equal(t, "ONE", commitmentSideName(0)) + assert.Equal(t, "TWO", commitmentSideName(1)) + assert.Equal(t, "UNKNOWN(2)", commitmentSideName(2)) +} + +func TestFormatBlockTime(t *testing.T) { + assert.Equal(t, "", formatBlockTime(0)) + // Unix timestamp 1700000000 = 2023-11-14 22:13:20 UTC + got := formatBlockTime(1700000000) + assert.Contains(t, got, "2023-11-14") + assert.Contains(t, got, "UTC") + assert.True(t, got[0] == ' ' && got[1] == '(') +} + +func TestPrinterFooter(t *testing.T) { + var buf bytes.Buffer + p := &printer{w: &buf} + + p.footer(12345, 11155111) + out := buf.String() + assert.Contains(t, out, "12345") + assert.Contains(t, out, "11155111") + assert.Contains(t, out, "experimental diagnostic tool") +} + +func TestPrinterFooterWithTimestamp(t *testing.T) { + var buf bytes.Buffer + p := &printer{w: &buf} + + p.footer(12345, 1, 1700000000) + out := buf.String() + assert.Contains(t, out, "12345") + assert.Contains(t, out, "2023-11-14") + assert.Contains(t, out, "UTC") + assert.Contains(t, out, "experimental diagnostic tool") +} + +func TestPrinterField(t *testing.T) { + var buf bytes.Buffer + p := &printer{w: &buf} + + p.field("Name", "Value") + out := buf.String() + assert.Contains(t, out, "Name") + assert.Contains(t, out, "Value") +} + +func TestPrinterWithSection(t *testing.T) { + var buf bytes.Buffer + p := &printer{w: &buf} + + p.withSection("Section Title", func() { + p.field("Key", "Val") + }) + + out := buf.String() + assert.Contains(t, out, "Section Title") + assert.Contains(t, out, "Key") + // After withSection, indent should be back to 0 + assert.Equal(t, 0, p.indent) +} + +func TestPrinterNestedSections(t *testing.T) { + var buf bytes.Buffer + p := &printer{w: &buf} + + p.withSection("Outer", func() { + require.Equal(t, 1, p.indent) + p.withSection("Inner", func() { + require.Equal(t, 2, p.indent) + p.field("Deep", "Value") + }) + require.Equal(t, 1, p.indent) + }) + require.Equal(t, 0, p.indent) +} + +func TestPrinterFieldErr(t *testing.T) { + var buf bytes.Buffer + p := &printer{w: &buf} + + p.fieldErr("test label", assert.AnError) + out := buf.String() + assert.Contains(t, out, "ERROR") + assert.Contains(t, out, "test label") + assert.Contains(t, out, assert.AnError.Error()) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/inputbox.go b/cmd/cartesi-rollups-cli/root/contract/inputbox.go new file mode 100644 index 000000000..72b624c2c --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/inputbox.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "fmt" + "os" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" +) + +var inputboxCmd = &cobra.Command{ + Use: "inputbox ", + Short: "Query InputBox contract state (total inputs for application)", + Args: cobra.ExactArgs(1), + RunE: runInputBox, +} + +func runInputBox(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + result, err := cc.queryInputBox() + if err != nil { + return err + } + + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + p.withSection("InputBox", func() { + if result.Address != "" { + p.field("Address", result.Address) + } + p.field("Total Inputs", fmt.Sprintf("%d", result.TotalInputs)) + }) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} + +// queryInputBox returns the InputBox state for the application. +func (c *chainClient) queryInputBox() (*InputBoxResult, error) { + inputBoxAddr, err := c.resolveInputBoxAddress() + if err != nil { + return nil, err + } + + if err := c.ensureContract(inputBoxAddr, "InputBox"); err != nil { + return nil, err + } + + caller, err := iinputbox.NewIInputBoxCaller(inputBoxAddr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IInputBox: %w", err) + } + + totalRaw, err := caller.GetNumberOfInputs(c.callOpts, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetNumberOfInputs: %w", err) + } + total, err := safeUint64(totalRaw, "total inputs") + if err != nil { + return nil, err + } + + return &InputBoxResult{ + Address: formatAddr(inputBoxAddr), + TotalInputs: total, + }, nil +} + +type applicationInputBoxCaller interface { + GetInputBox(opts *bind.CallOpts) (common.Address, error) +} + +// resolveInputBoxAddress reads the InputBox selected by this application. +func (c *chainClient) resolveInputBoxAddress() (common.Address, error) { + app, err := iapplication.NewIApplicationCaller(c.appAddr, c.eth) + if err != nil { + return common.Address{}, fmt.Errorf("bind IApplication for InputBox discovery: %w", err) + } + return readApplicationInputBox(app, c.callOpts) +} + +func readApplicationInputBox(caller applicationInputBoxCaller, opts *bind.CallOpts) (common.Address, error) { + inputBox, err := caller.GetInputBox(opts) + if err != nil { + return common.Address{}, fmt.Errorf("IApplication.GetInputBox: %w", err) + } + if inputBox == (common.Address{}) { + return common.Address{}, fmt.Errorf("IApplication.GetInputBox returned the zero address") + } + return inputBox, nil +} diff --git a/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go b/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go new file mode 100644 index 000000000..7de56723a --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/inputbox_test.go @@ -0,0 +1,40 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type applicationInputBoxCallerStub struct { + inputBox common.Address + err error +} + +func (s applicationInputBoxCallerStub) GetInputBox(*bind.CallOpts) (common.Address, error) { + return s.inputBox, s.err +} + +func TestReadApplicationInputBox(t *testing.T) { + want := common.HexToAddress("0x1234567890123456789012345678901234567890") + got, err := readApplicationInputBox(applicationInputBoxCallerStub{inputBox: want}, nil) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestReadApplicationInputBoxRejectsZeroAddress(t *testing.T) { + _, err := readApplicationInputBox(applicationInputBoxCallerStub{}, nil) + require.ErrorContains(t, err, "zero address") +} + +func TestReadApplicationInputBoxReturnsCallError(t *testing.T) { + wantErr := errors.New("call failed") + _, err := readApplicationInputBox(applicationInputBoxCallerStub{err: wantErr}, nil) + require.ErrorIs(t, err, wantErr) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/match.go b/cmd/cartesi-rollups-cli/root/contract/match.go new file mode 100644 index 000000000..419e863b3 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/match.go @@ -0,0 +1,309 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "os" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" +) + +var matchCmd = &cobra.Command{ + Use: "match ", + Short: "Inspect a specific tournament match", + Args: cobra.ExactArgs(2), //nolint:mnd + RunE: runMatch, +} + +func runMatch(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + if err := validateHash(args[1], "match ID hash"); err != nil { + return err + } + matchIDHash := common.HexToHash(args[1]) + + // Resolve tournament address using the same logic as the tournament subcommand. + res, err := cc.resolveTournamentAddress(args[:1]) + if err != nil { + return fmt.Errorf("resolve tournament: %w", err) + } + tournamentAddr, deployBlock := res.addr, res.deployBlock + + if err := cc.ensureContract(tournamentAddr, "tournament"); err != nil { + return err + } + + caller, err := itournament.NewITournamentCaller(tournamentAddr, cc.eth) + if err != nil { + return fmt.Errorf("bind ITournament: %w", err) + } + + bisecting, err := caller.BisectingMatch(cc.callOpts, [32]byte(matchIDHash)) + if err != nil { + return fmt.Errorf("BisectingMatch: %w", err) + } + + // Discover commitment hashes for this match from MatchCreated events. + // The timeout view takes the full match ID. The event also identifies the players. + commitOne, commitTwo, lookupErr := cc.findMatchCommitments( + tournamentAddr, deployBlock, [32]byte(matchIDHash)) + if lookupErr != nil { + return fmt.Errorf("look up MatchCreated commitments: %w", lookupErr) + } + + timeout, err := caller.ClassifyMatchTimeout(cc.callOpts, + itournament.MatchId{CommitmentOne: commitOne, CommitmentTwo: commitTwo}) + if err != nil { + return fmt.Errorf("ClassifyMatchTimeout: %w", err) + } + if timeout.ActualPhase != bisecting.ActualPhase { + return fmt.Errorf( + "inconsistent match phase at pinned block %d: BisectingMatch=%s, ClassifyMatchTimeout=%s", + cc.blockNum, + matchPhaseName(bisecting.ActualPhase), + matchPhaseName(timeout.ActualPhase), + ) + } + + // Build commitment registry for player address resolution. + registry := make(commitmentRegistry) + if commitOne != ([32]byte{}) || commitTwo != ([32]byte{}) { + events, eErr := cc.fetchTournamentEvents(tournamentAddr, deployBlock) + if eErr != nil { + slog.Warn("failed to fetch events for address resolution", "error", eErr) + } else { + for _, cj := range events.commitmentsJoined { + registry[cj.commitment] = cj.submitter + } + } + } + + result := &MatchResult{ + MatchIDHash: formatHash([32]byte(matchIDHash)), + Tournament: formatAddr(tournamentAddr), + CommitmentOne: formatHash(commitOne), + CommitmentTwo: formatHash(commitTwo), + PlayerOneAddr: registry.resolve(commitOne), + PlayerTwoAddr: registry.resolve(commitTwo), + ActualPhase: matchPhaseName(bisecting.ActualPhase), + TimeoutOutcome: matchTimeoutOutcomeName(timeout.Outcome), + DeferredCharge: timeout.DeferredCharge, + } + + switch bisecting.ActualPhase { + case matchPhaseUninitialized: + // No phase payload exists. + case matchPhaseBisecting: + if err := populateBisectingMatchResult(result, bisecting.Value); err != nil { + return err + } + case matchPhaseReadyToSeal: + ready, rErr := caller.ReadyToSealMatch(cc.callOpts, [32]byte(matchIDHash)) + if rErr != nil { + return fmt.Errorf("ReadyToSealMatch: %w", rErr) + } + if ready.ActualPhase != bisecting.ActualPhase { + return inconsistentMatchPhaseError(cc.blockNum, "ReadyToSealMatch", ready.ActualPhase, bisecting.ActualPhase) + } + if err := populateReadyToSealMatchResult(result, ready.Value); err != nil { + return err + } + case matchPhaseSealed: + sealed, sErr := caller.SealedMatch(cc.callOpts, [32]byte(matchIDHash)) + if sErr != nil { + return fmt.Errorf("SealedMatch: %w", sErr) + } + if sealed.ActualPhase != bisecting.ActualPhase { + return inconsistentMatchPhaseError(cc.blockNum, "SealedMatch", sealed.ActualPhase, bisecting.ActualPhase) + } + if err := populateSealedMatchResult(result, sealed.Value); err != nil { + return err + } + default: + return fmt.Errorf("unknown match phase %d", bisecting.ActualPhase) + } + + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + printMatchResult(p, result) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} + +func populateBisectingMatchResult(result *MatchResult, value itournament.ITournamentBisectingMatchView) error { + if value.SegmentStartPosition == nil || value.SegmentStartCycle == nil { + return errors.New("BisectingMatch returned nil position or cycle in BISECTING phase") + } + currentHeight := value.CurrentHeight + result.CurrentHeight = ¤tHeight + result.SegmentStartPosition = value.SegmentStartPosition.String() + result.SegmentStartCycle = value.SegmentStartCycle.String() + result.RevealingParent = formatHash(value.RevealingParent) + result.WaitingLeft = formatHash(value.WaitingLeft) + result.WaitingRight = formatHash(value.WaitingRight) + result.Responder = commitmentSideName(value.Responder) + return nil +} + +func populateReadyToSealMatchResult(result *MatchResult, value itournament.ITournamentReadyToSealMatchView) error { + if value.SegmentStartPosition == nil || value.SegmentStartCycle == nil { + return errors.New("ReadyToSealMatch returned nil position or cycle in READY_TO_SEAL phase") + } + result.SegmentStartPosition = value.SegmentStartPosition.String() + result.SegmentStartCycle = value.SegmentStartCycle.String() + result.RevealingParent = formatHash(value.RevealingParent) + result.WaitingLeft = formatHash(value.WaitingLeft) + result.WaitingRight = formatHash(value.WaitingRight) + result.Responder = commitmentSideName(value.Responder) + return nil +} + +func populateSealedMatchResult(result *MatchResult, value itournament.ITournamentSealedMatchView) error { + if value.DivergencePosition == nil || value.DivergenceCycle == nil { + return errors.New("SealedMatch returned nil position or cycle in SEALED phase") + } + result.AgreeState = formatHash(value.AgreeState) + result.DivergencePosition = value.DivergencePosition.String() + result.DivergenceCycle = value.DivergenceCycle.String() + result.FinalStateOne = formatHash(value.FinalStateOne) + result.FinalStateTwo = formatHash(value.FinalStateTwo) + return nil +} + +func inconsistentMatchPhaseError(block uint64, view string, got, want uint8) error { + return fmt.Errorf( + "inconsistent match phase at pinned block %d: %s=%s, expected %s", + block, + view, + matchPhaseName(got), + matchPhaseName(want), + ) +} + +func printMatchResult(p *printer, result *MatchResult) { + p.withSection(fmt.Sprintf("Match %s", result.MatchIDHash), func() { + p.field("Tournament", result.Tournament) + printMatchPlayer(p, "Player One", result.CommitmentOne, result.PlayerOneAddr) + printMatchPlayer(p, "Player Two", result.CommitmentTwo, result.PlayerTwoAddr) + p.field("Actual Phase", result.ActualPhase) + p.field("Timeout Outcome", result.TimeoutOutcome) + p.field("Deferred Charge", fmt.Sprintf("%d blocks", result.DeferredCharge)) + + switch result.ActualPhase { + case matchPhaseName(matchPhaseBisecting), matchPhaseName(matchPhaseReadyToSeal): + if result.CurrentHeight != nil { + p.field("Current Height", fmt.Sprintf("%d", *result.CurrentHeight)) + } + p.field("Segment Start Position", result.SegmentStartPosition) + p.field("Segment Start Cycle", result.SegmentStartCycle) + p.field("Revealing Parent", result.RevealingParent) + p.field("Waiting Left", result.WaitingLeft) + p.field("Waiting Right", result.WaitingRight) + p.field("Responder", result.Responder) + case matchPhaseName(matchPhaseSealed): + p.field("Agree State", result.AgreeState) + p.field("Divergence Position", result.DivergencePosition) + p.field("Divergence Cycle", result.DivergenceCycle) + p.field("Final State One", result.FinalStateOne) + p.field("Final State Two", result.FinalStateTwo) + } + }) +} + +func printMatchPlayer(p *printer, label, commitment, addr string) { + info := commitment + if addr != "" { + info += fmt.Sprintf(" (%s)", addr) + } + p.field(label, info) +} + +// findMatchCommitments looks up the MatchCreated event for the given match ID hash +// to retrieve the two commitment hashes. Returns zero hashes and an error on failure. +func (c *chainClient) findMatchCommitments( + tournamentAddr common.Address, + deployBlock uint64, + matchIDHash [32]byte, +) ([32]byte, [32]byte, error) { + filterer, err := itournament.NewITournamentFilterer(tournamentAddr, c.eth) + if err != nil { + return [32]byte{}, [32]byte{}, fmt.Errorf("bind ITournament filterer: %w", err) + } + caller, err := itournament.NewITournamentCaller(tournamentAddr, c.eth) + if err != nil { + return [32]byte{}, [32]byte{}, fmt.Errorf("bind ITournament caller: %w", err) + } + + var commitOne, commitTwo [32]byte + found := false + + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return caller.GetMatchCreatedCount(opts) + } + + onHit := func(block uint64) error { + q, qErr := buildEventFilterQuery( + tournamentAddr, "MatchCreated", + itournament.ITournamentMetaData, block, block, + ) + if qErr != nil { + return qErr + } + itr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, q) + if fErr != nil { + return fErr + } + for log, logErr := range itr { + if logErr != nil { + return logErr + } + ev, pErr := filterer.ParseMatchCreated(*log) + if pErr != nil { + return pErr + } + if ev.MatchIdHash == matchIDHash { + commitOne = ev.One + commitTwo = ev.Two + found = true + return errFound + } + } + return nil + } + + _, err = ethutil.FindTransitions( + c.callOpts.Context, deployBlock, c.blockNum, big.NewInt(0), oracle, onHit, + ) + if err != nil && !errors.Is(err, errFound) { + return [32]byte{}, [32]byte{}, fmt.Errorf("find match commitments: %w", err) + } + if !found { + return [32]byte{}, [32]byte{}, fmt.Errorf( + "MatchCreated event %s was not found between blocks %d and %d", + formatHash(matchIDHash), + deployBlock, + c.blockNum, + ) + } + return commitOne, commitTwo, nil +} diff --git a/cmd/cartesi-rollups-cli/root/contract/match_test.go b/cmd/cartesi-rollups-cli/root/contract/match_test.go new file mode 100644 index 000000000..f84d66730 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/match_test.go @@ -0,0 +1,112 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "encoding/json" + "math/big" + "strconv" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectAdvancedMatchSchedule(t *testing.T) { + for _, test := range []struct { + name string + height uint64 + responderDeadline uint64 + eliminableAt uint64 + }{ + {name: "bisection response", height: 6, responderDeadline: 210, eliminableAt: 310}, + {name: "seal response at height one", height: 1, responderDeadline: 420, eliminableAt: 520}, + } { + t.Run(test.name, func(t *testing.T) { + matchID := common.HexToHash("0xaa") + events := collectScheduleEvent(t, "MatchAdvanced", []common.Hash{matchID}, + [32]byte{0x01}, [32]byte{0x02}, [32]byte{0x03}, [32]byte{0x04}, + big.NewInt(0), test.height, test.responderDeadline, test.eliminableAt) + require.Len(t, events.matchesAdvanced, 1) + result := formatAdvanceEvents(events.matchesAdvanced) + require.Len(t, result, 1) + encoded, err := json.Marshal(result[0]) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.JSONEq(t, `"`+matchID.Hex()+`"`, string(fields["match_id_hash"])) + require.JSONEq(t, `"0"`, string(fields["segment_start_position"])) + require.Equal(t, strconv.FormatUint(test.height, 10), string(fields["current_height"])) + require.Equal(t, strconv.FormatUint(test.responderDeadline, 10), string(fields["responder_deadline"])) + require.Equal(t, strconv.FormatUint(test.eliminableAt, 10), string(fields["eliminable_at"])) + }) + } +} + +func TestPopulateBisectingMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentBisectingMatchView{ + RevealingParent: [32]byte{0x01}, + WaitingLeft: [32]byte{0x02}, + WaitingRight: [32]byte{0x03}, + SegmentStartPosition: big.NewInt(4), + SegmentStartCycle: big.NewInt(5), + CurrentHeight: 6, + Responder: 1, + } + + require.NoError(t, populateBisectingMatchResult(result, value)) + require.NotNil(t, result.CurrentHeight) + assert.Equal(t, uint64(6), *result.CurrentHeight) + assert.Equal(t, "4", result.SegmentStartPosition) + assert.Equal(t, "5", result.SegmentStartCycle) + assert.Equal(t, formatHash(value.RevealingParent), result.RevealingParent) + assert.Equal(t, formatHash(value.WaitingLeft), result.WaitingLeft) + assert.Equal(t, formatHash(value.WaitingRight), result.WaitingRight) + assert.Equal(t, "TWO", result.Responder) +} + +func TestPopulateReadyToSealMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentReadyToSealMatchView{ + RevealingParent: [32]byte{0x01}, + WaitingLeft: [32]byte{0x02}, + WaitingRight: [32]byte{0x03}, + SegmentStartPosition: big.NewInt(7), + SegmentStartCycle: big.NewInt(8), + Responder: 0, + } + + require.NoError(t, populateReadyToSealMatchResult(result, value)) + assert.Nil(t, result.CurrentHeight) + assert.Equal(t, "7", result.SegmentStartPosition) + assert.Equal(t, "8", result.SegmentStartCycle) + assert.Equal(t, "ONE", result.Responder) +} + +func TestPopulateSealedMatchResult(t *testing.T) { + result := &MatchResult{} + value := itournament.ITournamentSealedMatchView{ + AgreeState: [32]byte{0x01}, + DivergencePosition: big.NewInt(9), + DivergenceCycle: big.NewInt(10), + FinalStateOne: [32]byte{0x02}, + FinalStateTwo: [32]byte{0x03}, + } + + require.NoError(t, populateSealedMatchResult(result, value)) + assert.Equal(t, formatHash(value.AgreeState), result.AgreeState) + assert.Equal(t, "9", result.DivergencePosition) + assert.Equal(t, "10", result.DivergenceCycle) + assert.Equal(t, formatHash(value.FinalStateOne), result.FinalStateOne) + assert.Equal(t, formatHash(value.FinalStateTwo), result.FinalStateTwo) +} + +func TestMatchPayloadRejectsNilPositions(t *testing.T) { + assert.Error(t, populateBisectingMatchResult(&MatchResult{}, itournament.ITournamentBisectingMatchView{})) + assert.Error(t, populateReadyToSealMatchResult(&MatchResult{}, itournament.ITournamentReadyToSealMatchView{})) + assert.Error(t, populateSealedMatchResult(&MatchResult{}, itournament.ITournamentSealedMatchView{})) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/output.go b/cmd/cartesi-rollups-cli/root/contract/output.go new file mode 100644 index 000000000..630816a94 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/output.go @@ -0,0 +1,83 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "fmt" + "math/big" + "os" + "strconv" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/spf13/cobra" +) + +var outputCmd = &cobra.Command{ + Use: "output [output-index]", + Short: "Check output execution status", + Args: cobra.RangeArgs(1, 2), //nolint:mnd + RunE: runOutput, +} + +func runOutput(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + if err := cc.ensureContract(cc.appAddr, "application"); err != nil { + return err + } + + app, err := iapplication.NewIApplicationCaller(cc.appAddr, cc.eth) + if err != nil { + return fmt.Errorf("bind IApplication: %w", err) + } + + totalRaw, err := app.GetNumberOfExecutedOutputs(cc.callOpts) + if err != nil { + return fmt.Errorf("GetNumberOfExecutedOutputs: %w", err) + } + total, err := safeUint64(totalRaw, "executed outputs") + if err != nil { + return err + } + + // Single output mode: check a specific output index. + if len(args) > 1 { + idx, parseErr := strconv.ParseUint(args[1], 10, 64) + if parseErr != nil { + return fmt.Errorf("invalid output index: %q", args[1]) + } + executed, execErr := app.WasOutputExecuted(cc.callOpts, new(big.Int).SetUint64(idx)) + if execErr != nil { + return fmt.Errorf("WasOutputExecuted(%d): %w", idx, execErr) + } + + result := &OutputResult{OutputIndex: idx, Executed: executed} + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + p.withSection(fmt.Sprintf("Output #%d", idx), func() { + p.field("Executed", fmt.Sprintf("%t", executed)) + }) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil + } + + // Batch mode: show summary of executed outputs. + result := &OutputBatchResult{TotalExecuted: total} + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + p.withSection(fmt.Sprintf("Outputs (%d executed)", total), func() {}) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} diff --git a/cmd/cartesi-rollups-cli/root/contract/selectors.go b/cmd/cartesi-rollups-cli/root/contract/selectors.go new file mode 100644 index 000000000..b96b9efcb --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/selectors.go @@ -0,0 +1,17 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "errors" +) + +// errFound is a sentinel error used to short-circuit FindTransitions +// after the target item has been located. +var errFound = errors.New("found") + +// errLimitReached is a sentinel error used to short-circuit FindTransitions +// after the configured event limit has been reached, avoiding unnecessary +// RPC calls for remaining transition blocks. +var errLimitReached = errors.New("limit reached") diff --git a/cmd/cartesi-rollups-cli/root/contract/sentries_test.go b/cmd/cartesi-rollups-cli/root/contract/sentries_test.go new file mode 100644 index 000000000..b91de37fc --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/sentries_test.go @@ -0,0 +1,257 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "os" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" +) + +const getSentryByIDMethod = "getSentryById" + +type sentryInspectionRPC struct{ mock.Mock } + +func (m *sentryInspectionRPC) GetCode(_ context.Context, address common.Address, block rpc.BlockNumber) hexutil.Bytes { + args := m.Called(address, block) + return args.Get(0).([]byte) +} + +func (m *sentryInspectionRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +type sentryInspectionFixture struct { + manager common.Address + sentries []common.Address + count *big.Int + failMethod string + failID uint64 +} + +func newSentryInspectionClient(t *testing.T, fixture sentryInspectionFixture) (*chainClient, common.Address) { + t.Helper() + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x100") + block := rpc.BlockNumber(1234) + backend := &sentryInspectionRPC{} + backend.On("GetCode", address, block).Return([]byte{0x01}).Once() + expectCall := func(name string, inputs, outputs []any) { + t.Helper() + input, packErr := contractABI.Pack(name, inputs...) + require.NoError(t, packErr) + var encoded []byte + var callErr error + if name == fixture.failMethod && (name != getSentryByIDMethod || inputs[0].(*big.Int).Uint64() == fixture.failID) { + callErr = errors.New("RPC read failed") + } else { + encoded, packErr = contractABI.Methods[name].Outputs.Pack(outputs...) + require.NoError(t, packErr) + } + backend.On("Call", address, input, block).Return(encoded, callErr).Once() + } + if fixture.count == nil { + fixture.count = big.NewInt(int64(len(fixture.sentries))) + } + for _, call := range []struct { + name string + values []any + }{ + {"canStageTournamentResult", []any{false, false, false, big.NewInt(1), [32]byte{}, [32]byte{}}}, + {"getCurrentSealedEpoch", []any{ + big.NewInt(1), big.NewInt(2), big.NewInt(3), common.HexToAddress("0x200"), false, big.NewInt(0), [32]byte{}, [32]byte{}, + }}, + {"getDeploymentBlockNumber", []any{big.NewInt(100)}}, + {"getInputBox", []any{common.HexToAddress("0x300")}}, + {"getTournamentFactory", []any{common.HexToAddress("0x400")}}, + {"getClaimStagingPeriod", []any{big.NewInt(300)}}, + {"getSentryManager", []any{fixture.manager}}, + {"getNumberOfSentries", []any{fixture.count}}, + } { + expectCall(call.name, nil, call.values) + if call.name == fixture.failMethod { + break + } + } + if fixture.failMethod == "" || fixture.failMethod == getSentryByIDMethod { + for index, sentry := range fixture.sentries { + id := uint64(index + 1) + expectCall(getSentryByIDMethod, []any{new(big.Int).SetUint64(id)}, []any{sentry}) + if sentry == (common.Address{}) || (fixture.failMethod == getSentryByIDMethod && fixture.failID == id) { + break + } + } + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + t.Cleanup(func() { backend.AssertExpectations(t) }) + return &chainClient{ + eth: client, + callOpts: &bind.CallOpts{Context: t.Context(), BlockNumber: big.NewInt(int64(block))}, + blockNum: uint64(block), + chainID: 31337, + }, address +} + +func TestQueryDaveSentriesAtPinnedBlock(t *testing.T) { + manager := common.HexToAddress("0x500") + first := common.HexToAddress("0x501") + second := common.HexToAddress("0x502") + rotated := common.HexToAddress("0x503") + for _, test := range []struct { + name string + manager common.Address + sentries []common.Address + }{ + {name: "no sentries"}, + {name: "two sentries", manager: manager, sentries: []common.Address{first, second}}, + {name: "rotated first slot", manager: manager, sentries: []common.Address{rotated, second}}, + } { + t.Run(test.name, func(t *testing.T) { + client, address := newSentryInspectionClient(t, sentryInspectionFixture{manager: test.manager, sentries: test.sentries}) + result, err := client.queryDave(address) + require.NoError(t, err) + require.Equal(t, test.manager.Hex(), result.SentryManager) + require.EqualValues(t, len(test.sentries), result.NumSentries) + require.NotNil(t, result.Sentries) + require.Len(t, result.Sentries, len(test.sentries)) + for index, sentry := range test.sentries { + require.Equal(t, SentryResult{ID: uint64(index + 1), Address: sentry.Hex()}, result.Sentries[index]) + } + encoded, err := json.Marshal(result) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.JSONEq(t, fmt.Sprintf("%q", test.manager.Hex()), string(fields["sentry_manager"])) + require.Equal(t, fmt.Sprint(len(test.sentries)), string(fields["num_sentries"])) + if len(test.sentries) == 0 { + require.Equal(t, "[]", string(fields["sentries"])) + } else { + var sentries []SentryResult + require.NoError(t, json.Unmarshal(fields["sentries"], &sentries)) + require.Equal(t, result.Sentries, sentries) + } + var text bytes.Buffer + printConsensusSummary(&printer{w: &text}, consensusResult{result: result}) + require.Contains(t, text.String(), "Sentry Manager "+test.manager.Hex()) + require.Contains(t, text.String(), "Sentries "+fmt.Sprint(len(test.sentries))) + for index, sentry := range test.sentries { + require.Contains(t, text.String(), fmt.Sprintf(" Sentry #%d %s", index+1, sentry.Hex())) + } + }) + } +} + +func TestQueryDaveRejectsIncompleteSentryRoster(t *testing.T) { + for _, test := range []struct { + name string + fixture sentryInspectionFixture + wantErr string + }{ + {name: "manager read fails", fixture: sentryInspectionFixture{failMethod: "getSentryManager"}, wantErr: "GetSentryManager"}, + {name: "count read fails", fixture: sentryInspectionFixture{failMethod: "getNumberOfSentries"}, wantErr: "GetNumberOfSentries"}, + { + name: "second member read fails", + fixture: sentryInspectionFixture{ + sentries: []common.Address{common.HexToAddress("0x501"), common.HexToAddress("0x502")}, + failMethod: getSentryByIDMethod, failID: 2, + }, + wantErr: "GetSentryById(2)", + }, + { + name: "missing member", + fixture: sentryInspectionFixture{sentries: []common.Address{common.HexToAddress("0x501"), {}}}, + wantErr: "GetSentryById(2): registered sentry has zero address", + }, + { + name: "count exceeds inspection limit", + fixture: sentryInspectionFixture{count: big.NewInt(10001)}, + wantErr: "number of sentries 10001 exceeds inspection limit 10000", + }, + { + name: "count exceeds uint64", + fixture: sentryInspectionFixture{count: new(big.Int).Lsh(big.NewInt(1), 64)}, + wantErr: "number of sentries", + }, + } { + t.Run(test.name, func(t *testing.T) { + client, address := newSentryInspectionClient(t, test.fixture) + result, err := client.queryDave(address) + require.ErrorContains(t, err, test.wantErr) + require.Nil(t, result, "a failed member read must not expose a partial roster") + }) + } +} + +func TestPrintDaveIncludesSentryRoster(t *testing.T) { + previousJSON := jsonParam + t.Cleanup(func() { jsonParam = previousJSON }) + manager := common.HexToAddress("0x500") + sentry := common.HexToAddress("0x501") + for _, asJSON := range []bool{false, true} { + t.Run(fmt.Sprintf("json=%t", asJSON), func(t *testing.T) { + client, address := newSentryInspectionClient(t, sentryInspectionFixture{manager: manager, sentries: []common.Address{sentry}}) + jsonParam = asJSON + output := captureSentryInspectionOutput(t, func() { require.NoError(t, client.printDave(address)) }) + if asJSON { + var result DaveConsensusResult + require.NoError(t, json.Unmarshal([]byte(output), &result)) + require.Equal(t, manager.Hex(), result.SentryManager) + require.EqualValues(t, 1, result.NumSentries) + require.Equal(t, []SentryResult{{ID: 1, Address: sentry.Hex()}}, result.Sentries) + return + } + require.Contains(t, output, "Sentry Manager "+manager.Hex()) + require.Contains(t, output, "Sentries 1") + require.Contains(t, output, " Sentry #1 "+sentry.Hex()) + }) + } +} + +func captureSentryInspectionOutput(t *testing.T, fn func()) string { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "stdout") + require.NoError(t, err) + defer file.Close() + previous := os.Stdout + os.Stdout = file + defer func() { os.Stdout = previous }() + fn() + _, err = file.Seek(0, io.SeekStart) + require.NoError(t, err) + output, err := io.ReadAll(file) + require.NoError(t, err) + return string(output) +} diff --git a/cmd/cartesi-rollups-cli/root/contract/summary.go b/cmd/cartesi-rollups-cli/root/contract/summary.go new file mode 100644 index 000000000..97b7b995f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/summary.go @@ -0,0 +1,538 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "encoding/json" + "fmt" + "log/slog" + "math/big" + "os" + + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" +) + +const daveConsensusContractName = "DaveConsensus" + +var summaryCmd = &cobra.Command{ + Use: "summary ", + Short: "Full diagnostic snapshot (composes app + consensus + inputbox + root tournament)", + Args: cobra.ExactArgs(1), + RunE: runSummary, +} + +// consensusResult holds the query result for any consensus type. +type consensusResult struct { + cType string + result any // one of *Authority/Quorum/DaveConsensusResult + err error +} + +func runSummary(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + // Pre-resolve consensus address and type once. This avoids duplicate RPC calls + // between the consensus and tournament sections. + consensusAddr, consensusDetectErr := cc.getConsensusAddress() + var cType consensusType + var contractVersion string + if consensusDetectErr == nil && consensusAddr != (common.Address{}) { + cType, contractVersion, consensusDetectErr = cc.detectConsensus(consensusAddr) + } + + // Use plain errgroup (NOT WithContext) for partial failure — if one section fails, + // the others should continue to completion. + var g errgroup.Group + g.SetLimit(4) //nolint:mnd + + // Each goroutine writes to its own dedicated variable. g.Wait() provides + // the happens-before guarantee, so no mutex is needed. + var ( + appResult *AppResult + appErr error + cr consensusResult + inputBoxResult *InputBoxResult + inputBoxErr error + tournResult *TournamentResult + tournErr error + ) + + // Section 1: Application. + g.Go(func() error { + appResult, appErr = cc.queryApp() + return nil // always nil — partial failure + }) + + // Section 2: Consensus query (uses pre-resolved address and type). + g.Go(func() error { + if consensusDetectErr != nil { + cr.err = consensusDetectErr + return nil + } + if consensusAddr == (common.Address{}) { + return nil + } + + cr.cType = cType.String() + + var cResult any + var cErr error + switch cType { + case consensusAuthority: + cResult, cErr = cc.queryAuthority(consensusAddr, contractVersion) + case consensusQuorum: + cResult, cErr = cc.queryQuorum(consensusAddr, contractVersion) + case consensusDave: + cResult, cErr = cc.queryDave(consensusAddr) + case consensusUnknown: + cErr = fmt.Errorf("unknown consensus type at %s", consensusAddr) + } + + cr.result, cr.err = cResult, cErr + return nil + }) + + // Section 3: InputBox. + g.Go(func() error { + inputBoxResult, inputBoxErr = cc.queryInputBox() + return nil + }) + + // Section 4: Root Tournament (only for DaveConsensus). + if consensusDetectErr == nil && cType == consensusDave { + g.Go(func() error { + daveCaller, dErr := idaveconsensus.NewIDaveConsensusCaller( + consensusAddr, cc.eth) + if dErr != nil { + tournErr = dErr + return nil + } + + sealed, dErr := daveCaller.GetCurrentSealedEpoch(cc.callOpts) + if dErr != nil { + tournErr = dErr + return nil + } + if sealed.Tournament == (common.Address{}) { + return nil // no sealed epoch yet + } + + tournResult, tournErr = cc.queryTournament(sealed.Tournament) + return nil + }) + } + + _ = g.Wait() // errors are always nil (partial failure) + + if jsonParam { + result := &SummaryResult{} + if appErr != nil { + result.AppError = appErr.Error() + } else { + result.Application = appResult + } + if cr.err != nil { + result.ConsensusError = cr.err.Error() + } else if cr.result != nil { + result.Consensus, _ = json.Marshal(cr.result) + } + if inputBoxErr != nil { + result.InputBoxError = inputBoxErr.Error() + } else { + result.InputBox = inputBoxResult + } + if tournErr != nil { + result.TournamentError = tournErr.Error() + } else { + result.RootTournament = tournResult + } + return outputJSON(result) + } + + // Text output with partial failure. + p := &printer{w: os.Stdout} + + if appErr != nil { + p.withSection(fmt.Sprintf("Application %s", formatAddr(cc.appAddr)), func() { + p.fieldErr("application query", appErr) + }) + } else if appResult != nil { + p.withSection(fmt.Sprintf("Application %s", appResult.Address), func() { + printAppFields(p, appResult) + }) + } + + displayConsensusAddr := consensusAddr + if appResult != nil { + displayConsensusAddr = common.HexToAddress(appResult.ConsensusAddress) + } + + if cr.err != nil { + label := "Consensus" + if cr.cType != "" { + label = cr.cType + } + p.withSection(fmt.Sprintf("%s %s", label, formatAddr(displayConsensusAddr)), func() { + p.fieldErr("consensus query", cr.err) + }) + } else if cr.result != nil { + printConsensusSummary(p, cr) + } + + // Root Tournament (DaveConsensus only). + if tournErr != nil { + p.withSection("Root Tournament", func() { + p.fieldErr("tournament query", tournErr) + }) + } else if tournResult != nil { + printTournamentBasic(p, tournResult) + } + + // InputBox. + if inputBoxErr != nil { + p.withSection("InputBox", func() { + p.fieldErr("inputbox query", inputBoxErr) + }) + } else if inputBoxResult != nil { + p.withSection("InputBox", func() { + p.field("Total Inputs", fmt.Sprintf("%d", inputBoxResult.TotalInputs)) + }) + } + + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} + +func printConsensusSummary(p *printer, cr consensusResult) { + switch r := cr.result.(type) { + case *AuthorityConsensusResult: + p.withSection(fmt.Sprintf("Authority %s", r.Address), func() { + p.field("Owner (Validator)", r.Owner) + p.field("Epoch Length", fmt.Sprintf("%d blocks", r.EpochLength)) + p.field("Accepted Claims", fmt.Sprintf("%d", r.AcceptedClaims)) + p.field("IConsensus Version", r.ContractVersion) + }) + case *QuorumConsensusResult: + p.withSection(fmt.Sprintf("Quorum %s", r.Address), func() { + p.field("Validators", fmt.Sprintf("%d", r.NumValidators)) + p.field("Quorum Threshold", + fmt.Sprintf("%d (computed: strict majority)", r.QuorumThreshold)) + p.field("Epoch Length", fmt.Sprintf("%d blocks", r.EpochLength)) + p.field("Accepted Claims", fmt.Sprintf("%d", r.AcceptedClaims)) + if r.ContractVersion != "" { + p.field("IConsensus Version", r.ContractVersion) + } + }) + case *DaveConsensusResult: + p.withSection(fmt.Sprintf("DaveConsensus %s", r.Address), func() { + p.field("Deployment Block", fmt.Sprintf("%d", r.DeploymentBlock)) + printTournamentFinished(p, r) + p.field("Current Sealed Epoch", fmt.Sprintf("%d", r.CurrentEpochNumber)) + p.field("Root Tournament", r.RootTournament) + printDaveSentries(p, r) + printDaveStaging(p, r) + }) + } +} + +// queryAuthority returns a structured Authority result. +func (c *chainClient) queryAuthority( + addr common.Address, contractVersion string, +) (*AuthorityConsensusResult, error) { + if err := c.ensureContract(addr, consensusAuthority.String()); err != nil { + return nil, err + } + caller, err := iauthority.NewIAuthorityCaller(addr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IAuthority: %w", err) + } + + owner, err := caller.Owner(c.callOpts) + if err != nil { + return nil, fmt.Errorf("IAuthority.Owner: %w", err) + } + + epochLengthRaw, err := caller.GetEpochLength(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetEpochLength: %w", err) + } + epochLength, err := safeUint64(epochLengthRaw, "epoch length") + if err != nil { + return nil, err + } + + claimsRaw, err := caller.GetNumberOfAcceptedClaims(c.callOpts, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetNumberOfAcceptedClaims: %w", err) + } + claims, err := safeUint64(claimsRaw, "accepted claims") + if err != nil { + return nil, err + } + + stagingPeriodRaw, err := caller.GetClaimStagingPeriod(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetClaimStagingPeriod: %w", err) + } + stagingPeriod, err := safeUint64(stagingPeriodRaw, "claim staging period") + if err != nil { + return nil, err + } + + stagedRaw, err := caller.GetNumberOfStagedClaims(c.callOpts, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetNumberOfStagedClaims: %w", err) + } + staged, err := safeUint64(stagedRaw, "staged claims") + if err != nil { + return nil, err + } + + return &AuthorityConsensusResult{ + Type: consensusAuthority.String(), + Address: formatAddr(addr), + Owner: formatAddr(owner), + EpochLength: epochLength, + ClaimStagingPeriod: stagingPeriod, + AcceptedClaims: claims, + StagedClaims: staged, + ContractVersion: contractVersion, + }, nil +} + +// queryQuorum returns a structured Quorum result. +func (c *chainClient) queryQuorum( + addr common.Address, contractVersion string, +) (*QuorumConsensusResult, error) { + if err := c.ensureContract(addr, consensusQuorum.String()); err != nil { + return nil, err + } + caller, err := iquorum.NewIQuorumCaller(addr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IQuorum: %w", err) + } + + epochLengthRaw, err := caller.GetEpochLength(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetEpochLength: %w", err) + } + epochLength, err := safeUint64(epochLengthRaw, "epoch length") + if err != nil { + return nil, err + } + + claimsRaw, err := caller.GetNumberOfAcceptedClaims(c.callOpts, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetNumberOfAcceptedClaims: %w", err) + } + claims, err := safeUint64(claimsRaw, "accepted claims") + if err != nil { + return nil, err + } + + numValRaw, err := caller.NumOfValidators(c.callOpts) + if err != nil { + return nil, fmt.Errorf("NumOfValidators: %w", err) + } + numVal, err := safeUint64(numValRaw, "num validators") + if err != nil { + return nil, err + } + + const maxValidators = 10000 + if numVal > maxValidators { + slog.Warn("validator count exceeds cap, truncating", + "reported", numVal, "cap", maxValidators) + numVal = maxValidators + } + validators := make([]string, 0, numVal) + for i := uint64(1); i <= numVal; i++ { + valAddr, err := caller.ValidatorById(c.callOpts, new(big.Int).SetUint64(i)) + if err != nil { + return nil, fmt.Errorf("ValidatorById(%d): %w", i, err) + } + validators = append(validators, formatAddr(valAddr)) + } + + threshold := 1 + numVal/2 //nolint:mnd + + stagingPeriodRaw, err := caller.GetClaimStagingPeriod(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetClaimStagingPeriod: %w", err) + } + stagingPeriod, err := safeUint64(stagingPeriodRaw, "claim staging period") + if err != nil { + return nil, err + } + + stagedRaw, err := caller.GetNumberOfStagedClaims(c.callOpts, c.appAddr) + if err != nil { + return nil, fmt.Errorf("GetNumberOfStagedClaims: %w", err) + } + staged, err := safeUint64(stagedRaw, "staged claims") + if err != nil { + return nil, err + } + + return &QuorumConsensusResult{ + Type: consensusQuorum.String(), + Address: formatAddr(addr), + NumValidators: numVal, + QuorumThreshold: threshold, + Validators: validators, + EpochLength: epochLength, + ClaimStagingPeriod: stagingPeriod, + AcceptedClaims: claims, + StagedClaims: staged, + ContractVersion: contractVersion, + }, nil +} + +// queryDave returns a structured DaveConsensus result. +func (c *chainClient) queryDave( + addr common.Address, +) (*DaveConsensusResult, error) { + if err := c.ensureContract(addr, daveConsensusContractName); err != nil { + return nil, err + } + caller, err := idaveconsensus.NewIDaveConsensusCaller(addr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind IDaveConsensus: %w", err) + } + + stageInfo, err := caller.CanStageTournamentResult(c.callOpts) + if err != nil { + return nil, fmt.Errorf("CanStageTournamentResult: %w", err) + } + + sealed, err := caller.GetCurrentSealedEpoch(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetCurrentSealedEpoch: %w", err) + } + + epochNumber, err := safeUint64(sealed.EpochNumber, "epoch number") + if err != nil { + return nil, err + } + + inputLower, err := safeUint64(sealed.InputIndexLowerBound, "input lower bound") + if err != nil { + return nil, err + } + + inputUpper, err := safeUint64(sealed.InputIndexUpperBound, "input upper bound") + if err != nil { + return nil, err + } + + deployBlockRaw, err := caller.GetDeploymentBlockNumber(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetDeploymentBlockNumber: %w", err) + } + deployBlock, err := safeUint64(deployBlockRaw, "deployment block") + if err != nil { + return nil, err + } + + inputBox, err := caller.GetInputBox(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetInputBox: %w", err) + } + + factory, err := caller.GetTournamentFactory(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetTournamentFactory: %w", err) + } + stagingPeriodRaw, err := caller.GetClaimStagingPeriod(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetClaimStagingPeriod: %w", err) + } + stagingPeriod, err := safeUint64(stagingPeriodRaw, "claim staging period") + if err != nil { + return nil, err + } + + result := &DaveConsensusResult{ + Type: daveConsensusContractName, + Address: formatAddr(addr), + InputBox: formatAddr(inputBox), + Factory: formatAddr(factory), + DeploymentBlock: deployBlock, + ClaimStagingPeriod: stagingPeriod, + IsFinished: stageInfo.IsFinished, + IsTournamentFailed: stageInfo.IsTournamentFailed, + IsTournamentResultStaged: sealed.IsTournamentResultStaged, + CurrentEpochNumber: epochNumber, + InputLowerBound: inputLower, + InputUpperBound: inputUpper, + RootTournament: formatAddr(sealed.Tournament), + } + sentryManager, err := caller.GetSentryManager(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetSentryManager: %w", err) + } + numSentriesRaw, err := caller.GetNumberOfSentries(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetNumberOfSentries: %w", err) + } + numSentries, err := safeUint64(numSentriesRaw, "number of sentries") + if err != nil { + return nil, err + } + // Bound the work for untrusted contract addresses. Never print a partial roster. + const maxSentries = 10000 + if numSentries > maxSentries { + return nil, fmt.Errorf("number of sentries %d exceeds inspection limit %d", numSentries, maxSentries) + } + result.SentryManager = formatAddr(sentryManager) + result.NumSentries = numSentries + result.Sentries = make([]SentryResult, 0, numSentries) + for id := uint64(1); id <= numSentries; id++ { + sentry, err := caller.GetSentryById(c.callOpts, new(big.Int).SetUint64(id)) + if err != nil { + return nil, fmt.Errorf("GetSentryById(%d): %w", id, err) + } + if sentry == (common.Address{}) { + return nil, fmt.Errorf("GetSentryById(%d): registered sentry has zero address", id) + } + result.Sentries = append(result.Sentries, SentryResult{ID: id, Address: formatAddr(sentry)}) + } + if result.IsFinished { + hasWinner := !stageInfo.IsTournamentFailed && stageInfo.WinnerCommitment != [32]byte{} + result.HasWinner = &hasWinner + if hasWinner { + result.WinnerCommitment = formatHash(stageInfo.WinnerCommitment) + result.WinnerPostEpochMachineHash = formatHash(stageInfo.WinnerPostEpochMachineStateHash) + } + } + if sealed.IsTournamentResultStaged { + stagingBlock, err := safeUint64(sealed.StagingBlockNumber, "staging block") + if err != nil { + return nil, err + } + result.StagingBlock = &stagingBlock + result.StagedMachineHash = formatHash(sealed.StagedPostEpochMachineStateHash) + result.StagedOutputsMerkleRoot = formatHash(sealed.StagedPostEpochOutputsMerkleRoot) + + acceptInfo, err := caller.CanAcceptStagedTournamentResult(c.callOpts) + if err != nil { + return nil, fmt.Errorf("CanAcceptStagedTournamentResult: %w", err) + } + allSentriesAgree := acceptInfo.DoAllSentriesAgreeWithStagedTournamentResult + claimStagingPeriodOver := acceptInfo.IsClaimStagingPeriodOver + result.AllSentriesAgree = &allSentriesAgree + result.ClaimStagingPeriodOver = &claimStagingPeriodOver + } + return result, nil +} diff --git a/cmd/cartesi-rollups-cli/root/contract/tournament.go b/cmd/cartesi-rollups-cli/root/contract/tournament.go new file mode 100644 index 000000000..1fa36caf6 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/tournament.go @@ -0,0 +1,1241 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math" + "math/big" + "os" + "strings" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/imultileveltournamentfactory" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" +) + +var ( + tournamentTreeFlag bool + tournamentEventsFlag bool + tournamentEpochFlag int64 + tournamentLimitFlag int + tournamentMaxNodesFlag int +) + +var tournamentCmd = &cobra.Command{ + Use: "tournament [tournament-address]", + Short: "Query tournament contract state (PRT dispute resolution)", + Args: cobra.RangeArgs(1, 2), //nolint:mnd + RunE: runTournament, +} + +func init() { + tournamentCmd.Flags().BoolVar(&tournamentTreeFlag, "tree", false, + "Recursively traverse the full tournament hierarchy.\n"+ + "WARNING: generates many RPC calls (8+ view calls + event discovery\n"+ + "per node). On rate-limited providers, use --max-nodes and --limit\n"+ + "to control RPC volume") + tournamentCmd.Flags().BoolVar(&tournamentEventsFlag, "events", false, + "Show detailed event lists (commitments, matches, advances)") + tournamentCmd.Flags().Int64Var(&tournamentEpochFlag, "epoch", -1, + "Inspect tournament for a specific epoch number (default: current sealed epoch)") + tournamentCmd.Flags().IntVar(&tournamentLimitFlag, "limit", 50, //nolint:mnd + "Maximum events per type per tournament") + tournamentCmd.Flags().IntVar(&tournamentMaxNodesFlag, "max-nodes", 100, //nolint:mnd + "Maximum tournament nodes to visit in --tree traversal") +} + +func runTournament(cmd *cobra.Command, args []string) error { + cc, cancel, err := initChainClient(cmd, args) + if err != nil { + return err + } + defer cancel() + defer cc.eth.Close() + + res, err := cc.resolveTournamentAddress(args) + if err != nil { + return err + } + + if err := cc.ensureContract(res.addr, "tournament"); err != nil { + return err + } + + if tournamentTreeFlag { + // --tree always includes events for commitment registry. + tree, tErr := cc.walkTournamentTree(res.addr, res.deployBlock, true) + if tErr != nil { + return tErr + } + if jsonParam { + return outputJSON(tree.root) + } + p := &printer{w: os.Stdout} + printTournamentContext(p, cc, res) + renderTournamentTree(p, tree.root, tree, 0) + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil + } + + result, err := cc.queryTournament(res.addr) + if err != nil { + return err + } + + if tournamentEventsFlag { + events, eErr := cc.fetchTournamentEvents(res.addr, res.deployBlock) + if eErr != nil { + slog.Warn("failed to fetch tournament events", "error", eErr) + } else { + registry := make(commitmentRegistry) + for _, cj := range events.commitmentsJoined { + registry[cj.commitment] = cj.submitter + } + result.Commitments = formatCommitmentEvents(events.commitmentsJoined) + result.Matches = formatMatchEvents(events.matchesCreated, events.matchesDeleted, registry) + result.Advances = formatAdvanceEvents(events.matchesAdvanced) + } + } + + if jsonParam { + return outputJSON(result) + } + + p := &printer{w: os.Stdout} + printTournamentContext(p, cc, res) + printTournamentBasic(p, result) + + if tournamentEventsFlag { + printTournamentEvents(p, result) + } + + p.footer(cc.blockNum, cc.chainID, cc.resolveTimestamp(cc.blockNum)) + return nil +} + +// printTournamentContext prints application and epoch context before tournament details. +func printTournamentContext(p *printer, cc *chainClient, res tournamentResolution) { + p.withSection(fmt.Sprintf("Application %s", formatAddr(cc.appAddr)), func() { + if res.epochNumber >= 0 { + p.field("Epoch", fmt.Sprintf("%d", res.epochNumber)) + } + }) +} + +// tournamentResolution holds the result of resolving a tournament address. +type tournamentResolution struct { + addr common.Address + deployBlock uint64 + epochNumber int64 // -1 if unknown (direct address) +} + +// resolveTournamentAddress determines the tournament address from: +// (1) positional arg, (2) --epoch flag, (3) current sealed epoch. +func (c *chainClient) resolveTournamentAddress( + args []string, +) (tournamentResolution, error) { + consensusAddr, err := c.getConsensusAddress() + if err != nil { + return tournamentResolution{}, fmt.Errorf("get consensus address: %w", err) + } + + // Verify the consensus is DaveConsensus before calling Dave-specific methods. + // Authority/Quorum contracts do not have tournaments. + cType, _, err := c.detectConsensus(consensusAddr) + if err != nil { + return tournamentResolution{}, fmt.Errorf("detect consensus type: %w", err) + } + if cType != consensusDave { + return tournamentResolution{}, fmt.Errorf( + "tournament is only available for DaveConsensus (PRT); "+ + "consensus at %s is %s", consensusAddr, cType) + } + + daveCaller, err := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) + if err != nil { + return tournamentResolution{}, fmt.Errorf("bind IDaveConsensus: %w", err) + } + + deployBlockRaw, err := daveCaller.GetDeploymentBlockNumber(c.callOpts) + if err != nil { + return tournamentResolution{}, fmt.Errorf("GetDeploymentBlockNumber: %w", err) + } + deployBlock, err := safeUint64(deployBlockRaw, "deployment block") + if err != nil { + return tournamentResolution{}, err + } + + // (1) Direct tournament address from positional arg. + if len(args) > 1 { + if !common.IsHexAddress(args[1]) { + return tournamentResolution{}, + fmt.Errorf("invalid tournament address: %q", args[1]) + } + return tournamentResolution{ + addr: common.HexToAddress(args[1]), deployBlock: deployBlock, epochNumber: -1, + }, nil + } + + // (2) --epoch flag: find tournament from EpochSealed events. + if tournamentEpochFlag >= 0 { + addr, fErr := c.findTournamentForEpoch(consensusAddr, daveCaller, deployBlock, + uint64(tournamentEpochFlag)) + if fErr != nil { + return tournamentResolution{}, fErr + } + return tournamentResolution{ + addr: addr, deployBlock: deployBlock, epochNumber: tournamentEpochFlag, + }, nil + } + + // (3) Default: current sealed epoch. + sealed, err := daveCaller.GetCurrentSealedEpoch(c.callOpts) + if err != nil { + return tournamentResolution{}, fmt.Errorf("GetCurrentSealedEpoch: %w", err) + } + if sealed.Tournament == (common.Address{}) { + return tournamentResolution{}, + fmt.Errorf("no sealed epochs yet — tournament not available") + } + epochNum, err := safeUint64(sealed.EpochNumber, "epoch number") + if err != nil { + return tournamentResolution{}, err + } + if epochNum > math.MaxInt64 { + return tournamentResolution{}, + fmt.Errorf("epoch number %d exceeds int64 max", epochNum) + } + return tournamentResolution{ + addr: sealed.Tournament, deployBlock: deployBlock, epochNumber: int64(epochNum), + }, nil +} + +// findTournamentForEpoch locates the tournament address for a specific epoch +// using FindTransitions on the sealed epoch counter. +func (c *chainClient) findTournamentForEpoch( + consensusAddr common.Address, + daveCaller *idaveconsensus.IDaveConsensusCaller, + deployBlock, targetEpoch uint64, +) (common.Address, error) { + filterer, err := idaveconsensus.NewIDaveConsensusFilterer(consensusAddr, c.eth) + if err != nil { + return common.Address{}, fmt.Errorf("bind IDaveConsensus filterer: %w", err) + } + + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + sealed, sErr := daveCaller.GetCurrentSealedEpoch(opts) + if sErr != nil { + return nil, sErr + } + return sealed.EpochNumber, nil + } + + var result common.Address + + onHit := func(block uint64) error { + q, qErr := buildEventFilterQuery( + consensusAddr, "EpochSealed", + idaveconsensus.IDaveConsensusMetaData, block, block, + ) + if qErr != nil { + return qErr + } + itr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, q) + if fErr != nil { + return fErr + } + for log, logErr := range itr { + if logErr != nil { + return logErr + } + ev, pErr := filterer.ParseEpochSealed(*log) + if pErr != nil { + return pErr + } + epochNum, sErr := safeUint64(ev.EpochNumber, "epoch number") + if sErr != nil { + return sErr + } + if epochNum == targetEpoch { + result = ev.Tournament + return errFound + } + } + return nil + } + + // Use -1 as prevValue so epoch 0 is detected as a transition from -1→0. + _, err = ethutil.FindTransitions( + c.callOpts.Context, deployBlock, c.blockNum, big.NewInt(-1), oracle, onHit, + ) + if err != nil && !errors.Is(err, errFound) { + return common.Address{}, fmt.Errorf("find epoch %d tournament: %w", targetEpoch, err) + } + if result == (common.Address{}) { + return common.Address{}, fmt.Errorf("epoch %d not found in EpochSealed events", targetEpoch) + } + return result, nil +} + +// queryTournament reads ITournament view functions and returns a TournamentResult. +func (c *chainClient) queryTournament(addr common.Address) (*TournamentResult, error) { + if err := c.ensureContract(addr, "tournament"); err != nil { + return nil, err + } + caller, err := itournament.NewITournamentCaller(addr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind ITournament: %w", err) + } + + descriptor, err := caller.TournamentDescriptor(c.callOpts) + if err != nil { + return nil, fmt.Errorf("TournamentDescriptor: %w", err) + } + if descriptor.BaseCycle == nil || descriptor.BaseCycle.Sign() < 0 { + return nil, errors.New("TournamentDescriptor: invalid base cycle") + } + standing, err := caller.TournamentStanding(c.callOpts) + if err != nil { + return nil, fmt.Errorf("TournamentStanding: %w", err) + } + maxLevel, err := c.tournamentMaxLevel() + if err != nil { + return nil, err + } + if descriptor.Level > maxLevel { + return nil, fmt.Errorf("tournament level %d exceeds maximum level %d", descriptor.Level, maxLevel) + } + + bondWei, err := caller.BondValue(c.callOpts) + if err != nil { + return nil, fmt.Errorf("BondValue: %w", err) + } + + commitmentsRaw, err := caller.GetCommitmentJoinedCount(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetCommitmentJoinedCount: %w", err) + } + commitments, err := safeUint64(commitmentsRaw, "commitments joined") + if err != nil { + return nil, err + } + + matchesCreatedRaw, err := caller.GetMatchCreatedCount(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetMatchCreatedCount: %w", err) + } + matchesCreated, err := safeUint64(matchesCreatedRaw, "matches created") + if err != nil { + return nil, err + } + + matchesAdvancedRaw, err := caller.GetMatchAdvancedCount(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetMatchAdvancedCount: %w", err) + } + matchesAdvanced, err := safeUint64(matchesAdvancedRaw, "matches advanced") + if err != nil { + return nil, err + } + + matchesDeletedRaw, err := caller.GetMatchDeletedCount(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetMatchDeletedCount: %w", err) + } + matchesDeleted, err := safeUint64(matchesDeletedRaw, "matches deleted") + if err != nil { + return nil, err + } + + innerRaw, err := caller.GetNewInnerTournamentCount(c.callOpts) + if err != nil { + return nil, fmt.Errorf("GetNewInnerTournamentCount: %w", err) + } + inner, err := safeUint64(innerRaw, "inner tournaments") + if err != nil { + return nil, err + } + + result := &TournamentResult{ + Address: formatAddr(addr), + Level: descriptor.Level, + MaxLevel: maxLevel, + Log2Step: descriptor.Log2Stride, + Height: descriptor.Height, + Kind: tournamentKindName(descriptor.Kind), + InitialMachineHash: formatHash(descriptor.InitialHash), + BaseCycle: descriptor.BaseCycle.String(), + StartBlock: descriptor.StartInstant, + Allowance: descriptor.Allowance, + BondWei: bondWei.String(), + BondETH: weiToETH(bondWei), + CommitmentsJoined: commitments, + MatchesCreated: matchesCreated, + MatchesAdvanced: matchesAdvanced, + MatchesDeleted: matchesDeleted, + InnerTournaments: inner, + } + + err = populateTournamentStandingResult(result, standing, func(commitment [32]byte) ([32]byte, error) { + commitmentStanding, cErr := caller.CommitmentStanding(c.callOpts, commitment) + if cErr != nil { + return [32]byte{}, fmt.Errorf("CommitmentStanding: %w", cErr) + } + return commitmentStanding.FinalState, nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +type commitmentFinalStateReader func(commitment [32]byte) ([32]byte, error) + +func populateTournamentStandingResult( + result *TournamentResult, + standing itournament.ITournamentTournamentStandingView, + readCommitmentFinalState commitmentFinalStateReader, +) error { + result.Standing = tournamentStandingName(standing.Standing) + result.Closed = !standing.AcceptsJoins + result.Finished = standing.FinishedAt != 0 + + if result.Level > 0 { + canEliminate := standing.Standing == tournamentStandingInnerEliminableNoWinner || + standing.Standing == tournamentStandingInnerEliminableWinnerExpired + result.CanBeEliminated = &canEliminate + } + if !result.Finished { + return nil + } + + finishedAt := standing.FinishedAt + result.FinishedAtBlock = &finishedAt + hasWinner := standing.HasCandidate + result.HasWinner = &hasWinner + if standing.WinnerExpiresAt != 0 { + winnerExpiresAt := standing.WinnerExpiresAt + result.WinnerExpiresAt = &winnerExpiresAt + } + if !hasWinner { + return nil + } + + result.WinnerCommitment = formatHash(standing.Candidate) + finalState := standing.FinalState + if finalState == ([32]byte{}) && standing.Standing == tournamentStandingInnerEliminableWinnerExpired { + var err error + finalState, err = readCommitmentFinalState(standing.Candidate) + if err != nil { + return err + } + } + if finalState != ([32]byte{}) { + result.FinalMachineHash = formatHash(finalState) + } + return nil +} + +const ( + tournamentStandingMatchesActive uint8 = iota + tournamentStandingAwaitingClosure + tournamentStandingRootWinner + tournamentStandingRootFailed + tournamentStandingInnerWinner + tournamentStandingInnerEliminableNoWinner + tournamentStandingInnerEliminableWinnerExpired +) + +const ( + tournamentKindLeaf uint8 = iota + tournamentKindNonLeaf +) + +func tournamentStandingName(standing uint8) string { + names := [...]string{ + "MATCHES_ACTIVE", "AWAITING_CLOSURE", "ROOT_WINNER", "ROOT_FAILED", + "INNER_WINNER", "INNER_ELIMINABLE_NO_WINNER", "INNER_ELIMINABLE_WINNER_EXPIRED", + } + if int(standing) >= len(names) { + return fmt.Sprintf("UNKNOWN(%d)", standing) + } + return names[standing] +} + +func tournamentKindName(kind uint8) string { + switch kind { + case tournamentKindLeaf: + return "LEAF" + case tournamentKindNonLeaf: + return "NON_LEAF" + default: + return fmt.Sprintf("UNKNOWN(%d)", kind) + } +} + +func tournamentLevelName(level uint64, kind uint8) string { + switch kind { + case tournamentKindLeaf: + return "leaf" + case tournamentKindNonLeaf: + if level == 0 { + return "root" + } + return "inner" + default: + return fmt.Sprintf("unknown-kind-%d", kind) + } +} + +func maxLevelFromCount(levelCount uint64) (uint64, error) { + if levelCount == 0 { + return 0, errors.New("tournament level count is zero") + } + return levelCount - 1, nil +} + +func (c *chainClient) tournamentMaxLevel() (uint64, error) { + consensusAddr, err := c.getConsensusAddress() + if err != nil { + return 0, fmt.Errorf("get consensus address: %w", err) + } + dave, err := idaveconsensus.NewIDaveConsensusCaller(consensusAddr, c.eth) + if err != nil { + return 0, fmt.Errorf("bind IDaveConsensus: %w", err) + } + factoryAddr, err := dave.GetTournamentFactory(c.callOpts) + if err != nil { + return 0, fmt.Errorf("GetTournamentFactory: %w", err) + } + factory, err := imultileveltournamentfactory.NewIMultiLevelTournamentFactoryCaller(factoryAddr, c.eth) + if err != nil { + return 0, fmt.Errorf("bind IMultiLevelTournamentFactory: %w", err) + } + levelCount, err := factory.TournamentLevelCount(c.callOpts) + if err != nil { + return 0, fmt.Errorf("TournamentLevelCount: %w", err) + } + return maxLevelFromCount(levelCount) +} + +// commitmentRegistry maps commitment hashes to submitter addresses. +type commitmentRegistry map[[32]byte]common.Address + +func (r commitmentRegistry) resolve(commitment [32]byte) string { + if addr, ok := r[commitment]; ok { + return addr.Hex() + } + return "" +} + +// tournamentEvents holds raw event data from a single tournament. +type tournamentEvents struct { + commitmentsJoined []rawCommitmentJoined + matchesCreated []rawMatchCreated + matchesAdvanced []rawMatchAdvanced + matchesDeleted []rawMatchDeleted +} + +type rawCommitmentJoined struct { + commitment [32]byte + finalStateHash [32]byte + submitter common.Address + blockNumber uint64 + txHash common.Hash +} + +type rawMatchCreated struct { + matchIDHash [32]byte + one [32]byte + two [32]byte + leftOfTwo [32]byte + responderDeadline uint64 + eliminableAt uint64 + blockNumber uint64 + txHash common.Hash +} + +type rawMatchAdvanced struct { + matchIDHash [32]byte + otherParent [32]byte + leftNode [32]byte + segmentStartPosition *big.Int + currentHeight uint64 + responderDeadline uint64 + eliminableAt uint64 + blockNumber uint64 + txHash common.Hash +} + +type rawMatchDeleted struct { + matchIDHash [32]byte + one [32]byte + two [32]byte + reason uint8 + winnerCommitment uint8 + blockNumber uint64 + txHash common.Hash +} + +// fetchTournamentEvents retrieves events using FindTransitions per counter. +func (c *chainClient) fetchTournamentEvents( + addr common.Address, + fromBlock uint64, +) (*tournamentEvents, error) { + caller, err := itournament.NewITournamentCaller(addr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind ITournament caller: %w", err) + } + filterer, err := itournament.NewITournamentFilterer(addr, c.eth) + if err != nil { + return nil, fmt.Errorf("bind ITournament filterer: %w", err) + } + + events := &tournamentEvents{} + limit := tournamentLimitFlag + + // CommitmentJoined + if err := c.findAndCollectEvents(addr, fromBlock, "CommitmentJoined", + func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return caller.GetCommitmentJoinedCount(opts) + }, + func(log *types.Log) (bool, error) { + if limit > 0 && len(events.commitmentsJoined) >= limit { + return true, nil + } + ev, pErr := filterer.ParseCommitmentJoined(*log) + if pErr != nil { + return false, pErr + } + events.commitmentsJoined = append(events.commitmentsJoined, rawCommitmentJoined{ + commitment: ev.Commitment, + finalStateHash: ev.FinalStateHash, + submitter: ev.Submitter, + blockNumber: log.BlockNumber, + txHash: log.TxHash, + }) + return false, nil + }, + ); err != nil { + return nil, err + } + + // MatchCreated + if err := c.findAndCollectEvents(addr, fromBlock, "MatchCreated", + func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return caller.GetMatchCreatedCount(opts) + }, + func(log *types.Log) (bool, error) { + if limit > 0 && len(events.matchesCreated) >= limit { + return true, nil + } + ev, pErr := filterer.ParseMatchCreated(*log) + if pErr != nil { + return false, pErr + } + events.matchesCreated = append(events.matchesCreated, rawMatchCreated{ + matchIDHash: ev.MatchIdHash, + one: ev.One, + two: ev.Two, + leftOfTwo: ev.LeftOfTwo, + responderDeadline: ev.ResponderDeadline, + eliminableAt: ev.EliminableAt, + blockNumber: log.BlockNumber, + txHash: log.TxHash, + }) + return false, nil + }, + ); err != nil { + return nil, err + } + + // MatchAdvanced + if err := c.findAndCollectEvents(addr, fromBlock, "MatchAdvanced", + func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return caller.GetMatchAdvancedCount(opts) + }, + func(log *types.Log) (bool, error) { + if limit > 0 && len(events.matchesAdvanced) >= limit { + return true, nil + } + ev, pErr := filterer.ParseMatchAdvanced(*log) + if pErr != nil { + return false, pErr + } + events.matchesAdvanced = append(events.matchesAdvanced, rawMatchAdvanced{ + matchIDHash: ev.MatchIdHash, + otherParent: ev.OtherParent, + leftNode: ev.LeftNode, + segmentStartPosition: ev.SegmentStartPosition, + currentHeight: ev.CurrentHeight, + responderDeadline: ev.ResponderDeadline, + eliminableAt: ev.EliminableAt, + blockNumber: log.BlockNumber, + txHash: log.TxHash, + }) + return false, nil + }, + ); err != nil { + return nil, err + } + + // MatchDeleted + if err := c.findAndCollectEvents(addr, fromBlock, "MatchDeleted", + func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return caller.GetMatchDeletedCount(opts) + }, + func(log *types.Log) (bool, error) { + if limit > 0 && len(events.matchesDeleted) >= limit { + return true, nil + } + ev, pErr := filterer.ParseMatchDeleted(*log) + if pErr != nil { + return false, pErr + } + events.matchesDeleted = append(events.matchesDeleted, rawMatchDeleted{ + matchIDHash: ev.MatchIdHash, + one: ev.One, + two: ev.Two, + reason: ev.Reason, + winnerCommitment: ev.WinnerCommitment, + blockNumber: log.BlockNumber, + txHash: log.TxHash, + }) + return false, nil + }, + ); err != nil { + return nil, err + } + + return events, nil +} + +// findAndCollectEvents uses FindTransitions to discover transition blocks, +// then queries and processes events at each block. Processing stops when +// the process callback returns done=true. +func (c *chainClient) findAndCollectEvents( + addr common.Address, + fromBlock uint64, + eventName string, + oracle ethutil.TransitionQueryFn, + process func(*types.Log) (done bool, err error), +) error { + onHit := func(block uint64) error { + q, qErr := buildEventFilterQuery( + addr, eventName, itournament.ITournamentMetaData, block, block, + ) + if qErr != nil { + return qErr + } + itr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, q) + if fErr != nil { + return fErr + } + for log, logErr := range itr { + if logErr != nil { + return logErr + } + done, pErr := process(log) + if pErr != nil { + return pErr + } + if done { + return errLimitReached + } + } + return nil + } + + // Wrap oracle to handle contracts that don't exist at fromBlock yet + // (e.g., inner tournaments created after the DaveConsensus deploy block). + safeOracle := func(ctx context.Context, block uint64) (*big.Int, error) { + val, oErr := oracle(ctx, block) + if oErr != nil && errors.Is(oErr, bind.ErrNoCode) { + return big.NewInt(0), nil + } + return val, oErr + } + + _, err := ethutil.FindTransitions( + c.callOpts.Context, fromBlock, c.blockNum, big.NewInt(0), safeOracle, onHit, + ) + if err != nil && !errors.Is(err, errLimitReached) { + return fmt.Errorf("find %s transitions: %w", eventName, err) + } + return nil +} + +// tournamentTree holds the BFS traversal result. +type tournamentTree struct { + root *TournamentResult + nodes []*TournamentResult + children map[common.Address][]*TournamentResult + registry commitmentRegistry +} + +// walkTournamentTree performs BFS across the tournament hierarchy. +func (c *chainClient) walkTournamentTree( + rootAddr common.Address, + fromBlock uint64, + includeEvents bool, +) (*tournamentTree, error) { + tree := &tournamentTree{ + children: make(map[common.Address][]*TournamentResult), + registry: make(commitmentRegistry), + } + + type queueItem struct { + addr common.Address + parent *common.Address + } + + queue := []queueItem{{addr: rootAddr}} + visited := make(map[common.Address]bool) + maxNodes := tournamentMaxNodesFlag + + for len(queue) > 0 && len(tree.nodes) < maxNodes { + item := queue[0] + queue[0] = queueItem{} // avoid retaining references in the backing array + queue = queue[1:] + + if visited[item.addr] { + continue + } + visited[item.addr] = true + + info, err := c.queryTournament(item.addr) + if err != nil { + return nil, fmt.Errorf("query tournament %s: %w", item.addr, err) + } + + if includeEvents { + events, eErr := c.fetchTournamentEvents(item.addr, fromBlock) + if eErr != nil { + slog.Warn("failed to fetch events", "tournament", item.addr, "error", eErr) + } else { + for _, cj := range events.commitmentsJoined { + tree.registry[cj.commitment] = cj.submitter + } + info.Commitments = formatCommitmentEvents(events.commitmentsJoined) + info.Matches = formatMatchEvents( + events.matchesCreated, events.matchesDeleted, tree.registry) + info.Advances = formatAdvanceEvents(events.matchesAdvanced) + } + } + + tree.nodes = append(tree.nodes, info) + if tree.root == nil { + tree.root = info + } + if item.parent != nil { + tree.children[*item.parent] = append(tree.children[*item.parent], info) + } + + // Discover child tournaments. + if info.Level < info.MaxLevel && info.InnerTournaments > 0 { + children, dErr := c.discoverChildTournaments(item.addr, fromBlock) + if dErr != nil { + slog.Warn("failed to discover children", "tournament", item.addr, "error", dErr) + continue + } + for _, child := range children { + parentAddr := item.addr + queue = append(queue, queueItem{addr: child, parent: &parentAddr}) + } + } + } + + if len(queue) > 0 { + slog.Warn("tournament tree truncated", + "max_nodes", maxNodes, "remaining_in_queue", len(queue)) + } + + // Second pass: annotate winner addresses and populate Children. + for _, node := range tree.nodes { + if node.WinnerCommitment != "" { + winnerHash := common.HexToHash(node.WinnerCommitment) + node.WinnerAddress = tree.registry.resolve([32]byte(winnerHash)) + } + addr := common.HexToAddress(node.Address) + if kids, ok := tree.children[addr]; ok { + node.Children = kids + } + } + + return tree, nil +} + +// discoverChildTournaments uses FindTransitions with GetNewInnerTournamentCount. +func (c *chainClient) discoverChildTournaments( + tournamentAddr common.Address, + fromBlock uint64, +) ([]common.Address, error) { + caller, err := itournament.NewITournamentCaller(tournamentAddr, c.eth) + if err != nil { + return nil, err + } + filterer, err := itournament.NewITournamentFilterer(tournamentAddr, c.eth) + if err != nil { + return nil, err + } + + var children []common.Address + + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + opts := &bind.CallOpts{Context: ctx, BlockNumber: new(big.Int).SetUint64(block)} + return caller.GetNewInnerTournamentCount(opts) + } + + onHit := func(block uint64) error { + q, qErr := buildEventFilterQuery( + tournamentAddr, "NewInnerTournament", + itournament.ITournamentMetaData, block, block, + ) + if qErr != nil { + return qErr + } + itr, fErr := c.filter.ChunkedFilterLogs(c.callOpts.Context, c.eth, q) + if fErr != nil { + return fmt.Errorf("filter NewInnerTournament at block %d: %w", block, fErr) + } + for log, logErr := range itr { + if logErr != nil { + return logErr + } + ev, pErr := filterer.ParseNewInnerTournament(*log) + if pErr != nil { + return pErr + } + children = append(children, ev.ChildTournament) + } + return nil + } + + _, err = ethutil.FindTransitions( + c.callOpts.Context, fromBlock, c.blockNum, big.NewInt(0), oracle, onHit, + ) + if err != nil { + return nil, fmt.Errorf("find child tournament transitions: %w", err) + } + return children, nil +} + +// buildEventFilterQuery constructs a FilterQuery for the named event. +// Each indexedTopic corresponds to an indexed event parameter in order. +// Use nil for a wildcard (match any value at that position). +func buildEventFilterQuery( + contractAddr common.Address, + eventName string, + metaData *bind.MetaData, + fromBlock, toBlock uint64, + indexedTopics ...*common.Hash, +) (ethereum.FilterQuery, error) { + contractABI, err := metaData.GetAbi() + if err != nil { + return ethereum.FilterQuery{}, fmt.Errorf("get ABI: %w", err) + } + + ev, ok := contractABI.Events[eventName] + if !ok { + return ethereum.FilterQuery{}, fmt.Errorf("event %q not found in ABI", eventName) + } + + topicSets := [][]any{{ev.ID}} + for _, t := range indexedTopics { + if t == nil { + topicSets = append(topicSets, nil) + } else { + topicSets = append(topicSets, []any{*t}) + } + } + + topics, err := abi.MakeTopics(topicSets...) + if err != nil { + return ethereum.FilterQuery{}, fmt.Errorf("make topics for %s: %w", eventName, err) + } + + return ethereum.FilterQuery{ + Addresses: []common.Address{contractAddr}, + FromBlock: new(big.Int).SetUint64(fromBlock), + ToBlock: new(big.Int).SetUint64(toBlock), + Topics: topics, + }, nil +} + +// Format helpers: convert raw events to JSON output types. + +func formatCommitmentEvents(raw []rawCommitmentJoined) []CommitmentEvent { + if len(raw) == 0 { + return nil + } + out := make([]CommitmentEvent, len(raw)) + for i, r := range raw { + out[i] = CommitmentEvent{ + Commitment: formatHash(r.commitment), + FinalStateHash: formatHash(r.finalStateHash), + Submitter: formatAddr(r.submitter), + BlockNumber: r.blockNumber, + TxHash: r.txHash.Hex(), + } + } + return out +} + +func formatMatchEvents( + created []rawMatchCreated, + deleted []rawMatchDeleted, + registry commitmentRegistry, +) []MatchEvent { + if len(created) == 0 { + return nil + } + + // Build deletion map keyed by match ID hash. + deletionMap := make(map[[32]byte]rawMatchDeleted, len(deleted)) + for _, d := range deleted { + deletionMap[d.matchIDHash] = d + } + + out := make([]MatchEvent, len(created)) + for i, r := range created { + me := MatchEvent{ + MatchIDHash: formatHash(r.matchIDHash), + CommitmentOne: formatHash(r.one), + CommitmentTwo: formatHash(r.two), + PlayerOneAddr: registry.resolve(r.one), + PlayerTwoAddr: registry.resolve(r.two), + LeftOfTwo: formatHash(r.leftOfTwo), + ResponderDeadline: r.responderDeadline, + EliminableAt: r.eliminableAt, + BlockNumber: r.blockNumber, + TxHash: r.txHash.Hex(), + } + + if d, ok := deletionMap[r.matchIDHash]; ok { + me.DeletionReason = matchDeletionReason(d.reason) + me.Winner = matchWinner(d.winnerCommitment) + block := d.blockNumber + me.DeletionBlock = &block + me.DeletionTxHash = d.txHash.Hex() + + // Resolve winner address from commitment. + switch d.winnerCommitment { + case 1: + me.WinnerAddr = registry.resolve(d.one) + case 2: //nolint:mnd + me.WinnerAddr = registry.resolve(d.two) + } + } + + out[i] = me + } + return out +} + +func formatAdvanceEvents(raw []rawMatchAdvanced) []MatchAdvanceEvent { + if len(raw) == 0 { + return nil + } + out := make([]MatchAdvanceEvent, len(raw)) + for i, r := range raw { + segmentStartPosition := "" + if r.segmentStartPosition != nil { + segmentStartPosition = r.segmentStartPosition.String() + } + out[i] = MatchAdvanceEvent{ + MatchIDHash: formatHash(r.matchIDHash), + OtherParent: formatHash(r.otherParent), + LeftNode: formatHash(r.leftNode), + SegmentStartPosition: segmentStartPosition, + CurrentHeight: r.currentHeight, + ResponderDeadline: r.responderDeadline, + EliminableAt: r.eliminableAt, + BlockNumber: r.blockNumber, + TxHash: r.txHash.Hex(), + } + } + return out +} + +// Text output helpers. + +func printTournamentBasic(p *printer, r *TournamentResult) { + levelName := "Root" + if r.Level == r.MaxLevel { + levelName = "Leaf" + } else if r.Level > 0 { + levelName = "Inner" + } + + p.withSection(fmt.Sprintf("%s Tournament %s (level %d/%d)", + levelName, r.Address, r.Level, r.MaxLevel), func() { + p.field("Status", tournamentStatus(r.Closed, r.Finished)) + p.field("Standing", r.Standing) + p.field("Kind", r.Kind) + p.field("Initial Machine Hash", r.InitialMachineHash) + p.field("Base Cycle", r.BaseCycle) + p.field("Log2 Stride", fmt.Sprintf("%d", r.Log2Step)) + p.field("Height", fmt.Sprintf("%d", r.Height)) + p.field("Start Block", fmt.Sprintf("%d", r.StartBlock)) + p.field("Allowance", fmt.Sprintf("%d blocks", r.Allowance)) + if r.Finished && r.FinishedAtBlock != nil { + p.field("Finished", fmt.Sprintf("yes (block %d)", *r.FinishedAtBlock)) + } + if r.HasWinner != nil { + if *r.HasWinner { + winInfo := r.WinnerCommitment + if r.WinnerAddress != "" { + winInfo += fmt.Sprintf(" (%s)", r.WinnerAddress) + } + p.field("Winner", winInfo) + if r.FinalMachineHash != "" { + p.field("Final Machine Hash", r.FinalMachineHash) + } + } else { + p.field("Winner", "NONE (all commitments eliminated)") + } + } + if r.WinnerExpiresAt != nil { + p.field("Winner Expires At", fmt.Sprintf("block %d (inclusive)", *r.WinnerExpiresAt)) + } + p.field("Bond", r.BondETH) + p.field("Commitments Joined", fmt.Sprintf("%d", r.CommitmentsJoined)) + p.field("Matches Created", fmt.Sprintf("%d", r.MatchesCreated)) + p.field("Matches Advanced", fmt.Sprintf("%d", r.MatchesAdvanced)) + p.field("Matches Deleted", fmt.Sprintf("%d", r.MatchesDeleted)) + p.field("Inner Tournaments", fmt.Sprintf("%d", r.InnerTournaments)) + if r.CanBeEliminated != nil { + p.field("Can Be Eliminated", fmt.Sprintf("%t", *r.CanBeEliminated)) + } + }) +} + +func printTournamentEvents(p *printer, r *TournamentResult) { + if len(r.Commitments) > 0 { + p.withSection("Commitments Joined:", func() { + for i, c := range r.Commitments { + p.withSection(fmt.Sprintf("[%d] Commitment %s", i+1, c.Commitment), func() { + p.field("Submitter", c.Submitter) + p.field("Final State", c.FinalStateHash) + p.field("Block", fmt.Sprintf("%d", c.BlockNumber)) + p.field("Tx", c.TxHash) + }) + } + }) + } + + if len(r.Matches) > 0 { + p.withSection("Matches:", func() { + for i, m := range r.Matches { + p.withSection(fmt.Sprintf("[%d] Match %s", i+1, m.MatchIDHash), func() { + oneInfo := m.CommitmentOne + if m.PlayerOneAddr != "" { + oneInfo += fmt.Sprintf(" (%s)", m.PlayerOneAddr) + } + p.field("Player One", oneInfo) + twoInfo := m.CommitmentTwo + if m.PlayerTwoAddr != "" { + twoInfo += fmt.Sprintf(" (%s)", m.PlayerTwoAddr) + } + p.field("Player Two", twoInfo) + p.field("Eliminable At", fmt.Sprintf("block %d (inclusive)", m.EliminableAt)) + p.field("Block", fmt.Sprintf("%d", m.BlockNumber)) + p.field("Tx", m.TxHash) + if m.DeletionReason != "" { + p.field("Outcome", fmt.Sprintf("%s → %s", m.DeletionReason, m.Winner)) + if m.WinnerAddr != "" { + p.field("Winner Address", m.WinnerAddr) + } + } + }) + } + }) + } + + if len(r.Advances) > 0 { + p.withSection("Match Advances:", func() { + for i, a := range r.Advances { + p.withSection(fmt.Sprintf("[%d] Match %s", i+1, a.MatchIDHash), func() { + p.field("Segment Start Position", a.SegmentStartPosition) + p.field("Eliminable At", fmt.Sprintf("block %d (inclusive)", a.EliminableAt)) + p.field("Block", fmt.Sprintf("%d", a.BlockNumber)) + p.field("Tx", a.TxHash) + }) + } + }) + } +} + +func renderTournamentTree( + p *printer, + root *TournamentResult, + tree *tournamentTree, + depth int, +) { + prefix := "" + if depth > 0 { + prefix = strings.Repeat("\t", depth-1) + "└─ " + } + + levelName := "Root" + if root.Level == root.MaxLevel { + levelName = "Leaf" + } else if root.Level > 0 { + levelName = "Inner" + } + + if depth > 0 { + fmt.Fprintf(p.w, "\n") + } + fmt.Fprintf(p.w, "%s%s Tournament %s (level %d/%d)\n", + prefix, levelName, root.Address, + root.Level, root.MaxLevel) + + indent := strings.Repeat("\t", depth+1) + fmt.Fprintf(p.w, "%sStatus %s\n", + indent, tournamentStatus(root.Closed, root.Finished)) + + if root.Finished && root.FinishedAtBlock != nil { + fmt.Fprintf(p.w, "%sFinished yes (block %d)\n", + indent, *root.FinishedAtBlock) + } + if root.HasWinner != nil { + if *root.HasWinner { + winInfo := root.WinnerCommitment + if root.WinnerAddress != "" { + winInfo += fmt.Sprintf(" (%s)", root.WinnerAddress) + } + fmt.Fprintf(p.w, "%sWinner %s\n", indent, winInfo) + } else { + fmt.Fprintf(p.w, "%sWinner NONE (all commitments eliminated)\n", indent) + } + } + fmt.Fprintf(p.w, + "%sCommitments: %d | Matches: %d created, %d advanced, %d deleted | Inner: %d\n", + indent, root.CommitmentsJoined, root.MatchesCreated, + root.MatchesAdvanced, root.MatchesDeleted, root.InnerTournaments) + + // Recurse into children. + addr := common.HexToAddress(root.Address) + for _, child := range tree.children[addr] { + renderTournamentTree(p, child, tree, depth+1) + } +} diff --git a/cmd/cartesi-rollups-cli/root/contract/tournament_test.go b/cmd/cartesi-rollups-cli/root/contract/tournament_test.go new file mode 100644 index 000000000..1dfeb325f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/tournament_test.go @@ -0,0 +1,482 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type tournamentScheduleRPC struct{ mock.Mock } + +func (m *tournamentScheduleRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +func (m *tournamentScheduleRPC) GetLogs( + _ context.Context, query map[string]json.RawMessage, +) ([]types.Log, error) { + args := m.Called(query) + return args.Get(0).([]types.Log), args.Error(1) +} + +func collectScheduleEvent( + t *testing.T, eventName string, indexed []common.Hash, values ...any, +) *tournamentEvents { + t.Helper() + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + event := contractABI.Events[eventName] + data, err := event.Inputs.NonIndexed().Pack(values...) + require.NoError(t, err) + address := common.HexToAddress("0x100") + block := rpc.BlockNumber(1234) + log := types.Log{ + Address: address, BlockNumber: uint64(block), TxHash: common.HexToHash("0x200"), + Topics: append([]common.Hash{event.ID}, indexed...), Data: data, + } + backend := &tournamentScheduleRPC{} + for _, name := range []string{"CommitmentJoined", "MatchCreated", "MatchAdvanced", "MatchDeleted"} { + method := "get" + name + "Count" + input, packErr := contractABI.Pack(method) + require.NoError(t, packErr) + count := big.NewInt(0) + if name == eventName { + count.SetUint64(1) + } + output, packErr := contractABI.Methods[method].Outputs.Pack(count) + require.NoError(t, packErr) + backend.On("Call", address, input, block).Return(output, nil).Once() + } + queryJSON, err := json.Marshal(map[string]any{ + "address": []common.Address{address}, "topics": [][]common.Hash{{event.ID}}, + "fromBlock": hexutil.EncodeUint64(uint64(block)), "toBlock": hexutil.EncodeUint64(uint64(block)), + }) + require.NoError(t, err) + var query map[string]json.RawMessage + require.NoError(t, json.Unmarshal(queryJSON, &query)) + backend.On("GetLogs", query).Return([]types.Log{log}, nil).Once() + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + t.Cleanup(func() { backend.AssertExpectations(t) }) + c := &chainClient{ + eth: client, callOpts: &bind.CallOpts{Context: t.Context()}, blockNum: uint64(block), + filter: ethutil.Filter{Logger: slog.Default()}, + } + events, err := c.fetchTournamentEvents(address, uint64(block)) + require.NoError(t, err) + return events +} + +func TestCollectCreatedMatchSchedule(t *testing.T) { + matchID := common.HexToHash("0xaa") + one := common.HexToHash("0xbb") + two := common.HexToHash("0xcc") + leftOfTwo := [32]byte{0xdd} + // Distinct values above 2^53 also check exact decimal JSON numbers. + const responderDeadline, eliminableAt = uint64(9007199254740993), uint64(9007199254740999) + events := collectScheduleEvent(t, "MatchCreated", []common.Hash{matchID, one, two}, + leftOfTwo, responderDeadline, eliminableAt) + require.Len(t, events.matchesCreated, 1) + result := formatMatchEvents(events.matchesCreated, events.matchesDeleted, nil) + require.Len(t, result, 1) + encoded, err := json.Marshal(result[0]) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &fields)) + require.JSONEq(t, `"`+matchID.Hex()+`"`, string(fields["match_id_hash"])) + require.Equal(t, "9007199254740993", string(fields["responder_deadline"])) + require.Equal(t, "9007199254740999", string(fields["eliminable_at"])) +} + +func TestCommitmentRegistryResolve(t *testing.T) { + addr := common.HexToAddress("0xABCDEF0000000000000000000000000000000001") + commitment := [32]byte{0x01, 0x02, 0x03} + + registry := make(commitmentRegistry) + registry[commitment] = addr + + // Known key returns address. + got := registry.resolve(commitment) + assert.Equal(t, addr.Hex(), got) + + // Unknown key returns empty string. + unknown := [32]byte{0xFF} + assert.Equal(t, "", registry.resolve(unknown)) + + // Empty registry returns empty string. + empty := make(commitmentRegistry) + assert.Equal(t, "", empty.resolve(commitment)) +} + +func TestFormatCommitmentEvents(t *testing.T) { + // Empty input returns nil. + assert.Nil(t, formatCommitmentEvents(nil)) + assert.Nil(t, formatCommitmentEvents([]rawCommitmentJoined{})) + + raw := []rawCommitmentJoined{ + { + commitment: [32]byte{0xAA}, + finalStateHash: [32]byte{0xBB}, + submitter: common.HexToAddress("0x1111111111111111111111111111111111111111"), + blockNumber: 100, + txHash: common.HexToHash("0xCC"), + }, + { + commitment: [32]byte{0xDD}, + finalStateHash: [32]byte{0xEE}, + submitter: common.HexToAddress("0x2222222222222222222222222222222222222222"), + blockNumber: 200, + txHash: common.HexToHash("0xFF"), + }, + } + + result := formatCommitmentEvents(raw) + assert.Len(t, result, 2) + assert.Equal(t, formatHash(raw[0].commitment), result[0].Commitment) + assert.Equal(t, formatHash(raw[0].finalStateHash), result[0].FinalStateHash) + assert.Equal(t, "0x1111111111111111111111111111111111111111", result[0].Submitter) + assert.Equal(t, uint64(100), result[0].BlockNumber) + assert.Equal(t, raw[0].txHash.Hex(), result[0].TxHash) + + assert.Equal(t, formatHash(raw[1].commitment), result[1].Commitment) + assert.Equal(t, uint64(200), result[1].BlockNumber) +} + +func TestFormatMatchEvents(t *testing.T) { + // Empty input returns nil. + assert.Nil(t, formatMatchEvents(nil, nil, nil)) + assert.Nil(t, formatMatchEvents([]rawMatchCreated{}, nil, nil)) + + commitOne := [32]byte{0x01} + commitTwo := [32]byte{0x02} + matchID := [32]byte{0xAA} + matchID2 := [32]byte{0xBB} + + registry := make(commitmentRegistry) + registry[commitOne] = common.HexToAddress("0x1111111111111111111111111111111111111111") + registry[commitTwo] = common.HexToAddress("0x2222222222222222222222222222222222222222") + + created := []rawMatchCreated{ + { + matchIDHash: matchID, + one: commitOne, + two: commitTwo, + leftOfTwo: [32]byte{0x33}, + eliminableAt: 120, + blockNumber: 100, + txHash: common.HexToHash("0xC1"), + }, + { + matchIDHash: matchID2, + one: commitOne, + two: [32]byte{0x99}, // unknown in registry + leftOfTwo: [32]byte{0x44}, + blockNumber: 200, + txHash: common.HexToHash("0xC2"), + }, + } + + deleted := []rawMatchDeleted{ + { + matchIDHash: matchID, + one: commitOne, + two: commitTwo, + reason: 1, // TIMEOUT + winnerCommitment: 1, // ONE + blockNumber: 150, + txHash: common.HexToHash("0xD1"), + }, + } + + result := formatMatchEvents(created, deleted, registry) + assert.Len(t, result, 2) + + // First match: has deletion info. + m0 := result[0] + assert.Equal(t, formatHash(matchID), m0.MatchIDHash) + assert.Equal(t, "0x1111111111111111111111111111111111111111", m0.PlayerOneAddr) + assert.Equal(t, "0x2222222222222222222222222222222222222222", m0.PlayerTwoAddr) + assert.Equal(t, uint64(120), m0.EliminableAt) + assert.Equal(t, "TIMEOUT", m0.DeletionReason) + assert.Equal(t, "ONE", m0.Winner) + assert.NotNil(t, m0.DeletionBlock) + assert.Equal(t, uint64(150), *m0.DeletionBlock) + // Winner is player ONE -> resolve commitOne. + assert.Equal(t, "0x1111111111111111111111111111111111111111", m0.WinnerAddr) + + // Second match: no deletion. + m1 := result[1] + assert.Equal(t, formatHash(matchID2), m1.MatchIDHash) + assert.Equal(t, "0x1111111111111111111111111111111111111111", m1.PlayerOneAddr) + assert.Equal(t, "", m1.PlayerTwoAddr) // unknown commitment + assert.Equal(t, "", m1.DeletionReason) + assert.Nil(t, m1.DeletionBlock) +} + +func TestFormatMatchEventsWinnerTwo(t *testing.T) { + commitOne := [32]byte{0x01} + commitTwo := [32]byte{0x02} + matchID := [32]byte{0xAA} + + registry := make(commitmentRegistry) + registry[commitOne] = common.HexToAddress("0x1111111111111111111111111111111111111111") + registry[commitTwo] = common.HexToAddress("0x2222222222222222222222222222222222222222") + + created := []rawMatchCreated{ + {matchIDHash: matchID, one: commitOne, two: commitTwo, blockNumber: 100}, + } + deleted := []rawMatchDeleted{ + {matchIDHash: matchID, one: commitOne, two: commitTwo, + reason: 2, winnerCommitment: 2, blockNumber: 200}, + } + + result := formatMatchEvents(created, deleted, registry) + assert.Len(t, result, 1) + assert.Equal(t, "TWO", result[0].Winner) + assert.Equal(t, "0x2222222222222222222222222222222222222222", result[0].WinnerAddr) + assert.Equal(t, "CHILD_TOURNAMENT", result[0].DeletionReason) +} + +func TestFormatMatchEventsNoWinner(t *testing.T) { + matchID := [32]byte{0xAA} + commitOne := [32]byte{0x01} + commitTwo := [32]byte{0x02} + + created := []rawMatchCreated{ + {matchIDHash: matchID, one: commitOne, two: commitTwo, blockNumber: 100}, + } + deleted := []rawMatchDeleted{ + {matchIDHash: matchID, one: commitOne, two: commitTwo, + reason: 0, winnerCommitment: 0, blockNumber: 200}, + } + + result := formatMatchEvents(created, deleted, nil) + assert.Len(t, result, 1) + assert.Equal(t, "NONE (both eliminated)", result[0].Winner) + assert.Equal(t, "STEP (on-chain proof)", result[0].DeletionReason) + assert.Equal(t, "", result[0].WinnerAddr) // winner=0 -> no resolution +} + +func TestFormatAdvanceEvents(t *testing.T) { + // Empty input returns nil. + assert.Nil(t, formatAdvanceEvents(nil)) + assert.Nil(t, formatAdvanceEvents([]rawMatchAdvanced{})) + + raw := []rawMatchAdvanced{ + { + matchIDHash: [32]byte{0xAA}, + otherParent: [32]byte{0xBB}, + leftNode: [32]byte{0xCC}, + segmentStartPosition: big.NewInt(42), + eliminableAt: 120, + blockNumber: 100, + txHash: common.HexToHash("0xDD"), + }, + { + matchIDHash: [32]byte{0xEE}, + otherParent: [32]byte{0xFF}, + leftNode: [32]byte{0x11}, + blockNumber: 200, + txHash: common.HexToHash("0x22"), + }, + } + + result := formatAdvanceEvents(raw) + assert.Len(t, result, 2) + assert.Equal(t, formatHash(raw[0].matchIDHash), result[0].MatchIDHash) + assert.Equal(t, formatHash(raw[0].otherParent), result[0].OtherParent) + assert.Equal(t, formatHash(raw[0].leftNode), result[0].LeftNode) + assert.Equal(t, "42", result[0].SegmentStartPosition) + assert.Equal(t, uint64(120), result[0].EliminableAt) + assert.Equal(t, uint64(100), result[0].BlockNumber) + assert.Equal(t, raw[0].txHash.Hex(), result[0].TxHash) + + assert.Equal(t, uint64(200), result[1].BlockNumber) +} + +func TestTournamentEnumNamesMatchSolidityOrder(t *testing.T) { + assert.Equal(t, "MATCHES_ACTIVE", tournamentStandingName(0)) + assert.Equal(t, "AWAITING_CLOSURE", tournamentStandingName(1)) + assert.Equal(t, "ROOT_WINNER", tournamentStandingName(2)) + assert.Equal(t, "ROOT_FAILED", tournamentStandingName(3)) + assert.Equal(t, "INNER_WINNER", tournamentStandingName(4)) + assert.Equal(t, "INNER_ELIMINABLE_NO_WINNER", tournamentStandingName(5)) + assert.Equal(t, "INNER_ELIMINABLE_WINNER_EXPIRED", tournamentStandingName(6)) + assert.Equal(t, "UNKNOWN(7)", tournamentStandingName(7)) + + assert.Equal(t, "LEAF", tournamentKindName(0)) + assert.Equal(t, "NON_LEAF", tournamentKindName(1)) + assert.Equal(t, "UNKNOWN(2)", tournamentKindName(2)) + assert.Equal(t, "root", tournamentLevelName(0, tournamentKindNonLeaf)) + assert.Equal(t, "inner", tournamentLevelName(1, tournamentKindNonLeaf)) + assert.Equal(t, "leaf", tournamentLevelName(2, tournamentKindLeaf)) +} + +func TestMaxLevelFromCount(t *testing.T) { + maxLevel, err := maxLevelFromCount(1) + assert.NoError(t, err) + assert.Equal(t, uint64(0), maxLevel) + + maxLevel, err = maxLevelFromCount(3) + assert.NoError(t, err) + assert.Equal(t, uint64(2), maxLevel) + + _, err = maxLevelFromCount(0) + assert.EqualError(t, err, "tournament level count is zero") +} + +func TestPopulateTournamentStandingResult(t *testing.T) { + candidate := [32]byte{0x11} + directFinalState := [32]byte{0x22} + recoveredFinalState := [32]byte{0x33} + + tests := []struct { + name string + level uint64 + standing itournament.ITournamentTournamentStandingView + wantClosed bool + wantFinished bool + wantFinishedAt *uint64 + wantHasWinner *bool + wantWinner string + wantFinalState string + wantWinnerExpiresAt *uint64 + wantCanBeEliminated *bool + wantFinalStateReadCount int + }{ + { + name: "active root", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingMatchesActive, AcceptsJoins: true, + }, + }, + { + name: "root winner", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingRootWinner, HasCandidate: true, + Candidate: candidate, FinalState: directFinalState, FinishedAt: 42, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(42), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(directFinalState), + }, + { + name: "failed root", + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingRootFailed, FinishedAt: 43, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(43), + wantHasWinner: boolPtr(false), + }, + { + name: "inner winner", + level: 1, + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerWinner, HasCandidate: true, + Candidate: candidate, FinalState: directFinalState, FinishedAt: 44, WinnerExpiresAt: 60, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(44), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(directFinalState), + wantWinnerExpiresAt: uint64Ptr(60), + wantCanBeEliminated: boolPtr(false), + }, + { + name: "expired inner winner", + level: 1, + standing: itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerEliminableWinnerExpired, HasCandidate: true, + Candidate: candidate, FinishedAt: 45, + }, + wantClosed: true, + wantFinished: true, + wantFinishedAt: uint64Ptr(45), + wantHasWinner: boolPtr(true), + wantWinner: formatHash(candidate), + wantFinalState: formatHash(recoveredFinalState), + wantCanBeEliminated: boolPtr(true), + wantFinalStateReadCount: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := &TournamentResult{Level: test.level} + readCount := 0 + err := populateTournamentStandingResult(result, test.standing, func(got [32]byte) ([32]byte, error) { + readCount++ + assert.Equal(t, candidate, got) + return recoveredFinalState, nil + }) + + assert.NoError(t, err) + assert.Equal(t, test.wantClosed, result.Closed) + assert.Equal(t, test.wantFinished, result.Finished) + assert.Equal(t, test.wantFinishedAt, result.FinishedAtBlock) + assert.Equal(t, test.wantHasWinner, result.HasWinner) + assert.Equal(t, test.wantWinner, result.WinnerCommitment) + assert.Equal(t, test.wantFinalState, result.FinalMachineHash) + assert.Equal(t, test.wantWinnerExpiresAt, result.WinnerExpiresAt) + assert.Equal(t, test.wantCanBeEliminated, result.CanBeEliminated) + assert.Equal(t, test.wantFinalStateReadCount, readCount) + }) + } +} + +func TestPopulateTournamentStandingResultReturnsFinalStateError(t *testing.T) { + wantErr := errors.New("read failed") + result := &TournamentResult{Level: 1} + err := populateTournamentStandingResult(result, itournament.ITournamentTournamentStandingView{ + Standing: tournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, + Candidate: [32]byte{1}, + FinishedAt: 10, + }, func([32]byte) ([32]byte, error) { + return [32]byte{}, wantErr + }) + assert.ErrorIs(t, err, wantErr) +} + +func uint64Ptr(value uint64) *uint64 { return &value } + +func boolPtr(value bool) *bool { return &value } diff --git a/cmd/cartesi-rollups-cli/root/contract/types.go b/cmd/cartesi-rollups-cli/root/contract/types.go new file mode 100644 index 000000000..158a3f10d --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/contract/types.go @@ -0,0 +1,268 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package contract + +import "encoding/json" + +// AppResult is the JSON output of "contract app". +type AppResult struct { + Address string `json:"address"` + Owner string `json:"owner"` + TemplateHash string `json:"template_hash"` + DeploymentBlock uint64 `json:"deployment_block"` + ExecutedOutputs uint64 `json:"executed_outputs"` + ConsensusAddress string `json:"consensus_address"` + ConsensusType string `json:"consensus_type"` + InputBox string `json:"input_box"` + IsForeclosed bool `json:"is_foreclosed"` + Guardian string `json:"guardian"` + WithdrawalOutputBuilder string `json:"withdrawal_output_builder"` + Log2LeavesPerAccount uint8 `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts uint8 `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex uint64 `json:"accounts_drive_start_index"` +} + +// AuthorityConsensusResult is the JSON output for Authority consensus. +type AuthorityConsensusResult struct { + Type string `json:"type"` + Address string `json:"address"` + Owner string `json:"owner"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + AcceptedClaims uint64 `json:"accepted_claims"` + StagedClaims uint64 `json:"staged_claims"` + ContractVersion string `json:"contract_version"` +} + +// QuorumConsensusResult is the JSON output for Quorum consensus. +type QuorumConsensusResult struct { + Type string `json:"type"` + Address string `json:"address"` + NumValidators uint64 `json:"num_validators"` + QuorumThreshold uint64 `json:"quorum_threshold"` + Validators []string `json:"validators"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + AcceptedClaims uint64 `json:"accepted_claims"` + StagedClaims uint64 `json:"staged_claims"` + ContractVersion string `json:"contract_version"` +} + +// DaveConsensusResult is the JSON output for DaveConsensus. +// CurrentEpochNumber is the index of the currently sealed epoch (the one with an active +// tournament). Epochs 0..CurrentEpochNumber-1 have been settled; epoch CurrentEpochNumber +// is sealed but not yet settled. +type DaveConsensusResult struct { + Type string `json:"type"` + Address string `json:"address"` + InputBox string `json:"inputbox"` + Factory string `json:"factory"` + DeploymentBlock uint64 `json:"deployment_block"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + IsFinished bool `json:"is_finished"` + IsTournamentFailed bool `json:"is_tournament_failed"` + IsTournamentResultStaged bool `json:"is_tournament_result_staged"` + HasWinner *bool `json:"has_winner,omitempty"` + WinnerCommitment string `json:"winner_commitment,omitempty"` + WinnerPostEpochMachineHash string `json:"winner_post_epoch_machine_hash,omitempty"` + CurrentEpochNumber uint64 `json:"current_epoch_number"` + InputLowerBound uint64 `json:"input_lower_bound"` + InputUpperBound uint64 `json:"input_upper_bound"` + RootTournament string `json:"root_tournament"` + StagingBlock *uint64 `json:"staging_block,omitempty"` + StagedMachineHash string `json:"staged_machine_hash,omitempty"` + StagedOutputsMerkleRoot string `json:"staged_outputs_merkle_root,omitempty"` + AllSentriesAgree *bool `json:"all_sentries_agree,omitempty"` + ClaimStagingPeriodOver *bool `json:"claim_staging_period_over,omitempty"` + + SentryManager string `json:"sentry_manager"` + NumSentries uint64 `json:"num_sentries"` + Sentries []SentryResult `json:"sentries"` +} + +// SentryResult identifies a sentry slot and its address at the queried block. +// IDs start at one and remain unchanged when the manager rotates a sentry. +type SentryResult struct { + ID uint64 `json:"id"` + Address string `json:"address"` +} + +// InputBoxResult is the JSON output for InputBox state. +type InputBoxResult struct { + Address string `json:"address,omitempty"` + TotalInputs uint64 `json:"total_inputs"` +} + +// CommitmentEvent is a commitment joined event. +type CommitmentEvent struct { + Commitment string `json:"commitment"` + FinalStateHash string `json:"final_state_hash"` + Submitter string `json:"submitter"` + BlockNumber uint64 `json:"block_number"` + TxHash string `json:"tx_hash"` +} + +// MatchEvent is a match created event. +type MatchEvent struct { + MatchIDHash string `json:"match_id_hash"` + CommitmentOne string `json:"commitment_one"` + CommitmentTwo string `json:"commitment_two"` + PlayerOneAddr string `json:"player_one_addr,omitempty"` + PlayerTwoAddr string `json:"player_two_addr,omitempty"` + LeftOfTwo string `json:"left_of_two"` + ResponderDeadline uint64 `json:"responder_deadline"` + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash string `json:"tx_hash"` + DeletionReason string `json:"deletion_reason,omitempty"` + Winner string `json:"winner,omitempty"` + WinnerAddr string `json:"winner_addr,omitempty"` + DeletionBlock *uint64 `json:"deletion_block,omitempty"` + DeletionTxHash string `json:"deletion_tx_hash,omitempty"` +} + +// MatchAdvanceEvent is a match advance (bisection step) event. +type MatchAdvanceEvent struct { + MatchIDHash string `json:"match_id_hash"` + OtherParent string `json:"other_parent"` + LeftNode string `json:"left_node"` + SegmentStartPosition string `json:"segment_start_position"` + CurrentHeight uint64 `json:"current_height"` + ResponderDeadline uint64 `json:"responder_deadline"` + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash string `json:"tx_hash"` +} + +// TournamentResult is the JSON output for a tournament. +type TournamentResult struct { + Address string `json:"address"` + Level uint64 `json:"level"` + MaxLevel uint64 `json:"max_level"` + Log2Step uint64 `json:"log2step"` + Height uint64 `json:"height"` + Kind string `json:"kind"` + InitialMachineHash string `json:"initial_machine_hash"` + BaseCycle string `json:"base_cycle"` + StartBlock uint64 `json:"start_block"` + Allowance uint64 `json:"allowance"` + Standing string `json:"standing"` + Closed bool `json:"closed"` + Finished bool `json:"finished"` + FinishedAtBlock *uint64 `json:"finished_at_block,omitempty"` + HasWinner *bool `json:"has_winner,omitempty"` + WinnerCommitment string `json:"winner_commitment,omitempty"` + WinnerAddress string `json:"winner_address,omitempty"` + FinalMachineHash string `json:"final_machine_hash,omitempty"` + BondWei string `json:"bond_wei"` + BondETH string `json:"bond_eth"` + CommitmentsJoined uint64 `json:"commitments_joined"` + MatchesCreated uint64 `json:"matches_created"` + MatchesAdvanced uint64 `json:"matches_advanced"` + MatchesDeleted uint64 `json:"matches_deleted"` + InnerTournaments uint64 `json:"inner_tournaments"` + CanBeEliminated *bool `json:"can_be_eliminated,omitempty"` + WinnerExpiresAt *uint64 `json:"winner_expires_at,omitempty"` + Commitments []CommitmentEvent `json:"commitments,omitempty"` + Matches []MatchEvent `json:"matches,omitempty"` + Advances []MatchAdvanceEvent `json:"advances,omitempty"` + Children []*TournamentResult `json:"children,omitempty"` +} + +// ClaimEvent is a claim event in the epoch history. +type ClaimEvent struct { + EventType string `json:"event_type"` + BlockNumber uint64 `json:"block_number"` + TxHash string `json:"tx_hash"` + Submitter string `json:"submitter,omitempty"` + LastProcessedBlock uint64 `json:"last_processed_block"` + OutputsMerkleRoot string `json:"outputs_merkle_root"` + OutputsRootValid *bool `json:"outputs_root_valid,omitempty"` + VotesForClaim *uint64 `json:"votes_for_claim,omitempty"` + VotesNeeded *uint64 `json:"votes_needed,omitempty"` +} + +// EpochEvent is a single sealed epoch event (DaveConsensus). +type EpochEvent struct { + EpochNumber uint64 `json:"epoch_number"` + BlockNumber uint64 `json:"block_number"` + TxHash string `json:"tx_hash"` + InputLowerBound uint64 `json:"input_lower_bound"` + InputUpperBound uint64 `json:"input_upper_bound"` + InitialMachineHash string `json:"initial_machine_hash"` + OutputsMerkleRoot string `json:"outputs_merkle_root"` + OutputsRootValid *bool `json:"outputs_root_valid,omitempty"` + Tournament string `json:"tournament"` +} + +// EpochHistory is the full epoch history output. +type EpochHistory struct { + ConsensusType string `json:"consensus_type"` + TemplateHash string `json:"template_hash,omitempty"` + Epochs []EpochEvent `json:"epochs,omitempty"` + Claims []ClaimEvent `json:"claims,omitempty"` +} + +// SummaryResult is the JSON output of "contract summary". +type SummaryResult struct { + Application *AppResult `json:"application,omitempty"` + AppError string `json:"app_error,omitempty"` + Consensus json.RawMessage `json:"consensus,omitempty"` + ConsensusError string `json:"consensus_error,omitempty"` + InputBox *InputBoxResult `json:"inputbox,omitempty"` + InputBoxError string `json:"inputbox_error,omitempty"` + RootTournament *TournamentResult `json:"root_tournament,omitempty"` + TournamentError string `json:"tournament_error,omitempty"` +} + +// OutputResult is the JSON output for a single output execution check. +type OutputResult struct { + OutputIndex uint64 `json:"output_index"` + Executed bool `json:"executed"` +} + +// OutputBatchResult is the JSON output for batch output execution status. +type OutputBatchResult struct { + TotalExecuted uint64 `json:"total_executed"` + Outputs []OutputResult `json:"outputs,omitempty"` +} + +// CommitmentResult is the JSON output for a commitment's on-chain state. +type CommitmentResult struct { + Commitment string `json:"commitment"` + Tournament string `json:"tournament"` + TournamentLevel string `json:"tournament_level"` + Joined bool `json:"joined"` + Claimer string `json:"claimer,omitempty"` + ClockRunning bool `json:"clock_running"` + ClockAllowance uint64 `json:"clock_allowance"` + ClockDeadline uint64 `json:"clock_deadline"` + BlocksRemaining uint64 `json:"blocks_remaining"` + FinalMachineHash string `json:"final_machine_hash"` +} + +// MatchResult is the JSON output for a match's bisection state. +type MatchResult struct { + MatchIDHash string `json:"match_id_hash"` + Tournament string `json:"tournament"` + CommitmentOne string `json:"commitment_one"` + CommitmentTwo string `json:"commitment_two"` + PlayerOneAddr string `json:"player_one_addr,omitempty"` + PlayerTwoAddr string `json:"player_two_addr,omitempty"` + ActualPhase string `json:"actual_phase"` + CurrentHeight *uint64 `json:"current_height,omitempty"` + SegmentStartPosition string `json:"segment_start_position,omitempty"` + SegmentStartCycle string `json:"segment_start_cycle,omitempty"` + TimeoutOutcome string `json:"timeout_outcome"` + DeferredCharge uint64 `json:"deferred_charge"` + RevealingParent string `json:"revealing_parent,omitempty"` + WaitingLeft string `json:"waiting_left,omitempty"` + WaitingRight string `json:"waiting_right,omitempty"` + Responder string `json:"responder,omitempty"` + AgreeState string `json:"agree_state,omitempty"` + DivergencePosition string `json:"divergence_position,omitempty"` + DivergenceCycle string `json:"divergence_cycle,omitempty"` + FinalStateOne string `json:"final_state_one,omitempty"` + FinalStateTwo string `json:"final_state_two,omitempty"` +} diff --git a/cmd/cartesi-rollups-cli/root/db/check/check.go b/cmd/cartesi-rollups-cli/root/db/check/check.go new file mode 100644 index 000000000..b77d7d0a9 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/db/check/check.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package check + +import ( + "fmt" + "os" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "check-version", + Short: "Validate the Database Schema version", + Run: run, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +func init() { + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(_ *cobra.Command, _ []string) { + dsnURL, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + var s *schema.Schema + for i := range 5 { + //nolint:forbidigo // Database connection. + s, err = schema.New(dsnURL.Raw()) + if err == nil { + break + } + if i == 4 { //nolint:mnd + fmt.Fprintf(os.Stderr, "Failed to connect to database. (%s)\n", dsnURL) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Connection to database failed. Trying again... (%s)\n", dsnURL) + // wait before retrying + time.Sleep(5 * time.Second) //nolint:mnd + } + defer s.Close() + + version, err := s.ValidateVersion() + cobra.CheckErr(err) + + fmt.Printf("Database Schema is at the correct version: %d\n", version) +} diff --git a/cmd/cartesi-rollups-cli/root/db/db.go b/cmd/cartesi-rollups-cli/root/db/db.go new file mode 100644 index 000000000..9bbd42f7c --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/db/db.go @@ -0,0 +1,20 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package db + +import ( + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/db/check" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/db/init" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "db", + Short: "Database management related commands", +} + +func init() { + Cmd.AddCommand(initialize.Cmd) + Cmd.AddCommand(check.Cmd) +} diff --git a/cmd/cartesi-rollups-cli/root/db/init/init.go b/cmd/cartesi-rollups-cli/root/db/init/init.go new file mode 100644 index 000000000..898e39b05 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/db/init/init.go @@ -0,0 +1,96 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package initialize + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "init", + Short: "Initialize the Database Schema", + Run: run, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +var upgradeFlag bool + +func init() { + Cmd.Flags().BoolVar(&upgradeFlag, "upgrade", false, "Upgrade an existing database schema if one exists") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(_ *cobra.Command, _ []string) { + var s *schema.Schema + var err error + + dsnURL, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + + for i := range 5 { + //nolint:forbidigo // Database connection. + s, err = schema.New(dsnURL.Raw()) + if err == nil { + break + } + if i == 4 { //nolint:mnd + fmt.Fprintf(os.Stderr, "Failed to connect to database. (%s)\n", dsnURL) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Connection to database failed. Trying again... (%s)\n", dsnURL) + // wait before retrying + time.Sleep(5 * time.Second) //nolint:mnd + } + defer s.Close() + + // Check if a schema already exists + version, _, err := s.Version() + if err != nil { + if errors.Is(err, schema.ErrNoValidDatabaseSchema) { + // No schema exists, so create it + err = s.Upgrade() + cobra.CheckErr(err) + + version, err = s.ValidateVersion() + cobra.CheckErr(err) + + fmt.Printf("Database Schema was successfully initialized. Current version: %d\n", version) + return + } + cobra.CheckErr(err) + } + if version == schema.ExpectedVersion { + fmt.Printf("Database Schema is already at the correct version: %d\n", version) + return + } + + // Schema exists, check if we should upgrade + if !upgradeFlag { + fmt.Printf("Database Schema already exists (version: %d). Use --upgrade flag to upgrade it.\n", version) + return + } + + // Upgrade the existing schema + err = s.Upgrade() + cobra.CheckErr(err) + + version, err = s.ValidateVersion() + cobra.CheckErr(err) + + fmt.Printf("Database Schema was successfully upgraded. Current version: %d\n", version) +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go new file mode 100644 index 000000000..beaadfefd --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -0,0 +1,633 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/cobra" +) + +var ( + applicationConsensusAddressParam string + applicationEnableParam bool + applicationOwnerAddressParam string + applicationRegisterParam bool + applicationTemplateHashParam string + factoryAddressParam string + executionParametersFileParam string + prtFactoryAddressParam string + deploymentTypePRT bool +) + +var applicationCmd = &cobra.Command{ + Use: "application [application-name] [template-path]", + Short: "Deploy a new application and register it into the database", + + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 2 { //nolint:mnd // The command accepts at most two positional arguments. + return fmt.Errorf("error on argument count. Expected at most two positional arguments") + } + return cobra.OnlyValidArgs(cmd, args) + }, + Example: applicationExamples, + Run: runDeployApplication, + PreRunE: validateApplicationDeployment, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS Input Box contract address + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS Application Factory address + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS Self Hosted Application Factory address + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS Dave Application Factory address`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const applicationExamples = ` +# deploy both application and authority contracts together via self hosted application contract, then register the application + - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ + +# deploy an application contract using an existing consensus, then register the application + - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --consensus=0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +# deploy an application contract with a PRT consensus, then register the application + - cli deploy application echo-dapp applications/echo-dapp/ --prt + +# deploy a PRT application with two sentry slots and a manager that can rotate their addresses + - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --prt --claim-staging-period=300 --sentry-manager=0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA --sentries=0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB,0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC + +# deploy but don't register into the database + - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --register=false + +# deploy and register into the database, but disabled + - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ --enable=false + +# deploy an application without a machine template path (both application-name and template-path may be omitted in this case) + - cartesi-rollups-cli deploy application --template-hash=0x0000000000000000000000000000000000000000000000000000000000000000 --register=false` + +func init() { + applicationCmd.Flags().StringVarP(&applicationConsensusAddressParam, "consensus", "c", "", + "Consensus address. A new authority consensus will be created if this field is left empty.") + applicationCmd.Flags().StringVarP(&factoryAddressParam, "factory", "f", "", + "Application factory address. Default value is retrieved from configuration.") + applicationCmd.Flags().StringVarP(&prtFactoryAddressParam, "prt-factory", "", "", + "PRT Application factory address. Default value is retrieved from configuration.") + applicationCmd.Flags().StringVarP(&applicationOwnerAddressParam, "application-owner", "o", "", + "Application owner address. If not defined, it will be derived from the auth method.") + applicationCmd.Flags().StringVarP(&applicationTemplateHashParam, "template-hash", "H", "", + "Template hash. If not provided, it will be read from the template path") + applicationCmd.Flags().BoolVarP(&applicationRegisterParam, "register", "r", true, + "Register the application into the database") + applicationCmd.Flags().StringVarP(&executionParametersFileParam, "execution-parameters-file", "", "", + "JSON encoded execution parameters of the application. Default values will be used if not defined.") + applicationCmd.Flags().BoolVarP(&applicationEnableParam, "enable", "e", true, + "Start processing the application, requires 'register=true'.") + applicationCmd.Flags().StringVarP(&authorityOwnerAddressParam, "authority-owner", "O", "", + "Authority Owner address. If not defined, it will be derived from the auth method.") + applicationCmd.Flags().BoolVarP(&deploymentTypePRT, "prt", "", false, + "Deploy a PRT application.") + addPRTSentryFlags(applicationCmd) + + origHelpFunc := applicationCmd.HelpFunc() + applicationCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("epoch-length").Hidden = false + command.Flags().Lookup("salt").Hidden = false + command.Flags().Lookup("json").Hidden = false + command.Flags().Lookup("verbose").Hidden = false + origHelpFunc(command, strings) + }) + cli.AddTransactionFlags(applicationCmd) +} + +func validateApplicationDeployment(cmd *cobra.Command, args []string) error { + register, err := cmd.Flags().GetBool("register") + if err != nil { + return err + } + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return err + } + if noWait && register { + return fmt.Errorf("--no-wait requires --register=false; registration needs a confirmed deployment") + } + if register && len(args) < 1 { + return fmt.Errorf("missing application name: positional argument [application-name] is required when --register=true") + } + if !cmd.Flags().Changed("template-hash") && len(args) < 2 { + return fmt.Errorf("missing template: provide either positional [template-path] or --template-hash") + } + _, _, err = parsePRTSentryConfig(cmd) + return err +} + +func runDeployApplication(cmd *cobra.Command, args []string) { + applicationName := "" + templateURI := "" + + ctx := cmd.Context() + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + + client, err := cli.DialBlockchain(ctx, ethEndpoint) + cobra.CheckErr(err) + defer client.Close() + + chainID, err := client.ChainID(ctx) + cobra.CheckErr(err) + + txOpts, err := cli.GetTransactOpts(ctx, chainID) + cobra.CheckErr(err) + + // pre deployment checks + if len(args) >= 1 { + applicationName = args[0] + } + if len(args) >= 2 { //nolint:mnd // The optional second argument is the template path. + templateURI = args[1] + } + + if applicationRegisterParam { + // check if name is valid and available before deploying + applicationName, err = config.ToApplicationNameFromString(applicationName) + cobra.CheckErr(err) + + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() + + applicationInUse, err := repo.GetApplication(ctx, applicationName) + cobra.CheckErr(err) + + if applicationInUse != nil { + cobra.CheckErr(fmt.Errorf("application name is already in use: %v", applicationInUse.Name)) + } + } + + var deployment ethutil.IApplicationDeployment + if deploymentTypePRT { + deployment, err = buildPrtApplicationDeployment(cmd, args) + } else if deploySelfhosted := !cmd.Flags().Changed("consensus"); deploySelfhosted { + deployment, err = buildSelfhostedApplicationDeployment(ctx, cmd, args, client, txOpts) + } else { + deployment, err = buildApplicationOnlyDeployment(ctx, cmd, args, client, txOpts) + } + cobra.CheckErr(err) + + if verboseParam { + fmt.Fprint(os.Stderr, deployment) + fmt.Fprintln(os.Stderr, "\twallet address: ", txOpts.From) + } + + application := model.Application{} + application.Name = applicationName + application.TemplateURI = templateURI + application.Enabled = applicationEnableParam + application.Status = model.ApplicationStatus_OK + application.ConsensusType = model.Consensus_Authority + + // load execution parameters from a file? + withExecutionParameters := cmd.Flags().Changed("execution-parameters-file") + if withExecutionParameters { + filePath := executionParametersFileParam + if executionParametersFileParam == "-" { + filePath = os.Stdin.Name() + } + contents, err := os.ReadFile(filePath) + cobra.CheckErr(err) + + decoder := json.NewDecoder(strings.NewReader(string(contents))) + decoder.DisallowUnknownFields() // Prevent unexpected fields + err = decoder.Decode(&application.ExecutionParameters) + cobra.CheckErr(err) + cobra.CheckErr(application.ExecutionParameters.Validate()) + + if verboseParam { + fmt.Fprintln(os.Stderr, "loading execution parameters...success") + } + } + + // factory check + if verboseParam { + fmt.Fprint(os.Stderr, "checking factory address...") + } + + factoryAddress := deployment.GetFactoryAddress() + data, err := client.CodeAt(ctx, factoryAddress, nil) + cobra.CheckErr(err) + + if len(data) == 0 { + cobra.CheckErr(fmt.Errorf("no code at the factory address: %v", factoryAddress)) + } + if verboseParam { + fmt.Fprint(os.Stderr, "success\n") + } + + // deploy + if verboseParam || !asJSONParam { + fmt.Fprint(os.Stderr, "deploying...") + } + var tx *types.Transaction + var receipt *types.Receipt + applicationAddress, result, err := deployment.DeployWithTransaction(ctx, client, ethutil.NewStaticTransactOptsFactory(txOpts), + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) + // The revert surface spans the variant's factory plus the constructors it + // invokes; selectors are content-matched, so passing every factory ABI is + // harmless and covers all three deployment variants. + cobra.CheckErr(cli.DecorateRevert(err, + iapplicationfactory.IApplicationFactoryMetaData, + iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, + iauthorityfactory.IAuthorityFactoryMetaData, + iquorumfactory.IQuorumFactoryMetaData, + idaveappfactory.IDaveAppFactoryMetaData, + )) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, applicationAddress)) + return + } + + if verboseParam || !asJSONParam { + fmt.Fprint(os.Stderr, "success\n") + } + + // TODO(mpolitzer): can this be more concise? + // (they are similar but different in a couple of fields) + switch res := result.(type) { + case *ethutil.SelfhostedApplicationDeploymentResult: + application.IApplicationAddress = res.ApplicationAddress + application.IConsensusAddress = res.AuthorityAddress + application.IInputBoxAddress = res.Deployment.InputBoxAddress + application.TemplateHash = res.Deployment.TemplateHash + application.EpochLength = res.Deployment.EpochLength + application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod + application.IInputBoxBlock = res.Deployment.IInputBoxBlock + application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) + + case *ethutil.ApplicationDeploymentResult: + application.IApplicationAddress = res.ApplicationAddress + application.IConsensusAddress = res.Deployment.Consensus + application.IInputBoxAddress = res.Deployment.InputBoxAddress + application.TemplateHash = res.Deployment.TemplateHash + application.EpochLength = res.Deployment.EpochLength + application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod + application.IInputBoxBlock = res.Deployment.IInputBoxBlock + if res.Deployment.ConsensusType != "" { + application.ConsensusType = model.Consensus(res.Deployment.ConsensusType) + } + application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) + + case *ethutil.PRTApplicationDeploymentResult: + application.IApplicationAddress = res.ApplicationAddress + application.IConsensusAddress = res.DaveConsensusAddress + application.IInputBoxAddress = res.InputBoxAddress + application.TemplateHash = res.Deployment.TemplateHash + application.EpochLength = res.Deployment.EpochLength + application.ClaimStagingPeriod = res.Deployment.ClaimStagingPeriod + application.IInputBoxBlock = res.IInputBoxBlock + application.ConsensusType = model.Consensus_PRT + application.WithdrawalConfig = model.WithdrawalConfig(res.Deployment.WithdrawalConfig) + default: + panic("unimplemented deployment type\n") + } + + if applicationRegisterParam { + if verboseParam || !asJSONParam { + fmt.Fprint(os.Stderr, "registering...") + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to register application: %w", err)) + } + + repo, err := cli.OpenRepository(ctx, dsn) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to register application: %w", err)) + } + defer repo.Close() + + _, err = repo.CreateApplication(ctx, &application, withExecutionParameters) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to register application: %w", err)) + } + if verboseParam || !asJSONParam { + fmt.Fprint(os.Stderr, "success\n") + } + + if verboseParam || !asJSONParam { + if applicationName != "" || verboseParam { + fmt.Fprintln(os.Stderr, "\tapplication name: ", applicationName) + } + if templateURI != "" || verboseParam { + fmt.Fprintln(os.Stderr, "\tapplication path: ", templateURI) + } + if withExecutionParameters { + fmt.Fprintln(os.Stderr, "\texecution parameters file: ", executionParametersFileParam) + } + } + } else if verboseParam { + fmt.Fprint(os.Stderr, "registering...skipped\n") + } + + if asJSONParam { + report := struct { + model.Application + Transaction cli.TransactionResult `json:"transaction"` + }{Application: application, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprint(cmd.OutOrStdout(), result) + cobra.CheckErr(err) + } +} + +// parse args + cmd into a self hosted deployment structure +func buildSelfhostedApplicationDeployment( + ctx context.Context, + cmd *cobra.Command, + args []string, + client *ethclient.Client, + txOpts *bind.TransactOpts, +) (*ethutil.SelfhostedApplicationDeployment, error) { + var err error + request := ðutil.SelfhostedApplicationDeployment{} + + if !cmd.Flags().Changed("factory") { + request.FactoryAddress, err = config.GetContractsSelfHostedApplicationFactoryAddress() + } else { + request.FactoryAddress, err = parseHexAddress(factoryAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter selfhosted-factory: %w", err) + } + + if cmd.Flags().Changed("application-owner") { + return nil, fmt.Errorf("application-owner is not supported for self-hosted deployments; application ownership is renounced") + } + + if !cmd.Flags().Changed("authority-owner") { + request.AuthorityOwnerAddress = txOpts.From + } else { + request.AuthorityOwnerAddress, err = parseHexAddress(authorityOwnerAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter authority-owner: %w", err) + } + + if !cmd.Flags().Changed("template-hash") { + if len(args) >= 2 { //nolint:mnd // args[1] is mandatory if `template-hash` was absent + request.TemplateHash, err = util.ReadRootHash(args[1]) + } else { + err = fmt.Errorf("missing argument. One of `template-path` or `template-hash` is required") + } + } else { + request.TemplateHash, err = parseHexHash(applicationTemplateHashParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter template-hash: %w", err) + } + + request.InputBoxAddress, request.IInputBoxBlock, err = deploymentInputBox(ctx, client) + if err != nil { + return nil, err + } + + request.Salt, err = ethutil.ParseSalt(saltParam) + if err != nil { + return nil, fmt.Errorf("error on parameter salt: %w", err) + } + + request.WithdrawalConfig, err = parseWithdrawalConfig(withdrawalConfigParam, withdrawalConfigFileParam) + if err != nil { + return nil, err + } + + request.EpochLength = epochLengthParam + request.ClaimStagingPeriod = claimStagingPeriodParam + request.Verbose = verboseParam + return request, nil +} + +func buildApplicationOnlyDeployment( + ctx context.Context, + cmd *cobra.Command, + args []string, + client *ethclient.Client, + txOpts *bind.TransactOpts, +) ( + *ethutil.ApplicationDeployment, + error, +) { + request := ðutil.ApplicationDeployment{} + var err error + + if !cmd.Flags().Changed("factory") { + request.FactoryAddress, err = config.GetContractsApplicationFactoryAddress() + } else { + request.FactoryAddress, err = parseHexAddress(factoryAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter factory: %w", err) + } + + if !cmd.Flags().Changed("template-hash") { + if len(args) >= 2 { //nolint:mnd // args[1] is mandatory if `template-hash` was absent + request.TemplateHash, err = util.ReadRootHash(args[1]) + } else { + err = fmt.Errorf("missing argument. One of `template-path` or `template-hash` is required") + } + } else { + request.TemplateHash, err = parseHexHash(applicationTemplateHashParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter template-hash: %w", err) + } + + if !cmd.Flags().Changed("application-owner") { + request.OwnerAddress = txOpts.From + } else { + request.OwnerAddress, err = parseHexAddress(applicationOwnerAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter application-owner: %w", err) + } + + request.InputBoxAddress, request.IInputBoxBlock, err = deploymentInputBox(ctx, client) + if err != nil { + return nil, err + } + + request.Salt, err = ethutil.ParseSalt(saltParam) + if err != nil { + return nil, fmt.Errorf("error on parameter salt: %w", err) + } + + request.WithdrawalConfig, err = parseWithdrawalConfig(withdrawalConfigParam, withdrawalConfigFileParam) + if err != nil { + return nil, err + } + + request.Verbose = verboseParam + + var consensusType model.Consensus + request.Consensus, request.EpochLength, request.ClaimStagingPeriod, consensusType, err = + customConsensus(ctx, client, applicationConsensusAddressParam) + if err != nil { + return nil, fmt.Errorf("error on parameter consensus: %w", err) + } + request.ConsensusType = consensusType.String() + + return request, nil +} + +func deploymentInputBox(ctx context.Context, client *ethclient.Client) (common.Address, uint64, error) { + inputBoxAddress, err := config.GetContractsInputBoxAddress() + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to get input box address: %w", err) + } + + code, err := client.CodeAt(ctx, inputBoxAddress, nil) + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to probe input box address for contract: %w", err) + } + if len(code) == 0 { + return common.Address{}, 0, fmt.Errorf("input box address has no code: %v", inputBoxAddress) + } + + inputBoxBlock, err := ethutil.GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) + if err != nil { + return common.Address{}, 0, fmt.Errorf("failed to get input box deployment block: %w", err) + } + if !inputBoxBlock.IsUint64() { + return common.Address{}, 0, fmt.Errorf("input box deployment block does not fit uint64: %v", inputBoxBlock) + } + return inputBoxAddress, inputBoxBlock.Uint64(), nil +} + +func buildPrtApplicationDeployment( + cmd *cobra.Command, + args []string, +) ( + *ethutil.PRTApplicationDeployment, + error, +) { + var err error + request := ðutil.PRTApplicationDeployment{} + request.SentryManager, request.Sentries, err = parsePRTSentryConfig(cmd) + if err != nil { + return nil, err + } + if !cmd.Flags().Changed("prt-factory") { + request.FactoryAddress, err = config.GetContractsDaveAppFactoryAddress() + } else { + request.FactoryAddress, err = parseHexAddress(prtFactoryAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter factory: %w", err) + } + + if !cmd.Flags().Changed("template-hash") { + if len(args) >= 2 { //nolint:mnd // args[1] is mandatory if `template-hash` was absent + request.TemplateHash, err = util.ReadRootHash(args[1]) + } else { + err = fmt.Errorf("missing argument. One of `template-path` or `template-hash` is required") + } + } else { + request.TemplateHash, err = parseHexHash(applicationTemplateHashParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter template-hash: %w", err) + } + + request.Salt, err = ethutil.ParseSalt(saltParam) + if err != nil { + return nil, fmt.Errorf("error on parameter salt: %w", err) + } + + request.WithdrawalConfig, err = parseWithdrawalConfig(withdrawalConfigParam, withdrawalConfigFileParam) + if err != nil { + return nil, err + } + + request.ClaimStagingPeriod = claimStagingPeriodParam + request.Verbose = verboseParam + return request, nil +} + +func parseHexHash(hash string) (common.Hash, error) { + out := common.Hash{} + return out, out.UnmarshalText([]byte(hash)) +} + +func customConsensus( + ctx context.Context, client *ethclient.Client, consensusString string, +) (common.Address, uint64, uint64, model.Consensus, error) { + consensusAddress, err := parseHexAddress(consensusString) + if err != nil { + return common.Address{}, 0, 0, "", err + } + + consensus, err := iconsensus.NewIConsensus(consensusAddress, client) + if err != nil { + return common.Address{}, 0, 0, "", err + } + + callOpts := &bind.CallOpts{Context: ctx} + epochLengthBig, err := consensus.GetEpochLength(callOpts) + if err != nil { + return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus epoch length: %w", err) + } + + claimStagingPeriodBig, err := consensus.GetClaimStagingPeriod(callOpts) + if err != nil { + return common.Address{}, 0, 0, "", fmt.Errorf("failed to retrieve consensus claim staging period: %w", err) + } + + consensusType := model.Consensus_Authority + quorum, err := iquorum.NewIQuorum(consensusAddress, client) + if err != nil { + return common.Address{}, 0, 0, "", err + } + numOfValidators, err := quorum.NumOfValidators(callOpts) + if err == nil { + if numOfValidators.Sign() == 0 { + return common.Address{}, 0, 0, "", fmt.Errorf("quorum consensus reports zero validators") + } + consensusType = model.Consensus_Quorum + } + + return consensusAddress, epochLengthBig.Uint64(), claimStagingPeriodBig.Uint64(), consensusType, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/application_test.go b/cmd/cartesi-rollups-cli/root/deploy/application_test.go new file mode 100644 index 000000000..cbc09d0b2 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/application_test.go @@ -0,0 +1,81 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestBuildSelfhostedDeploymentRejectsApplicationOwner(t *testing.T) { + t.Setenv("CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS", "0x1000000000000000000000000000000000000001") + for _, ownerProvided := range []bool{false, true} { + name := "omitted" + if ownerProvided { + name = "explicit" + } + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("application-owner", "", "") + if ownerProvided { + require.NoError(t, cmd.Flags().Set("application-owner", "0x2000000000000000000000000000000000000002")) + } + _, err := buildSelfhostedApplicationDeployment(t.Context(), cmd, nil, nil, &bind.TransactOpts{}) + if ownerProvided { + require.ErrorContains(t, err, "ownership is renounced") + } else { + // Without an owner flag, validation reaches the next required argument. + require.ErrorContains(t, err, "template-hash") + } + }) + } +} + +func TestApplicationCommandDoesNotExposeDataAvailabilityFlag(t *testing.T) { + require.Nil(t, applicationCmd.Flags().Lookup("data-availability")) +} + +func TestApplicationDeploymentValidatesBeforeRunning(t *testing.T) { + for _, test := range []struct { + name string + register bool + noWait bool + templateHash bool + args []string + wantError string + }{ + {name: "registration requires receipt", register: true, noWait: true, wantError: "--no-wait requires --register=false"}, + {name: "registration requires name", register: true, templateHash: true, wantError: "missing application name"}, + {name: "template required", wantError: "missing template"}, + {name: "template hash only", templateHash: true}, + {name: "no wait with template hash", noWait: true, templateHash: true}, + {name: "registered template hash", register: true, templateHash: true, args: []string{"example"}}, + {name: "positional template", register: true, args: []string{"example", "applications/example"}}, + } { + t.Run(test.name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("register", test.register, "") + cmd.Flags().String("template-hash", "", "") + cli.AddTransactionFlags(cmd) + if test.noWait { + require.NoError(t, cmd.Flags().Set("no-wait", "true")) + } + if test.templateHash { + require.NoError(t, cmd.Flags().Set("template-hash", "0x01")) + } + // Invoke the actual command hook. The test must fail if the argument + // validation is removed from the command's pre-run path. + err := applicationCmd.PreRunE(cmd, test.args) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/authority.go b/cmd/cartesi-rollups-cli/root/deploy/authority.go new file mode 100644 index 000000000..df9089f2b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/authority.go @@ -0,0 +1,183 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" +) + +var ( + authorityFactoryAddressParam string + authorityOwnerAddressParam string +) + +var authorityCmd = &cobra.Command{ + Use: "authority", + Short: "Deploy a new authority contract", + Example: authorityExamples, + Run: runDeployAuthority, + Long: ` +Supported Environment Variables: + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS Authority Factory Address`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const authorityExamples = ` +# deploy a new authority contract + - cli deploy authority + +# deploy a new authority contract with a custom owner and factory address + - cli deploy authority --authority-owner 0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA --authority-factory 0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB` + +func init() { + authorityCmd.Flags().StringVarP(&authorityFactoryAddressParam, "authority-factory", "F", "", + "Authority Factory Address. If defined, epoch-length value will be used to create a new consensus") + authorityCmd.Flags().StringVarP(&authorityOwnerAddressParam, "authority-owner", "O", "", + "Authority Owner. If not defined, it will be derived from the auth method.") + + origHelpFunc := authorityCmd.HelpFunc() + authorityCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("epoch-length").Hidden = false + command.Flags().Lookup("salt").Hidden = false + command.Flags().Lookup("json").Hidden = false + command.Flags().Lookup("verbose").Hidden = false + // `claim-staging-period` is exposed on `authority` since it's + // the parameter for the authority contract being deployed. + origHelpFunc(command, strings) + }) + cli.AddTransactionFlags(authorityCmd) +} + +func runDeployAuthority(cmd *cobra.Command, _ []string) { + var err error + + ctx := cmd.Context() + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + + client, err := cli.DialBlockchain(ctx, ethEndpoint) + cobra.CheckErr(err) + defer client.Close() + + chainID, err := client.ChainID(ctx) + cobra.CheckErr(err) + + txOpts, err := cli.GetTransactOpts(ctx, chainID) + cobra.CheckErr(err) + + deployment, err := buildAuthorityDeployment(cmd, txOpts) + cobra.CheckErr(err) + + if verboseParam { + fmt.Fprint(os.Stderr, deployment) + fmt.Fprintln(os.Stderr, "\twallet address: ", txOpts.From) + + } + + // factory check + if verboseParam { + fmt.Fprint(os.Stderr, "checking factory address...") + } + + factoryAddress := deployment.FactoryAddress + data, err := client.CodeAt(ctx, factoryAddress, nil) + cobra.CheckErr(err) + + if len(data) == 0 { + cobra.CheckErr(fmt.Errorf("no code at the factory address: %v", factoryAddress)) + } + if verboseParam { + fmt.Fprint(os.Stderr, "success\n") + } + + // deploy + if verboseParam || !asJSONParam { + fmt.Fprintf(os.Stderr, "deploying authority...") + } + var tx *types.Transaction + var receipt *types.Receipt + deployment.Address, err = deployment.DeployWithTransaction(ctx, client, txOpts, + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) + cobra.CheckErr(cli.DecorateRevert(err, iauthorityfactory.IAuthorityFactoryMetaData)) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, deployment.Address)) + return + } + + // report + if verboseParam || !asJSONParam { + fmt.Fprintf(os.Stderr, "success\n") + } + + if asJSONParam { + report := struct { + *ethutil.AuthorityDeployment + Transaction cli.TransactionResult `json:"transaction"` + }{AuthorityDeployment: deployment, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Consensus address: %s\nEpoch length: %d\n", deployment.Address, deployment.EpochLength) + cobra.CheckErr(err) + } +} + +func buildAuthorityDeployment(cmd *cobra.Command, txOpts *bind.TransactOpts) (*ethutil.AuthorityDeployment, error) { + var err error + var authorityFactoryAddress common.Address + var authorityOwnerAddress common.Address + + if !cmd.Flags().Changed("authority-factory") { + authorityFactoryAddress, err = config.GetContractsAuthorityFactoryAddress() + } else { + authorityFactoryAddress, err = parseHexAddress(authorityFactoryAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter authority-factory: %w", err) + } + + if !cmd.Flags().Changed("authority-owner") { + authorityOwnerAddress = txOpts.From + } else { + authorityOwnerAddress, err = parseHexAddress(authorityOwnerAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter authority-owner: %w", err) + } + + salt, err := ethutil.ParseSalt(saltParam) + if err != nil { + return nil, fmt.Errorf("error on parameter salt: %w", err) + } + + return ðutil.AuthorityDeployment{ + FactoryAddress: authorityFactoryAddress, + OwnerAddress: authorityOwnerAddress, + EpochLength: epochLengthParam, + ClaimStagingPeriod: claimStagingPeriodParam, + Salt: salt, + Verbose: verboseParam, + }, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/broadcast.go b/cmd/cartesi-rollups-cli/root/deploy/broadcast.go new file mode 100644 index 000000000..11e258503 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/broadcast.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/internal/cli" +) + +func writeDeploymentBroadcast(cmd *cobra.Command, tx *types.Transaction, predictedAddress common.Address) error { + asJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + result := struct { + cli.TransactionResult + PredictedAddress common.Address `json:"predicted_address"` + }{TransactionResult: cli.NewTransactionResult(tx, nil), PredictedAddress: predictedAddress} + if asJSON { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + err = encoder.Encode(result) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Transaction broadcast: %s\nPredicted address (deployment not confirmed): %s\n", + result.TransactionHash, predictedAddress.Hex()) + } + if err != nil { + return fmt.Errorf("write deployment broadcast result: %w", err) + } + return nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go new file mode 100644 index 000000000..821e9018c --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/broadcast_test.go @@ -0,0 +1,226 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" +) + +const deploymentCLIProcessEnv = "CARTESI_TEST_DEPLOYMENT_CLI_PROCESS" + +func TestDeploymentBroadcastPlainOutput(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("json", false, "") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + tx := types.NewTx(&types.LegacyTx{}) + address := common.HexToAddress("0x1234") + require.NoError(t, writeDeploymentBroadcast(cmd, tx, address)) + require.Equal(t, "Transaction broadcast: "+tx.Hash().Hex()+ + "\nPredicted address (deployment not confirmed): "+address.Hex()+"\n", stdout.String()) + require.Empty(t, stderr.String()) +} + +// Run the actual command in a child process. Existing deployment handlers use +// cobra.CheckErr, and this also checks Cobra's default stdout/stderr selection. +func TestDeploymentCLIProcess(t *testing.T) { + if os.Getenv(deploymentCLIProcessEnv) != "1" { + return + } + config.SetDefaults() + for i, arg := range os.Args { + if arg == "--" { + Cmd.SetArgs(os.Args[i+1:]) + break + } + } + if err := Cmd.ExecuteContext(t.Context()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func TestDeploymentCLIReportsPredictedAddress(t *testing.T) { + testExecutable, err := os.Executable() + require.NoError(t, err) + factoryAddress := common.HexToAddress("0x1000") + inputBoxAddress := common.HexToAddress("0x2000") + predictedAddress := common.HexToAddress("0x3000") + secondaryAddress := common.HexToAddress("0x4000") + applicationArgs := []string{"application", "--register=false", "--template-hash=" + (common.Hash{}).Hex()} + for _, test := range []struct { + name string + args []string + }{ + {name: "authority", args: []string{"authority", "--authority-factory", factoryAddress.Hex()}}, + {name: "quorum", args: []string{"quorum", "--quorum-factory", factoryAddress.Hex(), "--validator", testValidatorAddress}}, + {name: "selfhosted", args: append(append([]string{}, applicationArgs...), "--factory", factoryAddress.Hex())}, + {name: "application", args: append(append([]string{}, applicationArgs...), "--factory", factoryAddress.Hex(), + "--consensus", inputBoxAddress.Hex())}, + {name: "prt", args: append(append([]string{}, applicationArgs...), "--prt", "--prt-factory", factoryAddress.Hex())}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &deploymentCLIBackend{ + responses: map[string]hexutil.Bytes{}, + sent: make(chan *types.Transaction, 2), predictedAddress: predictedAddress, secondaryAddress: secondaryAddress, + } + for _, response := range []struct { + metadata *bind.MetaData + method string + outputs []any + }{ + {iauthorityfactory.IAuthorityFactoryMetaData, "calculateAuthorityAddress", []any{predictedAddress}}, + {iquorumfactory.IQuorumFactoryMetaData, "calculateQuorumAddress", []any{predictedAddress}}, + {iapplicationfactory.IApplicationFactoryMetaData, "calculateApplicationAddress", []any{predictedAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "calculateAddresses", + []any{predictedAddress, secondaryAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "getApplicationFactory", []any{factoryAddress}}, + {iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, "getAuthorityFactory", []any{factoryAddress}}, + {idaveappfactory.IDaveAppFactoryMetaData, "calculateDaveAppAddress", []any{predictedAddress, secondaryAddress}}, + {iinputbox.IInputBoxMetaData, "getDeploymentBlockNumber", []any{big.NewInt(1)}}, + {iconsensus.IConsensusMetaData, "getEpochLength", []any{big.NewInt(10)}}, + {iconsensus.IConsensusMetaData, "getClaimStagingPeriod", []any{big.NewInt(0)}}, + {iquorum.IQuorumMetaData, "numOfValidators", []any{big.NewInt(1)}}, + } { + parsed, err := response.metadata.GetAbi() + require.NoError(t, err) + method := parsed.Methods[response.method] + data, err := method.Outputs.Pack(response.outputs...) + require.NoError(t, err) + backend.responses[string(method.ID)] = data + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", backend)) + defer rpcServer.Stop() + server := httptest.NewServer(rpcServer) + defer server.Close() + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(deploymentCLIProcessEnv, "1") + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + t.Setenv(config.CONTRACTS_INPUT_BOX_ADDRESS, inputBoxAddress.Hex()) + t.Setenv(config.DATABASE_CONNECTION, "invalid") + args := append([]string{"-test.run=^TestDeploymentCLIProcess$", "--"}, test.args...) + args = append(args, "--no-wait", "--json") + process := exec.CommandContext(t.Context(), testExecutable, args...) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + require.NoError(t, process.Run(), stderr.String()) + require.Len(t, backend.sent, 1) + tx := <-backend.sent + require.Equal(t, factoryAddress, *tx.To()) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, map[string]string{ + "transaction_hash": tx.Hash().Hex(), "status": "broadcast", "predicted_address": predictedAddress.Hex(), + }, result) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.Zero(t, backend.receiptReads.Load()) + require.Zero(t, backend.postBroadcastReads.Load(), "no deployment verification before mining") + }) + } +} + +type deploymentCLIBackend struct { + responses map[string]hexutil.Bytes + sent chan *types.Transaction + predictedAddress common.Address + secondaryAddress common.Address + broadcast atomic.Bool + receiptReads atomic.Int32 + postBroadcastReads atomic.Int32 +} + +func (*deploymentCLIBackend) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*deploymentCLIBackend) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: 1_000_000} +} + +func (*deploymentCLIBackend) GasPrice(context.Context) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1)) +} + +func (*deploymentCLIBackend) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (*deploymentCLIBackend) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + return 500_000 +} + +func (b *deploymentCLIBackend) GetCode(_ context.Context, address common.Address, _ string) hexutil.Bytes { + if b.broadcast.Load() { + b.postBroadcastReads.Add(1) + } + if address == b.predictedAddress || address == b.secondaryAddress { + return hexutil.Bytes{} + } + return hexutil.Bytes{0x01} +} + +func (b *deploymentCLIBackend) Call(_ context.Context, call struct { + Input hexutil.Bytes `json:"input"` +}, _ string) (hexutil.Bytes, error) { + if b.broadcast.Load() { + b.postBroadcastReads.Add(1) + } + if len(call.Input) >= 4 { + if response, found := b.responses[string(call.Input[:4])]; found { + return response, nil + } + } + return nil, fmt.Errorf("unexpected contract call: %x", call.Input) +} + +func (b *deploymentCLIBackend) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + b.sent <- &tx + b.broadcast.Store(true) + return tx.Hash(), nil +} + +func (b *deploymentCLIBackend) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + b.receiptReads.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(10), Logs: []*types.Log{}} +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/deploy.go b/cmd/cartesi-rollups-cli/root/deploy/deploy.go new file mode 100644 index 000000000..1b4d6fbca --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/deploy.go @@ -0,0 +1,67 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" +) + +var ( + epochLengthParam uint64 + claimStagingPeriodParam uint64 + withdrawalConfigParam string + withdrawalConfigFileParam string + saltParam string + asJSONParam bool + verboseParam bool +) + +var Cmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy Contracts and Applications", + Run: run, +} + +func init() { + Cmd.PersistentFlags().Uint64VarP(&epochLengthParam, "epoch-length", "", 10, //nolint:mnd + "Epoch length") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("epoch-length")) + Cmd.PersistentFlags().Uint64Var(&claimStagingPeriodParam, "claim-staging-period", 0, + "Number of blocks between claim staging and acceptance. Zero permits immediate acceptance; choose the delay for your deployment.") + Cmd.PersistentFlags().StringVar(&withdrawalConfigParam, "withdrawal-config", "", + "Inline JSON object describing the WithdrawalConfig "+ + "(see docs/withdrawal-config-guide.md). Omit to deploy without foreclosure.") + Cmd.PersistentFlags().StringVar(&withdrawalConfigFileParam, "withdrawal-config-file", "", + "Path to a JSON file describing the WithdrawalConfig. Mutually exclusive with --withdrawal-config.") + Cmd.PersistentFlags().StringVar(&saltParam, "salt", "0000000000000000000000000000000000000000000000000000000000000000", + "Salt value for contract deployment") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("salt")) + Cmd.PersistentFlags().BoolVarP(&asJSONParam, "json", "", false, + "Print results as JSON") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("json")) + Cmd.PersistentFlags().BoolVarP(&verboseParam, "verbose", "", false, + "Print extra information") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("verbose")) + + Cmd.AddCommand(applicationCmd) + Cmd.AddCommand(authorityCmd) + Cmd.AddCommand(quorumCmd) +} + +func run(cmd *cobra.Command, _ []string) { + // If no subcommand is provided, show help + err := cmd.Help() + cobra.CheckErr(err) +} + +// parse common.Address with error checking +func parseHexAddress(address string) (common.Address, error) { + if !common.IsHexAddress(address) { + return common.Address{}, fmt.Errorf("failed to parse hex address") + } + return common.HexToAddress(address), nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum.go b/cmd/cartesi-rollups-cli/root/deploy/quorum.go new file mode 100644 index 000000000..961176581 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum.go @@ -0,0 +1,199 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" +) + +var ( + quorumFactoryAddressParam string + quorumValidatorAddressArgs []string +) + +var quorumCmd = &cobra.Command{ + Use: "quorum", + Short: "Deploy a new quorum contract", + Example: quorumExamples, + Args: cobra.NoArgs, + Run: runDeployQuorum, + Long: ` +Supported Environment Variables: + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS Quorum Factory Address`, +} + +const quorumExamples = ` +# deploy a new quorum contract + - cli deploy quorum --validator 0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +# deploy a new quorum contract with multiple validators and a custom factory address + - cli deploy quorum --validator 0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \ + --validator 0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB \ + --quorum-factory 0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC` + +func init() { + quorumCmd.Flags().StringVarP(&quorumFactoryAddressParam, "quorum-factory", "F", "", + "Quorum Factory Address. If not defined, it will be retrieved from configuration.") + quorumCmd.Flags().StringArrayVarP(&quorumValidatorAddressArgs, "validator", "v", nil, + "Quorum validator address. Repeat this flag for multiple validators.") + + origHelpFunc := quorumCmd.HelpFunc() + quorumCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("epoch-length").Hidden = false + command.Flags().Lookup("salt").Hidden = false + command.Flags().Lookup("json").Hidden = false + command.Flags().Lookup("verbose").Hidden = false + origHelpFunc(command, strings) + }) + cli.AddTransactionFlags(quorumCmd) +} + +func runDeployQuorum(cmd *cobra.Command, _ []string) { + var err error + + ctx := cmd.Context() + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) + + client, err := cli.DialBlockchain(ctx, ethEndpoint) + cobra.CheckErr(err) + defer client.Close() + + chainID, err := client.ChainID(ctx) + cobra.CheckErr(err) + + txOpts, err := cli.GetTransactOpts(ctx, chainID) + cobra.CheckErr(err) + + deployment, err := buildQuorumDeployment(cmd) + cobra.CheckErr(err) + + if verboseParam { + fmt.Fprint(os.Stderr, deployment) + fmt.Fprintln(os.Stderr, "\twallet address: ", txOpts.From) + } + + if verboseParam { + fmt.Fprint(os.Stderr, "checking factory address...") + } + + factoryAddress := deployment.FactoryAddress + data, err := client.CodeAt(ctx, factoryAddress, nil) + cobra.CheckErr(err) + + if len(data) == 0 { + cobra.CheckErr(fmt.Errorf("no code at the factory address: %v", factoryAddress)) + } + if verboseParam { + fmt.Fprint(os.Stderr, "success\n") + } + + if verboseParam || !asJSONParam { + fmt.Fprintf(os.Stderr, "deploying quorum...") + } + var tx *types.Transaction + var receipt *types.Receipt + deployment.Address, err = deployment.DeployWithTransaction(ctx, client, txOpts, + func( + ctx context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + var err error + tx, receipt, err = cli.Transact(ctx, cmd, client, opts, build) + return receipt, err + }) + cobra.CheckErr(cli.DecorateRevert(err, iquorumfactory.IQuorumFactoryMetaData)) + if receipt == nil { + cobra.CheckErr(writeDeploymentBroadcast(cmd, tx, deployment.Address)) + return + } + + if verboseParam || !asJSONParam { + fmt.Fprintf(os.Stderr, "success\n") + } + + if asJSONParam { + report := struct { + *ethutil.QuorumDeployment + Transaction cli.TransactionResult `json:"transaction"` + }{QuorumDeployment: deployment, Transaction: cli.NewTransactionResult(tx, receipt)} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + cobra.CheckErr(encoder.Encode(report)) + } else { + cobra.CheckErr(cli.WriteTransactionResult(cmd, tx, receipt)) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Consensus address: %s\nEpoch length: %d\nClaim staging period: %d\n", + deployment.Address, deployment.EpochLength, deployment.ClaimStagingPeriod) + cobra.CheckErr(err) + } +} + +func buildQuorumDeployment(cmd *cobra.Command) (*ethutil.QuorumDeployment, error) { + var err error + var quorumFactoryAddress common.Address + + if !cmd.Flags().Changed("quorum-factory") { + quorumFactoryAddress, err = config.GetContractsQuorumFactoryAddress() + } else { + quorumFactoryAddress, err = parseHexAddress(quorumFactoryAddressParam) + } + if err != nil { + return nil, fmt.Errorf("error on parameter quorum-factory: %w", err) + } + + validators, err := parseValidatorAddresses(quorumValidatorAddressArgs) + if err != nil { + return nil, fmt.Errorf("error on parameter validator: %w", err) + } + + salt, err := ethutil.ParseSalt(saltParam) + if err != nil { + return nil, fmt.Errorf("error on parameter salt: %w", err) + } + + return ðutil.QuorumDeployment{ + FactoryAddress: quorumFactoryAddress, + Validators: validators, + EpochLength: epochLengthParam, + ClaimStagingPeriod: claimStagingPeriodParam, + Salt: salt, + Verbose: verboseParam, + }, nil +} + +func parseValidatorAddresses(values []string) ([]common.Address, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one --validator address is required") + } + + validators := make([]common.Address, 0, len(values)) + seen := map[common.Address]struct{}{} + for _, value := range values { + if !common.IsHexAddress(value) { + return nil, fmt.Errorf("failed to parse hex address: %s", value) + } + validator := common.HexToAddress(value) + if validator == (common.Address{}) { + return nil, fmt.Errorf("zero address validator is not allowed") + } + if _, ok := seen[validator]; ok { + return nil, fmt.Errorf("duplicate validator address: %s", validator.Hex()) + } + seen[validator] = struct{}{} + validators = append(validators, validator) + } + return validators, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go new file mode 100644 index 000000000..fc3aa6493 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum_test.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const testValidatorAddress = "0x1111111111111111111111111111111111111111" + +func TestParseValidatorAddresses_ValidRepeatedFlags(t *testing.T) { + got, err := parseValidatorAddresses([]string{ + testValidatorAddress, + "0x2222222222222222222222222222222222222222", + }) + + require.NoError(t, err) + require.Equal(t, []common.Address{ + common.HexToAddress(testValidatorAddress), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + }, got) +} + +func TestParseValidatorAddresses_RequiresAtLeastOneValidator(t *testing.T) { + _, err := parseValidatorAddresses(nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "at least one") +} + +func TestParseValidatorAddresses_RejectsInvalidAddress(t *testing.T) { + _, err := parseValidatorAddresses([]string{"not-an-address"}) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse") +} + +func TestParseValidatorAddresses_RejectsZeroAddress(t *testing.T) { + _, err := parseValidatorAddresses([]string{"0x0000000000000000000000000000000000000000"}) + + require.Error(t, err) + require.Contains(t, err.Error(), "zero address") +} + +func TestParseValidatorAddresses_RejectsDuplicates(t *testing.T) { + _, err := parseValidatorAddresses([]string{ + testValidatorAddress, + testValidatorAddress, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate") +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/sentries.go b/cmd/cartesi-rollups-cli/root/deploy/sentries.go new file mode 100644 index 000000000..56054e407 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/sentries.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func addPRTSentryFlags(cmd *cobra.Command) { + cmd.Flags().String("sentry-manager", common.Address{}.Hex(), + "PRT only: immutable sentry manager address. Zero disables address rotation, not sentry claims.") + cmd.Flags().StringSlice("sentries", nil, + "PRT only: initial sentry addresses in slot order (IDs start at 1). Repeat or comma-separate. Slot count cannot change.") +} + +func parsePRTSentryConfig(cmd *cobra.Command) (common.Address, []common.Address, error) { + manager := common.Address{} + sentries := []common.Address{} + if !cmd.Flags().Changed("sentry-manager") && !cmd.Flags().Changed("sentries") { + return manager, sentries, nil + } + prt, err := cmd.Flags().GetBool("prt") + if err != nil { + return manager, nil, err + } + if !prt { + return manager, nil, fmt.Errorf("--sentry-manager and --sentries require --prt") + } + managerText, err := cmd.Flags().GetString("sentry-manager") + if err != nil { + return manager, nil, err + } + manager, err = parseHexAddress(managerText) + if err != nil { + return manager, nil, fmt.Errorf("invalid --sentry-manager: %w", err) + } + addresses, err := cmd.Flags().GetStringSlice("sentries") + if err != nil { + return manager, nil, err + } + for i, text := range addresses { + address, err := parseHexAddress(text) + if err != nil { + return manager, nil, fmt.Errorf("invalid --sentries entry %d: %w", i+1, err) + } + sentries = append(sentries, address) + } + if err := ethutil.ValidateSentryAddresses(sentries); err != nil { + return manager, nil, fmt.Errorf("invalid --sentries: %w", err) + } + return manager, sentries, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go b/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go new file mode 100644 index 000000000..c3be1a4a7 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/sentries_test.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/cli" +) + +const ( + prtFlag = "--prt" + managerFlag = "--sentry-manager" + sentriesFlag = "--sentries" + requiresPRT = "require --prt" +) + +func TestApplicationDeploymentSentryFlags(t *testing.T) { + manager := common.HexToAddress("0x10").Hex() + sentryOne := common.HexToAddress("0xab").Hex() + sentryTwo := common.HexToAddress("0xcd").Hex() + for _, name := range []string{"sentry-manager", "sentries"} { + require.NotNil(t, applicationCmd.Flags().Lookup(name), "the deployed command must expose %s", name) + } + for _, test := range []struct { + name string + flags []string + manager common.Address + sentries []common.Address + wantError string + }{ + {name: "non PRT defaults"}, + {name: "PRT defaults", flags: []string{prtFlag}}, + {name: "manager only", flags: []string{prtFlag, managerFlag, manager}, manager: common.HexToAddress(manager)}, + {name: "fixed sentries", flags: []string{prtFlag, sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryOne)}}, + {name: "explicit zero manager", flags: []string{prtFlag, managerFlag, common.Address{}.Hex(), sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryOne)}}, + {name: "ordered list", flags: []string{prtFlag, managerFlag, manager, sentriesFlag, sentryTwo + "," + sentryOne}, + manager: common.HexToAddress(manager), + sentries: []common.Address{common.HexToAddress(sentryTwo), common.HexToAddress(sentryOne)}}, + {name: "repeated flag", flags: []string{prtFlag, sentriesFlag, sentryTwo, sentriesFlag, sentryOne}, + sentries: []common.Address{common.HexToAddress(sentryTwo), common.HexToAddress(sentryOne)}}, + {name: "empty list", flags: []string{prtFlag, "--sentries="}}, + {name: "manager without PRT", flags: []string{managerFlag, manager}, wantError: requiresPRT}, + {name: "sentries without PRT", flags: []string{sentriesFlag, sentryOne}, wantError: requiresPRT}, + {name: "explicit empty without PRT", flags: []string{"--sentries="}, wantError: requiresPRT}, + {name: "malformed manager", flags: []string{prtFlag, managerFlag, "0x01"}, wantError: "invalid --sentry-manager"}, + {name: "malformed sentry", flags: []string{prtFlag, sentriesFlag, "not-an-address"}, wantError: "invalid --sentries entry 1"}, + {name: "zero sentry", flags: []string{prtFlag, sentriesFlag, common.Address{}.Hex()}, wantError: "must not be the zero address"}, + {name: "duplicate sentry", flags: []string{prtFlag, sentriesFlag, sentryOne, sentriesFlag, strings.ToLower(sentryOne)}, + wantError: "duplicates address"}, + {name: "empty entry", flags: []string{prtFlag, sentriesFlag, sentryOne + ","}, wantError: "invalid --sentries entry 2"}, + } { + t.Run(test.name, func(t *testing.T) { + cmd := newSentryDeploymentTestCommand(t, test.flags) + // Exercise the command's real pre-run hook, not only the parser. + err := applicationCmd.PreRunE(cmd, nil) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + return + } + require.NoError(t, err) + gotManager, gotSentries, err := parsePRTSentryConfig(cmd) + require.NoError(t, err) + require.Equal(t, test.manager, gotManager) + require.Equal(t, append([]common.Address{}, test.sentries...), gotSentries) + }) + } +} + +func newSentryDeploymentTestCommand(t *testing.T, args []string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().Bool("prt", false, "") + cmd.Flags().Bool("register", false, "") + cmd.Flags().String("template-hash", "", "") + addPRTSentryFlags(cmd) + cli.AddTransactionFlags(cmd) + require.NoError(t, cmd.Flags().Set("template-hash", common.HexToHash("0x01").Hex())) + require.NoError(t, cmd.ParseFlags(args)) + return cmd +} + +func TestBuildPRTDeploymentPreservesSentryConfig(t *testing.T) { + t.Setenv("CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS", common.HexToAddress("0x100").Hex()) + previousHash := applicationTemplateHashParam + t.Cleanup(func() { applicationTemplateHashParam = previousHash }) + applicationTemplateHashParam = common.HexToHash("0x01").Hex() + manager := common.HexToAddress("0x10") + sentryOne, sentryTwo := common.HexToAddress("0x30"), common.HexToAddress("0x20") + cmd := newSentryDeploymentTestCommand(t, []string{ + prtFlag, managerFlag, manager.Hex(), sentriesFlag, sentryOne.Hex() + "," + sentryTwo.Hex(), + }) + request, err := buildPrtApplicationDeployment(cmd, nil) + require.NoError(t, err) + require.Equal(t, manager, request.SentryManager) + require.Equal(t, []common.Address{sentryOne, sentryTwo}, request.Sentries) +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config.go b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config.go new file mode 100644 index 000000000..827a2623f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config.go @@ -0,0 +1,130 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/common" +) + +// withdrawalConfigJSON is the on-the-wire schema. All five fields are +// required when the user supplies any of them — partial configs are always a +// misconfiguration (see docs/withdrawal-config-guide.md §6). Pointers let us +// distinguish "missing" from "zero". +type withdrawalConfigJSON struct { + Guardian *string `json:"guardian"` + Log2LeavesPerAccount *uint8 `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts *uint8 `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex *uint64 `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder *string `json:"withdrawal_output_builder"` +} + +// parseWithdrawalConfig reads the JSON object from one of the two sources +// (inline string or file path). Exactly one source must be non-empty; the +// caller is responsible for enforcing mutual exclusion via +// MarkFlagsMutuallyExclusive. When both are empty, returns the zero +// (no-foreclosure) config. +func parseWithdrawalConfig(inline, filePath string) (iapplicationfactory.WithdrawalConfig, error) { + var raw []byte + var src string + switch { + case inline != "" && filePath != "": + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config: --withdrawal-config and --withdrawal-config-file are mutually exclusive") + case inline != "": + raw = []byte(inline) + src = "--withdrawal-config" + case filePath != "": + b, err := os.ReadFile(filePath) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config: failed to read %s: %w", filePath, err) + } + raw = b + src = filePath + default: + return iapplicationfactory.WithdrawalConfig{}, nil + } + + var aux withdrawalConfigJSON + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&aux); err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): invalid JSON: %w", src, err) + } + + missing := aux.missingKeys() + if len(missing) > 0 { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): missing required keys: %s", + src, strings.Join(missing, ", ")) + } + + guardian, err := parseHexAddress(*aux.Guardian) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): invalid guardian address %q: %w", + src, *aux.Guardian, err) + } + builder, err := parseHexAddress(*aux.WithdrawalOutputBuilder) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): invalid withdrawal_output_builder address %q: %w", + src, *aux.WithdrawalOutputBuilder, err) + } + + wc := iapplicationfactory.WithdrawalConfig{ + Guardian: guardian, + Log2LeavesPerAccount: *aux.Log2LeavesPerAccount, + Log2MaxNumOfAccounts: *aux.Log2MaxNumOfAccounts, + AccountsDriveStartIndex: *aux.AccountsDriveStartIndex, + WithdrawalOutputBuilder: builder, + } + + if err := ethutil.ValidateWithdrawalConfig(wc); err != nil { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): %w", src, err) + } + + if guardian == (common.Address{}) { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): guardian address must not be the zero address "+ + "(omit the flag entirely to deploy without foreclosure)", src) + } + if builder == (common.Address{}) { + return iapplicationfactory.WithdrawalConfig{}, fmt.Errorf( + "withdrawal config (%s): withdrawal_output_builder address must not be the zero address", + src) + } + + return wc, nil +} + +func (w *withdrawalConfigJSON) missingKeys() []string { + var missing []string + if w.Guardian == nil { + missing = append(missing, "guardian") + } + if w.Log2LeavesPerAccount == nil { + missing = append(missing, "log2_leaves_per_account") + } + if w.Log2MaxNumOfAccounts == nil { + missing = append(missing, "log2_max_num_of_accounts") + } + if w.AccountsDriveStartIndex == nil { + missing = append(missing, "accounts_drive_start_index") + } + if w.WithdrawalOutputBuilder == nil { + missing = append(missing, "withdrawal_output_builder") + } + return missing +} diff --git a/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config_test.go b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config_test.go new file mode 100644 index 000000000..e2bdb3779 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deploy/withdrawal_config_test.go @@ -0,0 +1,146 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deploy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const validInlineJSON = `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + +func TestParseWithdrawalConfig_BothEmpty(t *testing.T) { + wc, err := parseWithdrawalConfig("", "") + require.NoError(t, err) + require.Equal(t, common.Address{}, wc.Guardian) + require.Equal(t, common.Address{}, wc.WithdrawalOutputBuilder) +} + +func TestParseWithdrawalConfig_BothSet(t *testing.T) { + _, err := parseWithdrawalConfig(validInlineJSON, "some/file.json") + require.Error(t, err) + require.Contains(t, err.Error(), "mutually exclusive") +} + +func TestParseWithdrawalConfig_ValidInline(t *testing.T) { + wc, err := parseWithdrawalConfig(validInlineJSON, "") + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), wc.Guardian) + require.Equal(t, common.HexToAddress("0x2222222222222222222222222222222222222222"), wc.WithdrawalOutputBuilder) + require.Equal(t, uint8(0), wc.Log2LeavesPerAccount) + require.Equal(t, uint8(20), wc.Log2MaxNumOfAccounts) + require.Equal(t, uint64(33554432), wc.AccountsDriveStartIndex) +} + +func TestParseWithdrawalConfig_ValidFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "wc.json") + require.NoError(t, os.WriteFile(path, []byte(validInlineJSON), 0o600)) + + wc, err := parseWithdrawalConfig("", path) + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), wc.Guardian) +} + +func TestParseWithdrawalConfig_FileNotFound(t *testing.T) { + _, err := parseWithdrawalConfig("", "/nonexistent/path/wc.json") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to read") +} + +func TestParseWithdrawalConfig_BadJSON(t *testing.T) { + _, err := parseWithdrawalConfig("not json", "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseWithdrawalConfig_UnknownField(t *testing.T) { + bad := `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222", + "gardian": "0x0" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseWithdrawalConfig_MissingKey(t *testing.T) { + bad := `{ + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "missing required keys") + require.Contains(t, err.Error(), "guardian") +} + +func TestParseWithdrawalConfig_BadGuardianAddress(t *testing.T) { + bad := `{ + "guardian": "not-an-address", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid guardian address") +} + +func TestParseWithdrawalConfig_FailsIsValid(t *testing.T) { + // log2_max + log2_leaves = 60 + 60 -> drive > 64 + bad := `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 60, + "log2_max_num_of_accounts": 60, + "accounts_drive_start_index": 0, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "larger than machine memory") +} + +func TestParseWithdrawalConfig_ZeroGuardianRejected(t *testing.T) { + bad := `{ + "guardian": "0x0000000000000000000000000000000000000000", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x2222222222222222222222222222222222222222" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "guardian address must not be the zero address") +} + +func TestParseWithdrawalConfig_ZeroBuilderRejected(t *testing.T) { + bad := `{ + "guardian": "0x1111111111111111111111111111111111111111", + "log2_leaves_per_account": 0, + "log2_max_num_of_accounts": 20, + "accounts_drive_start_index": 33554432, + "withdrawal_output_builder": "0x0000000000000000000000000000000000000000" +}` + _, err := parseWithdrawalConfig(bad, "") + require.Error(t, err) + require.Contains(t, err.Error(), "withdrawal_output_builder address must not be the zero address") +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit.go b/cmd/cartesi-rollups-cli/root/deposit/deposit.go new file mode 100644 index 000000000..f9acedc79 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit.go @@ -0,0 +1,306 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20errors" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20portal" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "deposit", + Short: "Deposit assets into an application through a portal", +} + +var erc20Cmd = &cobra.Command{ + Use: "erc20 [app-name-or-address]", + Short: "Deposit ERC-20 tokens through the Erc20Portal", + Example: erc20Examples, + Args: cobra.ExactArgs(1), + RunE: runERC20, + PreRunE: validateERC20Flags, + Long: ` +Calls Erc20Portal.depositErc20Tokens(token, app, amount, execData). + +The command does not approve token spending unless --approve is supplied. +Without --approve, the signer must already have enough allowance for the +portal. + +--approve cannot be combined with --no-wait. The approval must succeed +before the deposit transaction is prepared. Submit approval separately +if the deposit must return without waiting for mining. + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID signer + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, +} + +const erc20Examples = `# Deposit 100 units of a token through the Erc20Portal: +cartesi-rollups-cli deposit erc20 echo-dapp \ + --portal 0x3332DE61a8BB9aC84893b2f552Fe81C9a6dC5419 \ + --token 0x7a051EDffC0884cd88d4a377F4C87BE074CF6c81 \ + --amount 100 + +# Approve the portal first, then deposit: +cartesi-rollups-cli deposit erc20 echo-dapp --portal 0x... --token 0x... --amount 100 --approve --yes` + +var ( + portalParam string + tokenParam string + amountParam string + execDataParam string + approveParam bool + skipConfirmation bool + asJSONParam bool +) + +func init() { + Cmd.AddCommand(erc20Cmd) + + erc20Cmd.Flags().StringVar(&portalParam, "portal", "", "Erc20Portal contract address") + erc20Cmd.Flags().StringVar(&tokenParam, "token", "", "ERC-20 token contract address") + erc20Cmd.Flags().StringVar(&amountParam, "amount", "", "Token amount to deposit (decimal or 0x-prefixed)") + erc20Cmd.Flags().StringVar(&execDataParam, "exec-data", "0x", "Extra execution-layer data") + erc20Cmd.Flags().BoolVar(&approveParam, "approve", false, "Approve the portal for --amount before depositing") + erc20Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + erc20Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(erc20Cmd) + cobra.CheckErr(erc20Cmd.MarkFlagRequired("portal")) + cobra.CheckErr(erc20Cmd.MarkFlagRequired("token")) + cobra.CheckErr(erc20Cmd.MarkFlagRequired("amount")) + + origHelpFunc := erc20Cmd.HelpFunc() + erc20Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + command.Flags().Lookup("blockchain-http-endpoint").Hidden = false + origHelpFunc(command, strings) + }) +} + +func validateERC20Flags(cmd *cobra.Command, _ []string) error { + approve, err := cmd.Flags().GetBool("approve") + if err != nil { + return err + } + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return err + } + if approve && noWait { + return fmt.Errorf("--approve cannot be combined with --no-wait: the deposit requires a successful approval") + } + return nil +} + +func runERC20(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + appAddr, err := util.ResolveApplicationAddress(ctx, args[0]) + if err != nil { + return err + } + portalAddr, err := parseAddress("portal", portalParam) + if err != nil { + return err + } + tokenAddr, err := parseAddress("token", tokenParam) + if err != nil { + return err + } + amount, err := parseAmount(amountParam) + if err != nil { + return err + } + execData, err := hexutil.Decode(execDataParam) + if err != nil { + return err + } + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return err + } + defer client.Close() + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + txOptsFactory, err := auth.GetTransactOptsFactory(ctx, chainID) + if err != nil { + return err + } + + if !skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing ERC-20 deposit\n"+ + " signer: %s\n"+ + " application: %s\n"+ + " portal: %s\n"+ + " token: %s\n"+ + " amount: %s\n"+ + " approve: %t\n", + txOptsFactory.From(), appAddr, portalAddr, tokenAddr, amount.String(), approveParam) + if err != nil { + return err + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + + var approveHash *common.Hash + if approveParam { + token, err := ierc20metadata.NewIERC20Metadata(tokenAddr, client) + if err != nil { + return err + } + approveOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, approveOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return token.Approve(opts, portalAddr, amount) + }) + if err != nil { + return cli.DecorateRevert(err, ierc20metadata.IERC20MetadataMetaData, ierc20errors.IERC20ErrorsMetaData) + } + approved := false + for _, log := range receipt.Logs { + if log == nil || log.Address != tokenAddr || len(log.Data) != common.HashLength { + continue + } + event, err := token.ParseApproval(*log) + if err == nil && event.Owner == approveOpts.From && event.Spender == portalAddr && event.Value.Cmp(amount) == 0 { + approved = true + break + } + } + if !approved { + return fmt.Errorf("transaction %s mined, but its receipt has no matching Approval event", tx.Hash()) + } + hash := tx.Hash() + approveHash = &hash + } + + portal, err := ierc20portal.NewIErc20Portal(portalAddr, client) + if err != nil { + return err + } + depositOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, depositOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return portal.DepositErc20Tokens(opts, tokenAddr, appAddr, amount, execData) + }) + // The revert can come from three layers: the portal itself + // (Erc20TransferFailed or a balance-delta error), the token's transferFrom + // (ERC-6093 errors such as ERC20InsufficientBalance/Allowance), or the forwarded + // InputBox.addInput (InputTooLarge and the application foreclosure-probe + // family). + if err != nil { + return cli.DecorateRevert(err, + ierc20portal.IErc20PortalMetaData, + ierc20errors.IERC20ErrorsMetaData, + iinputbox.IInputBoxMetaData, + iapplication.IApplicationMetaData, + ) + } + if receipt != nil { + // Erc20Portal uses abi.encodePacked(token, sender, amount, execData). + payload := bytes.Join([][]byte{tokenAddr.Bytes(), depositOpts.From.Bytes(), common.LeftPadBytes(amount.Bytes(), common.HashLength), + execData}, nil) + if err := verifyDepositReceipt(ctx, client, receipt, appAddr, portalAddr, payload); err != nil { + return fmt.Errorf("transaction %s mined, but its deposit could not be confirmed: %w", tx.Hash(), err) + } + } + + if asJSONParam { + result := struct { + cli.TransactionResult + ApplicationAddress common.Address `json:"application_address"` + PortalAddress common.Address `json:"portal_address"` + TokenAddress common.Address `json:"token_address"` + Amount string `json:"amount"` + ApproveTxHash *common.Hash `json:"approve_transaction_hash,omitempty"` + }{ + TransactionResult: cli.NewTransactionResult(tx, receipt), + ApplicationAddress: appAddr, + PortalAddress: portalAddr, + TokenAddress: tokenAddr, + Amount: amount.String(), + ApproveTxHash: approveHash, + } + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + if approveHash != nil { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "approve tx-hash: %s\n", approveHash.Hex()); err != nil { + return err + } + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} + +func parseAddress(name string, value string) (common.Address, error) { + if !common.IsHexAddress(value) { + return common.Address{}, fmt.Errorf("invalid %s address %q", name, value) + } + return common.HexToAddress(value), nil +} + +func parseAmount(value string) (*big.Int, error) { + var amount *big.Int + var err error + if strings.HasPrefix(value, "0x") || strings.HasPrefix(value, "0X") { + amount, err = hexutil.DecodeBig(value) + if err != nil { + return nil, err + } + } else { + var ok bool + amount, ok = new(big.Int).SetString(value, 10) //nolint:mnd // User-facing amounts are decimal. + if !ok { + return nil, fmt.Errorf("invalid amount %q", value) + } + } + if amount.Sign() <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + const uint256Bits = 256 + if amount.BitLen() > uint256Bits { + return nil, fmt.Errorf("amount must fit in uint256") + } + return amount, nil +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go b/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go new file mode 100644 index 000000000..a12b3ac69 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit_test.go @@ -0,0 +1,268 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20portal" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testPortal = "0x0000000000000000000000000000000000000002" + testToken = "0x0000000000000000000000000000000000000003" + testInputBox = "0x0000000000000000000000000000000000000004" + testGasLimit = 90000 + approveFlag = "--approve" +) + +// depositRPC exercises the generated token and portal bindings. The service +// counts receipt reads so a no-wait command cannot pass by waiting on an +// immediately successful receipt. +type depositRPC struct { + sent chan *types.Transaction + nonce atomic.Uint64 + receipts atomic.Int32 + estimates atomic.Int32 + chainReads atomic.Int32 + failed bool + transactions sync.Map + approvalLogs []*types.Log + depositLogs []*types.Log + inputBoxReads atomic.Int32 + inputBoxBlock atomic.Value + inputBoxResponse hexutil.Bytes +} + +func (r *depositRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // Ethereum RPC method eth_chainId. + r.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(31337)), nil +} + +func (*depositRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*depositRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (*depositRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{0x01}, nil +} + +func (r *depositRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return hexutil.Uint64(r.nonce.Load()), nil +} + +func (r *depositRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, nil +} + +func (r *depositRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + r.transactions.Store(tx.Hash(), &tx) + r.nonce.Add(1) + return tx.Hash(), nil +} + +func (r *depositRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + logs := r.depositLogs + if value, ok := r.transactions.Load(hash); ok && *value.(*types.Transaction).To() == common.HexToAddress(testToken) { + logs = r.approvalLogs + } + if logs == nil { + logs = []*types.Log{} + } + return &types.Receipt{TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit}, nil +} + +func (r *depositRPC) Call(_ context.Context, args map[string]json.RawMessage, block string) (hexutil.Bytes, error) { + r.inputBoxReads.Add(1) + r.inputBoxBlock.Store(block) + var target common.Address + var data hexutil.Bytes + if err := json.Unmarshal(args["to"], &target); err != nil { + return nil, err + } + if err := json.Unmarshal(args["input"], &data); err != nil { + return nil, err + } + parsed, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return nil, err + } + if target != common.HexToAddress(testApplication) || !bytes.Equal(data, parsed.Methods["getInputBox"].ID) { + return nil, fmt.Errorf("unexpected contract call") + } + return r.inputBoxResponse, nil +} + +func setupDepositRPC(t *testing.T, service *depositRPC) { + t.Helper() + config.SetDefaults() + erc20Cmd.SetContext(t.Context()) + previousSilenceUsage := Cmd.SilenceUsage + Cmd.SilenceUsage = true + t.Cleanup(func() { Cmd.SilenceUsage = previousSilenceUsage }) + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + t.Setenv(config.DATABASE_CONNECTION, "invalid-database") + signer := crypto.PubkeyToAddress(key.PublicKey) + service.approvalLogs = []*types.Log{approvalLog(t, signer, common.HexToAddress(testPortal), big.NewInt(100))} + service.depositLogs = []*types.Log{depositInputLog(t, signer)} + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + service.inputBoxResponse, err = appABI.Methods["getInputBox"].Outputs.Pack(common.HexToAddress(testInputBox)) + require.NoError(t, err) + erc20Cmd.Flags().VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) +} + +func approvalLog(t *testing.T, owner, spender common.Address, value *big.Int) *types.Log { + t.Helper() + parsed, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["Approval"] + data, err := event.Inputs.NonIndexed().Pack(value) + require.NoError(t, err) + return &types.Log{Address: common.HexToAddress(testToken), Data: data, + Topics: []common.Hash{event.ID, common.BytesToHash(owner.Bytes()), common.BytesToHash(spender.Bytes())}} +} + +func depositInputLog(t *testing.T, signer common.Address) *types.Log { + t.Helper() + inputABI, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + payload := bytes.Join([][]byte{common.HexToAddress(testToken).Bytes(), signer.Bytes(), + common.LeftPadBytes(big.NewInt(100).Bytes(), common.HashLength)}, nil) + input, err := inputABI.Pack("EvmAdvance", big.NewInt(31337), common.HexToAddress(testApplication), + common.HexToAddress(testPortal), big.NewInt(10), big.NewInt(20), big.NewInt(0), big.NewInt(7), payload) + require.NoError(t, err) + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["InputAdded"] + data, err := event.Inputs.NonIndexed().Pack(input) + require.NoError(t, err) + return &types.Log{Address: common.HexToAddress(testInputBox), Data: data, + Topics: []common.Hash{event.ID, common.BytesToHash(common.HexToAddress(testApplication).Bytes()), common.BigToHash(big.NewInt(7))}} +} + +func TestDepositTransactionPolicy(t *testing.T) { + for _, tt := range []struct { + name string + extra []string + failed bool + wantErr string + wantTransactions int + wantReceipts int32 + }{ + {name: "default waits", wantTransactions: 1, wantReceipts: 1}, + {name: "no wait", extra: []string{"--no-wait"}, wantTransactions: 1}, + {name: "approval then deposit", extra: []string{approveFlag}, wantTransactions: 2, wantReceipts: 2}, + {name: "approval failure stops deposit", extra: []string{approveFlag}, failed: true, + wantErr: "failed in block", wantTransactions: 1, wantReceipts: 1}, + {name: "deposit failure", failed: true, wantErr: "failed in block", wantTransactions: 1, wantReceipts: 1}, + {name: "reject approval with no wait", extra: []string{approveFlag, "--no-wait"}, + wantErr: "--approve cannot be combined with --no-wait"}, + {name: "reject invalid timeout", extra: []string{"--wait-timeout", "0s"}, wantErr: "wait-timeout must be positive"}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &depositRPC{sent: make(chan *types.Transaction, 2), failed: tt.failed} + setupDepositRPC(t, service) + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + erc20Cmd.SetContext(t.Context()) + args := append([]string{"erc20", testApplication, "--portal", testPortal, "--token", testToken, + "--amount", "100", "--yes", "--json"}, tt.extra...) + Cmd.SetArgs(args) + err := Cmd.ExecuteContext(t.Context()) + if tt.wantErr == "" { + require.NoError(t, err, stderr.String()) + } + require.Len(t, service.sent, tt.wantTransactions) + require.Equal(t, tt.wantReceipts, service.receipts.Load()) + require.EqualValues(t, tt.wantTransactions, service.estimates.Load()) + if tt.wantTransactions == 0 { + require.Zero(t, service.chainReads.Load()) + } + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + return + } + require.NoError(t, err) + if tt.wantTransactions == 2 { + approval := <-service.sent + require.Equal(t, common.HexToAddress(testToken), *approval.To()) + tokenABI, err := ierc20metadata.IERC20MetadataMetaData.GetAbi() + require.NoError(t, err) + require.Equal(t, tokenABI.Methods["approve"].ID, approval.Data()[:4]) + } + deposit := <-service.sent + require.Equal(t, common.HexToAddress(testPortal), *deposit.To()) + portalABI, err := ierc20portal.IErc20PortalMetaData.GetAbi() + require.NoError(t, err) + require.Equal(t, portalABI.Methods["depositErc20Tokens"].ID, deposit.Data()[:4]) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, deposit.Hash().Hex(), result["transaction_hash"]) + if tt.wantReceipts == 0 { + require.Zero(t, service.inputBoxReads.Load()) + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.EqualValues(t, 1, service.inputBoxReads.Load()) + require.Equal(t, "0xa", service.inputBoxBlock.Load()) + require.Equal(t, "mined", result["status"]) + require.Equal(t, "0xa", result["block_number"]) + } + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/receipt.go b/cmd/cartesi-rollups-cli/root/deposit/receipt.go new file mode 100644 index 000000000..2858f357b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/receipt.go @@ -0,0 +1,71 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +// verifyDepositReceipt checks the input generated by the portal, not token +// Transfer events, which can also occur during other calls in the transaction. +func verifyDepositReceipt( + ctx context.Context, + client *ethclient.Client, + receipt *types.Receipt, + application, portal common.Address, + payload []byte, +) error { + app, err := iapplication.NewIApplication(application, client) + if err != nil { + return err + } + inputBoxAddress, err := app.GetInputBox(&bind.CallOpts{Context: ctx, BlockNumber: receipt.BlockNumber}) + if err != nil { + return fmt.Errorf("read application InputBox at receipt block: %w", err) + } + if inputBoxAddress == (common.Address{}) { + return fmt.Errorf("application returned a zero InputBox address") + } + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) + if err != nil { + return err + } + inputABI, err := inputs.InputsMetaData.GetAbi() + if err != nil { + return err + } + advance := inputABI.Methods["EvmAdvance"] + for _, log := range receipt.Logs { + if log == nil || log.Address != inputBoxAddress { + continue + } + event, err := inputBox.ParseInputAdded(*log) + if err != nil || event.AppContract != application || !bytes.HasPrefix(event.Input, advance.ID) { + continue + } + args := make(map[string]any) + if err := advance.Inputs.UnpackIntoMap(args, event.Input[len(advance.ID):]); err != nil { + continue + } + inputPayload, ok := args["payload"].([]byte) + index, indexOK := args["index"].(*big.Int) + if ok && indexOK && index.Cmp(event.Index) == 0 && args["appContract"] == application && args["msgSender"] == portal && + bytes.Equal(inputPayload, payload) { + return nil + } + } + return fmt.Errorf("receipt has no matching deposit InputAdded event") +} diff --git a/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go b/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go new file mode 100644 index 000000000..e7dd31cfe --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/deposit/receipt_test.go @@ -0,0 +1,218 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package deposit + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/ierc20portal" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +func TestDepositReceiptEvidence(t *testing.T) { + for _, tt := range []struct { + name string + change func(*testing.T, *depositRPC) + valid bool + }{ + {name: "matching input", valid: true}, + {name: "no events", change: func(_ *testing.T, r *depositRPC) { r.depositLogs = nil }}, + {name: "wrong input box", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Address = common.Address{} }}, + {name: "wrong outer application", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[1] = common.Hash{} }}, + {name: "wrong outer index", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[2] = common.Hash{} }}, + {name: "wrong event", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Topics[0] = common.Hash{} }}, + {name: "malformed event", change: func(_ *testing.T, r *depositRPC) { r.depositLogs[0].Data = []byte{1} }}, + {name: "zero input box", change: func(_ *testing.T, r *depositRPC) { r.inputBoxResponse = make([]byte, common.HashLength) }}, + {name: "bad input box response", change: func(_ *testing.T, r *depositRPC) { r.inputBoxResponse = []byte{1} }}, + {name: "wrong input selector", change: func(t *testing.T, r *depositRPC) { + changeDepositInput(t, r.depositLogs[0], func(input []byte) []byte { input[0] ^= 1; return input }) + }}, + {name: "malformed input", change: func(t *testing.T, r *depositRPC) { + changeDepositInput(t, r.depositLogs[0], func(input []byte) []byte { return input[:5] }) + }}, + {name: "wrong inner application", change: changeAdvanceField(1, common.Address{})}, + {name: "wrong portal sender", change: changeAdvanceField(2, common.Address{})}, + {name: "wrong inner index", change: changeAdvanceField(6, big.NewInt(8))}, + {name: "wrong token", change: changePayloadByte(0)}, + {name: "wrong depositor", change: changePayloadByte(20)}, + {name: "wrong amount", change: changePayloadByte(71)}, + {name: "wrong execution data", change: func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[7] = append(args[7].([]byte), 1) }) + }}, + {name: "matching event after unrelated event", valid: true, change: func(_ *testing.T, r *depositRPC) { + wrong := *r.depositLogs[0] + wrong.Data = []byte{1} + r.depositLogs = append([]*types.Log{&wrong}, r.depositLogs...) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + if tt.change != nil { + tt.change(t, rpc) + } + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t) + require.Len(t, rpc.sent, 1) + tx := <-rpc.sent + require.Zero(t, rpc.estimates.Load()) + require.EqualValues(t, 1, rpc.receipts.Load()) + require.Equal(t, "0xa", rpc.inputBoxBlock.Load()) + if tt.valid { + require.NoError(t, err) + require.Contains(t, output, `"status": "mined"`) + } else { + require.ErrorContains(t, err, "deposit could not be confirmed") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, output, `"status": "mined"`) + } + }) + } +} + +func TestApprovalReceiptEvidence(t *testing.T) { + for _, tt := range []struct { + name string + change func(*depositRPC) + valid bool + }{ + {name: "matching approval", valid: true}, + {name: "no events", change: func(r *depositRPC) { r.approvalLogs = nil }}, + {name: "wrong token", change: func(r *depositRPC) { r.approvalLogs[0].Address = common.Address{} }}, + {name: "wrong event", change: func(r *depositRPC) { r.approvalLogs[0].Topics[0] = common.Hash{} }}, + {name: "wrong owner", change: func(r *depositRPC) { r.approvalLogs[0].Topics[1] = common.Hash{} }}, + {name: "wrong spender", change: func(r *depositRPC) { r.approvalLogs[0].Topics[2] = common.Hash{} }}, + {name: "wrong amount", change: func(r *depositRPC) { r.approvalLogs[0].Data[31]++ }}, + {name: "missing amount", change: func(r *depositRPC) { r.approvalLogs[0].Data = nil }}, + {name: "malformed data", change: func(r *depositRPC) { r.approvalLogs[0].Data = []byte{1} }}, + {name: "matching event after unrelated event", valid: true, change: func(r *depositRPC) { + wrong := *r.approvalLogs[0] + wrong.Data = nil + r.approvalLogs = append([]*types.Log{&wrong}, r.approvalLogs...) + }}, + } { + t.Run(tt.name, func(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + if tt.change != nil { + tt.change(rpc) + } + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t, approveFlag) + require.Zero(t, rpc.estimates.Load()) + if tt.valid { + require.NoError(t, err) + require.Len(t, rpc.sent, 2) + require.Contains(t, output, `"approve_transaction_hash"`) + } else { + require.Len(t, rpc.sent, 1, "a deposit must not follow an unconfirmed approval") + require.ErrorContains(t, err, (<-rpc.sent).Hash().Hex()) + require.ErrorContains(t, err, "no matching Approval event") + require.NotContains(t, output, `"status": "mined"`) + require.Zero(t, rpc.inputBoxReads.Load()) + } + }) + } +} + +func TestDepositNoWaitSkipsReceiptEvidence(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + rpc.depositLogs = nil + rpc.inputBoxResponse = nil + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "90000") + output, err := runDeposit(t, "--no-wait") + require.NoError(t, err) + require.Contains(t, output, `"status": "broadcast"`) + require.Zero(t, rpc.receipts.Load()) + require.Zero(t, rpc.estimates.Load()) + require.Zero(t, rpc.inputBoxReads.Load()) +} + +func TestDepositReceiptWithExecutionData(t *testing.T) { + rpc := &depositRPC{sent: make(chan *types.Transaction, 2)} + setupDepositRPC(t, rpc) + execData := []byte{0xde, 0xad, 0xbe, 0xef} + changeAdvance(t, rpc.depositLogs[0], func(args []any) { args[7] = append(args[7].([]byte), execData...) }) + output, err := runDeposit(t, "--exec-data", "0xdeadbeef") + require.NoError(t, err) + require.Contains(t, output, `"status": "mined"`) + require.Len(t, rpc.sent, 1) + portalABI, err := ierc20portal.IErc20PortalMetaData.GetAbi() + require.NoError(t, err) + method := portalABI.Methods["depositErc20Tokens"] + tx := <-rpc.sent + require.Equal(t, method.ID, tx.Data()[:len(method.ID)]) + args, err := method.Inputs.Unpack(tx.Data()[len(method.ID):]) + require.NoError(t, err) + require.Equal(t, execData, args[3]) +} + +func TestParseAmountUint256(t *testing.T) { + limit := new(big.Int).Lsh(big.NewInt(1), 256) + maxAmount := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1)) + amount, err := parseAmount(maxAmount.String()) + require.NoError(t, err) + require.Equal(t, maxAmount, amount) + _, err = parseAmount(limit.String()) + require.ErrorContains(t, err, "uint256") +} + +func runDeposit(t *testing.T, extra ...string) (string, error) { + t.Helper() + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + args := append([]string{"erc20", testApplication, "--portal", testPortal, "--token", testToken, + "--amount", "100", "--yes", "--json"}, extra...) + Cmd.SetArgs(args) + err := Cmd.ExecuteContext(t.Context()) + return stdout.String(), err +} + +func changeDepositInput(t *testing.T, log *types.Log, change func([]byte) []byte) { + t.Helper() + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + args := parsed.Events["InputAdded"].Inputs.NonIndexed() + values, err := args.Unpack(log.Data) + require.NoError(t, err) + log.Data, err = args.Pack(change(values[0].([]byte))) + require.NoError(t, err) +} + +func changeAdvance(t *testing.T, log *types.Log, change func([]any)) { + t.Helper() + parsed, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + method := parsed.Methods["EvmAdvance"] + changeDepositInput(t, log, func(input []byte) []byte { + args, err := method.Inputs.Unpack(input[len(method.ID):]) + require.NoError(t, err) + change(args) + input, err = parsed.Pack("EvmAdvance", args...) + require.NoError(t, err) + return input + }) +} + +func changeAdvanceField(field int, value any) func(*testing.T, *depositRPC) { + return func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[field] = value }) + } +} + +func changePayloadByte(offset int) func(*testing.T, *depositRPC) { + return func(t *testing.T, r *depositRPC) { + changeAdvance(t, r.depositLogs[0], func(args []any) { args[7].([]byte)[offset] ^= 1 }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/deps/deps.go b/cmd/cartesi-rollups-cli/root/deps/deps.go deleted file mode 100644 index dfb2917a4..000000000 --- a/cmd/cartesi-rollups-cli/root/deps/deps.go +++ /dev/null @@ -1,89 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package deps - -import ( - "context" - "log/slog" - "os" - "os/signal" - "syscall" - - "github.com/cartesi/rollups-node/internal/deps" - "github.com/lmittmann/tint" - "github.com/mattn/go-isatty" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "run-deps", - Short: "Run node dependencies with Docker", - Example: examples, - Run: run, -} - -const examples = `# Run all deps: -cartesi-rollups-cli run-deps` - -var depsConfig = deps.NewDefaultDepsConfig() -var verbose = false - -func init() { - Cmd.Flags().StringVar(&depsConfig.Postgres.DockerImage, "postgres-docker-image", - deps.DefaultPostgresDockerImage, - "Postgress docker image name") - - Cmd.Flags().StringVar(&depsConfig.Postgres.Port, "postgres-mapped-port", - deps.DefaultPostgresPort, - "Postgres local listening port number") - - Cmd.Flags().StringVar(&depsConfig.Postgres.Password, "postgres-password", - deps.DefaultPostgresPassword, - "Postgres password") - - Cmd.Flags().StringVar(&depsConfig.Devnet.DockerImage, "devnet-docker-image", - deps.DefaultDevnetDockerImage, - "Devnet docker image name") - - Cmd.Flags().StringVar(&depsConfig.Devnet.Port, "devnet-mapped-port", - deps.DefaultDevnetPort, - "Devnet local listening port number") - - Cmd.Flags().StringVar(&depsConfig.Devnet.BlockTime, "devnet-block-time", - deps.DefaultDevnetBlockTime, - "Devnet mining block time") - - Cmd.Flags().BoolVar(&depsConfig.Devnet.NoMining, "devnet-no-mining", - deps.DefaultDevnetNoMining, - "Devnet disable mining") - - Cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logs") -} - -func run(cmd *cobra.Command, args []string) { - ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - if verbose { - // setup log - opts := &tint.Options{ - Level: slog.LevelDebug, - AddSource: true, - NoColor: false || !isatty.IsTerminal(os.Stdout.Fd()), - } - handler := tint.NewHandler(os.Stdout, opts) - logger := slog.New(handler) - slog.SetDefault(logger) - } - - depsContainers, err := deps.Run(ctx, *depsConfig) - cobra.CheckErr(err) - - slog.Info("All dependencies are up") - - <-ctx.Done() - - err = deps.Terminate(context.Background(), depsContainers) - cobra.CheckErr(err) -} diff --git a/cmd/cartesi-rollups-cli/root/dev_cli_test.go b/cmd/cartesi-rollups-cli/root/dev_cli_test.go new file mode 100644 index 000000000..79006b65a --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/dev_cli_test.go @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/stretchr/testify/require" +) + +func TestDevelopmentCLIPayerAndAuthorityOwner(t *testing.T) { + const ( + deployCommand = "deploy" + templateName = "template with spaces" + payloadValue = "payload with spaces" + applicationCommand = "application" + applicationName = "app" + ownerFlag = "--authority-owner" + authorityCommand = "authority" + sendCommand = "send" + ) + const claimerAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + const explicitOwner = "0x0000000000000000000000000000000000001234" + fixture := filepath.Join(t.TempDir(), "cli") + script := "#!/bin/bash\nset -euo pipefail\nprintf '%s\\n' \"$CARTESI_AUTH_KIND\" \"$CARTESI_AUTH_MNEMONIC\" " + + "\"$CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX\" \"$@\"\n" + require.NoError(t, os.WriteFile(fixture, []byte(script), 0o755)) //nolint:gosec // Executable test fixture. + t.Setenv("CARTESI_DEV_CLI_BINARY", fixture) + t.Setenv("CARTESI_AUTH_KIND", "private_key") + t.Setenv("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX", "0") + t.Setenv("CARTESI_DEV_CLI_ACCOUNT_INDEX", "5") + t.Setenv("CARTESI_DEV_AUTHORITY_OWNER", claimerAddress) + wrapper := filepath.Join("..", "..", "..", "scripts", "dev-cli") + for _, tc := range []struct { + name string + args, want []string + }{ + {applicationCommand, []string{deployCommand, applicationCommand, applicationName, templateName}, + []string{deployCommand, applicationCommand, applicationName, templateName, ownerFlag, claimerAddress}}, + {authorityCommand, []string{deployCommand, authorityCommand}, []string{deployCommand, authorityCommand, ownerFlag, claimerAddress}}, + {"explicit owner", []string{deployCommand, applicationCommand, applicationName, ownerFlag, explicitOwner}, + []string{deployCommand, applicationCommand, applicationName, ownerFlag, explicitOwner}}, + {"owner equals", []string{deployCommand, authorityCommand, "--authority-owner=" + explicitOwner}, + []string{deployCommand, authorityCommand, "--authority-owner=" + explicitOwner}}, + {"short owner", []string{deployCommand, authorityCommand, "-O", explicitOwner}, + []string{deployCommand, authorityCommand, "-O", explicitOwner}}, + {sendCommand, []string{sendCommand, applicationName, payloadValue}, + []string{sendCommand, applicationName, payloadValue}}, + } { + t.Run(tc.name, func(t *testing.T) { + before := os.Environ() + args := append([]string{wrapper}, tc.args...) + output, err := exec.CommandContext(t.Context(), "bash", args...).CombinedOutput() + require.NoError(t, err, "%s", output) + want := append([]string{"mnemonic", ethutil.FoundryMnemonic, "5"}, tc.want...) + require.Equal(t, want, strings.Split(strings.TrimSuffix(string(output), "\n"), "\n")) + require.Equal(t, before, os.Environ()) + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/execute/execute.go b/cmd/cartesi-rollups-cli/root/execute/execute.go index c5505ec61..aba08ceda 100644 --- a/cmd/cartesi-rollups-cli/root/execute/execute.go +++ b/cmd/cartesi-rollups-cli/root/execute/execute.go @@ -4,108 +4,260 @@ package execute import ( - "log/slog" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" "os" - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/addresses" - "github.com/cartesi/rollups-node/pkg/ethutil" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" ) -var Cmd = &cobra.Command{ - Use: "execute", - Short: "Executes a voucher", - Example: examples, - Run: run, -} +var Cmd = newCommand() -const examples = `# Executes voucher 5 from input 6: -cartesi-rollups-cli execute --voucher-index 5 --input-index 6` - -var ( - voucherIndex int - inputIndex int - graphqlEndpoint string - ethEndpoint string - mnemonic string - account uint32 - addressBookFile string -) +type options struct { + proofFile string + skipConfirmation bool +} -func init() { - Cmd.Flags().IntVar(&voucherIndex, "voucher-index", 0, - "index of the voucher") +func newCommand() *cobra.Command { + var opts options + cmd := &cobra.Command{ + Use: "execute [app-name-or-address] [output-index]", + Short: "Execute a proven application output", + Example: examples, + Args: cobra.ExactArgs(2), //nolint:mnd // Application and output index. + RunE: opts.run, + PreRunE: func(command *cobra.Command, _ []string) error { + if command.Flags().Changed("proof-file") && opts.proofFile == "" { + return fmt.Errorf("--proof-file cannot be empty") + } + return nil + }, + Long: `Execute the output at the application-wide output index. - cobra.CheckErr(Cmd.MarkFlagRequired("voucher-index")) +Without --proof-file, read the output and its proof from the local database. +With --proof-file, read a JSON object with raw_data and output_hashes_siblings. +raw_data must contain the complete 0x-prefixed output bytes, not decoded payload. +output_hashes_siblings must contain the ordered 0x-prefixed 32-byte proof hashes. +The output index remains the second positional argument; do not put it in the file. - Cmd.Flags().IntVar(&inputIndex, "input-index", 0, - "index of the input") +An application address plus --proof-file needs no database or node JSON-RPC API. +An application name still needs the database to resolve its address. - cobra.CheckErr(Cmd.MarkFlagRequired("input-index")) +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Required for local output or application-name lookup + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum HTTP endpoint`, + } + cmd.Flags().StringVar(&opts.proofFile, "proof-file", "", "JSON file with raw_data and output_hashes_siblings") + cmd.Flags().BoolVarP(&opts.skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().Bool("json", false, "Print result as JSON") + cli.AddTransactionFlags(cmd) - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") + originalHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + for _, name := range []string{"verbose", "database-connection", "blockchain-http-endpoint"} { + if flag := command.Flags().Lookup(name); flag != nil { + flag.Hidden = false + } + } + originalHelp(command, args) + }) + return cmd +} - Cmd.Flags().StringVar(ðEndpoint, "eth-endpoint", "http://localhost:8545", - "ethereum node JSON-RPC endpoint") +const examples = `# Execute output 5 with its proof from the local database: +cartesi-rollups-cli execute echo-dapp 5 - Cmd.Flags().StringVar(&mnemonic, "mnemonic", ethutil.FoundryMnemonic, - "mnemonic used to sign the transaction") +# Export a proof from the node JSON-RPC API: +cartesi-rollups-cli read outputs echo-dapp 5 --jsonrpc | jq '.data | {raw_data, output_hashes_siblings}' > output-proof.json - Cmd.Flags().Uint32Var(&account, "account", 0, - "account index used to sign the transaction (default: 0)") +# Execute the exported output without a database or node JSON-RPC connection: +cartesi-rollups-cli execute 0x1234567890123456789012345678901234567890 5 --proof-file output-proof.json --yes` - Cmd.Flags().StringVar(&addressBookFile, "address-book", "", - "if set, load the address book from the given file; else, use test addresses") +type executionInput struct { + applicationAddress common.Address + output []byte + proof iapplication.OutputValidityProof } -func run(cmd *cobra.Command, args []string) { +func (o *options) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - graphqlClient := graphql.NewClient(graphqlEndpoint, nil) - - resp, err := readerclient.GetVoucher(ctx, graphqlClient, voucherIndex, inputIndex) - cobra.CheckErr(err) - - if resp.Proof == nil { - slog.Warn("The voucher has no associated proof yet") - os.Exit(0) - } - - client, err := ethclient.DialContext(ctx, ethEndpoint) - cobra.CheckErr(err) - slog.Info("Connected", "eth-endpoint", ethEndpoint) - - signer, err := ethutil.NewMnemonicSigner(ctx, client, mnemonic, account) - cobra.CheckErr(err) - - var book *addresses.Book - if addressBookFile != "" { - book, err = addresses.GetBookFromFile(addressBookFile) - cobra.CheckErr(err) - } else { - book = addresses.GetTestBook() - } - - proof := readerclient.ConvertToContractProof(resp.Proof) - - slog.Info("Executing voucher", - "voucher-index", voucherIndex, - "input-index", inputIndex, - "application-address", book.CartesiDApp, - ) - txHash, err := ethutil.ExecuteVoucher( - ctx, - client, - book, - signer, - resp.Payload, - &resp.Destination, - proof, - ) - cobra.CheckErr(err) - - slog.Info("Voucher executed", "tx-hash", txHash) + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + outputIndex, err := config.ToUint64FromDecimalOrHexString(args[1]) + if err != nil { + return err + } + input, err := resolveExecution(ctx, nameOrAddress, outputIndex, o.proofFile) + if err != nil { + return err + } + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return err + } + defer client.Close() + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return err + } + if !o.skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to execute application %v (%v) output index %v with account %v\n", + nameOrAddress, input.applicationAddress, outputIndex, txOpts.From) + if err != nil { + return err + } + confirmed, err := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + appContract, err := iapplication.NewIApplication(input.applicationAddress, client) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.ExecuteOutput(opts, input.output, input.proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + if receipt != nil { + executed := false + for _, log := range receipt.Logs { + if log == nil || log.Address != input.applicationAddress { + continue + } + event, err := appContract.ParseOutputExecuted(*log) + if err == nil && event.OutputIndex == outputIndex && bytes.Equal(event.Output, input.output) { + executed = true + break + } + } + if !executed { + return fmt.Errorf("transaction %s mined, but its receipt has no matching OutputExecuted event", tx.Hash()) + } + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} + +func resolveExecution(ctx context.Context, nameOrAddress string, outputIndex uint64, proofFile string) (executionInput, error) { + input := executionInput{proof: iapplication.OutputValidityProof{OutputIndex: outputIndex}} + if proofFile != "" { + output, siblings, err := loadOutputProof(proofFile) + if err != nil { + return executionInput{}, err + } + address, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return executionInput{}, err + } + input.applicationAddress = address + input.output = output + input.proof.OutputHashesSiblings = siblings + return input, nil + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return executionInput{}, err + } + repo, err := cli.OpenRepository(ctx, dsn) + if err != nil { + return executionInput{}, err + } + defer repo.Close() + output, err := repo.GetOutput(ctx, nameOrAddress, outputIndex) + if err != nil { + return executionInput{}, err + } + if output == nil { + return executionInput{}, fmt.Errorf("output with index %d was not found in the database", outputIndex) + } + if len(output.OutputHashesSiblings) == 0 { + return executionInput{}, fmt.Errorf("output with index %d has no associated proof yet", outputIndex) + } + app, err := repo.GetApplication(ctx, nameOrAddress) + if err != nil { + return executionInput{}, err + } + if app == nil { + return executionInput{}, fmt.Errorf("application %q not found", nameOrAddress) + } + input.applicationAddress = app.IApplicationAddress + input.output = output.RawData + input.proof.OutputHashesSiblings = make([][32]byte, len(output.OutputHashesSiblings)) + for i, hash := range output.OutputHashesSiblings { + input.proof.OutputHashesSiblings[i] = hash + } + return input, nil +} + +func loadOutputProof(path string) ([]byte, [][32]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("read output proof file: %w", err) + } + var data struct { + RawData string `json:"raw_data"` + OutputHashesSiblings []string `json:"output_hashes_siblings"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&data); err != nil { + return nil, nil, fmt.Errorf("parse output proof file: %w", err) + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + if err != nil { + return nil, nil, fmt.Errorf("parse output proof file trailing data: %w", err) + } + return nil, nil, fmt.Errorf("output proof file must contain exactly one JSON object") + } + output, err := hexutil.Decode(data.RawData) + if err != nil { + return nil, nil, fmt.Errorf("invalid raw_data: %w", err) + } + if len(output) == 0 { + return nil, nil, fmt.Errorf("raw_data cannot be empty") + } + if len(data.OutputHashesSiblings) == 0 { + return nil, nil, fmt.Errorf("output_hashes_siblings cannot be empty") + } + siblings := make([][32]byte, len(data.OutputHashesSiblings)) + for i, encoded := range data.OutputHashesSiblings { + decoded, err := hexutil.Decode(encoded) + if err != nil { + return nil, nil, fmt.Errorf("invalid output_hashes_siblings[%d]: %w", i, err) + } + if len(decoded) != common.HashLength { + return nil, nil, fmt.Errorf("output_hashes_siblings[%d] must be %d bytes, got %d", i, common.HashLength, len(decoded)) + } + copy(siblings[i][:], decoded) + } + return output, siblings, nil } diff --git a/cmd/cartesi-rollups-cli/root/execute/execute_test.go b/cmd/cartesi-rollups-cli/root/execute/execute_test.go new file mode 100644 index 000000000..1a23ac187 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/execute/execute_test.go @@ -0,0 +1,413 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package execute + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/outputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testChainID = 31337 + testGasLimit = 90000 + proofFileFlag = "--proof-file" + jsonFlag = "--json" + yesFlag = "--yes" +) + +func writeProofFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "output-proof.json") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func proofDocument(t *testing.T, output []byte, siblings []common.Hash) string { + t.Helper() + data, err := json.Marshal(struct { + RawData string `json:"raw_data"` + OutputHashesSiblings []common.Hash `json:"output_hashes_siblings"` + }{hexutil.Encode(output), siblings}) + require.NoError(t, err) + return string(data) +} + +func TestLoadOutputProofPreservesBytesAndOrder(t *testing.T) { + t.Parallel() + output := []byte{0, 1, 2, 3, 0xfe, 0xff} + siblings := []common.Hash{common.HexToHash("0x1234"), common.HexToHash("0xabcd")} + path := writeProofFile(t, " \n"+proofDocument(t, output, siblings)+"\n\t") + gotOutput, gotSiblings, err := loadOutputProof(path) + require.NoError(t, err) + require.Equal(t, output, gotOutput) + require.Equal(t, [][32]byte{siblings[0], siblings[1]}, gotSiblings) +} + +func TestLoadOutputProofRejectsInvalidFiles(t *testing.T) { + t.Parallel() + hash := common.HexToHash("0x01").Hex() + for name, contents := range map[string]string{ + "empty file": "", + "null object": "null", + "array object": "[]", + "missing fields": "{}", + "missing output": `{"output_hashes_siblings":["` + hash + `"]}`, + "empty output": `{"raw_data":"0x","output_hashes_siblings":["` + hash + `"]}`, + "null output": `{"raw_data":null,"output_hashes_siblings":["` + hash + `"]}`, + "missing output prefix": `{"raw_data":"aabb","output_hashes_siblings":["` + hash + `"]}`, + "invalid output hex": `{"raw_data":"0xgg","output_hashes_siblings":["` + hash + `"]}`, + "odd output hex": `{"raw_data":"0xabc","output_hashes_siblings":["` + hash + `"]}`, + "missing siblings": `{"raw_data":"0xaa"}`, + "null siblings": `{"raw_data":"0xaa","output_hashes_siblings":null}`, + "empty siblings": `{"raw_data":"0xaa","output_hashes_siblings":[]}`, + "null sibling": `{"raw_data":"0xaa","output_hashes_siblings":[null]}`, + "non-string sibling": `{"raw_data":"0xaa","output_hashes_siblings":[1]}`, + "empty sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0x"]}`, + "missing sibling prefix": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash[2:] + `"]}`, + "short sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0xab"]}`, + "long sibling": `{"raw_data":"0xaa","output_hashes_siblings":["0x` + strings.Repeat("ab", 33) + `"]}`, + "invalid sibling hex": `{"raw_data":"0xaa","output_hashes_siblings":["0x` + strings.Repeat("gg", 32) + `"]}`, + "unknown index": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"],"output_index":7}`, + "trailing object": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}{}`, + "trailing null": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}null`, + "trailing garbage": `{"raw_data":"0xaa","output_hashes_siblings":["` + hash + `"]}garbage`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + output, siblings, err := loadOutputProof(writeProofFile(t, contents)) + require.Error(t, err) + require.Nil(t, output) + require.Nil(t, siblings) + }) + } + _, _, err := loadOutputProof(filepath.Join(t.TempDir(), "missing.json")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// executeRPC is a local Ethereum JSON-RPC server for the real generated binding. +// It does not implement a node JSON-RPC API or connect to a blockchain. +type executeRPC struct { + sent chan *types.Transaction + chainReads atomic.Int32 + estimates atomic.Int32 + receipts atomic.Int32 + failed bool + logs []*types.Log +} + +func (r *executeRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // Ethereum RPC method eth_chainId. + r.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(testChainID)), nil +} + +func (*executeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*executeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (*executeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (*executeRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return 0, nil +} + +func (r *executeRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, nil +} + +func (r *executeRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), nil +} + +func (r *executeRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + return &types.Receipt{TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: r.logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit}, nil +} + +func outputExecutedLog(t *testing.T, application common.Address, index uint64, output []byte) *types.Log { + t.Helper() + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["OutputExecuted"] + data, err := event.Inputs.NonIndexed().Pack(output) + require.NoError(t, err) + return &types.Log{ + Address: application, + Topics: []common.Hash{event.ID, common.BigToHash(new(big.Int).SetUint64(index))}, + Data: data, + } +} + +func setupExecuteRPC(t *testing.T, service *executeRPC) common.Address { + t.Helper() + config.SetDefaults() + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + // This value fails configuration parsing. A successful address/file command + // therefore proves that it did not read the database setting or open a DB. + t.Setenv(config.DATABASE_CONNECTION, "://invalid-database") + return crypto.PubkeyToAddress(key.PublicKey) +} + +func TestCommandExecutesProofFileWithoutDatabase(t *testing.T) { + outputABI, err := outputs.OutputsMetaData.GetAbi() + require.NoError(t, err) + output, err := outputABI.Pack("Voucher", common.HexToAddress("0x02"), big.NewInt(3), []byte{0xaa, 0xbb}) + require.NoError(t, err) + // The CLI must submit complete proven bytes, without decoding and rebuilding + // the output or dropping any committed suffix. + output = append(output, 0xcc, 0xdd) + siblings := []common.Hash{common.HexToHash("0x11"), common.HexToHash("0x22")} + file := writeProofFile(t, proofDocument(t, output, siblings)) + for _, tt := range []struct { + name string + manualGas bool + noWait bool + failed bool + }{ + {name: "estimate and wait"}, + {name: "manual gas and wait", manualGas: true}, + {name: "manual gas failed receipt", manualGas: true, failed: true}, + {name: "estimated no wait", noWait: true}, + {name: "manual gas no wait", manualGas: true, noWait: true}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &executeRPC{ + sent: make(chan *types.Transaction, 1), failed: tt.failed, + logs: []*types.Log{outputExecutedLog(t, common.HexToAddress(testApplication), 7, output)}, + } + signer := setupExecuteRPC(t, service) + if tt.manualGas { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "0x7", proofFileFlag, file, yesFlag, jsonFlag} + if tt.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1, "command error: %v", err) + tx := <-service.sent + if tt.failed { + require.ErrorContains(t, err, "receipt status 0") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + } else { + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if tt.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + } + } + if tt.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if tt.manualGas { + require.Zero(t, service.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + } else { + require.EqualValues(t, 1, service.estimates.Load()) + } + require.Equal(t, common.HexToAddress(testApplication), *tx.To()) + from, err := types.Sender(types.LatestSignerForChainID(big.NewInt(testChainID)), tx) + require.NoError(t, err) + require.Equal(t, signer, from) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + method, err := appABI.MethodById(tx.Data()[:4]) + require.NoError(t, err) + require.Equal(t, "executeOutput", method.Name) + callArgs, err := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, err) + require.Equal(t, output, callArgs[0]) + proof := abi.ConvertType(callArgs[1], new(iapplication.OutputValidityProof)).(*iapplication.OutputValidityProof) + require.Equal(t, uint64(7), proof.OutputIndex) + require.Equal(t, [][32]byte{siblings[0], siblings[1]}, proof.OutputHashesSiblings) + }) + } +} + +func TestCommandRequiresMatchingOutputExecuted(t *testing.T) { + application := common.HexToAddress(testApplication) + output := []byte{0xaa, 0xbb, 0xcc} + file := writeProofFile(t, proofDocument(t, output, []common.Hash{common.HexToHash("0x11")})) + valid := outputExecutedLog(t, application, 7, output) + wrongEmitter := outputExecutedLog(t, common.HexToAddress("0x02"), 7, output) + wrongIndex := outputExecutedLog(t, application, 8, output) + wrongBytes := outputExecutedLog(t, application, 7, []byte{0xaa, 0xbb}) + malformed := &types.Log{Address: application, Topics: valid.Topics, Data: []byte{1}} + missingIndex := &types.Log{Address: application, Topics: valid.Topics[:1], Data: valid.Data} + unrelated := &types.Log{Address: application, Topics: []common.Hash{common.HexToHash("0x03")}} + for _, test := range []struct { + name string + logs []*types.Log + noWait bool + wantError bool + }{ + {name: "empty receipt", logs: []*types.Log{}, wantError: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantError: true}, + {name: "wrong index", logs: []*types.Log{wrongIndex}, wantError: true}, + {name: "wrong bytes", logs: []*types.Log{wrongBytes}, wantError: true}, + {name: "malformed data", logs: []*types.Log{malformed}, wantError: true}, + {name: "missing index topic", logs: []*types.Log{missingIndex}, wantError: true}, + {name: "unrelated event", logs: []*types.Log{unrelated}, wantError: true}, + {name: "nil log", logs: []*types.Log{nil}, wantError: true}, + {name: "later matching event", + logs: []*types.Log{wrongEmitter, wrongIndex, wrongBytes, malformed, missingIndex, unrelated, nil, valid}}, + {name: "no wait does not require event", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1), logs: test.logs} + setupExecuteRPC(t, service) + // A manual limit permits broadcast even when estimation would have + // rejected a wrong destination. Receipt evidence must still be checked. + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + cmd := newCommand() + cmd.SilenceUsage = true // Match the root CLI's error-output policy. + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", proofFileFlag, file, yesFlag, jsonFlag} + if test.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Zero(t, service.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantError { + require.ErrorContains(t, err, "no matching OutputExecuted event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if test.noWait { + require.Equal(t, "broadcast", result["status"]) + } else { + require.Equal(t, "mined", result["status"]) + } + }) + } +} + +func TestCommandRejectsInvalidProofBeforeEthereum(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + for _, application := range []string{testApplication, "echo-dapp"} { + t.Run(application, func(t *testing.T) { + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{application, "7", proofFileFlag, writeProofFile(t, `{"raw_data":"0xaa","unknown":1}`)}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "unknown field") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) + }) + } +} + +func TestCommandWithoutFileStillRequiresDatabase(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testApplication, "7", yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), config.DATABASE_CONNECTION) + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} + +func TestCommandRejectsEmptyProofPathBeforeLookup(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{testApplication, "7", proofFileFlag, ""}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "--proof-file cannot be empty") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} + +func TestCommandProofFileNameStillRequiresDatabase(t *testing.T) { + service := &executeRPC{sent: make(chan *types.Transaction, 1)} + setupExecuteRPC(t, service) + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + file := writeProofFile(t, proofDocument(t, []byte{0xaa}, []common.Hash{common.HexToHash("0x01")})) + cmd.SetArgs([]string{"echo-dapp", "7", proofFileFlag, file, yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "resolving application") + require.Zero(t, service.chainReads.Load()) + require.Empty(t, service.sent) +} diff --git a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go new file mode 100644 index 000000000..0a3491175 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go @@ -0,0 +1,162 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package foreclose + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +var Cmd = &cobra.Command{ + Use: "foreclose [app-name-or-address]", + Short: "Foreclose an application (guardian-only)", + Example: examples, + Args: cobra.ExactArgs(1), + RunE: run, + Long: ` +Calls IApplication.foreclose() on the application contract. The transaction +must be signed by the guardian wallet configured at deploy time, otherwise it +reverts with NotGuardian. The signer is the wallet configured via +CARTESI_AUTH_*; override CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX to pick a +different derived account when the guardian differs from the node's default +signer. + +The [app-name-or-address] argument accepts EITHER an application name +(looked up in the local rollups-node database) OR an Ethereum address (used +directly without any DB access — useful on remote/reader hosts). + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID signer + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, +} + +const examples = `# Foreclose by application name (guardian signs from CARTESI_AUTH_*): +cartesi-rollups-cli foreclose echo-dapp + +# Foreclose by application address with the second derived mnemonic account as guardian: +CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX=1 cartesi-rollups-cli foreclose 0x7Ba726B1bc58b1fca5BD28fE3A752D57228891cC + +# Skip the confirmation prompt: +cartesi-rollups-cli foreclose echo-dapp --yes` + +var ( + skipConfirmation bool + asJSONParam bool +) + +func init() { + Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + command.Flags().Lookup("blockchain-http-endpoint").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return err + } + defer client.Close() + + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return err + } + + appContract, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return err + } + + // Surface the guardian / signer mismatch early as a hint, instead of letting + // the on-chain revert produce an opaque "NotGuardian" error. + guardian, err := appContract.GetGuardian(&bind.CallOpts{Context: ctx}) + if err != nil { + return err + } + if guardian != txOpts.From { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), + "warning: signer %s does not match the application guardian %s — foreclose() will revert with NotGuardian\n", + txOpts.From, guardian) + if err != nil { + return err + } + } + + if !skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to foreclose application %v with signer %v\n", + appAddr, txOpts.From) + if err != nil { + return err + } + + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.Foreclose(opts) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + + if asJSONParam { + result := struct { + cli.TransactionResult + ApplicationAddr common.Address `json:"application_address"` + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} diff --git a/cmd/cartesi-rollups-cli/root/increasetime/increasetime.go b/cmd/cartesi-rollups-cli/root/increasetime/increasetime.go deleted file mode 100644 index 2c9393555..000000000 --- a/cmd/cartesi-rollups-cli/root/increasetime/increasetime.go +++ /dev/null @@ -1,44 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package increasetime - -import ( - "context" - "log/slog" - - "github.com/cartesi/rollups-node/pkg/ethutil" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "increase-time", - Short: "Increases evm time of the current machine", - Example: examples, - Run: run, -} - -const examples = `# Increases evm time by one day (86400 seconds): -cartesi-rollups-cli increase-time` - -const defaultTime = 86400 - -var ( - time int - anvilEndpoint string -) - -func init() { - Cmd.Flags().IntVar(&time, "time", defaultTime, - "The amount of time to increase in the evm, in seconds") - - Cmd.Flags().StringVar(&anvilEndpoint, "anvil-endpoint", "http://localhost:8545", - "anvil address used to send to the request") -} - -func run(cmd *cobra.Command, args []string) { - - cobra.CheckErr(ethutil.AdvanceDevnetTime(context.Background(), anvilEndpoint, time)) - - slog.Info("Ok") -} diff --git a/cmd/cartesi-rollups-cli/root/inspect/inspect.go b/cmd/cartesi-rollups-cli/root/inspect/inspect.go index f88077e35..d79411075 100644 --- a/cmd/cartesi-rollups-cli/root/inspect/inspect.go +++ b/cmd/cartesi-rollups-cli/root/inspect/inspect.go @@ -5,58 +5,133 @@ package inspect import ( "bytes" + "context" "encoding/json" "fmt" "io" + "net/http" + "os" + "strings" - "github.com/cartesi/rollups-node/pkg/inspectclient" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/google/uuid" "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/httpclient" + "github.com/cartesi/rollups-node/pkg/inspectclient" ) var Cmd = &cobra.Command{ - Use: "inspect", + Use: "inspect [app-name-or-address] [payload]", Short: "Calls inspect API", + Args: cobra.MinimumNArgs(1), Example: examples, Run: run, + Long: ` +Supported Environment Variables: + CARTESI_INSPECT_URL Inspect API URL`, } -const examples = `# Makes a request with "hi" encoded as hex: -cartesi-rollups-cli inspect --payload 0x$(printf "hi" | xxd -p)` +const examples = `# Makes a request with "hi": +cartesi-rollups-cli inspect echo-dapp "hi" -var ( - hexPayload string - inspectEndpoint string -) +# Makes a request with "hi" encoded as hex: +cartesi-rollups-cli inspect echo-dapp 0x6869 --hex + +# Reads payload from stdin: +echo -n "hi" | cartesi-rollups-cli inspect echo-dapp` + +var isHex bool func init() { - Cmd.Flags().StringVar(&hexPayload, "payload", "", - "input payload hex-encoded starting with 0x") + Cmd.Flags().BoolVarP(&isHex, "hex", "x", false, + "Force interpretation of payload as hex.") + + Cmd.Flags().String("inspect-url", "http://localhost:10012/", + "address used to connect to the inspect API") + cobra.CheckErr(viper.BindPFlag(config.INSPECT_URL, Cmd.Flags().Lookup("inspect-url"))) + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + origHelpFunc(command, strings) + }) +} - cobra.CheckErr(Cmd.MarkFlagRequired("payload")) +func resolvePayload(args []string) ([]byte, error) { + // If we have exactly one argument (just the app name/address), read from stdin + if len(args) == 1 { + stdinBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("failed to read from stdin: %w", err) + } + if isHex { + return decodeHex(string(stdinBytes)) + } + return stdinBytes, nil + } + // Otherwise, use the second argument as payload + if isHex { + return decodeHex(args[1]) + } + return []byte(args[1]), nil +} - Cmd.Flags().StringVar(&inspectEndpoint, "inspect-endpoint", "http://localhost:10000/", - "address used to connect to the inspect api") +func decodeHex(s string) ([]byte, error) { + if !strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X") { + s = "0x" + s + } + + b, err := hexutil.Decode(s) + if err != nil { + return nil, fmt.Errorf("invalid hex payload %q: %w", s, err) + } + return b, nil } func run(cmd *cobra.Command, args []string) { ctx := cmd.Context() - client, err := inspectclient.NewClient(inspectEndpoint) + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) cobra.CheckErr(err) - payload, err := hexutil.Decode(hexPayload) + url, err := config.GetInspectUrl() cobra.CheckErr(err) - requestBody := bytes.NewReader(payload) + client, err := inspectclient.NewSafeClient(url.Raw()) //nolint:forbidigo // Connect through the protected inspect transport. + cobra.CheckErr(err) - response, err := client.InspectPostWithBody(ctx, "application/octet-stream", requestBody) + payload, err := resolvePayload(args) cobra.CheckErr(err) + requestBody := bytes.NewReader(payload) + + requestID := uuid.NewString() + checkRequestError := func(err error) { + if err != nil { + cobra.CheckErr(fmt.Errorf("%w (request_id=%s)", err, requestID)) + } + } + response, err := client.InspectPostWithBody(ctx, nameOrAddress, "application/octet-stream", requestBody, + func(_ context.Context, request *http.Request) error { + request.Header.Set("X-Request-ID", requestID) + return nil + }) + checkRequestError(err) + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(response.Body) + checkRequestError(fmt.Errorf("HTTP request failed with status %d: %s", response.StatusCode, string(bodyBytes))) + } respBytes, err := io.ReadAll(response.Body) - cobra.CheckErr(err) + checkRequestError(err) var prettyJSON bytes.Buffer - cobra.CheckErr(json.Indent(&prettyJSON, []byte(respBytes), "", " ")) + checkRequestError(httpclient.Diagnostic(ctx, json.Indent(&prettyJSON, respBytes, "", " "), + "decode inspect response", client.Endpoint())) fmt.Print(prettyJSON.String()) } diff --git a/cmd/cartesi-rollups-cli/root/mine/mine.go b/cmd/cartesi-rollups-cli/root/mine/mine.go deleted file mode 100644 index 437c9e3c9..000000000 --- a/cmd/cartesi-rollups-cli/root/mine/mine.go +++ /dev/null @@ -1,51 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package mine - -import ( - "context" - "log/slog" - - "github.com/cartesi/rollups-node/pkg/ethutil" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "mine", - Short: "Mine blocks", - Run: run, - Example: `# Mine 10 blocks with a 5-second interval between them: -cartesi-rollups-cli mine --number-of-blocks 10 --block-interval 5`, -} - -var ( - ethEndpoint string - numBlocks int - blockInterval int -) - -func init() { - Cmd.Flags().StringVar(ðEndpoint, "eth-endpoint", "http://localhost:8545", - "ethereum node JSON-RPC endpoint") - Cmd.Flags().IntVar(&numBlocks, "number-of-blocks", 1, - "number of blocks to mine") - Cmd.Flags().IntVar(&blockInterval, "block-interval", 1, - "interval, in seconds, between the timestamps of each block") -} - -func run(cmd *cobra.Command, args []string) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - slog.Debug("mining blocks", - "numBlocks", numBlocks, - "blockInterval", blockInterval) - blockNumber, err := ethutil.MineBlocks(ctx, - ethEndpoint, - uint64(numBlocks), - uint64(blockInterval)) - cobra.CheckErr(err) - - slog.Info("done", "last block number", blockNumber) -} diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go new file mode 100644 index 000000000..b2baba33f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go @@ -0,0 +1,225 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package provedriveroot + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +var Cmd = &cobra.Command{ + Use: "prove-drive-root [app-name-or-address]", + Short: "Anchor the accounts-drive Merkle root on a foreclosed application", + Example: examples, + Args: cobra.ExactArgs(1), + RunE: run, + Long: ` +Calls IApplication.proveAccountsDriveMerkleRoot(accountsDriveMerkleRoot, proof). +This must be done ONCE per foreclosed application before any user can call +withdraw(). The signer is just the gas-payer; the call is permissionless. + +The [app-name-or-address] argument accepts EITHER an application name +(looked up in the local rollups-node database) OR an Ethereum address (used +directly without any DB access — useful on remote/reader hosts). + +The proof data is consumed verbatim from --proof-file (JSON). Suggested shape: + + { + "accounts_drive_merkle_root": "0x... 32 bytes ...", + "proof": ["0x...", "0x...", ...] + } + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID signer + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, +} + +const examples = `# Anchor the accounts-drive Merkle root from a JSON proof file: +cartesi-rollups-cli prove-drive-root echo-dapp --proof-file ./drive-root-proof.json + +# Skip the confirmation prompt: +cartesi-rollups-cli prove-drive-root echo-dapp --proof-file ./drive-root-proof.json --yes` + +type proveDriveRootJSON struct { + AccountsDriveMerkleRoot string `json:"accounts_drive_merkle_root"` + Proof []string `json:"proof"` +} + +var ( + proofFileParam string + skipConfirmation bool + asJSONParam bool +) + +func init() { + Cmd.Flags().StringVar(&proofFileParam, "proof-file", "", + "Path to the JSON proof file emitted by the accounts-drive proof generation tool") + cobra.CheckErr(Cmd.MarkFlagRequired("proof-file")) + Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + command.Flags().Lookup("blockchain-http-endpoint").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + + root, proof, err := loadProof(proofFileParam) + if err != nil { + return err + } + + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return err + } + defer client.Close() + + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return err + } + + appContract, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return err + } + + if !skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to prove the accounts-drive Merkle root for application %v\n"+ + " signer: %v\n"+ + " root: 0x%x\n"+ + " proof size: %d siblings\n", + appAddr, txOpts.From, root, len(proof)) + if err != nil { + return err + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.ProveAccountsDriveMerkleRoot(opts, root, proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + if receipt != nil && !hasMatchingDriveRootEvent(appContract, receipt, appAddr, root) { + return fmt.Errorf("transaction %s mined, but its receipt has no matching AccountsDriveMerkleRootProved event", tx.Hash()) + } + + if asJSONParam { + result := struct { + cli.TransactionResult + ApplicationAddr common.Address `json:"application_address"` + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} + +func hasMatchingDriveRootEvent( + application *iapplication.IApplication, + receipt *types.Receipt, + address common.Address, + root [32]byte, +) bool { + for _, log := range receipt.Logs { + // The event has one non-indexed bytes32 field. Reject missing data even + // for a zero root; the binding otherwise leaves that field zero-valued. + if log == nil || log.Address != address || len(log.Data) != common.HashLength { + continue + } + event, err := application.ParseAccountsDriveMerkleRootProved(*log) + if err == nil && event.AccountsDriveMerkleRoot == root { + return true + } + } + return false +} + +func loadProof(path string) ([32]byte, [][32]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("read proof file %s: %w", path, err) + } + var aux proveDriveRootJSON + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&aux); err != nil { + return [32]byte{}, nil, fmt.Errorf("parse proof file %s: %w", path, err) + } + + rootBytes, err := hexutil.Decode(aux.AccountsDriveMerkleRoot) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("invalid accounts_drive_merkle_root: %w", err) + } + if len(rootBytes) != 32 { //nolint:mnd + return [32]byte{}, nil, fmt.Errorf( + "accounts_drive_merkle_root must be 32 bytes, got %d", len(rootBytes)) + } + var root [32]byte + copy(root[:], rootBytes) + + proof := make([][32]byte, len(aux.Proof)) + for i, s := range aux.Proof { + b, err := hexutil.Decode(s) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("invalid proof[%d]: %w", i, err) + } + if len(b) != 32 { //nolint:mnd + return [32]byte{}, nil, fmt.Errorf("proof[%d] must be 32 bytes, got %d", i, len(b)) + } + copy(proof[i][:], b) + } + return root, proof, nil +} diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot_test.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot_test.go new file mode 100644 index 000000000..dd5062e19 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot_test.go @@ -0,0 +1,146 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package provedriveroot + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// loadProof tests pin the JSON proof-file parser used by `prove-drive-root`. +// A malformed root or sibling must abort with a clear error before the tx +// is constructed; the on-chain `proveAccountsDriveMerkleRoot` reverts with +// less context. + +const validDriveRootProofJSON = `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002" + ] +}` + +func writeProofFile(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "proof.json") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestLoadProof_Valid(t *testing.T) { + path := writeProofFile(t, validDriveRootProofJSON) + root, proof, err := loadProof(path) + require.NoError(t, err) + require.Equal(t, byte(0x42), root[31]) + require.Len(t, proof, 2) + require.Equal(t, byte(0x01), proof[0][31]) + require.Equal(t, byte(0x02), proof[1][31]) +} + +func TestLoadProof_EmptyProofArray(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": [] + }` + path := writeProofFile(t, body) + _, proof, err := loadProof(path) + require.NoError(t, err) + require.Len(t, proof, 0, + "empty proof array is structurally valid here; the contract validates depth") +} + +func TestLoadProof_FileNotFound(t *testing.T) { + _, _, err := loadProof("/nonexistent/path/proof.json") + require.Error(t, err) + require.Contains(t, err.Error(), "read proof file") +} + +func TestLoadProof_InvalidJSON(t *testing.T) { + path := writeProofFile(t, `{not valid json`) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_UnknownField(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": [], + "extra_field": "rejected" + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_RootWrongLength(t *testing.T) { + cases := []struct { + name string + hex string + }{ + {"31_bytes", "0x" + repeatHex("aa", 31)}, + {"33_bytes", "0x" + repeatHex("aa", 33)}, + {"empty", "0x"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "` + tc.hex + `", + "proof": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "accounts_drive_merkle_root") + require.Contains(t, err.Error(), "32 bytes") + }) + } +} + +func TestLoadProof_BadRootHex(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "not-hex", + "proof": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid accounts_drive_merkle_root") +} + +func TestLoadProof_SiblingWrongLength(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": ["0x` + repeatHex("aa", 31) + `"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "proof[0]") + require.Contains(t, err.Error(), "32 bytes") +} + +func TestLoadProof_BadSiblingHex(t *testing.T) { + body := `{ + "accounts_drive_merkle_root": "0x0000000000000000000000000000000000000000000000000000000000000042", + "proof": ["not-hex"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid proof[0]") +} + +func repeatHex(b string, n int) string { + out := make([]byte, 0, n*len(b)) + for range n { + out = append(out, b...) + } + return string(out) +} diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go b/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go new file mode 100644 index 000000000..9ba1b8aa8 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/receipt_test.go @@ -0,0 +1,178 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package provedriveroot + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +func TestCommandRequiresMatchingDriveRootEvent(t *testing.T) { + config.SetDefaults() + address := common.HexToAddress("0x1234") + root := common.HexToHash("0x42") + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["AccountsDriveMerkleRootProved"] + matching := &types.Log{Address: address, Topics: []common.Hash{event.ID}, Data: root.Bytes()} + wrongEmitter := &types.Log{Address: common.HexToAddress("0x5678"), Topics: matching.Topics, Data: matching.Data} + wrongRoot := &types.Log{Address: address, Topics: matching.Topics, Data: common.HexToHash("0x43").Bytes()} + wrongTopic := &types.Log{Address: address, Topics: []common.Hash{common.HexToHash("0x01")}, Data: matching.Data} + malformed := &types.Log{Address: address, Topics: matching.Topics, Data: []byte{0x42}} + emptyData := &types.Log{Address: address, Topics: matching.Topics} + extraData := &types.Log{Address: address, Topics: matching.Topics, Data: append(root.Bytes(), root.Bytes()...)} + for _, test := range []struct { + name string + logs []*types.Log + estimated bool + noWait bool + zeroRoot bool + wantErr bool + }{ + {name: "matching manual gas", logs: []*types.Log{matching}}, + {name: "matching estimated gas", logs: []*types.Log{matching}, estimated: true}, + {name: "empty receipt at EOA", logs: []*types.Log{}, wantErr: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantErr: true}, + {name: "wrong root", logs: []*types.Log{wrongRoot}, wantErr: true}, + {name: "wrong event", logs: []*types.Log{wrongTopic}, wantErr: true}, + {name: "malformed data", logs: []*types.Log{malformed}, wantErr: true}, + {name: "empty data for zero root", logs: []*types.Log{emptyData}, zeroRoot: true, wantErr: true}, + {name: "extra data", logs: []*types.Log{extraData}, wantErr: true}, + {name: "later match", logs: []*types.Log{nil, wrongEmitter, wrongRoot, wrongTopic, malformed, matching}}, + {name: "no wait with empty receipt", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &driveRootRPC{logs: test.logs, sent: make(chan *types.Transaction, 1), emptyCode: len(test.logs) == 0} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", service)) + defer server.Stop() + httpServer := httptest.NewServer(server) + defer httpServer.Close() + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, httpServer.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.DATABASE_CONNECTION, "invalid") + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + if test.estimated { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + } + Cmd.Flags().VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) + Cmd.SilenceUsage = true + var stdout, stderr bytes.Buffer + Cmd.SetOut(&stdout) + Cmd.SetErr(&stderr) + proofRoot := root + if test.zeroRoot { + proofRoot = common.Hash{} + } + proof, err := json.Marshal(proveDriveRootJSON{AccountsDriveMerkleRoot: proofRoot.Hex(), Proof: []string{root.Hex()}}) + require.NoError(t, err) + args := []string{address.Hex(), "--proof-file", writeProofFile(t, string(proof)), "--yes", "--json"} + if test.noWait { + args = append(args, "--no-wait") + } + Cmd.SetArgs(args) + err = Cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.EqualValues(t, 123456, tx.Gas()) + if test.estimated { + require.EqualValues(t, 1, service.estimates.Load()) + } else { + require.Zero(t, service.estimates.Load()) + } + require.Contains(t, stderr.String(), tx.Hash().Hex()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantErr { + require.ErrorContains(t, err, "no matching AccountsDriveMerkleRootProved event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + status := "mined" + if test.noWait { + status = "broadcast" + } + require.Equal(t, status, result["status"]) + }) + } +} + +type driveRootRPC struct { + logs []*types.Log + sent chan *types.Transaction + emptyCode bool + estimates atomic.Int32 + receipts atomic.Int32 +} + +func (*driveRootRPC) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*driveRootRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: 1_000_000} +} + +func (*driveRootRPC) GasPrice(context.Context) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1)) +} + +func (r *driveRootRPC) GetCode(context.Context, common.Address, string) hexutil.Bytes { + if r.emptyCode { + return hexutil.Bytes{} + } + return hexutil.Bytes{0x01} +} + +func (*driveRootRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (r *driveRootRPC) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + r.estimates.Add(1) + return 123456 +} + +func (r *driveRootRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), nil +} + +func (r *driveRootRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + r.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(10), Logs: r.logs} +} diff --git a/cmd/cartesi-rollups-cli/root/read/commitments/commitments.go b/cmd/cartesi-rollups-cli/root/read/commitments/commitments.go new file mode 100644 index 000000000..c0417fd10 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/commitments/commitments.go @@ -0,0 +1,144 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package commitments + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "commitments [epoch index] [tournament address] [commitment]", + Short: "Reads commitments", + Example: examples, + Args: cobra.RangeArgs(1, 4), //nolint: mnd + Run: run, + Long: ` +Arguments: + application name or address + [epoch index] decimal or hex encoded + [tournament address] hex encoded + [commitment] hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const examples = `# Read specific commitment: +cartesi-rollups-cli read commitments echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 + +# Read all commitments: +cartesi-rollups-cli read commitments echo-dapp + +# Read all commitments with filter: +cartesi-rollups-cli read commitments echo-dapp --epoch-index 10 --tournament-address 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 + +# Read all commitments with pagination: +cartesi-rollups-cli read commitments echo-dapp --limit 10 --offset 10 --descending +` + +var ( + epochIndex string + tournamentAddress string + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().StringVar(&epochIndex, "epoch-index", "", + "Filter commitments by epoch index (decimal or hex encoded)") + Cmd.Flags().StringVar(&tournamentAddress, "tournament-address", "", + "Filter commitments by tournament address (hex encoded)") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of commitments to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of commitments") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, args []string) error { + if len(args) > 1 && len(args) < 4 { + return fmt.Errorf( + "expected 1 argument (list) or 4 arguments (get), got %d", len(args)) + } + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 4 { //nolint:mnd // Four positional arguments select the get operation. + var params api.GetCommitmentParams + params.Application = args[0] + params.EpochIndex, err = config.AsHexString(args[1]) + cobra.CheckErr(err) + params.TournamentAddress = args[2] + params.Commitment = args[3] + + result, err = readServ.GetCommitment(ctx, params) + } else { + var params api.ListCommitmentsParams + params.Application = args[0] + + // Add epoch index filter if provided + if cmd.Flags().Changed("epoch-index") { + epochIndexHex, hexErr := config.AsHexString(epochIndex) + cobra.CheckErr(hexErr) + params.EpochIndex = &epochIndexHex + } + + // Add tournament address filter if provided + if cmd.Flags().Changed("tournament-address") { + params.TournamentAddress = &tournamentAddress + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListCommitments(ctx, params) + } + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} diff --git a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go new file mode 100644 index 000000000..751cedb3e --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go @@ -0,0 +1,127 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package epochs + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "epochs [epoch index]", + Short: "Reads epochs", + Example: examples, + Args: cobra.RangeArgs(1, 2), //nolint: mnd + Run: run, + Long: ` +Arguments: + application name or address + [epoch index] decimal or hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +const examples = `# Read specific epoch: +cartesi-rollups-cli read epochs echo-dapp 10 + +# Read all epochs: +cartesi-rollups-cli read epochs echo-dapp + +# Read all epochs with filter: +cartesi-rollups-cli read epochs echo-dapp --status OPEN --status CLOSED + +# Read all epochs with pagination: +cartesi-rollups-cli read epochs echo-dapp --limit 10 --offset 10 --descending +` + +var ( + statuses []string + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().StringArrayVar(&statuses, "status", nil, + "Filter epochs by status (OPEN, CLOSED, INPUTS_PROCESSED, CLAIM_COMPUTED, CLAIM_SUBMITTED, "+ + "CLAIM_STAGED, CLAIM_ACCEPTED, CLAIM_REJECTED, CLAIM_FORECLOSED); may be specified multiple times") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of epochs to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of epochs") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. + var params api.GetEpochParams + params.Application = args[0] + params.EpochIndex, err = config.AsHexString(args[1]) + cobra.CheckErr(err) + + result, err = readServ.GetEpoch(ctx, params) + } else { + var params api.ListEpochsParams + params.Application = args[0] + + // Add status filter if provided + if cmd.Flags().Changed("status") { + epochStatuses := api.StringOrList(statuses) + params.Status = &epochStatuses + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListEpochs(ctx, params) + } + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} diff --git a/cmd/cartesi-rollups-cli/root/read/input/input.go b/cmd/cartesi-rollups-cli/root/read/input/input.go deleted file mode 100644 index b580814b4..000000000 --- a/cmd/cartesi-rollups-cli/root/read/input/input.go +++ /dev/null @@ -1,51 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package input - -import ( - "encoding/json" - "fmt" - - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "input", - Short: "Reads an input", - Example: examples, - Run: run, -} - -const examples = `# Read specific input from GraphQL: -cartesi-rollups-cli read input --index 5` - -var ( - index int - graphqlEndpoint string -) - -func init() { - Cmd.Flags().IntVar(&index, "index", 0, - "index of the input") - - cobra.CheckErr(Cmd.MarkFlagRequired("index")) - - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") -} - -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - - resp, err := readerclient.GetInput(ctx, client, index) - cobra.CheckErr(err) - - val, err := json.MarshalIndent(resp, "", " ") - cobra.CheckErr(err) - - fmt.Print(string(val)) -} diff --git a/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go b/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go index c05378229..8b0bf9eda 100644 --- a/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go +++ b/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go @@ -4,42 +4,139 @@ package inputs import ( + "bytes" "encoding/json" "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" "github.com/spf13/cobra" ) var Cmd = &cobra.Command{ - Use: "inputs", - Short: "Reads inputs ordered by index", + Use: "inputs [input index]", + Short: "Reads inputs", Example: examples, + Args: cobra.RangeArgs(1, 2), //nolint: mnd Run: run, + Long: ` +Arguments: + application name or address + [input index] decimal or hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, } -const examples = `# Read inputs from GraphQL: -cartesi-rollups-cli read inputs` +const examples = `# Read specific input: +cartesi-rollups-cli read inputs echo-dapp 10 + +# Read all inputs: +cartesi-rollups-cli read inputs echo-dapp + +# Read all inputs with filters: +cartesi-rollups-cli read inputs echo-dapp --epoch-index 10 --sender 0x95eac57f9d67c5e0f255d5a19eb5d3fd00cafa73 +cartesi-rollups-cli read inputs echo-dapp --transaction-hash 0x0000000000000000000000000000000000000000000000000000000000abc123 + +# Read all inputs with pagination: +cartesi-rollups-cli read inputs echo-dapp --limit 10 --offset 10 --descending +` var ( - graphqlEndpoint string + epochIndex string + sender string + transactionHash string + limit uint64 + offset uint64 + descending bool ) func init() { - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") + Cmd.Flags().StringVar(&epochIndex, "epoch-index", "", + "Filter inputs by epoch index (decimal or hex encoded)") + Cmd.Flags().StringVar(&sender, "sender", "", + "Filter inputs by sender address (hex encoded)") + Cmd.Flags().StringVar(&transactionHash, "transaction-hash", "", + "Filter inputs by transaction hash (hex encoded)") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of inputs to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of inputs") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } } func run(cmd *cobra.Command, args []string) { ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - resp, err := readerclient.GetInputs(ctx, client) + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. + var params api.GetInputParams + params.Application = args[0] + params.InputIndex, err = config.AsHexString(args[1]) + cobra.CheckErr(err) + + result, err = readServ.GetInput(ctx, params) + } else { + var params api.ListInputsParams + params.Application = args[0] + + // Add epoch index filter if provided + if cmd.Flags().Changed("epoch-index") { + epochIndexHex, hexErr := config.AsHexString(epochIndex) + cobra.CheckErr(hexErr) + params.EpochIndex = &epochIndexHex + } + + // Add sender filter if provided + if cmd.Flags().Changed("sender") { + params.Sender = &sender + } + if cmd.Flags().Changed("transaction-hash") { + params.TransactionHash = &transactionHash + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListInputs(ctx, params) + } cobra.CheckErr(err) - val, err := json.MarshalIndent(resp, "", " ") + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") cobra.CheckErr(err) + out.WriteString("\n") - fmt.Print(string(val)) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go new file mode 100644 index 000000000..e17c6dc33 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package matchadvances + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +const ( + listArgCount = 4 + getArgCount = 6 +) + +var Cmd = &cobra.Command{ + Use: "match_advances [transaction hash log index]", + Short: "Reads match advances", + Example: examples, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != listArgCount && len(args) != getArgCount { + return fmt.Errorf("requires %d arguments to list a match's advances, or %d with transaction hash and log index to read one", + listArgCount, getArgCount) + } + return nil + }, + Run: run, + Long: ` +Arguments: + application name or address + decimal or hex encoded + hex encoded + hex encoded + [transaction hash] hex encoded; provide together with log index + [log index] decimal or hex encoded block log index + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const examples = `# Read one match advance by transaction hash and block log index: +cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 0xa24a91c7ce97fb16b2f679875966dc50f84747fd54e006763ed0dd702c260370 3 + +# Read all advances for one match (includes each event's transaction hash and log index): +cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 + +# Read all match advances with pagination: +cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 --limit 10 --offset 10 --descending +` + +var ( + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of match advances to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of match advances") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + result, err := readMatchAdvances(ctx, readServ, args) + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} + +func readMatchAdvances(ctx context.Context, readServ service.ReadService, args []string) (json.RawMessage, error) { + epochIndex, err := config.AsHexString(args[1]) + if err != nil { + return nil, err + } + if len(args) == getArgCount { + logIndex, err := config.AsHexString(args[getArgCount-1]) + if err != nil { + return nil, err + } + return readServ.GetMatchAdvanced(ctx, api.GetMatchAdvanceParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], IDHash: args[3], + TxHash: args[listArgCount], LogIndex: logIndex, + }) + } + return readServ.ListMatchAdvances(ctx, api.ListMatchAdvancesParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], IDHash: args[3], + Limit: limit, Offset: offset, Descending: descending, + }) +} diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go new file mode 100644 index 000000000..26de105f8 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances_test.go @@ -0,0 +1,126 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package matchadvances + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestMatchAdvancesCommandRequiresScopeAndCompleteEventIdentity(t *testing.T) { + for count := range 8 { + err := Cmd.Args(Cmd, make([]string, count)) + if count == 4 || count == 6 { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } + require.Contains(t, Cmd.Use, "transaction hash log index") + require.NotContains(t, Cmd.Use, "parent") + require.NotContains(t, Cmd.Example, "[parent]") +} + +func TestMatchAdvancesGetExampleHasValidEventIdentity(t *testing.T) { + _, example, found := strings.Cut(Cmd.Example, "cartesi-rollups-cli read match_advances ") + require.True(t, found) + line, _, _ := strings.Cut(example, "\n") + args := strings.Fields(line) + require.Len(t, args, getArgCount) + require.NoError(t, Cmd.Args(Cmd, args)) + _, err := config.ToHashFromString(args[listArgCount]) + require.NoError(t, err) + _, err = config.AsHexString(args[getArgCount-1]) + require.NoError(t, err) +} + +type matchAdvanceReader struct { + service.ReadService + get func(context.Context, api.GetMatchAdvanceParams) (json.RawMessage, error) + list func(context.Context, api.ListMatchAdvancesParams) (json.RawMessage, error) +} + +func (reader *matchAdvanceReader) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + return reader.get(ctx, params) +} + +func (reader *matchAdvanceReader) ListMatchAdvances(ctx context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + return reader.list(ctx, params) +} + +func matchAdvanceCommandArgs() []string { + return []string{"echo-dapp", "16", common.HexToAddress("0xabc").Hex(), common.HexToHash("0xdef").Hex()} +} + +func TestMatchAdvancesCommandMapsGetEventIdentity(t *testing.T) { + for _, logIndex := range []string{"0", "0X2A"} { + t.Run(logIndex, func(t *testing.T) { + args := append(matchAdvanceCommandArgs(), common.HexToHash("0x123").Hex(), logIndex) + require.NoError(t, Cmd.Args(Cmd, args)) + expectedIndex := "0x0" + if logIndex == "0X2A" { + expectedIndex = "0x2a" + } + called := false + reader := &matchAdvanceReader{get: func(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + called = true + require.Equal(t, t.Context(), ctx) + require.Equal(t, api.GetMatchAdvanceParams{Application: args[0], EpochIndex: "0x10", + TournamentAddress: args[2], IDHash: args[3], TxHash: args[4], LogIndex: expectedIndex}, params) + return json.RawMessage(`{"data":{}}`), nil + }} + result, err := readMatchAdvances(t.Context(), reader, args) + require.NoError(t, err) + require.True(t, called) + require.JSONEq(t, `{"data":{}}`, string(result)) + }) + } +} + +func TestMatchAdvancesCommandKeepsListPaginationFlags(t *testing.T) { + previousLimit, previousOffset, previousDescending := limit, offset, descending + t.Cleanup(func() { limit, offset, descending = previousLimit, previousOffset, previousDescending }) + require.NoError(t, Cmd.ParseFlags([]string{"--limit", "3", "--offset", "2", "--descending"})) + args := matchAdvanceCommandArgs() + require.NoError(t, Cmd.Args(Cmd, args)) + require.NoError(t, Cmd.PreRunE(Cmd, args)) + called := false + reader := &matchAdvanceReader{list: func(_ context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + called = true + require.Equal(t, api.ListMatchAdvancesParams{Application: args[0], EpochIndex: "0x10", + TournamentAddress: args[2], IDHash: args[3], Limit: 3, Offset: 2, Descending: true}, params) + return json.RawMessage(`{"data":[]}`), nil + }} + _, err := readMatchAdvances(t.Context(), reader, args) + require.NoError(t, err) + require.True(t, called) + limit = 0 + require.NoError(t, Cmd.PreRunE(Cmd, args)) + require.Equal(t, uint64(jsonrpc.LIST_ITEM_LIMIT), limit) + limit = jsonrpc.LIST_ITEM_LIMIT + 1 + require.Error(t, Cmd.PreRunE(Cmd, args)) +} + +func TestMatchAdvancesCommandRejectsInvalidNumericIndices(t *testing.T) { + for _, epochIndex := range []string{"-1", "0x10000000000000000"} { + args := matchAdvanceCommandArgs() + args[1] = epochIndex + _, err := readMatchAdvances(t.Context(), nil, args) + require.Error(t, err) + } + for _, logIndex := range []string{"-1", "0x10000000000000000"} { + args := append(matchAdvanceCommandArgs(), common.HexToHash("0x123").Hex(), logIndex) + _, err := readMatchAdvances(t.Context(), nil, args) + require.Error(t, err) + } +} diff --git a/cmd/cartesi-rollups-cli/root/read/matches/matches.go b/cmd/cartesi-rollups-cli/root/read/matches/matches.go new file mode 100644 index 000000000..e24c6b933 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/matches/matches.go @@ -0,0 +1,144 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package matches + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "matches [epoch index] [tournament address] [ID hash]", + Short: "Reads matches", + Example: examples, + Args: cobra.RangeArgs(1, 4), //nolint: mnd + Run: run, + Long: ` +Arguments: + application name or address + [epoch index] decimal or hex encoded + [tournament address] hex encoded + [ID hash] hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const examples = `# Read specific match: +cartesi-rollups-cli read matches echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 + +# Read all matches: +cartesi-rollups-cli read matches echo-dapp + +# Read all matches with filter: +cartesi-rollups-cli read matches echo-dapp --epoch-index 10 --tournament-address 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 + +# Read all matches with pagination: +cartesi-rollups-cli read matches echo-dapp --limit 10 --offset 10 --descending +` + +var ( + epochIndex string + tournamentAddress string + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().StringVar(&epochIndex, "epoch-index", "", + "Filter matches by epoch index (decimal or hex encoded)") + Cmd.Flags().StringVar(&tournamentAddress, "tournament-address", "", + "Filter matches by tournament address (hex encoded)") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of matches to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of matches") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, args []string) error { + if len(args) > 1 && len(args) < 4 { + return fmt.Errorf( + "expected 1 argument (list) or 4 arguments (get), got %d", len(args)) + } + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 4 { //nolint:mnd // Four positional arguments select the get operation. + var params api.GetMatchParams + params.Application = args[0] + params.EpochIndex, err = config.AsHexString(args[1]) + cobra.CheckErr(err) + params.TournamentAddress = args[2] + params.IDHash = args[3] + + result, err = readServ.GetMatch(ctx, params) + } else { + var params api.ListMatchesParams + params.Application = args[0] + + // Add epoch index filter if provided + if cmd.Flags().Changed("epoch-index") { + epochIndexHex, hexErr := config.AsHexString(epochIndex) + cobra.CheckErr(hexErr) + params.EpochIndex = &epochIndexHex + } + + // Add tournament address filter if provided + if cmd.Flags().Changed("tournament-address") { + params.TournamentAddress = &tournamentAddress + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListMatches(ctx, params) + } + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} diff --git a/cmd/cartesi-rollups-cli/root/read/notice/notice.go b/cmd/cartesi-rollups-cli/root/read/notice/notice.go deleted file mode 100644 index 6cebc4424..000000000 --- a/cmd/cartesi-rollups-cli/root/read/notice/notice.go +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package notice - -import ( - "encoding/json" - "fmt" - - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "notice", - Short: "Reads a notice", - Example: examples, - Run: run, -} - -const examples = `# Read notice 5 from input 6: -cartesi-rollups-cli read notice --notice-index 5 --input-index 6` - -var ( - noticeIndex int - inputIndex int - graphqlEndpoint string -) - -func init() { - Cmd.Flags().IntVar(¬iceIndex, "notice-index", 0, - "index of the notice") - - cobra.CheckErr(Cmd.MarkFlagRequired("notice-index")) - - Cmd.Flags().IntVar(&inputIndex, "input-index", 0, - "index of the input") - - cobra.CheckErr(Cmd.MarkFlagRequired("input-index")) - - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") -} - -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - - resp, err := readerclient.GetNotice(ctx, client, noticeIndex, inputIndex) - cobra.CheckErr(err) - - val, err := json.MarshalIndent(resp, "", " ") - cobra.CheckErr(err) - - fmt.Print(string(val)) -} diff --git a/cmd/cartesi-rollups-cli/root/read/notices/notices.go b/cmd/cartesi-rollups-cli/root/read/notices/notices.go deleted file mode 100644 index c0043c814..000000000 --- a/cmd/cartesi-rollups-cli/root/read/notices/notices.go +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package notices - -import ( - "encoding/json" - "fmt" - - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "notices", - Short: "Reads all notices. If an input is specified, reads all notices from that input", - Example: examples, - Run: run, -} - -const examples = `# Read all notices: -cartesi-rollups-cli read notices` - -var ( - inputIndex int - graphqlEndpoint string -) - -func init() { - Cmd.Flags().IntVar(&inputIndex, "input-index", -1, - "index of the input") - - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") -} - -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - - var resp []readerclient.Notice - var err error - - if cmd.Flags().Changed("input-index") { - resp, err = readerclient.GetInputNotices(ctx, client, inputIndex) - cobra.CheckErr(err) - } else { - resp, err = readerclient.GetNotices(ctx, client) - cobra.CheckErr(err) - } - - val, err := json.MarshalIndent(resp, "", " ") - cobra.CheckErr(err) - - fmt.Print(string(val)) -} diff --git a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go new file mode 100644 index 000000000..08429211f --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go @@ -0,0 +1,163 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package outputs + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "outputs [output index]", + Short: "Reads outputs", + Example: examples, + Args: cobra.RangeArgs(1, 2), //nolint: mnd + Run: run, + Long: ` +Arguments: + application name or address + [output index] decimal or hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const examples = `# Read specific output: +cartesi-rollups-cli read outputs echo-dapp 10 + +# Read all outputs: +cartesi-rollups-cli read outputs echo-dapp + +# Read all outputs with filters: +cartesi-rollups-cli read outputs echo-dapp --epoch-index 10 --input-index 10 --output-type 0x237a816f --output-type 0x10321e8b --executed --voucher-address 0x95eac57f9d67c5e0f255d5a19eb5d3fd00cafa73 + +# Read all outputs with pagination: +cartesi-rollups-cli read outputs echo-dapp --limit 10 --offset 10 --descending +` + +var ( + epochIndex string + inputIndex string + outputTypes []string + executed bool + voucherAddress string + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().StringVar(&epochIndex, "epoch-index", "", + "Filter outputs by epoch index (decimal or hex encoded)") + Cmd.Flags().StringVar(&inputIndex, "input-index", "", + "Filter outputs by input index (decimal or hex encoded)") + Cmd.Flags().StringArrayVar(&outputTypes, "output-type", nil, + "Filter outputs by output type (first 4 bytes of raw data hex encoded); may be specified multiple times") + Cmd.Flags().BoolVar(&executed, "executed", false, + "Filter outputs by execution status") + Cmd.Flags().StringVar(&voucherAddress, "voucher-address", "", + "Filter outputs by voucher address (hex encoded)") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of outputs to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of outputs") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. + var params api.GetOutputParams + params.Application = args[0] + params.OutputIndex, err = config.AsHexString(args[1]) + cobra.CheckErr(err) + + result, err = readServ.GetOutput(ctx, params) + } else { + var params api.ListOutputsParams + params.Application = args[0] + + // Add epoch index filter if provided + if cmd.Flags().Changed("epoch-index") { + epochIndexHex, hexErr := config.AsHexString(epochIndex) + cobra.CheckErr(hexErr) + params.EpochIndex = &epochIndexHex + } + + // Add input index filter if provided + if cmd.Flags().Changed("input-index") { + inputIndexHex, hexErr := config.AsHexString(inputIndex) + cobra.CheckErr(hexErr) + params.InputIndex = &inputIndexHex + } + + // Add output type filter if provided + if cmd.Flags().Changed("output-type") { + selectors := api.StringOrList(outputTypes) + params.OutputType = &selectors + } + + // Add execution status filter if provided + if cmd.Flags().Changed("executed") { + params.Executed = &executed + } + + // Add voucher address filter if provided + if cmd.Flags().Changed("voucher-address") { + params.VoucherAddress = &voucherAddress + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListOutputs(ctx, params) + } + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} diff --git a/cmd/cartesi-rollups-cli/root/read/read.go b/cmd/cartesi-rollups-cli/root/read/read.go index 52573ad6e..75f55611d 100644 --- a/cmd/cartesi-rollups-cli/root/read/read.go +++ b/cmd/cartesi-rollups-cli/root/read/read.go @@ -4,30 +4,50 @@ package read import ( - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/input" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/commitments" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/epochs" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/inputs" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/notice" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/notices" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/report" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/matchadvances" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/matches" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/outputs" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/reports" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/voucher" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/vouchers" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/standingchanges" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/tournaments" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/withdrawals" + "github.com/cartesi/rollups-node/internal/config" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var Cmd = &cobra.Command{ Use: "read", - Short: "Read the node state from the GraphQL API", + Short: "Read the node state from the database", + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + if !cmd.Flags().Changed("jsonrpc") && cmd.Flags().Changed("jsonrpc-api-url") { + if err := cmd.Flags().Set("jsonrpc", "true"); err != nil { + return err + } + } + return nil + }, } func init() { - Cmd.AddCommand(input.Cmd) + Cmd.PersistentFlags().String("jsonrpc-api-url", "", + "JSON-RPC endpoint string in the URL format\n(eg.: 'https://localhost:10011/rpc')") + cobra.CheckErr(viper.BindPFlag(config.JSONRPC_API_URL, Cmd.PersistentFlags().Lookup("jsonrpc-api-url"))) + + Cmd.PersistentFlags().Bool("jsonrpc", false, "Use JSON-RPC API to retrieve data") + + Cmd.AddCommand(epochs.Cmd) Cmd.AddCommand(inputs.Cmd) - Cmd.AddCommand(notice.Cmd) - Cmd.AddCommand(notices.Cmd) - Cmd.AddCommand(voucher.Cmd) - Cmd.AddCommand(vouchers.Cmd) - Cmd.AddCommand(report.Cmd) + Cmd.AddCommand(outputs.Cmd) Cmd.AddCommand(reports.Cmd) + Cmd.AddCommand(withdrawals.Cmd) + Cmd.AddCommand(tournaments.Cmd) + Cmd.AddCommand(commitments.Cmd) + Cmd.AddCommand(matches.Cmd) + Cmd.AddCommand(matchadvances.Cmd) + Cmd.AddCommand(standingchanges.Cmd) } diff --git a/cmd/cartesi-rollups-cli/root/read/report/report.go b/cmd/cartesi-rollups-cli/root/read/report/report.go deleted file mode 100644 index 8ee67f895..000000000 --- a/cmd/cartesi-rollups-cli/root/read/report/report.go +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package report - -import ( - "encoding/json" - "fmt" - - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "report", - Short: "Reads a report", - Example: examples, - Run: run, -} - -const examples = `# Read report 5 from input 6: -cartesi-rollups-cli read report --report-index 5 --input-index 6` - -var ( - reportIndex int - inputIndex int - graphqlEndpoint string -) - -func init() { - Cmd.Flags().IntVar(&reportIndex, "report-index", 0, - "index of the report") - - cobra.CheckErr(Cmd.MarkFlagRequired("report-index")) - - Cmd.Flags().IntVar(&inputIndex, "input-index", 0, - "index of the input") - - cobra.CheckErr(Cmd.MarkFlagRequired("input-index")) - - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") -} - -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - - resp, err := readerclient.GetReport(ctx, client, reportIndex, inputIndex) - cobra.CheckErr(err) - - val, err := json.MarshalIndent(resp, "", " ") - cobra.CheckErr(err) - - fmt.Print(string(val)) -} diff --git a/cmd/cartesi-rollups-cli/root/read/reports/reports.go b/cmd/cartesi-rollups-cli/root/read/reports/reports.go index bd33aad40..d6fea0350 100644 --- a/cmd/cartesi-rollups-cli/root/read/reports/reports.go +++ b/cmd/cartesi-rollups-cli/root/read/reports/reports.go @@ -4,54 +4,134 @@ package reports import ( + "bytes" "encoding/json" "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" "github.com/spf13/cobra" ) var Cmd = &cobra.Command{ - Use: "reports", - Short: "Reads all reports. If an input is specified, reads all reports from that input", + Use: "reports [report index]", + Short: "Reads reports", Example: examples, + Args: cobra.RangeArgs(1, 2), //nolint: mnd Run: run, + Long: ` +Arguments: + application name or address + [report index] decimal or hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, } -const examples = `# Read all reports: -cartesi-rollups-cli read reports` +const examples = `# Read specific report: +cartesi-rollups-cli read reports echo-dapp 10 + +# Read all reports: +cartesi-rollups-cli read reports echo-dapp + +# Read all reports with filter: +cartesi-rollups-cli read reports echo-dapp --epoch-index 10 --input-index 10 + +# Read all reports with pagination: +cartesi-rollups-cli read reports echo-dapp --limit 10 --offset 10 --descending +` var ( - inputIndex int - graphqlEndpoint string + epochIndex string + inputIndex string + limit uint64 + offset uint64 + descending bool ) func init() { - Cmd.Flags().IntVar(&inputIndex, "input-index", -1, - "index of the input") + Cmd.Flags().StringVar(&epochIndex, "epoch-index", "", + "Filter reports by epoch index (decimal or hex encoded)") + Cmd.Flags().StringVar(&inputIndex, "input-index", "", + "Filter reports by input index (decimal or hex encoded)") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of reports to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of reports") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } } func run(cmd *cobra.Command, args []string) { ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - var resp []readerclient.Report - var err error + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) - if cmd.Flags().Changed("input-index") { - resp, err = readerclient.GetInputReports(ctx, client, inputIndex) + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. + var params api.GetReportParams + params.Application = args[0] + params.ReportIndex, err = config.AsHexString(args[1]) cobra.CheckErr(err) + + result, err = readServ.GetReport(ctx, params) } else { - resp, err = readerclient.GetReports(ctx, client) - cobra.CheckErr(err) + var params api.ListReportsParams + params.Application = args[0] + + // Add epoch index filter if provided + if cmd.Flags().Changed("epoch-index") { + epochIndexHex, hexErr := config.AsHexString(epochIndex) + cobra.CheckErr(hexErr) + params.EpochIndex = &epochIndexHex + } + + // Add input index filter if provided + if cmd.Flags().Changed("input-index") { + inputIndexHex, hexErr := config.AsHexString(inputIndex) + cobra.CheckErr(hexErr) + params.InputIndex = &inputIndexHex + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListReports(ctx, params) } + cobra.CheckErr(err) - val, err := json.MarshalIndent(resp, "", " ") + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") cobra.CheckErr(err) + out.WriteString("\n") - fmt.Print(string(val)) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go new file mode 100644 index 000000000..1e12185ff --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go @@ -0,0 +1,434 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/jsonrpc/client" +) + +type JsonrpcReadService struct { + Client *client.Client +} + +func (s *JsonrpcReadService) GetApplication(ctx context.Context, params api.GetApplicationParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getApplication", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetEpoch(ctx context.Context, params api.GetEpochParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getEpoch", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListEpochs(ctx context.Context, params api.ListEpochsParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + // Validate status filter if provided + if params.Status != nil { + for i, status := range *params.Status { + var statusVal model.EpochStatus + if err := statusVal.Scan(status); err != nil { + return nil, fmt.Errorf("invalid status #%d: %w", i+1, err) + } + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listEpochs", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetInput(ctx context.Context, params api.GetInputParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.InputIndex); err != nil { + return nil, fmt.Errorf("invalid input index: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getInput", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListInputs(ctx context.Context, params api.ListInputsParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + // Add epoch index filter if provided + if params.EpochIndex != nil { + if _, err := config.ToIndexFromString(*params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + } + // Add sender filter if provided + if params.Sender != nil { + if _, err := config.ToAddressFromString(*params.Sender); err != nil { + return nil, fmt.Errorf("invalid sender: %w", err) + } + } + if params.TransactionHash != nil { + if _, err := config.ToHashFromString(*params.TransactionHash); err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listInputs", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetOutput(ctx context.Context, params api.GetOutputParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.OutputIndex); err != nil { + return nil, fmt.Errorf("invalid output index: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getOutput", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListOutputs(ctx context.Context, params api.ListOutputsParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + // Add epoch index filter if provided + if params.EpochIndex != nil { + if _, err := config.ToIndexFromString(*params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + } + // Add input index filter if provided + if params.InputIndex != nil { + if _, err := config.ToIndexFromString(*params.InputIndex); err != nil { + return nil, fmt.Errorf("invalid input index: %w", err) + } + } + // Add output type filter if provided + if params.OutputType != nil { + for i, selector := range *params.OutputType { + if _, err := api.ParseOutputType(selector); err != nil { + return nil, fmt.Errorf("invalid output type #%d: %w", i+1, err) + } + } + } + // Add voucher address filter if provided + if params.VoucherAddress != nil { + if _, err := config.ToAddressFromString(*params.VoucherAddress); err != nil { + return nil, fmt.Errorf("invalid voucher address: %w", err) + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listOutputs", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetReport(ctx context.Context, params api.GetReportParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.ReportIndex); err != nil { + return nil, fmt.Errorf("invalid report index: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getReport", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListReports(ctx context.Context, params api.ListReportsParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + // Add epoch index filter if provided + if params.EpochIndex != nil { + if _, err := config.ToIndexFromString(*params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + } + // Add input index filter if provided + if params.InputIndex != nil { + if _, err := config.ToIndexFromString(*params.InputIndex); err != nil { + return nil, fmt.Errorf("invalid input index: %w", err) + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listReports", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetWithdrawal(ctx context.Context, params api.GetWithdrawalParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.AccountIndex); err != nil { + return nil, fmt.Errorf("invalid account index: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getWithdrawal", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListWithdrawals(ctx context.Context, params api.ListWithdrawalsParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if params.AccountIndex != nil { + if _, err := config.ToIndexFromString(*params.AccountIndex); err != nil { + return nil, fmt.Errorf("invalid account index: %w", err) + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listWithdrawals", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetTournament(ctx context.Context, params api.GetTournamentParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToAddressFromString(params.Address); err != nil { + return nil, fmt.Errorf("invalid address: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getTournament", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListTournaments(ctx context.Context, params api.ListTournamentsParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + // Add epoch index filter if provided + if params.EpochIndex != nil { + if _, err := config.ToIndexFromString(*params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + } + // Add level filter if provided + if params.Level != nil { + if _, err := config.ToIndexFromString(*params.Level); err != nil { + return nil, fmt.Errorf("invalid level: %w", err) + } + } + // Add parent tournament address filter if provided + if params.ParentTournamentAddress != nil { + if _, err := config.ToAddressFromString(*params.ParentTournamentAddress); err != nil { + return nil, fmt.Errorf("invalid parent tournament address: %w", err) + } + } + // Add parent match ID hash filter if provided + if params.ParentMatchIDHash != nil { + if _, err := config.ToHashFromString(*params.ParentMatchIDHash); err != nil { + return nil, fmt.Errorf("invalid parent match ID hash: %w", err) + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listTournaments", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetCommitment(ctx context.Context, params api.GetCommitmentParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.Commitment); err != nil { + return nil, fmt.Errorf("invalid commitment: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getCommitment", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListCommitments(ctx context.Context, params api.ListCommitmentsParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + // Add epoch index filter if provided + if params.EpochIndex != nil { + if _, err := config.ToIndexFromString(*params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + } + // Add tournament address filter if provided + if params.TournamentAddress != nil { + if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listCommitments", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetMatch(ctx context.Context, params api.GetMatchParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.IDHash); err != nil { + return nil, fmt.Errorf("invalid ID hash: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getMatch", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListMatches(ctx context.Context, params api.ListMatchesParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + // Add epoch index filter if provided + if params.EpochIndex != nil { + if _, err := config.ToIndexFromString(*params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + } + // Add tournament address filter if provided + if params.TournamentAddress != nil { + if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listMatches", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.IDHash); err != nil { + return nil, fmt.Errorf("invalid ID hash: %w", err) + } + if _, err := config.ToHashFromString(params.TxHash); err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + if _, err := config.ToIndexFromString(params.LogIndex); err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getMatchAdvance", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListMatchAdvances(ctx context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.IDHash); err != nil { + return nil, fmt.Errorf("invalid ID hash: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listMatchAdvances", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) GetStandingChange(ctx context.Context, params api.GetStandingChangeParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.TxHash); err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + if _, err := config.ToIndexFromString(params.LogIndex); err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_getStandingChange", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) ListStandingChanges(ctx context.Context, params api.ListStandingChangesParams) (json.RawMessage, error) { + if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToIndexFromString(params.EpochIndex); err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + + var resp json.RawMessage + err := s.Client.Call(ctx, "cartesi_listStandingChanges", params, &resp) + return resp, err +} + +func (s *JsonrpcReadService) Close() { +} + +func NewJsonrpcReadService(_ context.Context, serviceURL string) (ReadService, error) { + service := &JsonrpcReadService{ + Client: client.NewClient(serviceURL), + } + return service, nil +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go b/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go new file mode 100644 index 000000000..b84453fb0 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/service/matchadvances_test.go @@ -0,0 +1,188 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const invalidMatchAdvanceLogIndex = "invalid log index" + +func validMatchAdvanceParams() api.GetMatchAdvanceParams { + return api.GetMatchAdvanceParams{Application: "echo-dapp", EpochIndex: "0x10", + TournamentAddress: common.HexToAddress("0xabc").Hex(), IDHash: common.HexToHash("0xdef").Hex(), + TxHash: common.HexToHash("0x123").Hex(), LogIndex: "0x0"} +} + +func TestMatchAdvanceBackendsRejectInvalidIdentityBeforeIO(t *testing.T) { + for _, test := range []struct { + name string + change func(*api.GetMatchAdvanceParams) + message string + }{ + {"application", func(p *api.GetMatchAdvanceParams) { p.Application = "" }, "invalid application"}, + {"epoch", func(p *api.GetMatchAdvanceParams) { p.EpochIndex = "" }, "invalid epoch index"}, + {"tournament", func(p *api.GetMatchAdvanceParams) { p.TournamentAddress = "" }, "invalid tournament address"}, + {"match", func(p *api.GetMatchAdvanceParams) { p.IDHash = "" }, "invalid ID hash"}, + {"transaction absent", func(p *api.GetMatchAdvanceParams) { p.TxHash = "" }, "invalid transaction hash"}, + {"transaction short", func(p *api.GetMatchAdvanceParams) { p.TxHash = "0x123" }, "invalid transaction hash"}, + {"log index absent", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "" }, invalidMatchAdvanceLogIndex}, + {"log index decimal", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "12" }, invalidMatchAdvanceLogIndex}, + {"log index negative", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "-1" }, invalidMatchAdvanceLogIndex}, + {"log index overflow", func(p *api.GetMatchAdvanceParams) { p.LogIndex = "0x10000000000000000" }, invalidMatchAdvanceLogIndex}, + } { + for _, backend := range []struct { + name string + service ReadService + }{ + {"repository", &RepositoryReadService{}}, {"jsonrpc", &JsonrpcReadService{}}, + } { + t.Run(backend.name+"/"+test.name, func(t *testing.T) { + params := validMatchAdvanceParams() + test.change(¶ms) + // Both dependencies are nil: reaching I/O before validation fails this test. + _, err := backend.service.GetMatchAdvanced(t.Context(), params) + require.ErrorContains(t, err, test.message) + }) + } + } +} + +type matchAdvanceRepository struct { + repository.Repository + get func(context.Context, string, uint64, string, string, common.Hash, uint64) (*model.MatchAdvanced, error) + list func(context.Context, string, uint64, string, string, repository.Pagination, bool) ([]*model.MatchAdvanced, uint64, error) +} + +func (r *matchAdvanceRepository) GetMatchAdvanced(ctx context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, logIndex uint64) (*model.MatchAdvanced, error) { + return r.get(ctx, app, epoch, tournament, id, tx, logIndex) +} + +func (r *matchAdvanceRepository) ListMatchAdvances(ctx context.Context, app string, epoch uint64, tournament, id string, + pagination repository.Pagination, descending bool) ([]*model.MatchAdvanced, uint64, error) { + return r.list(ctx, app, epoch, tournament, id, pagination, descending) +} + +func TestRepositoryMatchAdvanceUsesScopedEventIdentity(t *testing.T) { + params := validMatchAdvanceParams() + called := false + repo := &matchAdvanceRepository{get: func(ctx context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, logIndex uint64) (*model.MatchAdvanced, error) { + called = true + require.Equal(t, t.Context(), ctx) + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, params.IDHash, id) + require.Equal(t, common.HexToHash(params.TxHash), tx) + require.Zero(t, logIndex, "block log index zero is valid") + return &model.MatchAdvanced{TxHash: tx, LogIndex: logIndex}, nil + }} + s := &RepositoryReadService{Repository: repo} + result, err := s.GetMatchAdvanced(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.SingleResponse[*model.MatchAdvanced] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, common.HexToHash(params.TxHash), response.Data.TxHash) + require.Zero(t, response.Data.LogIndex) +} + +func TestRepositoryMatchAdvanceListKeepsMatchScopeAndPagination(t *testing.T) { + identity := validMatchAdvanceParams() + params := api.ListMatchAdvancesParams{Application: identity.Application, EpochIndex: identity.EpochIndex, + TournamentAddress: identity.TournamentAddress, IDHash: identity.IDHash, Limit: 3, Offset: 2, Descending: true} + called := false + repo := &matchAdvanceRepository{list: func(_ context.Context, app string, epoch uint64, tournament, id string, + pagination repository.Pagination, descending bool) ([]*model.MatchAdvanced, uint64, error) { + called = true + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, params.IDHash, id) + require.Equal(t, repository.Pagination{Limit: 3, Offset: 2}, pagination) + require.True(t, descending) + return []*model.MatchAdvanced{{LogIndex: 4}}, 7, nil + }} + result, err := (&RepositoryReadService{Repository: repo}).ListMatchAdvances(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.ListResponse[*model.MatchAdvanced] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, uint64(7), response.Pagination.TotalCount) + require.Equal(t, uint64(3), response.Pagination.Limit) + require.Equal(t, uint64(2), response.Pagination.Offset) +} + +type matchAdvanceTransport func(*http.Request) (*http.Response, error) + +func (transport matchAdvanceTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestJSONRPCMatchAdvanceSendsScopedParams(t *testing.T) { + identity := validMatchAdvanceParams() + for _, get := range []bool{false, true} { + name := "list" + method := "cartesi_listMatchAdvances" + if get { + name, method = "get", "cartesi_getMatchAdvance" + } + t.Run(name, func(t *testing.T) { + called := false + transport := matchAdvanceTransport(func(request *http.Request) (*http.Response, error) { + called = true + var wire struct { + Method string + Params map[string]any + } + require.NoError(t, json.NewDecoder(request.Body).Decode(&wire)) + require.NoError(t, request.Body.Close()) + require.Equal(t, method, wire.Method) + require.Equal(t, identity.Application, wire.Params["application"]) + require.Equal(t, identity.EpochIndex, wire.Params["epoch_index"]) + require.Equal(t, identity.TournamentAddress, wire.Params["tournament_address"]) + require.Equal(t, identity.IDHash, wire.Params["id_hash"]) + require.NotContains(t, wire.Params, "parent") + if get { + require.Equal(t, identity.TxHash, wire.Params["tx_hash"]) + require.Equal(t, identity.LogIndex, wire.Params["log_index"]) + } else { + require.NotContains(t, wire.Params, "tx_hash") + require.NotContains(t, wire.Params, "log_index") + require.Equal(t, float64(3), wire.Params["limit"]) + require.Equal(t, float64(2), wire.Params["offset"]) + require.Equal(t, true, wire.Params["descending"]) + } + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"data":[]}}`))}, nil + }) + s := &JsonrpcReadService{Client: &client.Client{URL: "http://example.invalid/rpc", + HTTPClient: &http.Client{Transport: transport}}} + var err error + if get { + _, err = s.GetMatchAdvanced(t.Context(), identity) + } else { + _, err = s.ListMatchAdvances(t.Context(), api.ListMatchAdvancesParams{Application: identity.Application, + EpochIndex: identity.EpochIndex, TournamentAddress: identity.TournamentAddress, IDHash: identity.IDHash, + Limit: 3, Offset: 2, Descending: true}) + } + require.NoError(t, err) + require.True(t, called) + }) + } +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go new file mode 100644 index 000000000..d9db83896 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -0,0 +1,1004 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" + "github.com/cartesi/rollups-node/pkg/contracts/outputs" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +type RepositoryReadService struct { + Repository repository.Repository + InputAbi *abi.ABI + OutputAbi *abi.ABI +} + +func (s *RepositoryReadService) GetApplication(ctx context.Context, params api.GetApplicationParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + + data, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetEpoch(ctx context.Context, params api.GetEpochParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + + data, err := repo.GetEpoch(ctx, application, epochIndex) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListEpochs(ctx context.Context, params api.ListEpochsParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.EpochFilter{} + pagination := repository.Pagination{} + // Add status filter if provided + if params.Status != nil { + filter.Status = make([]model.EpochStatus, len(*params.Status)) + for i, status := range *params.Status { + var statusVal model.EpochStatus + if err := statusVal.Scan(status); err != nil { + return nil, fmt.Errorf("invalid status #%d: %w", i+1, err) + } + filter.Status[i] = statusVal + } + } + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListEpochs(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Epoch, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetInput(ctx context.Context, params api.GetInputParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + inputIndex, err := config.ToIndexFromString(params.InputIndex) + if err != nil { + return nil, fmt.Errorf("invalid input index: %w", err) + } + + data, err := repo.GetInput(ctx, application, inputIndex) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + dataVal, err := api.DecodeInput(data, s.InputAbi) + if err != nil { + dataVal = &api.DecodedInput{Input: data} + } + + response := map[string]any{ + "data": dataVal, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListInputs(ctx context.Context, params api.ListInputsParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.InputFilter{} + pagination := repository.Pagination{} + // Add epoch index filter if provided + if params.EpochIndex != nil { + epochIndexVal, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + filter.EpochIndex = &epochIndexVal + } + // Add sender filter if provided + if params.Sender != nil { + senderVal, err := config.ToAddressFromString(*params.Sender) + if err != nil { + return nil, fmt.Errorf("invalid sender: %w", err) + } + filter.Sender = &senderVal + } + if params.TransactionHash != nil { + transactionHashVal, err := config.ToHashFromString(*params.TransactionHash) + if err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + filter.TransactionHash = &transactionHashVal + } + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListInputs(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Input, 0) + } + dataVal := make([]*api.DecodedInput, 0, len(data)) + for _, item := range data { + decoded, err := api.DecodeInput(item, s.InputAbi) + if err != nil { + decoded = &api.DecodedInput{Input: item} + } + dataVal = append(dataVal, decoded) + } + + response := map[string]any{ + "data": dataVal, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetOutput(ctx context.Context, params api.GetOutputParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + outputIndex, err := config.ToIndexFromString(params.OutputIndex) + if err != nil { + return nil, fmt.Errorf("invalid output index: %w", err) + } + + data, err := repo.GetOutput(ctx, application, outputIndex) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + dataVal, err := api.DecodeOutput(data, s.OutputAbi) + if err != nil { + dataVal = &api.DecodedOutput{ + Output: data, + DecodedData: &api.DecodedData{Type: "error", RawData: err.Error()}, + } + } + + response := map[string]any{ + "data": dataVal, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListOutputs(ctx context.Context, params api.ListOutputsParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.OutputFilter{} + pagination := repository.Pagination{} + // Add epoch index filter if provided + if params.EpochIndex != nil { + epochIndexVal, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + filter.EpochIndex = &epochIndexVal + } + // Add input index filter if provided + if params.InputIndex != nil { + inputIndexVal, err := config.ToIndexFromString(*params.InputIndex) + if err != nil { + return nil, fmt.Errorf("invalid input index: %w", err) + } + filter.InputIndex = &inputIndexVal + } + // Add output type filter if provided + if params.OutputType != nil { + outputTypeVal := make([][]byte, len(*params.OutputType)) + for i, selector := range *params.OutputType { + parsed, err := api.ParseOutputType(selector) + if err != nil { + return nil, fmt.Errorf("invalid output type #%d: %w", i+1, err) + } + outputTypeVal[i] = parsed + } + filter.OutputType = &outputTypeVal + } + // Add voucher address filter if provided + if params.VoucherAddress != nil { + voucherAddressVal, err := config.ToAddressFromString(*params.VoucherAddress) + if err != nil { + return nil, fmt.Errorf("invalid voucher address: %w", err) + } + filter.VoucherAddress = &voucherAddressVal + } + filter.Executed = params.Executed + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListOutputs(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Output, 0) + } + dataVal := make([]*api.DecodedOutput, 0, len(data)) + for _, item := range data { + decoded, err := api.DecodeOutput(item, s.OutputAbi) + if err != nil { + decoded = &api.DecodedOutput{ + Output: item, + DecodedData: &api.DecodedData{Type: "error", RawData: err.Error()}, + } + } + dataVal = append(dataVal, decoded) + } + + response := map[string]any{ + "data": dataVal, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetReport(ctx context.Context, params api.GetReportParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + reportIndex, err := config.ToIndexFromString(params.ReportIndex) + if err != nil { + return nil, fmt.Errorf("invalid report index: %w", err) + } + + data, err := repo.GetReport(ctx, application, reportIndex) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListReports(ctx context.Context, params api.ListReportsParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.ReportFilter{} + pagination := repository.Pagination{} + // Add epoch index filter if provided + if params.EpochIndex != nil { + epochIndexVal, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + filter.EpochIndex = &epochIndexVal + } + // Add input index filter if provided + if params.InputIndex != nil { + inputIndexVal, err := config.ToIndexFromString(*params.InputIndex) + if err != nil { + return nil, fmt.Errorf("invalid input index: %w", err) + } + filter.InputIndex = &inputIndexVal + } + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListReports(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Report, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetWithdrawal(ctx context.Context, params api.GetWithdrawalParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + accountIndex, err := config.ToIndexFromString(params.AccountIndex) + if err != nil { + return nil, fmt.Errorf("invalid account index: %w", err) + } + + data, err := repo.GetWithdrawal(ctx, application, accountIndex) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListWithdrawals(ctx context.Context, params api.ListWithdrawalsParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.WithdrawalFilter{} + pagination := repository.Pagination{} + if params.AccountIndex != nil { + accountIndexVal, err := config.ToIndexFromString(*params.AccountIndex) + if err != nil { + return nil, fmt.Errorf("invalid account index: %w", err) + } + filter.AccountIndex = &accountIndexVal + } + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListWithdrawals(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Withdrawal, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetTournament(ctx context.Context, params api.GetTournamentParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + if _, err := config.ToAddressFromString(params.Address); err != nil { + return nil, fmt.Errorf("invalid address: %w", err) + } + + data, err := repo.GetTournament(ctx, application, params.Address) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListTournaments(ctx context.Context, params api.ListTournamentsParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.TournamentFilter{} + pagination := repository.Pagination{} + // Add epoch index filter if provided + if params.EpochIndex != nil { + epochIndexVal, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + filter.EpochIndex = &epochIndexVal + } + // Add level filter if provided + if params.Level != nil { + levelVal, err := config.ToIndexFromString(*params.Level) + if err != nil { + return nil, fmt.Errorf("invalid level: %w", err) + } + filter.Level = &levelVal + } + // Add parent tournament address filter if provided + if params.ParentTournamentAddress != nil { + parentTournamentAddressVal, err := config.ToAddressFromString(*params.ParentTournamentAddress) + if err != nil { + return nil, fmt.Errorf("invalid parent tournament address: %w", err) + } + filter.ParentTournamentAddress = &parentTournamentAddressVal + } + // Add parent match ID hash filter if provided + if params.ParentMatchIDHash != nil { + parentMatchIDHashVal, err := config.ToHashFromString(*params.ParentMatchIDHash) + if err != nil { + return nil, fmt.Errorf("invalid parent match ID hash: %w", err) + } + filter.ParentMatchIDHash = &parentMatchIDHashVal + } + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListTournaments(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Tournament, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetCommitment(ctx context.Context, params api.GetCommitmentParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.Commitment); err != nil { + return nil, fmt.Errorf("invalid commitment: %w", err) + } + + data, err := repo.GetCommitment(ctx, application, epochIndex, params.TournamentAddress, params.Commitment) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListCommitments(ctx context.Context, params api.ListCommitmentsParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.CommitmentFilter{} + pagination := repository.Pagination{} + // Add epoch index filter if provided + if params.EpochIndex != nil { + epochIndexVal, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + filter.EpochIndex = &epochIndexVal + } + // Add tournament address filter if provided + if params.TournamentAddress != nil { + if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + filter.TournamentAddress = params.TournamentAddress + } + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListCommitments(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Commitment, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetMatch(ctx context.Context, params api.GetMatchParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.IDHash); err != nil { + return nil, fmt.Errorf("invalid ID hash: %w", err) + } + + data, err := repo.GetMatch(ctx, application, epochIndex, params.TournamentAddress, params.IDHash) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListMatches(ctx context.Context, params api.ListMatchesParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + filter := repository.MatchFilter{} + pagination := repository.Pagination{} + // Add epoch index filter if provided + if params.EpochIndex != nil { + epochIndexVal, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + filter.EpochIndex = &epochIndexVal + } + // Add tournament address filter if provided + if params.TournamentAddress != nil { + if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + filter.TournamentAddress = params.TournamentAddress + } + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListMatches(ctx, application, filter, pagination, params.Descending) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.Match, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.IDHash); err != nil { + return nil, fmt.Errorf("invalid ID hash: %w", err) + } + txHash, err := config.ToHashFromString(params.TxHash) + if err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + logIndex, err := config.ToIndexFromString(params.LogIndex) + if err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) + } + + data, err := repo.GetMatchAdvanced(ctx, application, epochIndex, params.TournamentAddress, params.IDHash, txHash, logIndex) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListMatchAdvances(ctx context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + if _, err := config.ToHashFromString(params.IDHash); err != nil { + return nil, fmt.Errorf("invalid ID hash: %w", err) + } + pagination := repository.Pagination{} + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListMatchAdvances( + ctx, application, epochIndex, params.TournamentAddress, params.IDHash, pagination, params.Descending, + ) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.MatchAdvanced, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) GetStandingChange(ctx context.Context, params api.GetStandingChangeParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + txHash, err := config.ToHashFromString(params.TxHash) + if err != nil { + return nil, fmt.Errorf("invalid transaction hash: %w", err) + } + logIndex, err := config.ToIndexFromString(params.LogIndex) + if err != nil { + return nil, fmt.Errorf("invalid log index: %w", err) + } + + data, err := repo.GetStandingChange(ctx, application, epochIndex, params.TournamentAddress, txHash, logIndex) + if err != nil { + return nil, err + } + if data == nil { + app, lookupErr := repo.GetApplication(ctx, application) + if lookupErr != nil { + return nil, lookupErr + } + if app == nil { + return nil, ErrApplicationNotFound + } + return nil, ErrNotFound + } + + response := map[string]any{ + "data": data, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) ListStandingChanges(ctx context.Context, params api.ListStandingChangesParams) (json.RawMessage, error) { + repo := s.Repository + application, err := config.ToApplicationNameOrAddressFromString(params.Application) + if err != nil { + return nil, fmt.Errorf("invalid application: %w", err) + } + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, fmt.Errorf("invalid epoch index: %w", err) + } + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, fmt.Errorf("invalid tournament address: %w", err) + } + pagination := repository.Pagination{} + pagination.Limit = params.Limit + pagination.Offset = params.Offset + + data, total, err := repo.ListStandingChanges( + ctx, application, epochIndex, params.TournamentAddress, pagination, params.Descending, + ) + if err != nil { + return nil, err + } + if len(data) == 0 { + app, err := repo.GetApplication(ctx, application) + if err != nil { + return nil, err + } + if app == nil { + return nil, ErrApplicationNotFound + } + data = make([]*model.StandingChanged, 0) + } + + response := map[string]any{ + "data": data, + "pagination": map[string]uint64{ + "total_count": total, + "limit": pagination.Limit, + "offset": pagination.Offset, + }, + } + + result, err := json.Marshal(response) + + return json.RawMessage(result), err +} + +func (s *RepositoryReadService) Close() { + s.Repository.Close() +} + +func NewRepositoryReadService(ctx context.Context, dsn config.SafeURL) (ReadService, error) { + inputAbi, err := inputs.InputsMetaData.GetAbi() + if err != nil { + return nil, err + } + outputAbi, err := outputs.OutputsMetaData.GetAbi() + if err != nil { + return nil, err + } + repo, err := cli.OpenRepository(ctx, dsn) + if err != nil { + return nil, err + } + + service := &RepositoryReadService{ + Repository: repo, + InputAbi: inputAbi, + OutputAbi: outputAbi, + } + return service, nil +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/standingchanges_test.go b/cmd/cartesi-rollups-cli/root/read/service/standingchanges_test.go new file mode 100644 index 000000000..af3dd6b13 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/service/standingchanges_test.go @@ -0,0 +1,240 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/jsonrpc/client" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + invalidStandingChangeLogIndex = "invalid log index" + invalidStandingTransactionHash = "invalid transaction hash" +) + +func validStandingChangeParams() api.GetStandingChangeParams { + return api.GetStandingChangeParams{Application: "echo-dapp", EpochIndex: "0x10", + TournamentAddress: common.HexToAddress("0xabc").Hex(), + TxHash: common.HexToHash("0x123").Hex(), LogIndex: "0x0"} +} + +func TestStandingChangeBackendsRejectInvalidIdentityBeforeIO(t *testing.T) { + for _, test := range []struct { + name string + change func(*api.GetStandingChangeParams) + message string + }{ + {"application", func(p *api.GetStandingChangeParams) { p.Application = "" }, "invalid application"}, + {"epoch", func(p *api.GetStandingChangeParams) { p.EpochIndex = "" }, "invalid epoch index"}, + {"tournament", func(p *api.GetStandingChangeParams) { p.TournamentAddress = "" }, "invalid tournament address"}, + {"transaction absent", func(p *api.GetStandingChangeParams) { p.TxHash = "" }, invalidStandingTransactionHash}, + {"transaction short", func(p *api.GetStandingChangeParams) { p.TxHash = "0x123" }, invalidStandingTransactionHash}, + {"log index absent", func(p *api.GetStandingChangeParams) { p.LogIndex = "" }, invalidStandingChangeLogIndex}, + {"log index decimal", func(p *api.GetStandingChangeParams) { p.LogIndex = "12" }, invalidStandingChangeLogIndex}, + {"log index negative", func(p *api.GetStandingChangeParams) { p.LogIndex = "-1" }, invalidStandingChangeLogIndex}, + {"log index overflow", func(p *api.GetStandingChangeParams) { p.LogIndex = "0x10000000000000000" }, invalidStandingChangeLogIndex}, + } { + for _, backend := range []struct { + name string + service ReadService + }{ + {"repository", &RepositoryReadService{}}, {"jsonrpc", &JsonrpcReadService{}}, + } { + t.Run(backend.name+"/"+test.name, func(t *testing.T) { + params := validStandingChangeParams() + test.change(¶ms) + // Both dependencies are nil: reaching I/O before validation fails this test. + _, err := backend.service.GetStandingChange(t.Context(), params) + require.ErrorContains(t, err, test.message) + }) + } + } +} + +type standingChangeRepository struct { + repository.Repository + application func(context.Context, string) (*model.Application, error) + get func(context.Context, string, uint64, string, common.Hash, uint64) (*model.StandingChanged, error) + list func(context.Context, string, uint64, string, repository.Pagination, bool) ([]*model.StandingChanged, uint64, error) +} + +func (r *standingChangeRepository) GetApplication(ctx context.Context, name string) (*model.Application, error) { + return r.application(ctx, name) +} + +func (r *standingChangeRepository) GetStandingChange(ctx context.Context, app string, epoch uint64, tournament string, + tx common.Hash, logIndex uint64) (*model.StandingChanged, error) { + return r.get(ctx, app, epoch, tournament, tx, logIndex) +} + +func (r *standingChangeRepository) ListStandingChanges(ctx context.Context, app string, epoch uint64, tournament string, + pagination repository.Pagination, descending bool) ([]*model.StandingChanged, uint64, error) { + return r.list(ctx, app, epoch, tournament, pagination, descending) +} + +func TestRepositoryStandingChangeUsesScopedEventIdentity(t *testing.T) { + params := validStandingChangeParams() + called := false + repo := &standingChangeRepository{get: func(ctx context.Context, app string, epoch uint64, tournament string, + tx common.Hash, logIndex uint64) (*model.StandingChanged, error) { + called = true + require.Equal(t, t.Context(), ctx) + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, common.HexToHash(params.TxHash), tx) + require.Zero(t, logIndex, "block log index zero is valid") + return &model.StandingChanged{TxHash: tx, LogIndex: logIndex}, nil + }} + s := &RepositoryReadService{Repository: repo} + result, err := s.GetStandingChange(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.SingleResponse[*model.StandingChanged] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, common.HexToHash(params.TxHash), response.Data.TxHash) + require.Zero(t, response.Data.LogIndex) +} + +func TestRepositoryStandingChangeListKeepsTournamentScopeAndPagination(t *testing.T) { + identity := validStandingChangeParams() + params := api.ListStandingChangesParams{Application: identity.Application, EpochIndex: identity.EpochIndex, + TournamentAddress: identity.TournamentAddress, Limit: 3, Offset: 2, Descending: true} + called := false + repo := &standingChangeRepository{list: func(_ context.Context, app string, epoch uint64, tournament string, + pagination repository.Pagination, descending bool) ([]*model.StandingChanged, uint64, error) { + called = true + require.Equal(t, params.Application, app) + require.Equal(t, uint64(16), epoch) + require.Equal(t, params.TournamentAddress, tournament) + require.Equal(t, repository.Pagination{Limit: 3, Offset: 2}, pagination) + require.True(t, descending) + return []*model.StandingChanged{{LogIndex: 4}}, 7, nil + }} + result, err := (&RepositoryReadService{Repository: repo}).ListStandingChanges(t.Context(), params) + require.NoError(t, err) + require.True(t, called) + var response api.ListResponse[*model.StandingChanged] + require.NoError(t, json.Unmarshal(result, &response)) + require.Equal(t, uint64(7), response.Pagination.TotalCount) + require.Equal(t, uint64(3), response.Pagination.Limit) + require.Equal(t, uint64(2), response.Pagination.Offset) +} + +type standingChangeTransport func(*http.Request) (*http.Response, error) + +func (transport standingChangeTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestJSONRPCStandingChangeSendsScopedParams(t *testing.T) { + identity := validStandingChangeParams() + for _, get := range []bool{false, true} { + name := "list" + method := "cartesi_listStandingChanges" + if get { + name, method = "get", "cartesi_getStandingChange" + } + t.Run(name, func(t *testing.T) { + called := false + transport := standingChangeTransport(func(request *http.Request) (*http.Response, error) { + called = true + var wire struct { + Method string + Params map[string]any + } + require.NoError(t, json.NewDecoder(request.Body).Decode(&wire)) + require.NoError(t, request.Body.Close()) + require.Equal(t, method, wire.Method) + require.Equal(t, identity.Application, wire.Params["application"]) + require.Equal(t, identity.EpochIndex, wire.Params["epoch_index"]) + require.Equal(t, identity.TournamentAddress, wire.Params["tournament_address"]) + require.NotContains(t, wire.Params, "id_hash") + require.NotContains(t, wire.Params, "parent") + if get { + require.Equal(t, identity.TxHash, wire.Params["tx_hash"]) + require.Equal(t, identity.LogIndex, wire.Params["log_index"]) + } else { + require.NotContains(t, wire.Params, "tx_hash") + require.NotContains(t, wire.Params, "log_index") + require.Equal(t, float64(3), wire.Params["limit"]) + require.Equal(t, float64(2), wire.Params["offset"]) + require.Equal(t, true, wire.Params["descending"]) + } + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{"data":[]}}`))}, nil + }) + s := &JsonrpcReadService{Client: &client.Client{URL: "http://example.invalid/rpc", + HTTPClient: &http.Client{Transport: transport}}} + var err error + if get { + _, err = s.GetStandingChange(t.Context(), identity) + } else { + _, err = s.ListStandingChanges(t.Context(), api.ListStandingChangesParams{Application: identity.Application, + EpochIndex: identity.EpochIndex, TournamentAddress: identity.TournamentAddress, + Limit: 3, Offset: 2, Descending: true}) + } + require.NoError(t, err) + require.True(t, called) + }) + } +} + +func TestStandingChangeRepositoryErrorsAndEmptyHistory(t *testing.T) { + cause := errors.New("standing history unavailable") + for _, scenario := range []struct { + name string + getErr error + app *model.Application + appErr error + want error + }{ + {name: "dependency error", getErr: cause, want: cause}, + {name: "missing application", want: ErrApplicationNotFound}, + {name: "missing event", app: &model.Application{ID: 1}, want: ErrNotFound}, + {name: "lookup error", appErr: cause, want: cause}, + {name: "cancellation", getErr: context.Canceled, want: context.Canceled}, + } { + t.Run(scenario.name, func(t *testing.T) { + repo := &standingChangeRepository{ + get: func(context.Context, string, uint64, string, common.Hash, uint64) (*model.StandingChanged, error) { + return nil, scenario.getErr + }, + application: func(context.Context, string) (*model.Application, error) { + require.NoError(t, scenario.getErr, "failed event reads must not query the application") + return scenario.app, scenario.appErr + }, + } + _, err := (&RepositoryReadService{Repository: repo}).GetStandingChange(t.Context(), validStandingChangeParams()) + require.ErrorIs(t, err, scenario.want) + }) + } + t.Run("empty page preserves total and an array", func(t *testing.T) { + repo := &standingChangeRepository{ + list: func(context.Context, string, uint64, string, repository.Pagination, bool) ([]*model.StandingChanged, uint64, error) { + return nil, 7, nil + }, + application: func(context.Context, string) (*model.Application, error) { return &model.Application{ID: 1}, nil }, + } + identity := validStandingChangeParams() + result, err := (&RepositoryReadService{Repository: repo}).ListStandingChanges(t.Context(), api.ListStandingChangesParams{ + Application: identity.Application, EpochIndex: identity.EpochIndex, TournamentAddress: identity.TournamentAddress, + Limit: 3, Offset: 9, + }) + require.NoError(t, err) + require.JSONEq(t, `{"data":[],"pagination":{"total_count":7,"limit":3,"offset":9}}`, string(result)) + }) +} diff --git a/cmd/cartesi-rollups-cli/root/read/service/types.go b/cmd/cartesi-rollups-cli/root/read/service/types.go new file mode 100644 index 000000000..f1f9a81b7 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/service/types.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "context" + "encoding/json" + "errors" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" +) + +var ( + ErrNotFound = errors.New("not found") + ErrApplicationNotFound = errors.New("application not found") +) + +type ReadService interface { + GetApplication(ctx context.Context, params api.GetApplicationParams) (json.RawMessage, error) + GetEpoch(ctx context.Context, params api.GetEpochParams) (json.RawMessage, error) + ListEpochs(ctx context.Context, params api.ListEpochsParams) (json.RawMessage, error) + GetInput(ctx context.Context, params api.GetInputParams) (json.RawMessage, error) + ListInputs(ctx context.Context, params api.ListInputsParams) (json.RawMessage, error) + GetOutput(ctx context.Context, params api.GetOutputParams) (json.RawMessage, error) + ListOutputs(ctx context.Context, params api.ListOutputsParams) (json.RawMessage, error) + GetReport(ctx context.Context, params api.GetReportParams) (json.RawMessage, error) + ListReports(ctx context.Context, params api.ListReportsParams) (json.RawMessage, error) + GetWithdrawal(ctx context.Context, params api.GetWithdrawalParams) (json.RawMessage, error) + ListWithdrawals(ctx context.Context, params api.ListWithdrawalsParams) (json.RawMessage, error) + GetTournament(ctx context.Context, params api.GetTournamentParams) (json.RawMessage, error) + ListTournaments(ctx context.Context, params api.ListTournamentsParams) (json.RawMessage, error) + GetCommitment(ctx context.Context, params api.GetCommitmentParams) (json.RawMessage, error) + ListCommitments(ctx context.Context, params api.ListCommitmentsParams) (json.RawMessage, error) + GetMatch(ctx context.Context, params api.GetMatchParams) (json.RawMessage, error) + ListMatches(ctx context.Context, params api.ListMatchesParams) (json.RawMessage, error) + GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) + ListMatchAdvances(ctx context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) + GetStandingChange(ctx context.Context, params api.GetStandingChangeParams) (json.RawMessage, error) + ListStandingChanges(ctx context.Context, params api.ListStandingChangesParams) (json.RawMessage, error) + Close() +} + +func CreateReadService(ctx context.Context, useJsonrpc bool) (ReadService, error) { + if useJsonrpc { + url, err := config.GetJsonrpcApiUrl() + if err != nil { + return nil, err + } + return NewJsonrpcReadService(ctx, url.Raw()) //nolint:forbidigo // Connect through the protected JSON-RPC transport. + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return nil, err + } + return NewRepositoryReadService(ctx, dsn) +} diff --git a/cmd/cartesi-rollups-cli/root/read/standingchanges/standingchanges.go b/cmd/cartesi-rollups-cli/root/read/standingchanges/standingchanges.go new file mode 100644 index 000000000..f83e3ae44 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/standingchanges/standingchanges.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package standingchanges + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +const ( + listArgCount = 3 + getArgCount = 5 +) + +var Cmd = &cobra.Command{ + Use: "standing_changes [transaction hash log index]", + Short: "Reads standing changes", + Example: examples, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != listArgCount && len(args) != getArgCount { + return fmt.Errorf("requires %d arguments to list a tournament's standing changes, "+ + "or %d with transaction hash and log index to read one", + listArgCount, getArgCount) + } + return nil + }, + Run: run, + Long: ` +Arguments: + application name or address + decimal or hex encoded + hex encoded + [transaction hash] hex encoded; provide together with log index + [log index] decimal or hex encoded block log index + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const examples = `# Read one standing change by transaction hash and block log index: +cartesi-rollups-cli read standing_changes echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xa24a91c7ce97fb16b2f679875966dc50f84747fd54e006763ed0dd702c260370 3 + +# Read all standing changes for one tournament (includes each event's transaction hash and log index): +cartesi-rollups-cli read standing_changes echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 + +# Read all standing changes with pagination: +cartesi-rollups-cli read standing_changes echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 --limit 10 --offset 10 --descending +` + +var ( + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of standing changes to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of standing changes") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + result, err := readStandingChanges(ctx, readServ, args) + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} + +func readStandingChanges(ctx context.Context, readServ service.ReadService, args []string) (json.RawMessage, error) { + epochIndex, err := config.AsHexString(args[1]) + if err != nil { + return nil, err + } + if len(args) == getArgCount { + logIndex, err := config.AsHexString(args[getArgCount-1]) + if err != nil { + return nil, err + } + return readServ.GetStandingChange(ctx, api.GetStandingChangeParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], + TxHash: args[listArgCount], LogIndex: logIndex, + }) + } + return readServ.ListStandingChanges(ctx, api.ListStandingChangesParams{ + Application: args[0], EpochIndex: epochIndex, TournamentAddress: args[2], + Limit: limit, Offset: offset, Descending: descending, + }) +} diff --git a/cmd/cartesi-rollups-cli/root/read/standingchanges/standingchanges_test.go b/cmd/cartesi-rollups-cli/root/read/standingchanges/standingchanges_test.go new file mode 100644 index 000000000..dfc6eea84 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/standingchanges/standingchanges_test.go @@ -0,0 +1,124 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package standingchanges + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestStandingChangesCommandRequiresScopeAndCompleteEventIdentity(t *testing.T) { + for count := range 8 { + err := Cmd.Args(Cmd, make([]string, count)) + if count == 3 || count == 5 { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } +} + +func TestStandingChangesGetExampleHasValidEventIdentity(t *testing.T) { + _, example, found := strings.Cut(Cmd.Example, "cartesi-rollups-cli read standing_changes ") + require.True(t, found) + line, _, _ := strings.Cut(example, "\n") + args := strings.Fields(line) + require.Len(t, args, getArgCount) + require.NoError(t, Cmd.Args(Cmd, args)) + _, err := config.ToHashFromString(args[listArgCount]) + require.NoError(t, err) + _, err = config.AsHexString(args[getArgCount-1]) + require.NoError(t, err) +} + +type standingChangeReader struct { + service.ReadService + get func(context.Context, api.GetStandingChangeParams) (json.RawMessage, error) + list func(context.Context, api.ListStandingChangesParams) (json.RawMessage, error) +} + +func (reader *standingChangeReader) GetStandingChange(ctx context.Context, params api.GetStandingChangeParams) (json.RawMessage, error) { + return reader.get(ctx, params) +} + +func (reader *standingChangeReader) ListStandingChanges( + ctx context.Context, params api.ListStandingChangesParams, +) (json.RawMessage, error) { + return reader.list(ctx, params) +} + +func standingChangeCommandArgs() []string { + return []string{"echo-dapp", "16", common.HexToAddress("0xabc").Hex()} +} + +func TestStandingChangesCommandMapsGetEventIdentity(t *testing.T) { + for _, test := range []struct { + epoch, logIndex, wantEpoch, wantLog string + }{ + {epoch: "16", logIndex: "0", wantEpoch: "0x10", wantLog: "0x0"}, + {epoch: "18446744073709551615", logIndex: "0XFFFFFFFFFFFFFFFF", + wantEpoch: "0xffffffffffffffff", wantLog: "0xffffffffffffffff"}, + } { + t.Run(test.logIndex, func(t *testing.T) { + args := []string{"echo-dapp", test.epoch, common.HexToAddress("0xabc").Hex(), + common.HexToHash("0x123").Hex(), test.logIndex} + require.NoError(t, Cmd.Args(Cmd, args)) + reader := &standingChangeReader{get: func(_ context.Context, params api.GetStandingChangeParams) (json.RawMessage, error) { + require.Equal(t, api.GetStandingChangeParams{Application: args[0], EpochIndex: test.wantEpoch, + TournamentAddress: args[2], TxHash: args[3], LogIndex: test.wantLog}, params) + return json.RawMessage(`{"data":{}}`), nil + }} + result, err := readStandingChanges(t.Context(), reader, args) + require.NoError(t, err) + require.JSONEq(t, `{"data":{}}`, string(result)) + }) + } +} + +func TestStandingChangesCommandKeepsListPaginationFlags(t *testing.T) { + previousLimit, previousOffset, previousDescending := limit, offset, descending + t.Cleanup(func() { limit, offset, descending = previousLimit, previousOffset, previousDescending }) + require.NoError(t, Cmd.ParseFlags([]string{"--limit", "3", "--offset", "2", "--descending"})) + args := standingChangeCommandArgs() + require.NoError(t, Cmd.Args(Cmd, args)) + require.NoError(t, Cmd.PreRunE(Cmd, args)) + called := false + reader := &standingChangeReader{list: func(_ context.Context, params api.ListStandingChangesParams) (json.RawMessage, error) { + called = true + require.Equal(t, api.ListStandingChangesParams{Application: args[0], EpochIndex: "0x10", + TournamentAddress: args[2], Limit: 3, Offset: 2, Descending: true}, params) + return json.RawMessage(`{"data":[]}`), nil + }} + _, err := readStandingChanges(t.Context(), reader, args) + require.NoError(t, err) + require.True(t, called) + limit = 0 + require.NoError(t, Cmd.PreRunE(Cmd, args)) + require.Equal(t, uint64(jsonrpc.LIST_ITEM_LIMIT), limit) + limit = jsonrpc.LIST_ITEM_LIMIT + 1 + require.Error(t, Cmd.PreRunE(Cmd, args)) +} + +func TestStandingChangesCommandRejectsInvalidNumericIndices(t *testing.T) { + for _, epochIndex := range []string{"-1", "0x10000000000000000"} { + args := standingChangeCommandArgs() + args[1] = epochIndex + _, err := readStandingChanges(t.Context(), nil, args) + require.Error(t, err) + } + for _, logIndex := range []string{"-1", "0x10000000000000000"} { + args := append(standingChangeCommandArgs(), common.HexToHash("0x123").Hex(), logIndex) + _, err := readStandingChanges(t.Context(), nil, args) + require.Error(t, err) + } +} diff --git a/cmd/cartesi-rollups-cli/root/read/tournaments/tournaments.go b/cmd/cartesi-rollups-cli/root/read/tournaments/tournaments.go new file mode 100644 index 000000000..e4d89ca84 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/tournaments/tournaments.go @@ -0,0 +1,153 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package tournaments + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "tournaments [address]", + Short: "Reads tournaments", + Example: examples, + Args: cobra.RangeArgs(1, 2), //nolint: mnd + Run: run, + Long: ` +Arguments: + application name or address + [address] hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +//nolint:lll // Long CLI examples are kept copy-pasteable. +const examples = `# Read specific tournament: +cartesi-rollups-cli read tournaments echo-dapp 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 + +# Read all tournaments: +cartesi-rollups-cli read tournaments echo-dapp + +# Read all tournaments with filter: +cartesi-rollups-cli read tournaments echo-dapp --epoch-index 10 --level 10 --parent-tournament-address 0x95eac57f9d67c5e0f255d5a19eb5d3fd00cafa73 --parent-match-id-hash 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 + +# Read all tournaments with pagination: +cartesi-rollups-cli read tournaments echo-dapp --limit 10 --offset 10 --descending +` + +var ( + epochIndex string + level string + parentTournamentAddress string + parentMatchIDHash string + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().StringVar(&epochIndex, "epoch-index", "", + "Filter tournaments by epoch index (decimal or hex encoded)") + Cmd.Flags().StringVar(&level, "level", "", + "Filter tournaments by level (decimal or hex encoded)") + Cmd.Flags().StringVar(&parentTournamentAddress, "parent-tournament-address", "", + "Filter tournaments by parent tournament address (hex encoded)") + Cmd.Flags().StringVar(&parentMatchIDHash, "parent-match-id-hash", "", + "Filter tournaments by parent match ID hash (hex encoded)") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd + "Maximum number of tournaments to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of tournaments") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. + var params api.GetTournamentParams + params.Application = args[0] + params.Address = args[1] + + result, err = readServ.GetTournament(ctx, params) + } else { + var params api.ListTournamentsParams + params.Application = args[0] + + // Add epoch index filter if provided + if cmd.Flags().Changed("epoch-index") { + epochIndexHex, hexErr := config.AsHexString(epochIndex) + cobra.CheckErr(hexErr) + params.EpochIndex = &epochIndexHex + } + + // Add level filter if provided + if cmd.Flags().Changed("level") { + levelHex, hexErr := config.AsHexString(level) + cobra.CheckErr(hexErr) + params.Level = &levelHex + } + + // Add parent tournament address filter if provided + if cmd.Flags().Changed("parent-tournament-address") { + params.ParentTournamentAddress = &parentTournamentAddress + } + + // Add parent match ID hash filter if provided + if cmd.Flags().Changed("parent-match-id-hash") { + params.ParentMatchIDHash = &parentMatchIDHash + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListTournaments(ctx, params) + } + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} diff --git a/cmd/cartesi-rollups-cli/root/read/voucher/voucher.go b/cmd/cartesi-rollups-cli/root/read/voucher/voucher.go deleted file mode 100644 index 32d939682..000000000 --- a/cmd/cartesi-rollups-cli/root/read/voucher/voucher.go +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package voucher - -import ( - "encoding/json" - "fmt" - - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "voucher", - Short: "Reads a voucher", - Example: examples, - Run: run, -} - -const examples = `# Read voucher 5 from input 6: -cartesi-rollups-cli read voucher --voucher-index 5 --input-index 6` - -var ( - voucherIndex int - inputIndex int - graphqlEndpoint string -) - -func init() { - Cmd.Flags().IntVar(&voucherIndex, "voucher-index", 0, - "index of the voucher") - - cobra.CheckErr(Cmd.MarkFlagRequired("voucher-index")) - - Cmd.Flags().IntVar(&inputIndex, "input-index", 0, - "index of the input") - - cobra.CheckErr(Cmd.MarkFlagRequired("input-index")) - - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") -} - -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - - resp, err := readerclient.GetVoucher(ctx, client, voucherIndex, inputIndex) - cobra.CheckErr(err) - - val, err := json.MarshalIndent(resp, "", " ") - cobra.CheckErr(err) - - fmt.Print(string(val)) -} diff --git a/cmd/cartesi-rollups-cli/root/read/vouchers/vouchers.go b/cmd/cartesi-rollups-cli/root/read/vouchers/vouchers.go deleted file mode 100644 index 6f40b2809..000000000 --- a/cmd/cartesi-rollups-cli/root/read/vouchers/vouchers.go +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package vouchers - -import ( - "encoding/json" - "fmt" - - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "vouchers", - Short: "Reads all vouchers. If an input is specified, reads all vouchers from that input", - Example: examples, - Run: run, -} - -const examples = `# Read all vouchers: -cartesi-rollups-cli read vouchers` - -var ( - inputIndex int - graphqlEndpoint string -) - -func init() { - Cmd.Flags().IntVar(&inputIndex, "input-index", -1, - "index of the input") - - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") -} - -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - client := graphql.NewClient(graphqlEndpoint, nil) - - var resp []readerclient.Voucher - var err error - - if cmd.Flags().Changed("input-index") { - resp, err = readerclient.GetInputVouchers(ctx, client, inputIndex) - cobra.CheckErr(err) - } else { - resp, err = readerclient.GetVouchers(ctx, client) - cobra.CheckErr(err) - } - - val, err := json.MarshalIndent(resp, "", " ") - cobra.CheckErr(err) - - fmt.Print(string(val)) -} diff --git a/cmd/cartesi-rollups-cli/root/read/withdrawals/withdrawals.go b/cmd/cartesi-rollups-cli/root/read/withdrawals/withdrawals.go new file mode 100644 index 000000000..49f0b8549 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/read/withdrawals/withdrawals.go @@ -0,0 +1,127 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package withdrawals + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read/service" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "withdrawals [account index]", + Aliases: []string{"withdraws"}, + Short: "Reads post-foreclosure withdrawal events", + Example: examples, + Args: cobra.RangeArgs(1, 2), //nolint:mnd + Run: run, + Long: ` +Arguments: + application name or address + [account index] decimal or hex encoded + +Supported Environment Variables: + CARTESI_JSONRPC_API_URL JSON-RPC API URL + CARTESI_DATABASE_CONNECTION Database connection string`, +} + +const examples = `# Read specific withdrawal by account index: +cartesi-rollups-cli read withdrawals echo-dapp 10 + +# Read all withdrawals: +cartesi-rollups-cli read withdrawals echo-dapp + +# Read all withdrawals with filter: +cartesi-rollups-cli read withdrawals echo-dapp --account-index 10 + +# Read all withdrawals with pagination: +cartesi-rollups-cli read withdrawals echo-dapp --limit 10 --offset 10 --descending +` + +var ( + accountIndex string + limit uint64 + offset uint64 + descending bool +) + +func init() { + Cmd.Flags().StringVar(&accountIndex, "account-index", "", + "Filter withdrawals by account index (decimal or hex encoded)") + Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint:mnd + "Maximum number of withdrawals to return") + Cmd.Flags().Uint64Var(&offset, "offset", 0, + "Starting point for the list of withdrawals") + Cmd.Flags().BoolVar(&descending, "descending", false, + "Sort results in descending order") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + origHelpFunc(command, strings) + }) + + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if limit > jsonrpc.LIST_ITEM_LIMIT { + return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) + } + if limit == 0 { + limit = jsonrpc.LIST_ITEM_LIMIT + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + + useJsonrpc, err := cmd.Flags().GetBool("jsonrpc") + cobra.CheckErr(err) + + readServ, err := service.CreateReadService(ctx, useJsonrpc) + cobra.CheckErr(err) + defer readServ.Close() + + var result json.RawMessage + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. + var params api.GetWithdrawalParams + params.Application = args[0] + params.AccountIndex, err = config.AsHexString(args[1]) + cobra.CheckErr(err) + + result, err = readServ.GetWithdrawal(ctx, params) + } else { + var params api.ListWithdrawalsParams + params.Application = args[0] + + if cmd.Flags().Changed("account-index") { + accountIndexHex, hexErr := config.AsHexString(accountIndex) + cobra.CheckErr(hexErr) + params.AccountIndex = &accountIndexHex + } + params.Limit = limit + params.Offset = offset + params.Descending = descending + + result, err = readServ.ListWithdrawals(ctx, params) + } + cobra.CheckErr(err) + + var out bytes.Buffer + err = json.Indent(&out, result, "", " ") + cobra.CheckErr(err) + out.WriteString("\n") + + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) +} diff --git a/cmd/cartesi-rollups-cli/root/refund/refund.go b/cmd/cartesi-rollups-cli/root/refund/refund.go new file mode 100644 index 000000000..a798031b4 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/refund/refund.go @@ -0,0 +1,229 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package refund submits refunds for deposits in a foreclosed application. +package refund + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "os" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +var Cmd = newCommand() + +type options struct { + inputFile string + skipConfirmation bool + asJSON bool +} + +func newCommand() *cobra.Command { + var opts options + cmd := &cobra.Command{ + Use: "refund ", + Short: "Refund an unfinalized deposit from a foreclosed application", + Args: cobra.ExactArgs(2), //nolint:mnd // Application and input index. + RunE: opts.run, + Long: `Calls IApplication.issueRefund(inputIndex, input). The guardian must have +already foreclosed the application, and consensus must not have finalized +the deposit input. Local ACCEPTED or REJECTED status does not determine +whether the input is finalized. + +Any funded signer can pay the gas. The canonical refund builder sends the +funds to the original depositor recorded by the portal. The gas payer does +not have to be the depositor or guardian and cannot choose a new recipient. +No machine, output, accounts-drive, or account proof is required. + +The application argument accepts a name (resolved through the local database) +or an Ethereum address (no database access). The input index is application-wide, +not epoch-relative, and accepts decimal or 0x-prefixed hexadecimal uint256 values. + +--input-file must contain the complete original InputAdded.input bytes as +0x-prefixed hexadecimal text, with optional surrounding whitespace. Use the +node's data.raw_data field, not decoded_data.payload, the portal payload, +deposit transaction calldata, or JSON. + +The command waits for a successful mined receipt by default. With --no-wait, +it returns after broadcast without confirmation of payment. In that case, +check the receipt, RefundIssued event, and wasRefundForInputIssued on chain. + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Required only for an application name + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Ethereum HTTP endpoint + CARTESI_AUTH_KIND Signer authentication method + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX Derived mnemonic account index`, + Example: `# Export the complete input through the node JSON-RPC API: +cartesi-rollups-cli read inputs echo-dapp 7 --jsonrpc | jq -er '.data.raw_data' > deposit-input.hex + +# Refund after the guardian has foreclosed the application: +cartesi-rollups-cli refund echo-dapp 7 --input-file deposit-input.hex --yes --json`, + } + cmd.Flags().StringVar(&opts.inputFile, "input-file", "", "File containing the complete 0x-prefixed InputAdded.input hex") + cobra.CheckErr(cmd.MarkFlagRequired("input-file")) + cmd.Flags().BoolVarP(&opts.skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Print the transaction hash, application address, and input index as JSON") + cli.AddTransactionFlags(cmd) + origHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + for _, name := range []string{"database-connection", "blockchain-http-endpoint", "gas-limit"} { + if flag := command.Flags().Lookup(name); flag != nil { + flag.Hidden = false + } + } + origHelp(command, args) + }) + return cmd +} + +func (o *options) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + inputIndex, err := parseInputIndex(args[1]) + if err != nil { + return err + } + input, err := loadInput(o.inputFile) + if err != nil { + return err + } + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } + endpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + client, err := cli.DialBlockchain(ctx, endpoint) + if err != nil { + return fmt.Errorf("connect to Ethereum: %w", err) + } + defer client.Close() + chainID, err := client.ChainID(ctx) + if err != nil { + return fmt.Errorf("read chain ID: %w", err) + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return fmt.Errorf("prepare refund signer: %w", err) + } + appContract, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return fmt.Errorf("bind application: %w", err) + } + if !o.skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to refund input %s from application %s\n"+ + " gas payer: %s\n"+ + "The canonical refund builder pays the original depositor; the gas payer cannot change the recipient.\n", + inputIndex, appAddr, txOpts.From) + if err != nil { + return err + } + confirmed, err := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.IssueRefund(opts, inputIndex, input) + }) + if err != nil { + return fmt.Errorf("refund: %w", cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + } + if receipt != nil && !hasMatchingRefundEvent(appContract, receipt, appAddr, inputIndex, input) { + return fmt.Errorf("transaction %s mined, but its receipt has no matching RefundIssued event for input %s", tx.Hash(), inputIndex) + } + if o.asJSON { + result := struct { + cli.TransactionResult + ApplicationAddr common.Address `json:"application_address"` + InputIndex string `json:"input_index"` + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr, InputIndex: inputIndex.String()} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + return fmt.Errorf("write refund result: %w", err) + } + } else { + return cli.WriteTransactionResult(cmd, tx, receipt) + } + return nil +} + +func hasMatchingRefundEvent( + application *iapplication.IApplication, + receipt *types.Receipt, + address common.Address, + inputIndex *big.Int, + input []byte, +) bool { + for _, log := range receipt.Logs { + if log == nil || log.Address != address { + continue + } + event, err := application.ParseRefundIssued(*log) + if err == nil && event.InputIndex.Cmp(inputIndex) == 0 && bytes.Equal(event.Input, input) { + return true + } + } + return false +} + +func parseInputIndex(raw string) (*big.Int, error) { + const uint256Bits = 256 + base, digits := 10, raw + if strings.HasPrefix(raw, "0x") { + base, digits = 16, raw[2:] + } + if digits == "" || strings.HasPrefix(digits, "+") || strings.HasPrefix(digits, "-") { + return nil, fmt.Errorf("invalid input index: expected a decimal or 0x-prefixed uint256") + } + index, ok := new(big.Int).SetString(digits, base) + if !ok || index.Sign() < 0 || index.BitLen() > uint256Bits { + return nil, fmt.Errorf("invalid input index: expected a decimal or 0x-prefixed uint256") + } + return index, nil +} + +func loadInput(path string) ([]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read input file: %w", err) + } + encoded := strings.TrimSpace(string(raw)) + if !strings.HasPrefix(encoded, "0x") { + return nil, fmt.Errorf("invalid input file: expected 0x-prefixed hexadecimal InputAdded.input bytes") + } + input, err := hexutil.Decode(encoded) + if err != nil { + return nil, fmt.Errorf("invalid input file: %w", err) + } + if len(input) == 0 { + return nil, fmt.Errorf("invalid input file: input bytes cannot be empty") + } + return input, nil +} diff --git a/cmd/cartesi-rollups-cli/root/refund/refund_test.go b/cmd/cartesi-rollups-cli/root/refund/refund_test.go new file mode 100644 index 000000000..d2bbf9074 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/refund/refund_test.go @@ -0,0 +1,501 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package refund + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" +) + +const ( + testApplication = "0x0000000000000000000000000000000000000001" + testChainID = 31337 + testGasLimit = 90000 + inputFileFlag = "--input-file" + yesFlag = "--yes" + jsonFlag = "--json" + + maxUint256Decimal = "115792089237316195423570985008687907853269984665640564039457584007913129639935" +) + +func TestParseInputIndex(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"0", "0"}, + {"0x0", "0"}, + {"007", "7"}, + {"0xFF", "255"}, + {"18446744073709551616", "18446744073709551616"}, + {maxUint256Decimal, maxUint256Decimal}, + {"0x" + strings.Repeat("f", 64), maxUint256Decimal}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + index, err := parseInputIndex(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, index.String()) + }) + } + for _, input := range []string{ + "", "-1", "+1", "-0", "0x", "0x-1", "0x+1", "0xgg", "0b10", "1_0", " 7", "7 ", + "0x1" + strings.Repeat("0", 64), + "115792089237316195423570985008687907853269984665640564039457584007913129639936", + } { + t.Run("reject_"+input, func(t *testing.T) { + index, err := parseInputIndex(input) + require.ErrorContains(t, err, "invalid input index") + require.Nil(t, index) + }) + } +} + +func writeInputFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "input.hex") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func originalInput(t *testing.T) []byte { + t.Helper() + inputABI, err := inputs.InputsMetaData.GetAbi() + require.NoError(t, err) + input, err := inputABI.Pack("EvmAdvance", big.NewInt(testChainID), common.HexToAddress(testApplication), + common.HexToAddress("0x2"), big.NewInt(10), big.NewInt(20), big.NewInt(0), big.NewInt(7), []byte{0xaa, 0xbb}) + require.NoError(t, err) + return input +} + +func TestLoadInput(t *testing.T) { + input := originalInput(t) + loaded, err := loadInput(writeInputFile(t, " \t\n"+hexutil.Encode(input)+"\r\n ")) + require.NoError(t, err) + require.Equal(t, input, loaded) + for _, contents := range []string{"", " \n", "0x", "aabb", "0Xaa", "0xabc", "0xgg", "0xaa bb", `"0xaa"`, `{}`} { + t.Run("reject_"+contents, func(t *testing.T) { + loaded, err := loadInput(writeInputFile(t, contents)) + require.ErrorContains(t, err, "invalid input file") + require.Nil(t, loaded) + }) + } + _, err = loadInput(filepath.Join(t.TempDir(), "missing.hex")) + require.ErrorContains(t, err, "read input file") + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestCommandRejectsArgumentsBeforeEthereum(t *testing.T) { + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, "invalid-endpoint") + validFile := writeInputFile(t, hexutil.Encode(originalInput(t))) + tests := []struct { + name string + args []string + want string + }{ + {"missing arguments", nil, "accepts 2 arg(s)"}, + {"missing file flag", []string{testApplication, "7"}, `required flag(s) "input-file" not set`}, + {"invalid application", []string{"0x1234", "7", inputFileFlag, validFile}, "invalid Ethereum address"}, + {"invalid index", []string{testApplication, "bad", inputFileFlag, validFile}, "invalid input index"}, + {"missing file", []string{testApplication, "7", inputFileFlag, filepath.Join(t.TempDir(), "missing")}, "read input file"}, + {"invalid file", []string{testApplication, "7", inputFileFlag, writeInputFile(t, "{}")}, "invalid input file"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(append([]string{}, tt.args...)) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), tt.want) + }) + } +} + +// refundRPC serves only the Ethereum methods used by the real generated binding. +// It never connects to a chain or sends a transaction outside this test server. +type refundRPC struct { + estimateErr error + sendErr error + sent chan *types.Transaction + estimates atomic.Int32 + receipts atomic.Int32 + failed bool + pending bool + logs []*types.Log + emptyCode bool +} + +func (*refundRPC) ChainId(context.Context) (*hexutil.Big, error) { //nolint:revive // RPC method eth_chainId. + return (*hexutil.Big)(big.NewInt(testChainID)), nil +} + +func (*refundRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(10), Difficulty: big.NewInt(1), GasLimit: testGasLimit}, nil +} + +func (*refundRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (r *refundRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + if r.emptyCode { + return hexutil.Bytes{}, nil + } + return hexutil.Bytes{0x01}, nil +} + +func (r *refundRPC) EstimateGas(context.Context, map[string]json.RawMessage) (hexutil.Uint64, error) { + r.estimates.Add(1) + return testGasLimit, r.estimateErr +} + +// The result is any so that a pending receipt is an untyped nil. The RPC server +// calls MarshalJSON directly, and a nil *types.Receipt makes it panic. +func (r *refundRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (any, error) { + r.receipts.Add(1) + if r.pending { + return nil, nil + } + status := uint64(types.ReceiptStatusSuccessful) + if r.failed { + status = types.ReceiptStatusFailed + } + return &types.Receipt{ + TxHash: hash, Status: status, BlockNumber: big.NewInt(10), + Logs: r.logs, GasUsed: testGasLimit, CumulativeGasUsed: testGasLimit, + }, nil +} + +func (*refundRPC) GetTransactionCount(context.Context, common.Address, string) (hexutil.Uint64, error) { + return 0, nil +} + +func (r *refundRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.sent <- &tx + return tx.Hash(), r.sendErr +} + +type refundRevert struct { + data string +} + +func (*refundRevert) Error() string { return "execution reverted" } +func (*refundRevert) ErrorCode() int { return 3 } +func (e *refundRevert) ErrorData() any { return e.data } + +func setupRPC(t *testing.T, service *refundRPC) common.Address { + t.Helper() + config.SetDefaults() + if service.logs == nil { + service.logs = []*types.Log{refundEventLog(t, common.HexToAddress(testApplication), big.NewInt(7), originalInput(t))} + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", service)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + return crypto.PubkeyToAddress(key.PublicKey) +} + +func refundEventLog(t *testing.T, address common.Address, index *big.Int, input []byte) *types.Log { + t.Helper() + parsed, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["RefundIssued"] + data, err := event.Inputs.NonIndexed().Pack(input, []byte{0xab}) + require.NoError(t, err) + return &types.Log{Address: address, Topics: []common.Hash{event.ID, common.BigToHash(index)}, Data: data} +} + +func TestCommandRequiresMatchingRefundEvent(t *testing.T) { + address := common.HexToAddress(testApplication) + input := originalInput(t) + matching := refundEventLog(t, address, big.NewInt(7), input) + wrongEmitter := refundEventLog(t, common.HexToAddress("0x1234"), big.NewInt(7), input) + wrongIndex := refundEventLog(t, address, big.NewInt(8), input) + wrongInput := refundEventLog(t, address, big.NewInt(7), []byte{0x12}) + malformed := &types.Log{Address: address, Topics: matching.Topics, Data: []byte{0x01}} + wrongTopic := &types.Log{Address: address, Topics: []common.Hash{common.HexToHash("0x12")}, Data: matching.Data} + missingIndex := &types.Log{Address: address, Topics: matching.Topics[:1], Data: matching.Data} + for _, test := range []struct { + name string + logs []*types.Log + noWait bool + wantErr bool + }{ + {name: "matching", logs: []*types.Log{matching}}, + {name: "empty receipt at EOA", logs: []*types.Log{}, wantErr: true}, + {name: "wrong emitter", logs: []*types.Log{wrongEmitter}, wantErr: true}, + {name: "wrong index", logs: []*types.Log{wrongIndex}, wantErr: true}, + {name: "wrong input", logs: []*types.Log{wrongInput}, wantErr: true}, + {name: "wrong event", logs: []*types.Log{wrongTopic}, wantErr: true}, + {name: "malformed", logs: []*types.Log{malformed}, wantErr: true}, + {name: "missing index", logs: []*types.Log{missingIndex}, wantErr: true}, + {name: "later match", logs: []*types.Log{nil, wrongEmitter, wrongIndex, wrongInput, malformed, wrongTopic, missingIndex, matching}}, + {name: "no wait with empty receipt", logs: []*types.Log{}, noWait: true}, + } { + t.Run(test.name, func(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), logs: test.logs, emptyCode: len(test.logs) == 0} + setupRPC(t, service) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + t.Setenv(config.DATABASE_CONNECTION, "invalid") + cmd := newCommand() + cmd.SilenceUsage = true + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(input)), yesFlag, jsonFlag} + if test.noWait { + args = append(args, "--no-wait") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.EqualValues(t, 123456, tx.Gas()) + require.Zero(t, service.estimates.Load()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + if test.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.EqualValues(t, 1, service.receipts.Load()) + } + if test.wantErr { + require.ErrorContains(t, err, "no matching RefundIssued event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + status := "mined" + if test.noWait { + status = "broadcast" + } + require.Equal(t, status, result["status"]) + }) + } +} + +func TestCommandSignsOriginalInput(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1)} + signer := setupRPC(t, service) + input := originalInput(t) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "0x7", inputFileFlag, writeInputFile(t, hexutil.Encode(input)), yesFlag, jsonFlag}) + require.NoError(t, cmd.ExecuteContext(t.Context())) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Equal(t, common.HexToAddress(testApplication), *tx.To()) + require.Zero(t, tx.Value().Sign()) + require.Equal(t, uint64(testGasLimit), tx.Gas()) + from, err := types.Sender(types.LatestSignerForChainID(big.NewInt(testChainID)), tx) + require.NoError(t, err) + require.Equal(t, signer, from) + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + method, err := appABI.MethodById(tx.Data()[:4]) + require.NoError(t, err) + require.Equal(t, "issueRefund", method.Name) + args, err := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, err) + require.Equal(t, big.NewInt(7), args[0]) + require.Equal(t, input, args[1]) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, map[string]string{ + "transaction_hash": tx.Hash().Hex(), "application_address": testApplication, "input_index": "7", + "status": "mined", "block_number": "0xa", + }, result) + require.EqualValues(t, 1, service.estimates.Load()) + require.EqualValues(t, 1, service.receipts.Load()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) +} + +func TestCommandReceiptPolicy(t *testing.T) { + for _, tt := range []struct { + name string + manualGas bool + noWait bool + failed bool + pending bool + wantErr string + }{ + {name: "estimated mined"}, + {name: "manual gas mined", manualGas: true}, + {name: "manual gas receipt failure", manualGas: true, failed: true, wantErr: "failed in block"}, + {name: "estimated receipt failure", failed: true, wantErr: "failed in block"}, + {name: "estimated broadcast only", noWait: true, pending: true}, + {name: "manual gas broadcast only", manualGas: true, noWait: true, pending: true}, + {name: "pending timeout", pending: true, wantErr: "outcome unknown"}, + } { + t.Run(tt.name, func(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), failed: tt.failed, pending: tt.pending} + setupRPC(t, service) + if tt.manualGas { + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + args := []string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag, jsonFlag} + if tt.noWait { + args = append(args, "--no-wait") + } + if tt.pending && !tt.noWait { + args = append(args, "--wait-timeout", "20ms") + } + cmd.SetArgs(args) + err := cmd.ExecuteContext(t.Context()) + require.Len(t, service.sent, 1) + tx := <-service.sent + if tt.manualGas { + require.EqualValues(t, 123456, tx.Gas()) + require.Zero(t, service.estimates.Load()) + } else { + require.EqualValues(t, testGasLimit, tx.Gas()) + require.EqualValues(t, 1, service.estimates.Load()) + } + if tt.noWait { + require.Zero(t, service.receipts.Load()) + } else { + require.Positive(t, service.receipts.Load()) + } + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.ErrorContains(t, err, tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) + return + } + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + if tt.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + } + }) + } +} + +// This subprocess keeps Cobra's default writers. SetOut would hide a regression +// from OutOrStdout back to Printf, because Cobra Printf defaults to stderr. +func TestCommandDefaultOutputProcess(t *testing.T) { + if os.Getenv("CARTESI_TEST_REFUND_OUTPUT_PROCESS") != "1" { + return + } + config.SetDefaults() + cmd := newCommand() + for i, arg := range os.Args { + if arg == "--" { + cmd.SetArgs(os.Args[i+1:]) + break + } + } + if err := cmd.ExecuteContext(t.Context()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func TestCommandPlainResultUsesDefaultStdout(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1)} + setupRPC(t, service) + t.Setenv("CARTESI_TEST_REFUND_OUTPUT_PROCESS", "1") + //nolint:gosec // Runs this test binary with fixed arguments and a local fixture path, without a shell. + process := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestCommandDefaultOutputProcess$", "--", + testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + require.NoError(t, process.Run(), stderr.String()) + require.Len(t, service.sent, 1) + tx := <-service.sent + require.Equal(t, "Transaction mined: "+tx.Hash().Hex()+"\n", stdout.String()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.NotContains(t, stderr.String(), "Transaction mined:") +} + +func TestCommandReportsRefundReverts(t *testing.T) { + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + for _, name := range []string{"NotForeclosed", "CannotRefundFinalizedInput", "RefundAlreadyIssued"} { + t.Run(name, func(t *testing.T) { + contractError := appABI.Errors[name] + data := append([]byte(nil), contractError.ID[:4]...) + if len(contractError.Inputs) > 0 { + args, err := contractError.Inputs.Pack(big.NewInt(7)) + require.NoError(t, err) + data = append(data, args...) + } + service := &refundRPC{sent: make(chan *types.Transaction, 1), estimateErr: &refundRevert{data: hexutil.Encode(data)}} + setupRPC(t, service) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "decoded revert: "+name) + require.Empty(t, service.sent) + require.NotContains(t, stdout.String(), "Refund tx-hash") + }) + } +} + +func TestCommandKeepsHashOnBroadcastError(t *testing.T) { + service := &refundRPC{sent: make(chan *types.Transaction, 1), sendErr: errors.New("response unavailable")} + setupRPC(t, service) + cmd := newCommand() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{testApplication, "7", inputFileFlag, writeInputFile(t, hexutil.Encode(originalInput(t))), yesFlag, jsonFlag}) + err := cmd.ExecuteContext(t.Context()) + require.ErrorContains(t, err, "response unavailable") + require.Len(t, service.sent, 1) + tx := <-service.sent + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.NotContains(t, stdout.String(), `"transaction_hash"`) +} diff --git a/cmd/cartesi-rollups-cli/root/root.go b/cmd/cartesi-rollups-cli/root/root.go index 7a612fc69..0258eb11b 100644 --- a/cmd/cartesi-rollups-cli/root/root.go +++ b/cmd/cartesi-rollups-cli/root/root.go @@ -4,34 +4,86 @@ package root import ( - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/deps" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/app" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/contract" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/db" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/deploy" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/deposit" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/execute" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/increasetime" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/foreclose" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/inspect" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/mine" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/provedriveroot" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/read" - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/savesnapshot" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/refund" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/send" "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/validate" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/withdraw" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/version" + "github.com/spf13/cobra" + "github.com/spf13/viper" ) var Cmd = &cobra.Command{ - Use: "cartesi-rollups-cli", - Short: "Command line interface for Cartesi Rollups", - Long: `This command line interface provides functionality to help develop and debug the -Cartesi Rollups node.`, + Use: "cartesi-rollups-cli", + Short: "Command line interface for the Cartesi Rollups Node", + Version: version.BuildVersion, + SilenceUsage: true, } +var ( + verbose bool + databaseConnection string + blockchainEndpoint string + gasLimit uint64 + inputBoxAddress string +) + func init() { + config.SetDefaults() + + // Common persistent flags that will be available to all commands + Cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("verbose")) + + // Database connection flag + Cmd.PersistentFlags().StringVar(&databaseConnection, "database-connection", "", + "Database connection string in the URL format\n(eg.: 'postgres://user:password@hostname:port/database')") + cobra.CheckErr(viper.BindPFlag(config.DATABASE_CONNECTION, Cmd.PersistentFlags().Lookup("database-connection"))) + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("database-connection")) + + // Blockchain endpoint flag + Cmd.PersistentFlags().StringVar(&blockchainEndpoint, "blockchain-http-endpoint", "", + "Blockchain HTTP endpoint") + cobra.CheckErr(viper.BindPFlag(config.BLOCKCHAIN_HTTP_ENDPOINT, Cmd.PersistentFlags().Lookup("blockchain-http-endpoint"))) + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("blockchain-http-endpoint")) + + // Blockchain gas limit + Cmd.PersistentFlags().Uint64Var(&gasLimit, "gas-limit", 0, + "Transaction gas limit. Zero estimates gas; a nonzero value skips estimation.") + cobra.CheckErr(viper.BindPFlag(config.BLOCKCHAIN_GAS_LIMIT, Cmd.PersistentFlags().Lookup("gas-limit"))) + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("gas-limit")) + + // Input box address flag + Cmd.PersistentFlags().StringVar(&inputBoxAddress, "inputbox", "", + "Input Box contract address") + cobra.CheckErr(viper.BindPFlag(config.CONTRACTS_INPUT_BOX_ADDRESS, Cmd.PersistentFlags().Lookup("inputbox"))) + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("inputbox")) + Cmd.AddCommand(send.Cmd) Cmd.AddCommand(read.Cmd) - Cmd.AddCommand(savesnapshot.Cmd) Cmd.AddCommand(inspect.Cmd) - Cmd.AddCommand(increasetime.Cmd) Cmd.AddCommand(validate.Cmd) - Cmd.AddCommand(deps.Cmd) Cmd.AddCommand(execute.Cmd) - Cmd.AddCommand(mine.Cmd) + Cmd.AddCommand(foreclose.Cmd) + Cmd.AddCommand(refund.Cmd) + Cmd.AddCommand(provedriveroot.Cmd) + Cmd.AddCommand(withdraw.Cmd) + Cmd.AddCommand(app.Cmd) + Cmd.AddCommand(db.Cmd) + Cmd.AddCommand(deploy.Cmd) + Cmd.AddCommand(deposit.Cmd) + Cmd.AddCommand(contract.Cmd) Cmd.DisableAutoGenTag = true } diff --git a/cmd/cartesi-rollups-cli/root/root_transaction_test.go b/cmd/cartesi-rollups-cli/root/root_transaction_test.go new file mode 100644 index 000000000..193d9dce3 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/root_transaction_test.go @@ -0,0 +1,194 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "bytes" + "context" + "errors" + "math/big" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +const ( + rootCommandProcess = "CARTESI_TEST_ROOT_COMMAND_PROCESS" + rootTestChainID = 31337 + rootTestGasLimit = 500000 + rootTestPrivateKey = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +) + +// Run each invocation in a fresh process. The real command tree has global +// Cobra flags and Viper bindings. Do not reset or replace either in these tests. +func TestRootCommandProcess(t *testing.T) { + if os.Getenv(rootCommandProcess) != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + Cmd.SetArgs(os.Args[i+1:]) + if err := Cmd.ExecuteContext(t.Context()); err != nil { + os.Exit(1) + } + os.Exit(0) + } + } + t.Fatal("missing subprocess argument separator") +} + +func runRootCommandProcess(t *testing.T, extraEnv []string, args ...string) (string, string, error) { + t.Helper() + executable, err := os.Executable() + require.NoError(t, err) + const processTimeout = 20 * time.Second + ctx, cancel := context.WithTimeout(t.Context(), processTimeout) + defer cancel() + processArgs := append([]string{"-test.run=^TestRootCommandProcess$", "--"}, args...) + process := exec.CommandContext(ctx, executable, processArgs...) + // Each covered child must have its own output directory. Concurrent children + // can otherwise collide when they write coverage metadata at process exit. + process.Env = append(os.Environ(), rootCommandProcess+"=1", "GOCOVERDIR="+t.TempDir()) + process.Env = append(process.Env, extraEnv...) + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + err = process.Run() + return stdout.String(), stderr.String(), err +} + +func TestRootTransactionHelp(t *testing.T) { + t.Parallel() + for _, command := range []string{ + "send", "execute", "deposit erc20", "foreclose", "prove-drive-root", "withdraw", "refund", + "deploy application", "deploy authority", "deploy quorum", + } { + t.Run(command, func(t *testing.T) { + t.Parallel() + args := append(strings.Fields(command), "--help") + stdout, stderr, err := runRootCommandProcess(t, nil, args...) + require.NoError(t, err, stderr) + require.Empty(t, stderr) + require.Contains(t, stdout, "Usage:") + help := strings.Join(strings.Fields(stdout), " ") + require.Contains(t, help, "--gas-limit") + require.Contains(t, help, "Zero estimates gas; a nonzero value skips estimation.") + require.Contains(t, help, "--no-wait") + require.Contains(t, help, "Return after broadcast without checking execution success") + require.Contains(t, help, "--wait-timeout") + require.NotContains(t, help, "--async") + }) + } +} + +func TestRootHelpRemainsAvailable(t *testing.T) { + t.Parallel() + stdout, stderr, err := runRootCommandProcess(t, nil, "--help") + require.NoError(t, err, stderr) + require.Empty(t, stderr) + require.Contains(t, stdout, "Usage:") + require.Contains(t, stdout, "Available Commands:") +} + +func TestRootTransactionErrorsKeepHashWithoutUsage(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + sendErr error + want string + }{ + {name: "broadcast", sendErr: errors.New("broadcast response lost"), want: "broadcast response lost"}, + {name: "mined failure", want: "receipt status 0"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + backend := &rootTransactionRPC{sendErr: test.sendErr, sent: make(chan *types.Transaction, 1)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + inputFile := filepath.Join(t.TempDir(), "input.hex") + require.NoError(t, os.WriteFile(inputFile, []byte("0xaabb\n"), 0o600)) + application := common.HexToAddress("0x01") + stdout, stderr, err := runRootCommandProcess(t, []string{ + config.AUTH_KIND + "=private_key", + config.AUTH_PRIVATE_KEY + "=" + rootTestPrivateKey, + }, "refund", application.Hex(), "7", "--input-file", inputFile, + "--blockchain-http-endpoint", httpServer.URL, "--gas-limit", "500000", "--yes", "--json") + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode(), stderr) + require.Empty(t, stdout, "a failed transaction must not produce a success result") + require.Len(t, backend.sent, 1, stderr) + tx := <-backend.sent + require.Equal(t, &application, tx.To()) + require.EqualValues(t, rootTestGasLimit, tx.Gas()) + _, err = types.Sender(types.LatestSignerForChainID(big.NewInt(rootTestChainID)), tx) + require.NoError(t, err, "the provider must receive a signed transaction") + appABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + calldata, err := appABI.Pack("issueRefund", big.NewInt(7), []byte{0xaa, 0xbb}) + require.NoError(t, err) + require.Equal(t, calldata, tx.Data()) + require.Contains(t, stderr, "Transaction hash: "+tx.Hash().Hex()) + require.Contains(t, stderr, "Error: refund:") + require.Contains(t, stderr, test.want) + require.GreaterOrEqual(t, strings.Count(stderr, tx.Hash().Hex()), 2, + "the returned error must retain the hash in addition to the pre-broadcast diagnostic") + require.NotContains(t, stderr, "Usage:", "runtime errors must not dump command help") + }) + } +} + +// This local RPC server lets the actual refund binding sign and submit its +// calldata. It reports either a lost broadcast response or a failed receipt. +// No request reaches a blockchain or a database. +type rootTransactionRPC struct { + sendErr error + sent chan *types.Transaction +} + +func (*rootTransactionRPC) ChainId(context.Context) hexutil.Uint64 { //nolint:revive // RPC method eth_chainId. + return rootTestChainID +} + +func (*rootTransactionRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(1), GasLimit: rootTestGasLimit} +} + +func (*rootTransactionRPC) GasPrice(context.Context) hexutil.Uint64 { return 1 } + +func (*rootTransactionRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 0 +} + +func (r *rootTransactionRPC) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + select { + case r.sent <- tx: + default: + return tx.Hash(), errors.New("unexpected repeated broadcast") + } + return tx.Hash(), r.sendErr +} + +func (*rootTransactionRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(1), Logs: []*types.Log{}} +} diff --git a/cmd/cartesi-rollups-cli/root/savesnapshot/savesnapshot.go b/cmd/cartesi-rollups-cli/root/savesnapshot/savesnapshot.go deleted file mode 100644 index e9f400ea0..000000000 --- a/cmd/cartesi-rollups-cli/root/savesnapshot/savesnapshot.go +++ /dev/null @@ -1,42 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package savesnapshot - -import ( - "github.com/cartesi/rollups-node/internal/machine" - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "save-snapshot", - Short: "Saves the testing Cartesi machine snapshot to the designated folder", - Example: examples, - Run: run, -} - -const examples = `# Save the default Rollups Echo Application snapshot: -cartesi-rollups-cli save-snapshot` - -var ( - sourceDockerImage string - tempContainerName string - destDir string -) - -func init() { - Cmd.Flags().StringVar(&sourceDockerImage, "docker-image", - "cartesi/rollups-node-snapshot:devel", - "Docker image containing the Cartesi Machine snapshot to be used") - - Cmd.Flags().StringVar(&tempContainerName, "temp-container-name", "temp-machine", - "Name of the temporary container needed to extract the machine snapshot files") - - Cmd.Flags().StringVar(&destDir, "dest-dir", "./machine-snapshot", - "directory where to store the Cartesi Machine snapshot to be used by the local Node") -} - -func run(cmd *cobra.Command, args []string) { - err := machine.Save(sourceDockerImage, destDir, tempContainerName) - cobra.CheckErr(err) -} diff --git a/cmd/cartesi-rollups-cli/root/send/send.go b/cmd/cartesi-rollups-cli/root/send/send.go index d44554eb2..690524e3d 100644 --- a/cmd/cartesi-rollups-cli/root/send/send.go +++ b/cmd/cartesi-rollups-cli/root/send/send.go @@ -4,75 +4,226 @@ package send import ( - "log/slog" - - "github.com/cartesi/rollups-node/pkg/addresses" - "github.com/cartesi/rollups-node/pkg/ethutil" + "encoding/json" + "fmt" + "io" + "math/big" + "os" + "strings" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/core/types" "github.com/spf13/cobra" ) var Cmd = &cobra.Command{ - Use: "send", - Short: "Send a rollups input to the Ethereum node", + Use: "send [app-name-or-address] [payload]", + Short: "Sends a rollups input transaction to the ethereum provider", Example: examples, - Run: run, + Args: cobra.MinimumNArgs(1), + PreRunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("inputbox") { + return fmt.Errorf("--inputbox is not supported by send; the application selects its InputBox") + } + return nil + }, + RunE: run, + Long: ` +Send to the InputBox selected by the Application contract. +An application address does not require a database. An application name does. +The global InputBox setting does not select the input destination. + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint`, } -const examples = `# Send the string "hi" encoded as hex: -cartesi-rollups-cli send --payload 0x$(printf "hi" | xxd -p)` +const examples = `# Send the string "hi": +cartesi-rollups-cli send echo-dapp "hi" + +# Send the string "hi" encoded as hex: +cartesi-rollups-cli send echo-dapp 0x6869 --hex + +# Read from stdin: +echo "hi" | cartesi-rollups-cli send echo-dapp --yes + +# Skip confirmation prompt: +cartesi-rollups-cli send echo-dapp "hi" --yes` var ( - ethEndpoint string - mnemonic string - account uint32 - hexPayload string - addressBookFile string + isHex bool + skipConfirmation bool + asJSONParam bool ) func init() { - Cmd.Flags().StringVar(ðEndpoint, "eth-endpoint", "http://localhost:8545", - "ethereum node JSON-RPC endpoint") + Cmd.Flags().BoolVarP(&isHex, "hex", "x", false, "Force interpretation of payload as hex.") + Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + command.Flags().Lookup("blockchain-http-endpoint").Hidden = false + origHelpFunc(command, strings) + }) +} - Cmd.Flags().StringVar(&mnemonic, "mnemonic", ethutil.FoundryMnemonic, - "mnemonic used to sign the transaction") +func resolvePayload(args []string) ([]byte, error) { + // If we have exactly one argument (just the app name/address), read from stdin + if len(args) == 1 { + stdinBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("failed to read from stdin: %w", err) + } + if isHex { + return decodeHex(string(stdinBytes)) + } + return stdinBytes, nil + } + // Otherwise, use the second argument as payload + if isHex { + return decodeHex(args[1]) + } + return []byte(args[1]), nil +} - Cmd.Flags().Uint32Var(&account, "account", 0, - "account index used to sign the transaction (default: 0)") +func decodeHex(s string) ([]byte, error) { + if !strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X") { + s = "0x" + s + } - Cmd.Flags().StringVar(&hexPayload, "payload", "", - "input payload hex-encoded starting with 0x") + b, err := hexutil.Decode(s) + if err != nil { + return nil, fmt.Errorf("invalid hex payload %q: %w", s, err) + } + return b, nil +} - cobra.CheckErr(Cmd.MarkFlagRequired("payload")) +func run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() - Cmd.Flags().StringVar(&addressBookFile, "address-book", "", - "if set, load the address book from the given file; else, use test addresses") -} + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } -func run(cmd *cobra.Command, args []string) { - payload, err := hexutil.Decode(hexPayload) - cobra.CheckErr(err) + appAddress, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } - ctx := cmd.Context() - client, err := ethclient.DialContext(ctx, ethEndpoint) - cobra.CheckErr(err) - slog.Info("Connected", "eth-endpoint", ethEndpoint) + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } - signer, err := ethutil.NewMnemonicSigner(ctx, client, mnemonic, account) - cobra.CheckErr(err) + // Check if stdin is being used for payload and --yes flag is not set + if len(args) == 1 && !skipConfirmation && !cli.IsTerminal(os.Stdin) { + return fmt.Errorf("reading payload from stdin: use --yes to skip confirmation when piping data") + } - var book *addresses.Book - if addressBookFile != "" { - book, err = addresses.GetBookFromFile(addressBookFile) - cobra.CheckErr(err) - } else { - book = addresses.GetTestBook() + payload, err := resolvePayload(args) + if err != nil { + return err } - slog.Info("Sending input", "application-address", book.CartesiDApp) - inputIndex, err := ethutil.AddInput(ctx, client, book, signer, payload) - cobra.CheckErr(err) + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return err + } + defer client.Close() + application, err := iapplication.NewIApplication(appAddress, client) + if err != nil { + return err + } + inputBoxAddress, err := application.GetInputBox(&bind.CallOpts{Context: ctx}) + if err != nil { + return fmt.Errorf("read application InputBox: %w", cli.DecorateRevert(err, iapplication.IApplicationMetaData)) + } + if inputBoxAddress == (common.Address{}) { + return fmt.Errorf("application %s returned the zero InputBox address", appAddress) + } - slog.Info("Input added", "input-index", inputIndex) + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return err + } + + // Ask for confirmation unless --yes flag is set + if !skipConfirmation { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to send input to application %v with account %v\n", + appAddress, txOpts.From) + if err != nil { + return err + } + + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to proceed?") + if promptErr != nil { + return promptErr + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled") + return err + } + } + + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) + if err != nil { + return err + } + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return inputBox.AddInput(opts, appAddress, payload) + }) + if err != nil { + return cli.DecorateRevert(err, iinputbox.IInputBoxMetaData, iapplication.IApplicationMetaData) + } + + result := cli.SendResult{ + TransactionResult: cli.NewTransactionResult(tx, receipt), + ApplicationAddress: appAddress.Hex(), + } + var inputIndex *big.Int + if receipt != nil { + for _, log := range receipt.Logs { + if log.Address != inputBoxAddress { + continue + } + event, err := inputBox.ParseInputAdded(*log) + if err == nil && event.AppContract == appAddress { + inputIndex = event.Index + result.InputIndex = hexutil.EncodeBig(event.Index) + break + } + } + if result.InputIndex == "" { + return fmt.Errorf("transaction %s mined, but its receipt has no matching InputAdded event", tx.Hash()) + } + } + if asJSONParam { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + if receipt != nil { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "Input sent to app at %s. Index: %s BlockNumber: %s Tx-hash: %s\n", + appAddress, inputIndex.String(), receipt.BlockNumber.String(), result.TransactionHash) + return err + } + return cli.WriteTransactionResult(cmd, tx, receipt) } diff --git a/cmd/cartesi-rollups-cli/root/send/send_test.go b/cmd/cartesi-rollups-cli/root/send/send_test.go new file mode 100644 index 000000000..0e9d60b17 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/send/send_test.go @@ -0,0 +1,295 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package send_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root/send" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +const ( + sendTestGas = 90000 + jsonFlag = "--json" + noWaitFlag = "--no-wait" + gasLimitFlag = "--gas-limit" +) + +func TestSendUsesApplicationInputBox(t *testing.T) { + t.Run("address without database", func(t *testing.T) { runSendInputBoxTest(t, false) }) + t.Run("name from database", func(t *testing.T) { runSendInputBoxTest(t, true) }) +} + +func runSendInputBoxTest(t *testing.T, byName bool) { + t.Helper() + inputBox := common.HexToAddress("0xb2") + app := &model.Application{ + Name: "send-inputbox", IApplicationAddress: common.HexToAddress("0xc3"), + IConsensusAddress: common.HexToAddress("0xd4"), IInputBoxAddress: common.HexToAddress("0xf6"), + EpochLength: 10, ConsensusType: model.Consensus_Authority, Status: model.ApplicationStatus_OK, + } + appReference := app.IApplicationAddress.Hex() + t.Setenv(config.DATABASE_CONNECTION, "invalid") + if byName { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + release, err := db.LockTestPostgres(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(release) + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := factory.NewRepositoryFromConnectionString(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + _, err = repo.CreateApplication(t.Context(), app, false) + require.NoError(t, err) + t.Setenv(config.DATABASE_CONNECTION, endpoint) + appReference = app.Name + } + globalBox := common.HexToAddress("0xa1") + t.Setenv(config.CONTRACTS_INPUT_BOX_ADDRESS, globalBox.Hex()) + key, err := crypto.GenerateKey() + require.NoError(t, err) + t.Setenv(config.AUTH_KIND, "private_key") + t.Setenv(config.AUTH_PRIVATE_KEY, hexutil.Encode(crypto.FromECDSA(key))) + t.Setenv(config.BLOCKCHAIN_GAS_LIMIT, "0") + applicationABI, err := iapplication.IApplicationMetaData.GetAbi() + require.NoError(t, err) + getter := applicationABI.Methods["getInputBox"] + + parsed, err := iinputbox.IInputBoxMetaData.GetAbi() + require.NoError(t, err) + event := parsed.Events["InputAdded"] + data, err := event.Inputs.NonIndexed().Pack([]byte("encoded input")) + require.NoError(t, err) + inputLog := func(box, application common.Address, index int64) *types.Log { + return &types.Log{Address: box, Data: data, Topics: []common.Hash{ + event.ID, common.BytesToHash(application.Bytes()), common.BigToHash(big.NewInt(index)), + }} + } + for _, test := range []struct { + name string + flags []string + missingEvent bool + noWait bool + json bool + zeroInputBox bool + manualGas bool + }{ + {name: "decimal text"}, + {name: "hex JSON", flags: []string{jsonFlag}, json: true}, + {name: "broadcast only", flags: []string{jsonFlag, noWaitFlag}, json: true, noWait: true}, + {name: "reject unrelated events", missingEvent: true}, + {name: "manual gas", flags: []string{gasLimitFlag, "123456"}, manualGas: true}, + {name: "manual gas broadcast only", flags: []string{jsonFlag, noWaitFlag, gasLimitFlag, "123456"}, + json: true, noWait: true, manualGas: true}, + {name: "reject zero InputBox", zeroInputBox: true}, + {name: "reject zero InputBox with manual gas", flags: []string{gasLimitFlag, "123456"}, zeroInputBox: true, manualGas: true}, + {name: "reject zero InputBox without waiting", flags: []string{noWaitFlag}, zeroInputBox: true, noWait: true}, + {name: "reject zero InputBox with manual gas without waiting", flags: []string{noWaitFlag, gasLimitFlag, "123456"}, + zeroInputBox: true, noWait: true, manualGas: true}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &sendRPC{ + sent: make(chan *types.Transaction, 2), application: app.IApplicationAddress, + inputBox: inputBox, getterID: getter.ID, + logs: []*types.Log{ + inputLog(globalBox, app.IApplicationAddress, 1), + inputLog(inputBox, common.HexToAddress("0xe5"), 2), + }, + } + if !test.missingEvent { + backend.logs = append(backend.logs, inputLog(inputBox, app.IApplicationAddress, 17)) + } + if test.zeroInputBox { + backend.inputBox = common.Address{} + // Reject the destination before reading signer configuration. + t.Setenv(config.AUTH_KIND, "invalid-signer") + } + rpcServer := rpc.NewServer() + require.NoError(t, rpcServer.RegisterName("eth", backend)) + server := httptest.NewServer(rpcServer) + t.Cleanup(server.Close) + t.Cleanup(rpcServer.Stop) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, server.URL) + resetSendCommand(t) + var stdout, stderr bytes.Buffer + root.Cmd.SetOut(&stdout) + root.Cmd.SetErr(&stderr) + root.Cmd.SetArgs(append([]string{"send", appReference, "hello", "--yes"}, test.flags...)) + err := root.Cmd.ExecuteContext(t.Context()) + if test.zeroInputBox { + require.ErrorContains(t, err, "zero InputBox address") + require.EqualValues(t, 1, backend.getterCalls.Load()) + require.Zero(t, backend.chainReads.Load()) + require.Zero(t, backend.estimates.Load()) + require.Zero(t, backend.receipts.Load()) + require.Empty(t, backend.sent) + require.Empty(t, stdout.String()) + return + } + require.Len(t, backend.sent, 1, stderr.String()) + tx := <-backend.sent + require.Equal(t, inputBox, *tx.To(), "the contract selects the InputBox, not global or cached configuration") + require.EqualValues(t, 1, backend.getterCalls.Load()) + method := parsed.Methods["addInput"] + require.Equal(t, method.ID, tx.Data()[:4]) + args, decodeErr := method.Inputs.Unpack(tx.Data()[4:]) + require.NoError(t, decodeErr) + require.Equal(t, app.IApplicationAddress, args[0]) + require.Equal(t, []byte("hello"), args[1]) + if test.manualGas { + require.Zero(t, backend.estimates.Load()) + require.EqualValues(t, 123456, tx.Gas()) + } else { + require.EqualValues(t, 1, backend.estimates.Load()) + } + if test.noWait { + require.Zero(t, backend.receipts.Load()) + } else { + require.EqualValues(t, 1, backend.receipts.Load()) + } + if test.missingEvent { + require.ErrorContains(t, err, "no matching InputAdded event") + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Empty(t, stdout.String()) + return + } + require.NoError(t, err, stderr.String()) + if !test.json { + require.Contains(t, stdout.String(), "Index: 17 BlockNumber: 16") + require.Contains(t, stdout.String(), tx.Hash().Hex()) + return + } + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.Equal(t, tx.Hash().Hex(), result["transaction_hash"]) + if test.noWait { + require.Equal(t, "broadcast", result["status"]) + require.NotContains(t, result, "input_index") + require.NotContains(t, result, "block_number") + } else { + require.Equal(t, "mined", result["status"]) + require.Equal(t, "0x11", result["input_index"]) + require.Equal(t, "0x10", result["block_number"]) + } + }) + } +} + +func TestSendRejectsInputBoxOverride(t *testing.T) { + resetSendCommand(t) + // An unusable DSN proves the flag fails before database or chain work. + t.Setenv(config.DATABASE_CONNECTION, "invalid") + var stdout, stderr bytes.Buffer + root.Cmd.SetOut(&stdout) + root.Cmd.SetErr(&stderr) + root.Cmd.SetArgs([]string{"send", "example", "hello", "--inputbox", common.HexToAddress("0xa1").Hex()}) + require.ErrorContains(t, root.Cmd.ExecuteContext(t.Context()), "--inputbox is not supported by send") + require.Empty(t, stdout.String()) +} + +func resetSendCommand(t *testing.T) { + t.Helper() + for _, flags := range []*pflag.FlagSet{root.Cmd.PersistentFlags(), send.Cmd.Flags()} { + flags.VisitAll(func(flag *pflag.Flag) { + require.NoError(t, flag.Value.Set(flag.DefValue)) + flag.Changed = false + }) + } + send.Cmd.SetContext(t.Context()) + t.Cleanup(func() { + root.Cmd.SetOut(nil) + root.Cmd.SetErr(nil) + }) +} + +type sendRPC struct { + sent chan *types.Transaction + logs []*types.Log + application common.Address + inputBox common.Address + getterID []byte + getterCalls atomic.Int32 + chainReads atomic.Int32 + receipts atomic.Int32 + estimates atomic.Int32 +} + +type sendCall struct { + To common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` +} + +func (b *sendRPC) Call(_ context.Context, call sendCall, block string) (hexutil.Bytes, error) { + if call.To != b.application || !bytes.Equal(call.Input, b.getterID) || block != "latest" { + return nil, fmt.Errorf("unexpected application getter: %+v at %s", call, block) + } + b.getterCalls.Add(1) + return common.LeftPadBytes(b.inputBox.Bytes(), common.HashLength), nil +} + +func (b *sendRPC) ChainId(context.Context) *hexutil.Big { //nolint:revive // Ethereum RPC method eth_chainId. + b.chainReads.Add(1) + return (*hexutil.Big)(big.NewInt(31337)) +} + +func (*sendRPC) GetBlockByNumber(context.Context, string, bool) *types.Header { + return &types.Header{Number: big.NewInt(16), Difficulty: big.NewInt(1), GasLimit: sendTestGas} +} + +func (*sendRPC) GasPrice(context.Context) *hexutil.Big { return (*hexutil.Big)(big.NewInt(1)) } + +func (*sendRPC) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { return 0 } + +func (*sendRPC) GetCode(context.Context, common.Address, string) hexutil.Bytes { + return hexutil.Bytes{1} +} + +func (b *sendRPC) EstimateGas(context.Context, map[string]json.RawMessage) hexutil.Uint64 { + b.estimates.Add(1) + return sendTestGas +} + +func (b *sendRPC) SendRawTransaction(ctx context.Context, data hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + select { + case b.sent <- tx: + case <-ctx.Done(): + return common.Hash{}, ctx.Err() + } + return tx.Hash(), nil +} + +func (b *sendRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) *types.Receipt { + b.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(16), + Logs: b.logs, GasUsed: sendTestGas, CumulativeGasUsed: sendTestGas} +} diff --git a/cmd/cartesi-rollups-cli/root/validate/validate.go b/cmd/cartesi-rollups-cli/root/validate/validate.go index fcca2fc79..1509d13b5 100644 --- a/cmd/cartesi-rollups-cli/root/validate/validate.go +++ b/cmd/cartesi-rollups-cli/root/validate/validate.go @@ -4,89 +4,110 @@ package validate import ( - "log/slog" + "encoding/json" + "fmt" "os" - "github.com/Khan/genqlient/graphql" - "github.com/cartesi/rollups-node/pkg/addresses" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/ethutil" - "github.com/cartesi/rollups-node/pkg/readerclient" - "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/cobra" ) var Cmd = &cobra.Command{ - Use: "validate", + Use: "validate [app-name-or-address] [output-index]", Short: "Validates a notice", Example: examples, + Args: cobra.ExactArgs(2), //nolint:mnd Run: run, + Long: ` +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection string + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint`, } -const examples = `# Validates notice 5 from input 6: -cartesi-rollups-cli validate --notice-index 5 --input-index 6` +const examples = `# Validates output with index 5: +cartesi-rollups-cli validate echo-dapp 5 -var ( - noticeIndex int - inputIndex int - graphqlEndpoint string - ethEndpoint string - addressBookFile string -) +# Validates output with index 3 using application address: +cartesi-rollups-cli validate 0x1234567890123456789012345678901234567890 3` -func init() { - Cmd.Flags().IntVar(¬iceIndex, "notice-index", 0, - "index of the notice") +var asJSONParam bool - cobra.CheckErr(Cmd.MarkFlagRequired("notice-index")) +func init() { + Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + command.Flags().Lookup("blockchain-http-endpoint").Hidden = false + origHelpFunc(command, strings) + }) +} - Cmd.Flags().IntVar(&inputIndex, "input-index", 0, - "index of the input") +func run(cmd *cobra.Command, args []string) { + ctx := cmd.Context() - cobra.CheckErr(Cmd.MarkFlagRequired("input-index")) + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + cobra.CheckErr(err) - Cmd.Flags().StringVar(&graphqlEndpoint, "graphql-endpoint", "http://localhost:10000/graphql", - "address used to connect to graphql") + outputIndex, err := config.ToUint64FromDecimalOrHexString(args[1]) + cobra.CheckErr(err) - Cmd.Flags().StringVar(ðEndpoint, "eth-endpoint", "http://localhost:8545", - "ethereum node JSON-RPC endpoint") + dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) - Cmd.Flags().StringVar(&addressBookFile, "address-book", "", - "if set, load the address book from the given file; else, use test addresses") -} + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + cobra.CheckErr(err) -func run(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - graphqlClient := graphql.NewClient(graphqlEndpoint, nil) + repo, err := cli.OpenRepository(ctx, dsn) + cobra.CheckErr(err) + defer repo.Close() - resp, err := readerclient.GetNotice(ctx, graphqlClient, noticeIndex, inputIndex) + output, err := repo.GetOutput(ctx, nameOrAddress, outputIndex) cobra.CheckErr(err) - if resp.Proof == nil { - slog.Warn("The notice has no associated proof yet") - os.Exit(0) + if output == nil { + fmt.Fprintf(os.Stderr, "The output with index %d was not found in the database\n", outputIndex) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } - client, err := ethclient.DialContext(ctx, ethEndpoint) + app, err := repo.GetApplication(ctx, nameOrAddress) cobra.CheckErr(err) - slog.Info("Connected", "eth-endpoint", ethEndpoint) - var book *addresses.Book - if addressBookFile != "" { - book, err = addresses.GetBookFromFile(addressBookFile) - cobra.CheckErr(err) - } else { - book = addresses.GetTestBook() + if len(output.OutputHashesSiblings) == 0 { + fmt.Fprintf(os.Stderr, "The output with index %d has no associated proof yet\n", outputIndex) + repo.Close() + os.Exit(0) } - proof := readerclient.ConvertToContractProof(resp.Proof) + client, err := cli.DialBlockchain(ctx, ethEndpoint) + cobra.CheckErr(err) - slog.Info("Validating notice", - "notice-index", noticeIndex, - "input-index", inputIndex, - "application-address", book.CartesiDApp, + if !asJSONParam { + fmt.Printf("Validating output app: %v (%v) output_index: %v\n", + app.Name, app.IApplicationAddress, outputIndex) + } + err = ethutil.ValidateOutput( + ctx, + client, + app.IApplicationAddress, + outputIndex, + output.RawData, + output.OutputHashesSiblings, ) - err = ethutil.ValidateNotice(ctx, client, book, resp.Payload, proof) - cobra.CheckErr(err) + cobra.CheckErr(cli.DecorateRevert(err, iapplication.IApplicationMetaData)) - slog.Info("Notice validated") + if asJSONParam { + result := cli.ValidateResult{Valid: true} + jsonBytes, err := json.MarshalIndent(&result, "", " ") + cobra.CheckErr(err) + fmt.Println(string(jsonBytes)) + } else { + fmt.Println("Output validated!") + } } diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go new file mode 100644 index 000000000..3d72b308b --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go @@ -0,0 +1,247 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package withdraw + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "os" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +var Cmd = &cobra.Command{ + Use: "withdraw [app-name-or-address]", + Short: "Withdraw the funds of a single account from a foreclosed application", + Example: examples, + Args: cobra.ExactArgs(1), + RunE: run, + Long: ` +Calls IApplication.withdraw(account, AccountValidityProof). The signer is just +the gas-payer; the recipient of the funds is encoded inside the 'account' +bytes per the application's WithdrawalOutputBuilder convention. The same +wallet that pays gas does NOT need to match (or own) the account being +withdrawn — they can be different. + +The [app-name-or-address] argument accepts EITHER an application name +(looked up in the local rollups-node database) OR an Ethereum address (used +directly without any DB access — useful on remote/reader hosts). + +The proof data is consumed verbatim from --proof-file (JSON). Suggested shape: + + { + "account": "0x... bytes ...", + "account_index": "0x...", + "account_root_siblings": ["0x...", "0x...", ...] + } + +Supported Environment Variables: + CARTESI_DATABASE_CONNECTION Database connection (only when an app name is passed) + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT Blockchain HTTP endpoint + CARTESI_AUTH_MNEMONIC, CARTESI_AUTH_PRIVATE_KEY, CARTESI_AUTH_AWS_KMS_KEY_ID signer + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX derived account index (mnemonic auth)`, +} + +const examples = `# Withdraw one account from a foreclosed application: +cartesi-rollups-cli withdraw echo-dapp --proof-file ./account-proof.json + +# Skip the confirmation prompt: +cartesi-rollups-cli withdraw echo-dapp --proof-file ./account-proof.json --yes` + +type withdrawProofJSON struct { + Account string `json:"account"` + AccountIndex string `json:"account_index"` + AccountRootSiblings []string `json:"account_root_siblings"` +} + +var ( + proofFileParam string + skipConfirmation bool + asJSONParam bool +) + +func init() { + Cmd.Flags().StringVar(&proofFileParam, "proof-file", "", + "Path to the JSON account proof file emitted by the proof generation tool") + cobra.CheckErr(Cmd.MarkFlagRequired("proof-file")) + Cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt") + Cmd.Flags().BoolVar(&asJSONParam, "json", false, "Print result as JSON") + cli.AddTransactionFlags(Cmd) + + origHelpFunc := Cmd.HelpFunc() + Cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + command.Flags().Lookup("verbose").Hidden = false + command.Flags().Lookup("database-connection").Hidden = false + command.Flags().Lookup("blockchain-http-endpoint").Hidden = false + origHelpFunc(command, strings) + }) +} + +func run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + nameOrAddress, err := config.ToApplicationNameOrAddressFromString(args[0]) + if err != nil { + return err + } + + account, proof, err := loadProof(proofFileParam) + if err != nil { + return err + } + + appAddr, err := util.ResolveApplicationAddress(ctx, nameOrAddress) + if err != nil { + return err + } + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + if err != nil { + return err + } + client, err := cli.DialBlockchain(ctx, ethEndpoint) + if err != nil { + return err + } + defer client.Close() + + chainID, err := client.ChainID(ctx) + if err != nil { + return err + } + txOpts, err := cli.GetTransactOpts(ctx, chainID) + if err != nil { + return err + } + + appContract, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return err + } + + // Identify the WithdrawalOutputBuilder and try to surface a decoded + // recipient + amount. A hand-edit that flips a few characters in + // `account` would otherwise produce a self-consistent proof against + // the wrong recipient and the withdraw would silently succeed. + builderAddr, err := appContract.GetWithdrawalOutputBuilder(&bind.CallOpts{Context: ctx}) + if err != nil { + return err + } + accountDesc, matched, err := ethutil.DescribeWithdrawalAccount(ctx, client, builderAddr, account) + if err != nil { + return err + } + + if !matched { + // Unknown builder family. Print the raw bytes so the operator can + // verify character-for-character, and force interactive + // confirmation even when --yes is set. + _, err := fmt.Fprintf(cmd.ErrOrStderr(), + "WARNING: builder %s is not a recognized WithdrawalOutputBuilder family.\n"+ + " The recipient cannot be auto-decoded. Verify the bytes below\n"+ + " match your intended account before confirming; --yes is ignored.\n%s", + builderAddr, hex.Dump(account)) + if err != nil { + return err + } + } + + if !skipConfirmation || !matched { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Preparing to withdraw an account from application %v\n"+ + " gas-payer: %v (does NOT have to be the funds recipient)\n"+ + " withdrawal builder: %v\n"+ + " account size: %d bytes\n"+ + " account index: %d\n"+ + " proof siblings: %d\n", + appAddr, txOpts.From, builderAddr, + len(account), proof.AccountIndex, len(proof.AccountRootSiblings)) + if err != nil { + return err + } + if matched { + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), accountDesc); err != nil { + return err + } + } + confirmed, promptErr := cli.ConfirmPromptTo(cmd.ErrOrStderr(), "Do you want to continue?") + if promptErr != nil { + return promptErr + } + if !confirmed { + _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Transaction cancelled") + return err + } + } + + tx, receipt, err := cli.Transact(ctx, cmd, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return appContract.Withdraw(opts, account, proof) + }) + if err != nil { + return cli.DecorateRevert(err, iapplication.IApplicationMetaData) + } + + if asJSONParam { + result := struct { + cli.TransactionResult + ApplicationAddr common.Address `json:"application_address"` + }{TransactionResult: cli.NewTransactionResult(tx, receipt), ApplicationAddr: appAddr} + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + return cli.WriteTransactionResult(cmd, tx, receipt) +} + +func loadProof(path string) ([]byte, iapplication.AccountValidityProof, error) { + zero := iapplication.AccountValidityProof{} + raw, err := os.ReadFile(path) + if err != nil { + return nil, zero, fmt.Errorf("read proof file %s: %w", path, err) + } + var aux withdrawProofJSON + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&aux); err != nil { + return nil, zero, fmt.Errorf("parse proof file %s: %w", path, err) + } + + account, err := hexutil.Decode(aux.Account) + if err != nil { + return nil, zero, fmt.Errorf("invalid account: %w", err) + } + + idx, err := hexutil.DecodeUint64(aux.AccountIndex) + if err != nil { + return nil, zero, fmt.Errorf("invalid account_index: %w", err) + } + + siblings := make([][32]byte, len(aux.AccountRootSiblings)) + for i, s := range aux.AccountRootSiblings { + b, err := hexutil.Decode(s) + if err != nil { + return nil, zero, fmt.Errorf("invalid account_root_siblings[%d]: %w", i, err) + } + if len(b) != 32 { //nolint:mnd + return nil, zero, fmt.Errorf( + "account_root_siblings[%d] must be 32 bytes, got %d", i, len(b)) + } + copy(siblings[i][:], b) + } + return account, iapplication.AccountValidityProof{ + AccountIndex: idx, + AccountRootSiblings: siblings, + }, nil +} diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw_test.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw_test.go new file mode 100644 index 000000000..337eb5a82 --- /dev/null +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw_test.go @@ -0,0 +1,140 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package withdraw + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// loadProof tests pin the JSON proof-file parser used by `withdraw`. The +// parser is the last sanity gate before a fund-moving tx is constructed — +// a malformed account or proof must abort with a clear error, never +// silently produce a self-consistent garbage proof. + +const validWithdrawProofJSON = `{ + "account": "0xaabbccdd", + "account_index": "0x7", + "account_root_siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002" + ] +}` + +func writeProofFile(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "proof.json") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestLoadProof_Valid(t *testing.T) { + path := writeProofFile(t, validWithdrawProofJSON) + account, proof, err := loadProof(path) + require.NoError(t, err) + require.Equal(t, []byte{0xaa, 0xbb, 0xcc, 0xdd}, account) + require.Equal(t, uint64(7), proof.AccountIndex) + require.Len(t, proof.AccountRootSiblings, 2) + require.Equal(t, byte(0x01), proof.AccountRootSiblings[0][31]) + require.Equal(t, byte(0x02), proof.AccountRootSiblings[1][31]) +} + +func TestLoadProof_FileNotFound(t *testing.T) { + _, _, err := loadProof("/nonexistent/path/proof.json") + require.Error(t, err) + require.Contains(t, err.Error(), "read proof file") +} + +func TestLoadProof_InvalidJSON(t *testing.T) { + path := writeProofFile(t, `{not valid json`) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_UnknownField(t *testing.T) { + body := `{ + "account": "0x00", + "account_index": "0x0", + "account_root_siblings": [], + "extra_field": "this should not be accepted" + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse proof file") +} + +func TestLoadProof_BadAccountHex(t *testing.T) { + body := `{ + "account": "not-hex", + "account_index": "0x0", + "account_root_siblings": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid account") +} + +func TestLoadProof_BadAccountIndex(t *testing.T) { + body := `{ + "account": "0xaa", + "account_index": "not-hex", + "account_root_siblings": [] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid account_index") +} + +func TestLoadProof_SiblingWrongLength(t *testing.T) { + cases := []struct { + name string + hex string + }{ + {"31_bytes", "0x" + repeatHex("aa", 31)}, + {"33_bytes", "0x" + repeatHex("aa", 33)}, + {"empty", "0x"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{ + "account": "0x00", + "account_index": "0x0", + "account_root_siblings": ["` + tc.hex + `"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "account_root_siblings") + require.Contains(t, err.Error(), "32 bytes") + }) + } +} + +func TestLoadProof_BadSiblingHex(t *testing.T) { + body := `{ + "account": "0x00", + "account_index": "0x0", + "account_root_siblings": ["not-hex"] + }` + path := writeProofFile(t, body) + _, _, err := loadProof(path) + require.Error(t, err) + require.Contains(t, err.Error(), "account_root_siblings[0]") +} + +func repeatHex(b string, n int) string { + out := make([]byte, 0, n*len(b)) + for range n { + out = append(out, b...) + } + return string(out) +} diff --git a/cmd/cartesi-rollups-cli/secret_output_test.go b/cmd/cartesi-rollups-cli/secret_output_test.go new file mode 100644 index 000000000..9205a822b --- /dev/null +++ b/cmd/cartesi-rollups-cli/secret_output_test.go @@ -0,0 +1,19 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root" + "github.com/cartesi/rollups-node/test/tooling/command" +) + +func TestSecretCommandProcess(t *testing.T) { + command.Child(t, main, root.Cmd) +} + +func TestSecretCommandHelp(t *testing.T) { + command.CheckHelp(t, "cli") +} diff --git a/cmd/cartesi-rollups-cli/secret_runtime_test.go b/cmd/cartesi-rollups-cli/secret_runtime_test.go new file mode 100644 index 000000000..8a54f2fab --- /dev/null +++ b/cmd/cartesi-rollups-cli/secret_runtime_test.go @@ -0,0 +1,323 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/cartesi/rollups-node/test/tooling/command" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +const secretTestAddress = "0x0000000000000000000000000000000000000001" +const secretContractCommand = "contract" +const secretSummaryCommand = "summary" +const secretApplicationName = "echo-dapp" +const secretInspectCommand = "inspect" +const secretReadCommand = "read" +const jsonRPCFlag = "--jsonrpc" +const readInputsOperation = "inputs" + +var secretRequestIDSuffix = regexp.MustCompile(` \(request_id=([^()\r\n]+)\)\n?$`) + +func withoutSecretRequestID(t *testing.T, output string) string { + t.Helper() + match := secretRequestIDSuffix.FindStringSubmatch(output) + require.Len(t, match, 2, "request failure must print its local ID") + id, err := uuid.Parse(match[1]) + require.NoError(t, err) + require.Equal(t, id.String(), match[1]) + require.Equal(t, uuid.Version(4), id.Version()) + return strings.TrimSuffix(output, match[0]) +} + +func closedSecretEndpoint(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + endpoint := "http://" + listener.Addr().String() + require.NoError(t, listener.Close()) + return endpoint +} + +func configuredSecretEndpoint(t *testing.T, base, marker string) string { + t.Helper() + endpoint, err := url.Parse(base + "/" + marker + "?key=" + marker) + require.NoError(t, err) + endpoint.User = url.UserPassword(marker, "password") + return endpoint.String() +} + +func TestSecretRuntimeFailures(t *testing.T) { + base := closedSecretEndpoint(t) + for _, test := range []struct { + name string + args []string + }{ + {name: "contract text", args: []string{secretContractCommand, secretSummaryCommand, secretTestAddress}}, + {name: "contract json", args: []string{secretContractCommand, secretSummaryCommand, secretTestAddress, "--json"}}, + {name: secretReadCommand, args: []string{secretReadCommand, readInputsOperation, secretApplicationName, jsonRPCFlag}}, + {name: secretInspectCommand, args: []string{secretInspectCommand, secretApplicationName, "hi"}}, + } { + t.Run(test.name, func(t *testing.T) { + var firstOutput string + for i, marker := range []string{"SECRET_MARKER_A", "SECRET_MARKER_B"} { + endpoint := configuredSecretEndpoint(t, base, marker) + stdout, stderr, err := command.Run(t, []string{ + config.BLOCKCHAIN_HTTP_ENDPOINT + "=" + endpoint, + config.JSONRPC_API_URL + "=" + endpoint, + config.INSPECT_URL + "=" + endpoint, + config.LOG_COLOR + "=false", + }, test.args...) + require.Error(t, err) + output := stdout + stderr + require.NotContains(t, output, marker) + require.Contains(t, output, base) + require.Contains(t, output, "request failed") + if test.name == secretReadCommand || test.name == secretInspectCommand { + output = withoutSecretRequestID(t, output) + } + if i == 0 { + firstOutput = output + } else { + require.Equal(t, firstOutput, output, "secret values must not affect failure output") + } + } + }) + } +} + +func TestSecretContractSummaryPartialFailure(t *testing.T) { + const marker = "PARTIAL_SUMMARY_ENDPOINT_SECRET" + var failures atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/"+marker+"?key="+marker, r.RequestURI) + user, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, marker, user) + require.Equal(t, "password", password) + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid test request", http.StatusBadRequest) + return + } + if request.Method == "eth_chainId" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": "0x1"}) + return + } + failures.Add(1) + http.Error(w, r.RequestURI+" "+r.Header.Get("Authorization"), http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + endpoint := configuredSecretEndpoint(t, server.URL, marker) + for _, asJSON := range []bool{false, true} { + args := []string{secretContractCommand, secretSummaryCommand, secretTestAddress, "--block=0"} + if asJSON { + args = append(args, "--json") + } + stdout, stderr, err := command.Run(t, []string{config.BLOCKCHAIN_HTTP_ENDPOINT + "=" + endpoint}, args...) + require.NoError(t, err, stderr) + require.NotContains(t, stdout+stderr, marker) + require.Contains(t, stdout, "500 Internal Server Error") + if asJSON { + var result struct { + AppError string `json:"app_error"` + ConsensusError string `json:"consensus_error"` + InputBoxError string `json:"inputbox_error"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.NotEmpty(t, result.AppError) + require.NotEmpty(t, result.ConsensusError) + require.NotEmpty(t, result.InputBoxError) + } + } + require.Positive(t, failures.Load(), "partial query failures must actually occur") +} + +func TestSecretReadFragmentDoesNotExposeUserinfo(t *testing.T) { + const marker = "FRAGMENT_USERINFO_SECRET" + base := closedSecretEndpoint(t) + endpoint, err := url.Parse(base + "#fragment") + require.NoError(t, err) + endpoint.User = url.UserPassword(marker, marker) + stdout, stderr, err := command.Run(t, []string{config.JSONRPC_API_URL + "=" + endpoint.String()}, + secretReadCommand, readInputsOperation, secretApplicationName, jsonRPCFlag) + require.Error(t, err) + require.NotContains(t, stdout+stderr, marker) + require.Contains(t, stdout+stderr, base) + require.Contains(t, stdout+stderr, "connection refused") +} + +func TestSecretCLIReadAndInspectPreserveWireCredentials(t *testing.T) { + const marker = "CLI_WIRE_CREDENTIAL" + for _, test := range []struct { + name, key, operation, status string + args []string + }{ + {secretReadCommand, config.JSONRPC_API_URL, "", ": 500 Internal Server Error, body:", + []string{secretReadCommand, readInputsOperation, secretApplicationName, jsonRPCFlag}}, + {secretInspectCommand, config.INSPECT_URL, "/inspect/echo-dapp", "with status 500:", + []string{secretInspectCommand, secretApplicationName, "hi"}}, + } { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + requestIDs := make(chan string, 1) + server := httptest.NewServer(service.RequestIDMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + requestID := service.RequestIDFromContext(r.Context()) + require.Equal(t, r.Header.Get("X-Request-ID"), requestID) + _, err := uuid.Parse(requestID) + require.NoError(t, err) + requestIDs <- requestID + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/base%2F"+marker+test.operation+"?key="+marker+"&key=second+", r.RequestURI) + user, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, marker, password) + if test.name == secretReadCommand { + var request struct{ Method string } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, "cartesi_listInputs", request.Method) + } + w.Header().Set("X-Request-ID", marker) + http.Error(w, r.RequestURI+" "+r.Header.Get("Authorization")+" (request_id="+marker+")", http.StatusInternalServerError) + }))) + defer server.Close() + endpoint, err := url.Parse(server.URL + "/base%2F" + marker + "?key=" + marker + "&key=second+") + require.NoError(t, err) + endpoint.User = url.UserPassword("operator", marker) + stdout, stderr, err := command.Run(t, []string{test.key + "=" + endpoint.String()}, test.args...) + require.Error(t, err) + require.Equal(t, int32(1), requests.Load()) + require.NotContains(t, stdout+stderr, marker) + require.Contains(t, stdout+stderr, test.status) + select { + case id := <-requestIDs: + require.Contains(t, stdout+stderr, " (request_id="+id+")") + default: + t.Fatal("the node did not receive the request ID") + } + }) + } +} + +func TestSecretCLIRequestIDsDoNotChangeSuccessOutput(t *testing.T) { + for _, test := range []struct { + name, key, response, output string + args []string + }{ + {secretReadCommand, config.JSONRPC_API_URL, `{"jsonrpc":"2.0","id":1,"result":{"data":[]}}`, "{\n \"data\": []\n}\n", + []string{secretReadCommand, readInputsOperation, secretApplicationName, jsonRPCFlag}}, + {secretInspectCommand, config.INSPECT_URL, + `{"status":"Accepted","reports":[]}`, "{\n \"status\": \"Accepted\",\n \"reports\": []\n}", + []string{secretInspectCommand, secretApplicationName, "hi"}}, + } { + t.Run(test.name, func(t *testing.T) { + observed := make(chan string, 1) + server := httptest.NewServer(service.RequestIDMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := service.RequestIDFromContext(r.Context()) + _, err := uuid.Parse(id) + require.NoError(t, err) + observed <- id + _, _ = io.WriteString(w, test.response) + }))) + defer server.Close() + stdout, stderr, err := command.Run(t, []string{test.key + "=" + server.URL}, test.args...) + require.NoError(t, err, stderr) + require.Equal(t, test.output, stdout) + select { + case id := <-observed: + require.NotContains(t, stdout+stderr, id) + default: + t.Fatal("the node did not receive the request ID") + } + require.NotContains(t, stdout+stderr, "request_id") + }) + } +} + +func TestSecretCLIDecodeFailuresKeepLocalRequestID(t *testing.T) { + const marker = "CLI_DECODE_CREDENTIAL" + for _, test := range []struct { + name, key string + args []string + }{ + {secretReadCommand, config.JSONRPC_API_URL, []string{secretReadCommand, readInputsOperation, secretApplicationName, jsonRPCFlag}}, + {secretInspectCommand, config.INSPECT_URL, []string{secretInspectCommand, secretApplicationName, "hi"}}, + } { + t.Run(test.name, func(t *testing.T) { + observed := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + observed <- r.Header.Get("X-Request-ID") + w.Header().Set("X-Request-ID", marker) + _, _ = io.WriteString(w, marker) + })) + defer server.Close() + stdout, stderr, err := command.Run(t, []string{test.key + "=" + configuredSecretEndpoint(t, server.URL, marker)}, test.args...) + require.Error(t, err) + output := stdout + stderr + require.Contains(t, output, "decode") + require.Contains(t, output, server.URL) + select { + case id := <-observed: + require.Contains(t, output, " (request_id="+id+")") + default: + t.Fatal("the node did not receive the request ID") + } + require.NotContains(t, output, marker) + withoutSecretRequestID(t, output) + }) + } +} + +func TestSecretFileMistakeCLI(t *testing.T) { + const marker = "SECRET_MISTAKEN_FOR_FILE_PATH" + stdout, stderr, err := command.Run(t, + []string{config.BLOCKCHAIN_HTTP_ENDPOINT_FILE + "=" + marker}, + secretContractCommand, secretSummaryCommand, secretTestAddress) + require.Error(t, err) + require.NotContains(t, stdout+stderr, marker) + require.Contains(t, stdout+stderr, config.BLOCKCHAIN_HTTP_ENDPOINT_FILE) + require.Contains(t, stdout+stderr, "no such file") +} + +func TestSecretAppRegisterFatalOutput(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip("registration integration requires CARTESI_TEST_DATABASE_CONNECTION") + } + release, err := db.LockTestPostgres(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(release) + require.NoError(t, db.SetupTestPostgres(endpoint)) + base := closedSecretEndpoint(t) + const marker = "REGISTER_ENDPOINT_SECRET" + stdout, stderr, err := command.Run(t, []string{ + config.DATABASE_CONNECTION + "=" + endpoint, + config.BLOCKCHAIN_HTTP_ENDPOINT + "=" + configuredSecretEndpoint(t, base, marker), + }, "app", "register", "--name=secret-test", "--address="+secretTestAddress, "--template-path="+t.TempDir()) + require.Error(t, err) + require.NotContains(t, stdout+stderr, marker) + require.Contains(t, stderr, "Failed to get template hash from application:") + require.Contains(t, stderr, base) +} diff --git a/cmd/cartesi-rollups-cli/util/util.go b/cmd/cartesi-rollups-cli/util/util.go new file mode 100644 index 000000000..9d90092b1 --- /dev/null +++ b/cmd/cartesi-rollups-cli/util/util.go @@ -0,0 +1,83 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package util + +import ( + "context" + "fmt" + "io" + "os" + "path" + "strings" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" +) + +// ResolveApplicationAddress returns the IApplication address corresponding +// to a name-or-address CLI argument. +// +// - If the input is a 0x-prefixed string, it is treated as an Ethereum +// address and returned directly. No DB connection is made. This lets the +// CLI operate against an application that is NOT registered in any local +// repository (remote use, ad-hoc inspection, foreclosure flow on a +// reader-only host). +// - Otherwise the input is treated as an application name and looked up +// in the local repository. A DB connection is required and an error is +// returned if the application is not found. +func ResolveApplicationAddress(ctx context.Context, nameOrAddress string) (common.Address, error) { + if strings.HasPrefix(nameOrAddress, "0x") || strings.HasPrefix(nameOrAddress, "0X") { + if !common.IsHexAddress(nameOrAddress) { + return common.Address{}, fmt.Errorf("invalid Ethereum address %q", nameOrAddress) + } + return common.HexToAddress(nameOrAddress), nil + } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return common.Address{}, fmt.Errorf( + "resolving application %q by name requires the database; pass the application address (0x…) "+ + "instead to skip the local repository: %w", nameOrAddress, err) + } + repo, err := cli.OpenRepository(ctx, dsn) + if err != nil { + return common.Address{}, err + } + defer repo.Close() + app, err := repo.GetApplication(ctx, nameOrAddress) + if err != nil { + return common.Address{}, err + } + if app == nil { + return common.Address{}, fmt.Errorf("application %q not found in the database", nameOrAddress) + } + return app.IApplicationAddress, nil +} + +// Reads the Cartesi Machine hash from machineDir. Returns it as a commonHash +// or an error +func ReadRootHash(machineDir string) (common.Hash, error) { + zero := common.Hash{} + path := path.Join(machineDir, "hash_tree.sht") + f, err := os.Open(path) + if err != nil { + return zero, err + } + defer f.Close() + + // root hash is located at this offset (0x60). Double check its value + // with the cartesi-machine-stored-hash tool. + _, err = f.Seek(0x60, io.SeekStart) //nolint:mnd // Fixed root-hash offset in the machine image header. + if err != nil { + return zero, err + } + + hash := common.Hash{} + _, err = io.ReadFull(f, hash[:]) + if err != nil { + return zero, err + } + return hash, nil +} diff --git a/cmd/cartesi-rollups-cli/util/util_test.go b/cmd/cartesi-rollups-cli/util/util_test.go new file mode 100644 index 000000000..74d3ed9fa --- /dev/null +++ b/cmd/cartesi-rollups-cli/util/util_test.go @@ -0,0 +1,76 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package util //nolint:revive // Tests intentionally exercise unexported package helpers. + +import ( + "context" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// The 0x-bypass invariant is the whole point of allowing remote / reader-only +// hosts to use the foreclose / prove-drive-root / withdraw subcommands +// against an application that is NOT registered in any local repository. +// If a future change reorders the function so the database lookup happens +// before the prefix check, every one of those CLIs silently starts requiring +// CARTESI_DATABASE_CONNECTION. These tests pin the invariant by setting the +// DB env to something deliberately broken — a real DB lookup against this +// value would fail loudly, so a passing test means the 0x branch ran first. + +func TestResolveApplicationAddress_HexBypassesDB(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "postgres://nobody@nowhere:1/nodb") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + addr := "0x1111111111111111111111111111111111111111" + got, err := ResolveApplicationAddress(context.Background(), addr) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(addr), got) +} + +func TestResolveApplicationAddress_UppercaseHexPrefixAlsoBypasses(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "postgres://nobody@nowhere:1/nodb") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + addr := "0X2222222222222222222222222222222222222222" + got, err := ResolveApplicationAddress(context.Background(), addr) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(addr), got) +} + +func TestResolveApplicationAddress_InvalidHex(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "postgres://nobody@nowhere:1/nodb") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + cases := []string{ + "0xnothex", + "0x123", // too short + "0x11111111111111111111111111111111111111111111", // too long + "0xZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", // non-hex chars + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + _, err := ResolveApplicationAddress(context.Background(), in) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid Ethereum address") + }) + } +} + +// When the caller passes a name and CARTESI_DATABASE_CONNECTION is not set, +// the error message must point the user at the 0x-bypass alternative — the +// CLI's documented escape hatch for remote / reader-only operation. +func TestResolveApplicationAddress_NameWithoutDBPointsAtBypass(t *testing.T) { + t.Setenv("CARTESI_DATABASE_CONNECTION", "") + t.Setenv("CARTESI_DATABASE_CONNECTION_FILE", "") + + _, err := ResolveApplicationAddress(context.Background(), "some-app-name") + require.Error(t, err) + msg := err.Error() + require.True(t, + strings.Contains(msg, "0x") && strings.Contains(msg, "address"), + "name-without-DB error must point at the 0x-bypass: got %q", msg) +} diff --git a/cmd/cartesi-rollups-evm-reader/main.go b/cmd/cartesi-rollups-evm-reader/main.go new file mode 100644 index 000000000..fb9739144 --- /dev/null +++ b/cmd/cartesi-rollups-evm-reader/main.go @@ -0,0 +1,17 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-evm-reader/root" +) + +func main() { + err := root.Cmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/cmd/cartesi-rollups-evm-reader/root/root.go b/cmd/cartesi-rollups-evm-reader/root/root.go new file mode 100644 index 000000000..1304db309 --- /dev/null +++ b/cmd/cartesi-rollups-evm-reader/root/root.go @@ -0,0 +1,90 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "context" + "log/slog" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/version" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/spf13/cobra" +) + +var ( + logLevel string + logColor bool + defaultBlockString string + blockchainHTTPEndpoint string + pollInterval string + databaseConnection string + maxStartupTime string + telemetryAddress string + cfg *config.EvmreaderConfig + maxBlockRange uint64 +) + +var Cmd = &cobra.Command{ + Use: "cartesi-rollups-" + config.ServiceEvmReader, + Short: "Runs cartesi-rollups-" + config.ServiceEvmReader, + Long: "Runs cartesi-rollups-" + config.ServiceEvmReader + " in standalone mode", + RunE: run, + Version: version.BuildVersion, +} + +func init() { + flags := Cmd.Flags() + + config.SetDefaults() + + cli.AddFlagStrVarP(flags, &defaultBlockString, "default-block", "d", config.BLOCKCHAIN_DEFAULT_BLOCK, + "Default block to be used when fetching new blocks.\n"+ + "One of 'latest', 'safe', 'finalized'; provisional observations have no reorg rollback") + cli.AddFlagStrVar(flags, &telemetryAddress, "telemetry-address", config.EVM_READER_TELEMETRY_ADDRESS, + "Health check and metrics address and port") + cli.AddFlagStrVar(flags, &logLevel, "log-level", config.LOG_LEVEL, + "Log level: debug, info, warn or error") + cli.AddFlagBoolVar(flags, &logColor, "log-color", config.LOG_COLOR, + "Tint the logs (colored output)") + cli.AddFlagStrVar(flags, &databaseConnection, "database-connection", config.DATABASE_CONNECTION, + "Database connection string in the URL format\n(eg.: 'postgres://user:password@hostname:port/database') ") + cli.AddFlagStrVar(flags, &blockchainHTTPEndpoint, "blockchain-http-endpoint", config.BLOCKCHAIN_HTTP_ENDPOINT, + "Blockchain http endpoint") + cli.AddFlagStrVar(flags, &pollInterval, "poll-interval", config.EVM_READER_POLLING_INTERVAL, + "Poll interval") + cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, + "Maximum startup time in seconds") + cli.AddFlagUint64Var(flags, &maxBlockRange, "max-block-range", config.BLOCKCHAIN_MAX_BLOCK_RANGE, + "Maximum number of blocks in a single query. large queries will be split automatically. Zero for unlimited.") + + // TODO: validate on preRunE + Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + var err error + cfg, err = config.LoadEvmreaderConfig() + if err != nil { + return err + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) error { + return cli.RunSingleService(cli.SingleServiceOptions{ + Command: cmd, + Name: config.ServiceEvmReader, + LogLevel: cfg.LogLevel, + LogColor: cfg.LogColor, + MaxStartupTime: cfg.MaxStartupTime, + DatabaseConnection: cfg.DatabaseConnection, + TelemetryAddress: cfg.EvmReaderTelemetryAddress, + Create: func(ctx context.Context, logger *slog.Logger, repo repository.Repository) (service.SupervisedService, error) { + return evmreader.Create(ctx, &evmreader.CreateInfo{Config: *cfg, Logger: logger, Repository: repo}) + }, + }) +} diff --git a/cmd/cartesi-rollups-evm-reader/secret_output_test.go b/cmd/cartesi-rollups-evm-reader/secret_output_test.go new file mode 100644 index 000000000..6b69df1ba --- /dev/null +++ b/cmd/cartesi-rollups-evm-reader/secret_output_test.go @@ -0,0 +1,19 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-evm-reader/root" + "github.com/cartesi/rollups-node/test/tooling/command" +) + +func TestSecretCommandProcess(t *testing.T) { + command.Child(t, main, root.Cmd) +} + +func TestSecretCommandHelp(t *testing.T) { + command.CheckHelp(t, "evm-reader") +} diff --git a/cmd/cartesi-rollups-jsonrpc-api/config_usage_test.go b/cmd/cartesi-rollups-jsonrpc-api/config_usage_test.go new file mode 100644 index 000000000..be11f9291 --- /dev/null +++ b/cmd/cartesi-rollups-jsonrpc-api/config_usage_test.go @@ -0,0 +1,25 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/test/tooling/command" + "github.com/stretchr/testify/require" +) + +func TestMalformedSensitiveConfigUsageOmitsValue(t *testing.T) { + const marker = "MALFORMED_DATABASE_SECRET" + stdout, stderr, err := command.Run(t, []string{ + config.DATABASE_CONNECTION + "=postgres://user:" + marker + "@localhost/%zz", + config.LOG_COLOR + "=false", + }) + require.Error(t, err) + require.NotContains(t, stdout+stderr, marker) + require.Contains(t, stdout+stderr, config.DATABASE_CONNECTION) + require.Contains(t, stdout+stderr, "invalid") + require.Contains(t, stdout+stderr, "Usage:") +} diff --git a/cmd/cartesi-rollups-jsonrpc-api/main.go b/cmd/cartesi-rollups-jsonrpc-api/main.go new file mode 100644 index 000000000..848f819f3 --- /dev/null +++ b/cmd/cartesi-rollups-jsonrpc-api/main.go @@ -0,0 +1,17 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-jsonrpc-api/root" +) + +func main() { + err := root.Cmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/cmd/cartesi-rollups-jsonrpc-api/root/root.go b/cmd/cartesi-rollups-jsonrpc-api/root/root.go new file mode 100644 index 000000000..472fec72c --- /dev/null +++ b/cmd/cartesi-rollups-jsonrpc-api/root/root.go @@ -0,0 +1,80 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "context" + "log/slog" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/version" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/spf13/cobra" +) + +var ( + logLevel string + logColor bool + databaseConnection string + maxStartupTime string + telemetryAddress string + jsonrpcApiAddress string + cfg *config.JsonrpcConfig +) + +var Cmd = &cobra.Command{ + Use: "cartesi-rollups-" + config.ServiceJsonrpc, + Short: "Runs cartesi-rollups-" + config.ServiceJsonrpc, + Long: "Runs cartesi-rollups-" + config.ServiceJsonrpc + " in standalone mode", + RunE: run, + Version: version.BuildVersion, +} + +func init() { + flags := Cmd.Flags() + + config.SetDefaults() + + cli.AddFlagStrVar(flags, &jsonrpcApiAddress, "jsonrpc-address", config.JSONRPC_API_ADDRESS, + "Jsonrpc API service address and port") + cli.AddFlagStrVar(flags, &telemetryAddress, "telemetry-address", config.JSONRPC_TELEMETRY_ADDRESS, + "Health check and metrics address and port") + cli.AddFlagStrVar(flags, &logLevel, "log-level", config.LOG_LEVEL, + "Log level: debug, info, warn or error") + cli.AddFlagBoolVar(flags, &logColor, "log-color", config.LOG_COLOR, + "Tint the logs (colored output)") + cli.AddFlagStrVar(flags, &databaseConnection, "database-connection", config.DATABASE_CONNECTION, + "Database connection string in the URL format\n(eg.: 'postgres://user:password@hostname:port/database') ") + cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, + "Maximum startup time in seconds") + + // TODO: validate on preRunE + Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + var err error + cfg, err = config.LoadJsonrpcConfig() + if err != nil { + return err + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) error { + return cli.RunSingleService(cli.SingleServiceOptions{ + Command: cmd, + Name: config.ServiceJsonrpc, + LogLevel: cfg.LogLevel, + LogColor: cfg.LogColor, + MaxStartupTime: cfg.MaxStartupTime, + DatabaseConnection: cfg.DatabaseConnection, + TelemetryAddress: cfg.JsonrpcTelemetryAddress, + Create: func(ctx context.Context, logger *slog.Logger, repo repository.Repository) (service.SupervisedService, error) { + return jsonrpc.Create(ctx, &jsonrpc.CreateInfo{Config: *cfg, Logger: logger, Repository: repo}) + }, + }) +} diff --git a/cmd/cartesi-rollups-jsonrpc-api/secret_output_test.go b/cmd/cartesi-rollups-jsonrpc-api/secret_output_test.go new file mode 100644 index 000000000..0a34e7445 --- /dev/null +++ b/cmd/cartesi-rollups-jsonrpc-api/secret_output_test.go @@ -0,0 +1,19 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-jsonrpc-api/root" + "github.com/cartesi/rollups-node/test/tooling/command" +) + +func TestSecretCommandProcess(t *testing.T) { + command.Child(t, main, root.Cmd) +} + +func TestSecretCommandHelp(t *testing.T) { + command.CheckHelp(t, "jsonrpc-api") +} diff --git a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go new file mode 100644 index 000000000..fff6794f4 --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive.go @@ -0,0 +1,167 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package accountdrive + +import ( + "bytes" + "errors" + "fmt" + "math" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + Log2AccountSize = 5 + AccountSize = 1 << Log2AccountSize + DefaultLog2MaxAccount = 17 + usdBalanceSize = 12 +) + +var ( + ErrUnsupportedLayout = errors.New("unsupported accounts-drive layout") + ErrAccountNotFound = errors.New("account not found") +) + +type Proof struct { + Account [AccountSize]byte + AccountIndex uint64 + AccountRoot common.Hash + DriveRoot common.Hash + Siblings []common.Hash +} + +func DriveSize(log2MaxNumOfAccounts uint8, log2LeavesPerAccount uint8) (uint64, error) { + if log2LeavesPerAccount != 0 { + return 0, fmt.Errorf("%w: log2_leaves_per_account=%d", ErrUnsupportedLayout, log2LeavesPerAccount) + } + if log2MaxNumOfAccounts >= 58 { // 2^(5+58) still fits in uint64; keep a margin for int conversion. + return 0, fmt.Errorf("log2_max_num_of_accounts %d is too large", log2MaxNumOfAccounts) + } + return 1 << (Log2AccountSize + log2MaxNumOfAccounts), nil +} + +func Encode(address common.Address, balance *big.Int) ([AccountSize]byte, error) { + var account [AccountSize]byte + if address == (common.Address{}) { + return account, errors.New("account address must not be zero") + } + if balance == nil || balance.Sign() <= 0 { + return account, errors.New("account balance must be positive") + } + if balance.BitLen() > 8*usdBalanceSize { + return account, fmt.Errorf("account balance %s exceeds uint96 accounts-drive limit", balance) + } + balanceBytes := balance.Bytes() + for i := range balanceBytes { + account[i] = balanceBytes[len(balanceBytes)-1-i] + } + copy(account[usdBalanceSize:], address.Bytes()) + return account, nil +} + +func Decode(account []byte) (common.Address, *big.Int, bool, error) { + var zero [AccountSize]byte + if len(account) != AccountSize { + return common.Address{}, nil, false, fmt.Errorf("account record must be %d bytes, got %d", AccountSize, len(account)) + } + if bytes.Equal(account, zero[:]) { + return common.Address{}, nil, false, nil + } + var balanceBytes [usdBalanceSize]byte + for i := range balanceBytes { + balanceBytes[len(balanceBytes)-1-i] = account[i] + } + balance := new(big.Int).SetBytes(balanceBytes[:]) + address := common.BytesToAddress(account[usdBalanceSize:]) + return address, balance, true, nil +} + +func BuildProof( + drive []byte, + address common.Address, + log2MaxNumOfAccounts uint8, + log2LeavesPerAccount uint8, +) (*Proof, error) { + if address == (common.Address{}) { + return nil, errors.New("account address must not be zero") + } + driveSize, err := DriveSize(log2MaxNumOfAccounts, log2LeavesPerAccount) + if err != nil { + return nil, err + } + if uint64(len(drive)) > driveSize { + return nil, fmt.Errorf("accounts drive is too large: got %d bytes, want at most %d", len(drive), driveSize) + } + + leafCount := 1 << log2MaxNumOfAccounts + leaves := make([]common.Hash, leafCount) + foundIndex := uint64(math.MaxUint64) + var foundAccount [AccountSize]byte + seenEnd := false + + for i := range leaves { + var account [AccountSize]byte + offset := i * AccountSize + if offset < len(drive) { + copy(account[:], drive[offset:min(offset+AccountSize, len(drive))]) + } + + decodedAddress, _, nonEmpty, err := Decode(account[:]) + if err != nil { + return nil, fmt.Errorf("decode account %d: %w", i, err) + } + if nonEmpty { + if seenEnd { + return nil, fmt.Errorf("account %d appears after the first empty slot", i) + } + if decodedAddress == address { + foundIndex = uint64(i) + foundAccount = account + } + } else { + seenEnd = true + } + leaves[i] = crypto.Keccak256Hash(account[:]) + } + if foundIndex == math.MaxUint64 { + return nil, fmt.Errorf("%w: %s", ErrAccountNotFound, address) + } + + accountRoot := leaves[foundIndex] + siblings := make([]common.Hash, log2MaxNumOfAccounts) + nodeIndex := foundIndex + level := leaves + for height := range int(log2MaxNumOfAccounts) { + siblings[height] = level[nodeIndex^1] + parents := make([]common.Hash, len(level)/2) + for i := 0; i < len(parents); i++ { + parents[i] = crypto.Keccak256Hash(level[2*i].Bytes(), level[2*i+1].Bytes()) + } + nodeIndex >>= 1 + level = parents + } + + return &Proof{ + Account: foundAccount, + AccountIndex: foundIndex, + AccountRoot: accountRoot, + DriveRoot: level[0], + Siblings: siblings, + }, nil +} + +func RootFromProof(accountRoot common.Hash, accountIndex uint64, siblings []common.Hash) common.Hash { + root := accountRoot + for height, sibling := range siblings { + if (accountIndex>>height)&1 == 0 { + root = crypto.Keccak256Hash(root.Bytes(), sibling.Bytes()) + } else { + root = crypto.Keccak256Hash(sibling.Bytes(), root.Bytes()) + } + } + return root +} diff --git a/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go new file mode 100644 index 000000000..b1c0bb75c --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/accountdrive/accountdrive_test.go @@ -0,0 +1,179 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package accountdrive + +import ( + "encoding/hex" + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestEncodeMatchesLibUsdAccountLayout(t *testing.T) { + address := common.HexToAddress("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266") + balance, ok := new(big.Int).SetString("0123456789abcdef01234567", 16) + require.True(t, ok) + + account, err := Encode(address, balance) + require.NoError(t, err) + + require.Equal(t, + "67452301efcdab8967452301f39fd6e51aad88f6f4ce6ab8827279cfffb92266", + hex.EncodeToString(account[:]), + ) + + decodedAddress, decodedBalance, nonEmpty, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, nonEmpty) + require.Equal(t, address, decodedAddress) + require.Zero(t, balance.Cmp(decodedBalance)) +} + +func TestEncodeSupportsUint96(t *testing.T) { + maxUint96 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 96), big.NewInt(1)) + address := common.HexToAddress("0x1111111111111111111111111111111111111111") + + account, err := Encode(address, maxUint96) + require.NoError(t, err) + require.Equal(t, "ffffffffffffffffffffffff1111111111111111111111111111111111111111", + hex.EncodeToString(account[:])) + + decodedAddress, decodedBalance, nonEmpty, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, nonEmpty) + require.Equal(t, address, decodedAddress) + require.Zero(t, maxUint96.Cmp(decodedBalance)) +} + +func TestEncodeRejectsInvalidBalances(t *testing.T) { + address := common.HexToAddress("0x1111111111111111111111111111111111111111") + tooLarge := new(big.Int).Lsh(big.NewInt(1), 96) + for _, balance := range []*big.Int{nil, big.NewInt(0), big.NewInt(-1), tooLarge} { + _, err := Encode(address, balance) + require.Error(t, err) + } +} + +func TestDecodeRejectsCorruptRecords(t *testing.T) { + t.Run("wrong record size is invalid", func(t *testing.T) { + for _, size := range []int{AccountSize - 1, AccountSize + 1} { + _, _, _, err := Decode(make([]byte, size)) + require.ErrorContains(t, err, "must be 32 bytes") + } + }) + + t.Run("zero record is empty", func(t *testing.T) { + var zero [AccountSize]byte + _, _, ok, err := Decode(zero[:]) + require.NoError(t, err) + require.False(t, ok) + }) + + t.Run("zero balance is a non-empty account", func(t *testing.T) { + var account [AccountSize]byte + copy(account[usdBalanceSize:], common.HexToAddress("0x1111111111111111111111111111111111111111").Bytes()) + + address, balance, ok, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), address) + require.Zero(t, balance.Sign()) + }) + + t.Run("zero address with nonzero balance is a non-empty account", func(t *testing.T) { + var account [AccountSize]byte + account[0] = 1 + + address, balance, ok, err := Decode(account[:]) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, common.Address{}, address) + require.Equal(t, big.NewInt(1), balance) + }) +} + +func TestBuildProof_BuildsVerifiableAccountProof(t *testing.T) { + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + account1, err := Encode(addr1, big.NewInt(10)) + require.NoError(t, err) + account2, err := Encode(addr2, big.NewInt(20)) + require.NoError(t, err) + + drive := make([]byte, 1<<(Log2AccountSize+DefaultLog2MaxAccount)) + copy(drive[0:AccountSize], account1[:]) + copy(drive[AccountSize:2*AccountSize], account2[:]) + + proof, err := BuildProof(drive, addr2, DefaultLog2MaxAccount, 0) + require.NoError(t, err) + + require.Equal(t, uint64(1), proof.AccountIndex) + require.Equal(t, account2, proof.Account) + require.Len(t, proof.Siblings, DefaultLog2MaxAccount) + require.Equal(t, proof.DriveRoot, RootFromProof(proof.AccountRoot, proof.AccountIndex, proof.Siblings)) +} + +func zeroBalanceAccount(address common.Address) [AccountSize]byte { + var account [AccountSize]byte + copy(account[usdBalanceSize:], address.Bytes()) + return account +} + +func TestBuildProof_FindsAccountAmongZeroBalanceEntries(t *testing.T) { + zeroBefore := zeroBalanceAccount(common.HexToAddress("0x4444444444444444444444444444444444444444")) + target, err := Encode(common.HexToAddress("0x5555555555555555555555555555555555555555"), big.NewInt(10)) + require.NoError(t, err) + zeroAfter := zeroBalanceAccount(common.HexToAddress("0x6666666666666666666666666666666666666666")) + + drive := make([]byte, 3*AccountSize) + copy(drive[0:AccountSize], zeroBefore[:]) + copy(drive[AccountSize:2*AccountSize], target[:]) + copy(drive[2*AccountSize:3*AccountSize], zeroAfter[:]) + + proof, err := BuildProof(drive, common.HexToAddress("0x5555555555555555555555555555555555555555"), 2, 0) + require.NoError(t, err) + require.Equal(t, uint64(1), proof.AccountIndex) + require.Equal(t, target, proof.Account) + require.Equal(t, proof.DriveRoot, RootFromProof(proof.AccountRoot, proof.AccountIndex, proof.Siblings)) +} + +func TestBuildProof_ProvesZeroBalanceAccount(t *testing.T) { + target := zeroBalanceAccount(common.HexToAddress("0x7777777777777777777777777777777777777777")) + + drive := make([]byte, 2*AccountSize) + copy(drive[0:AccountSize], target[:]) + + proof, err := BuildProof(drive, common.HexToAddress("0x7777777777777777777777777777777777777777"), 2, 0) + require.NoError(t, err) + require.Equal(t, uint64(0), proof.AccountIndex) + require.Equal(t, target, proof.Account) + require.Equal(t, proof.DriveRoot, RootFromProof(proof.AccountRoot, proof.AccountIndex, proof.Siblings)) +} + +func TestBuildProof_ReturnsClearErrorForMissingAccount(t *testing.T) { + drive := make([]byte, 1<<(Log2AccountSize+DefaultLog2MaxAccount)) + _, err := BuildProof(drive, common.HexToAddress("0x3333333333333333333333333333333333333333"), DefaultLog2MaxAccount, 0) + require.ErrorIs(t, err, ErrAccountNotFound) +} + +func TestBuildProof_RejectsUnsupportedLayout(t *testing.T) { + _, err := BuildProof(nil, common.HexToAddress("0x1111111111111111111111111111111111111111"), DefaultLog2MaxAccount, 1) + require.ErrorIs(t, err, ErrUnsupportedLayout) +} + +func TestBuildProof_RejectsNonCompactAccountTable(t *testing.T) { + addr := common.HexToAddress("0x1111111111111111111111111111111111111111") + account, err := Encode(addr, big.NewInt(10)) + require.NoError(t, err) + drive := make([]byte, 3*AccountSize) + copy(drive[2*AccountSize:3*AccountSize], account[:]) + + _, err = BuildProof(drive, addr, 2, 0) + require.Error(t, err) + require.False(t, errors.Is(err, ErrAccountNotFound)) + require.ErrorContains(t, err, "after the first empty slot") +} diff --git a/cmd/cartesi-rollups-machine-tool/database_errors_test.go b/cmd/cartesi-rollups-machine-tool/database_errors_test.go new file mode 100644 index 000000000..d45f5ad03 --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/database_errors_test.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/test/tooling/command" + "github.com/stretchr/testify/require" +) + +func TestReplayDatabaseErrorsDoNotExposeSettings(t *testing.T) { + for _, flag := range []bool{false, true} { + name := "environment" + if flag { + name = "flag" + } + t.Run(name, func(t *testing.T) { + const marker = "REPLAY_DATABASE_SECRET" + dsn := "postgres://" + marker + ":" + marker + "@127.0.0.1:1/" + marker + "?connect_timeout=" + marker + args := []string{"replay", "--template=unused", "--application=echo", "--store=unused", "--to-epoch=0"} + var env []string + if flag { + args = append(args, "--database-connection="+dsn) + } else { + env = []string{config.DATABASE_CONNECTION + "=" + dsn} + } + stdout, stderr, err := command.Run(t, env, args...) + require.Error(t, err) + require.Contains(t, stdout+stderr, "failed to parse Postgres connection string") + require.NotContains(t, stdout+stderr, marker) + }) + } +} diff --git a/cmd/cartesi-rollups-machine-tool/main.go b/cmd/cartesi-rollups-machine-tool/main.go new file mode 100644 index 000000000..21b05dc5f --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/main.go @@ -0,0 +1,520 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-machine-tool/accountdrive" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/spf13/cobra" +) + +const defaultInputPageSize = uint64(500) + +func main() { + config.SetDefaults() + if err := newRootCommand().ExecuteContext(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func newRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "cartesi-rollups-machine-tool", + Short: "Rollups-aware helper for replaying machines and generating withdrawal proofs", + } + root.AddCommand(newReplayCommand()) + root.AddCommand(newProveCommand()) + return root +} + +func newReplayCommand() *cobra.Command { + var opts replayOptions + cmd := &cobra.Command{ + Use: "replay", + Short: "Replay accepted inputs from the node database into a machine template", + RunE: func(cmd *cobra.Command, _ []string) error { + opts.HasToEpoch = cmd.Flags().Changed("to-epoch") + opts.HasToInputIndex = cmd.Flags().Changed("to-input-index") + return runReplay(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&opts.Template, "template", "", "Stored machine template path") + cmd.Flags().StringVar(&opts.Application, "application", "", "Application name or address") + cmd.Flags().StringVar(&opts.DatabaseConnection, "database-connection", "", "Database connection string") + cmd.Flags().StringVar(&opts.Store, "store", "", "Output stored machine path") + cmd.Flags().StringVar(&opts.CartesiMachine, "cartesi-machine", "cartesi-machine", "cartesi-machine executable") + cmd.Flags().StringVar(&opts.Lua, "lua", "lua5.4", "Lua executable used by Dave/PRT replay") + cmd.Flags().StringVar(&opts.CartesiSDKRoot, "cartesi-sdk-root", "", "Cartesi SDK root used to resolve Lua modules") + cmd.Flags().Uint64Var(&opts.ToEpoch, "to-epoch", 0, "Replay accepted inputs in epochs up to this epoch") + cmd.Flags().Uint64Var(&opts.ToInputIndex, "to-input-index", 0, "Replay accepted inputs up to this input index") + cobra.CheckErr(cmd.MarkFlagRequired("template")) + cobra.CheckErr(cmd.MarkFlagRequired("application")) + cobra.CheckErr(cmd.MarkFlagRequired("store")) + return cmd +} + +type replayOptions struct { + Template string + Application string + DatabaseConnection string + Store string + CartesiMachine string + Lua string + CartesiSDKRoot string + ToEpoch uint64 + ToInputIndex uint64 + HasToEpoch bool + HasToInputIndex bool +} + +func runReplay(ctx context.Context, opts replayOptions) error { + var dsn config.SafeURL + var err error + if opts.DatabaseConnection == "" { + dsn, err = config.GetDatabaseConnection() + if err != nil { + return fmt.Errorf("database connection is required for replay: %w", err) + } + } else { + dsn, err = config.ToURLFromString(opts.DatabaseConnection) + if err != nil { + return fmt.Errorf("--database-connection: %w", err) + } + } + if opts.HasToEpoch == opts.HasToInputIndex { + return errors.New("exactly one replay target is required: --to-epoch or --to-input-index") + } + + repo, err := cli.OpenRepository(ctx, dsn) + if err != nil { + return fmt.Errorf("open repository: %w", err) + } + defer repo.Close() + + app, err := repo.GetApplication(ctx, opts.Application) + if err != nil { + return fmt.Errorf("get application: %w", err) + } + if app == nil { + return fmt.Errorf("application %q not found", opts.Application) + } + + inputs, lastInputIndex, err := collectReplayInputs(ctx, repo, opts) + if err != nil { + return err + } + + tmp, err := os.MkdirTemp("", "cartesi-rollups-machine-tool-replay-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tmp) //nolint:errcheck + + if err := replayInputs(ctx, opts, tmp, app, inputs); err != nil { + return err + } + + root, err := readStoredMachineRoot(ctx, opts.CartesiMachine, opts.Store) + if err != nil { + return err + } + summary := struct { + ProcessedInputs int `json:"processed_inputs"` + LastInputIndex string `json:"last_input_index,omitempty"` + MachineRoot string `json:"machine_root"` + Store string `json:"store"` + }{ + ProcessedInputs: len(inputs), + MachineRoot: root, + Store: opts.Store, + } + if lastInputIndex != nil { + summary.LastInputIndex = fmt.Sprintf("0x%x", *lastInputIndex) + } + return json.NewEncoder(os.Stdout).Encode(summary) +} + +func replayInputs( + ctx context.Context, + opts replayOptions, + tmp string, + app *model.Application, + inputs []*model.Input, +) error { + if app.IsDaveConsensus() && len(inputs) > 0 { + return replayDaveInputs(ctx, opts, tmp, inputs) + } + return replayInputsBatch(ctx, opts, tmp, inputs) +} + +func replayInputsBatch(ctx context.Context, opts replayOptions, tmp string, inputs []*model.Input) error { + if _, err := writeReplayInputFiles(tmp, inputs); err != nil { + return err + } + + args := []string{ + "--quiet", + "--no-revert", + "--load=" + opts.Template, + fmt.Sprintf("--cmio-advance-state=input:%s,input_file_index_begin:0,input_file_index_end:%d", + filepath.Join(tmp, "input-%i.bin"), len(inputs)), + "--store=" + opts.Store, + } + return runCommand(ctx, opts.CartesiMachine, args...) +} + +func collectReplayInputs( + ctx context.Context, + repo repository.Repository, + opts replayOptions, +) ([]*model.Input, *uint64, error) { + status := model.InputCompletionStatus_Accepted + filter := repository.InputFilter{Status: &status} + var offset uint64 + var result []*model.Input + var lastInputIndex *uint64 + for { + inputs, _, err := repo.ListInputs(ctx, opts.Application, filter, + repository.Pagination{Limit: defaultInputPageSize, Offset: offset}, false) + if err != nil { + return nil, nil, fmt.Errorf("list inputs: %w", err) + } + if len(inputs) == 0 { + break + } + for _, input := range inputs { + if opts.HasToEpoch && input.EpochIndex > opts.ToEpoch { + return result, lastInputIndex, nil + } + if opts.HasToInputIndex && input.Index > opts.ToInputIndex { + return result, lastInputIndex, nil + } + result = append(result, input) + idx := input.Index + lastInputIndex = &idx + } + if uint64(len(inputs)) < defaultInputPageSize { + break + } + offset += uint64(len(inputs)) + } + return result, lastInputIndex, nil +} + +func newProveCommand() *cobra.Command { + prove := &cobra.Command{ + Use: "prove", + Short: "Generate Rollups proof files from a stored machine", + } + prove.AddCommand(newProveAccountsDriveCommand()) + return prove +} + +func newProveAccountsDriveCommand() *cobra.Command { + var opts proveAccountsDriveOptions + cmd := &cobra.Command{ + Use: "accounts-drive", + Short: "Generate accounts-drive root and account withdrawal proofs", + RunE: func(cmd *cobra.Command, _ []string) error { + return runProveAccountsDrive(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&opts.Snapshot, "snapshot", "", "Stored machine snapshot path") + cmd.Flags().StringVar(&opts.Account, "account", "", "Account address to prove") + cmd.Flags().Uint64Var(&opts.AccountsDriveStartIndex, "accounts-drive-start-index", 0, "Accounts-drive start index") + cmd.Flags().Uint8Var(&opts.Log2MaxNumOfAccounts, "log2-max-num-of-accounts", accountdrive.DefaultLog2MaxAccount, + "Log2 of max number of accounts") + cmd.Flags().Uint8Var(&opts.Log2LeavesPerAccount, "log2-leaves-per-account", 0, "Log2 of leaves per account") + cmd.Flags().StringVar(&opts.OutDriveRootProof, "out-drive-root-proof", "", "Output JSON for prove-drive-root") + cmd.Flags().StringVar(&opts.OutWithdrawProof, "out-withdraw-proof", "", "Output JSON for withdraw") + cmd.Flags().StringVar(&opts.CartesiMachine, "cartesi-machine", "cartesi-machine", "cartesi-machine executable") + cobra.CheckErr(cmd.MarkFlagRequired("snapshot")) + cobra.CheckErr(cmd.MarkFlagRequired("account")) + cobra.CheckErr(cmd.MarkFlagRequired("out-drive-root-proof")) + cobra.CheckErr(cmd.MarkFlagRequired("out-withdraw-proof")) + return cmd +} + +type proveAccountsDriveOptions struct { + Snapshot string + Account string + AccountsDriveStartIndex uint64 + Log2MaxNumOfAccounts uint8 + Log2LeavesPerAccount uint8 + OutDriveRootProof string + OutWithdrawProof string + CartesiMachine string +} + +func runProveAccountsDrive(ctx context.Context, opts proveAccountsDriveOptions) error { + if !common.IsHexAddress(opts.Account) { + return fmt.Errorf("invalid account address %q", opts.Account) + } + account := common.HexToAddress(opts.Account) + log2DriveSize := accountdrive.Log2AccountSize + opts.Log2MaxNumOfAccounts + opts.Log2LeavesPerAccount + driveSize, err := accountdrive.DriveSize(opts.Log2MaxNumOfAccounts, opts.Log2LeavesPerAccount) + if err != nil { + return err + } + driveStart := opts.AccountsDriveStartIndex << log2DriveSize + + drivePath, err := findStoredDrive(opts.Snapshot, driveStart, driveSize) + if err != nil { + return err + } + drive, err := os.ReadFile(drivePath) //nolint:gosec + if err != nil { + return fmt.Errorf("read accounts drive %s: %w", drivePath, err) + } + if uint64(len(drive)) > driveSize { + drive = drive[:driveSize] + } + + accountProof, err := accountdrive.BuildProof(drive, account, opts.Log2MaxNumOfAccounts, opts.Log2LeavesPerAccount) + if err != nil { + return err + } + machineProof, err := generateMachineProof(ctx, opts.CartesiMachine, opts.Snapshot, driveStart, log2DriveSize) + if err != nil { + return err + } + if !strings.EqualFold(strip0x(machineProof.TargetHash), accountProof.DriveRoot.Hex()[2:]) { + return fmt.Errorf("accounts-drive root mismatch: machine proof has %s, local drive has %s", + ensure0x(machineProof.TargetHash), accountProof.DriveRoot.Hex()) + } + + if err := writeDriveRootProof(opts.OutDriveRootProof, machineProof); err != nil { + return err + } + if err := writeWithdrawProof(opts.OutWithdrawProof, accountProof); err != nil { + return err + } + + summary := struct { + Account common.Address `json:"account"` + AccountIndex string `json:"account_index"` + AccountsDriveMerkleRoot string `json:"accounts_drive_merkle_root"` + MachineRoot string `json:"machine_root"` + DriveRootProofFile string `json:"drive_root_proof_file"` + WithdrawProofFile string `json:"withdraw_proof_file"` + }{ + Account: account, + AccountIndex: fmt.Sprintf("0x%x", accountProof.AccountIndex), + AccountsDriveMerkleRoot: accountProof.DriveRoot.Hex(), + MachineRoot: ensure0x(machineProof.RootHash), + DriveRootProofFile: opts.OutDriveRootProof, + WithdrawProofFile: opts.OutWithdrawProof, + } + return json.NewEncoder(os.Stdout).Encode(summary) +} + +type storedDriveConfig struct { + BackingStore struct { + DataFilename string `json:"data_filename"` + } `json:"backing_store"` + Length uint64 `json:"length"` + Start uint64 `json:"start"` +} + +type storedMachineConfig struct { + Config struct { + FlashDrive []storedDriveConfig `json:"flash_drive"` + NVRAM []storedDriveConfig `json:"nvram"` + } `json:"config"` +} + +func findStoredDrive(snapshot string, start uint64, length uint64) (string, error) { + raw, err := os.ReadFile(filepath.Join(snapshot, "config.json")) //nolint:gosec + if err != nil { + return "", fmt.Errorf("read stored machine config: %w", err) + } + var cfg storedMachineConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", fmt.Errorf("parse stored machine config: %w", err) + } + for _, drives := range [][]storedDriveConfig{cfg.Config.FlashDrive, cfg.Config.NVRAM} { + for _, drive := range drives { + if drive.Start == start && drive.Length >= length { + return filepath.Join(snapshot, strings.TrimPrefix(drive.BackingStore.DataFilename, "./")), nil + } + } + } + return "", fmt.Errorf("accounts drive not found in stored machine: start=0x%x length=0x%x", start, length) +} + +type cartesiMachineProof struct { + TargetAddress uint64 `json:"target_address"` + Log2TargetSize uint8 `json:"log2_target_size"` + Log2RootSize uint8 `json:"log2_root_size"` + TargetHash string `json:"target_hash"` + SiblingHashes []string `json:"sibling_hashes"` + RootHash string `json:"root_hash"` +} + +func generateMachineProof( + ctx context.Context, + cartesiMachine string, + snapshot string, + address uint64, + log2Size uint8, +) (*cartesiMachineProof, error) { + tmp, err := os.CreateTemp("", "cartesi-rollups-machine-proof-*.json") + if err != nil { + return nil, fmt.Errorf("create proof temp file: %w", err) + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) //nolint:errcheck + + args := []string{ + "--quiet", + "--no-revert", + "--load=" + snapshot, + fmt.Sprintf("--initial-proof=address:0x%x,log2_size:%d,filename:%s", address, log2Size, tmpPath), + "--", + "/bin/true", + } + if err := runCommand(ctx, cartesiMachine, args...); err != nil { + return nil, err + } + + raw, err := os.ReadFile(tmpPath) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("read machine proof: %w", err) + } + var proof cartesiMachineProof + if err := json.Unmarshal(raw, &proof); err != nil { + return nil, fmt.Errorf("parse machine proof: %w", err) + } + + // convert the file hashes from base64 to hex + proof.RootHash, err = base64ToHex(proof.RootHash) + if err != nil { + return nil, err + } + proof.TargetHash, err = base64ToHex(proof.TargetHash) + if err != nil { + return nil, err + } + for i := range proof.SiblingHashes { + proof.SiblingHashes[i], err = base64ToHex(proof.SiblingHashes[i]) + if err != nil { + return nil, err + } + } + + return &proof, nil +} + +func writeDriveRootProof(path string, proof *cartesiMachineProof) error { + out := struct { + AccountsDriveMerkleRoot string `json:"accounts_drive_merkle_root"` + Proof []string `json:"proof"` + }{ + AccountsDriveMerkleRoot: ensure0x(proof.TargetHash), + Proof: make([]string, len(proof.SiblingHashes)), + } + for i, sibling := range proof.SiblingHashes { + out.Proof[i] = ensure0x(sibling) + } + return writeJSON(path, out) +} + +func writeWithdrawProof(path string, proof *accountdrive.Proof) error { + out := struct { + Account string `json:"account"` + AccountIndex string `json:"account_index"` + AccountRootSiblings []string `json:"account_root_siblings"` + }{ + Account: hexutil.Encode(proof.Account[:]), + AccountIndex: fmt.Sprintf("0x%x", proof.AccountIndex), + AccountRootSiblings: make([]string, len(proof.Siblings)), + } + for i, sibling := range proof.Siblings { + out.AccountRootSiblings[i] = sibling.Hex() + } + return writeJSON(path, out) +} + +func writeJSON(path string, value any) error { + raw, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + if err := os.WriteFile(path, raw, 0600); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +func runCommand(ctx context.Context, name string, args ...string) error { + return runCommandWithEnv(ctx, name, nil, args...) +} + +func runCommandWithEnv(ctx context.Context, name string, env []string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Stdout = ioDiscardUnlessDebug() + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s failed: %w\n%s", name, strings.Join(args, " "), err, stderr.String()) + } + return nil +} + +func ioDiscardUnlessDebug() *bytes.Buffer { + return &bytes.Buffer{} +} + +func readStoredMachineRoot(ctx context.Context, cartesiMachine string, store string) (string, error) { + const minProofLog2Size = 5 + proof, err := generateMachineProof(ctx, cartesiMachine, store, 0, minProofLog2Size) + if err != nil { + return "", fmt.Errorf("read stored machine root: %w", err) + } + return ensure0x(proof.RootHash), nil +} + +func ensure0x(s string) string { + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + return "0x" + s[2:] + } + return "0x" + s +} + +func strip0x(s string) string { + return strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X") +} + +func base64ToHex(s string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", fmt.Errorf("expected %q to be a hash in base64. %w", s, err) + } + if len(raw) != common.HashLength { + return "", fmt.Errorf("expected %q to decode to %d bytes, got %d", s, common.HashLength, len(raw)) + } + return common.BytesToHash(raw).Hex(), nil +} diff --git a/cmd/cartesi-rollups-machine-tool/main_test.go b/cmd/cartesi-rollups-machine-tool/main_test.go new file mode 100644 index 000000000..8cd7c34fc --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/main_test.go @@ -0,0 +1,153 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindStoredDrive_MemoryRanges_ReturnsBackingFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + }{ + { + name: "flash drive without nvram field", + config: `{"config":{"flash_drive":[ + {"start":65536,"length":8192,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + { + name: "nvram without flash drive field", + config: `{"config":{"nvram":[ + {"start":65536,"length":8192,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + { + name: "nvram with empty flash drive list", + config: `{"config":{"flash_drive":[],"nvram":[ + {"start":65536,"length":8192,"backing_store":{"data_filename":"accounts.bin"}} + ]}}`, + }, + { + name: "nvram among unrelated flash drives and nvrams", + config: `{"config":{"flash_drive":[ + {"start":131072,"length":8192,"backing_store":{"data_filename":"./root.bin"}} + ],"nvram":[ + {"start":262144,"length":8192,"backing_store":{"data_filename":"./other.bin"}}, + {"start":65536,"length":8192,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + { + name: "flash drive in mixed configuration", + config: `{"config":{"flash_drive":[ + {"start":65536,"length":8192,"backing_store":{"data_filename":"./accounts.bin"}} + ],"nvram":[ + {"start":131072,"length":8192,"backing_store":{"data_filename":"./other.bin"}} + ]}}`, + }, + { + name: "nvram larger than requested range", + config: `{"config":{"nvram":[ + {"start":65536,"length":16384,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + snapshot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(snapshot, "config.json"), []byte(tt.config), 0600)) + + path, err := findStoredDrive(snapshot, 65536, 8192) + + require.NoError(t, err) + require.Equal(t, filepath.Join(snapshot, "accounts.bin"), path) + }) + } +} + +func TestFindStoredDrive_NoMatchingRange_ReturnsError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + }{ + { + name: "missing memory ranges", + config: `{"config":{}}`, + }, + { + name: "empty memory ranges", + config: `{"config":{"flash_drive":[],"nvram":[]}}`, + }, + { + name: "flash drive at wrong address", + config: `{"config":{"flash_drive":[ + {"start":131072,"length":8192,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + { + name: "nvram at wrong address", + config: `{"config":{"nvram":[ + {"start":131072,"length":8192,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + { + name: "undersized flash drive", + config: `{"config":{"flash_drive":[ + {"start":65536,"length":4096,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + { + name: "undersized nvram", + config: `{"config":{"nvram":[ + {"start":65536,"length":4096,"backing_store":{"data_filename":"./accounts.bin"}} + ]}}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + snapshot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(snapshot, "config.json"), []byte(tt.config), 0600)) + + path, err := findStoredDrive(snapshot, 65536, 8192) + + require.ErrorContains(t, err, "accounts drive not found in stored machine: start=0x10000 length=0x2000") + require.Empty(t, path) + }) + } +} + +func TestFindStoredDrive_MissingConfig_ReturnsReadError(t *testing.T) { + t.Parallel() + + path, err := findStoredDrive(t.TempDir(), 65536, 8192) + + require.ErrorIs(t, err, os.ErrNotExist) + require.ErrorContains(t, err, "read stored machine config") + require.Empty(t, path) +} + +func TestFindStoredDrive_InvalidConfig_ReturnsParseError(t *testing.T) { + t.Parallel() + + snapshot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(snapshot, "config.json"), []byte(`{"config":`), 0600)) + + path, err := findStoredDrive(snapshot, 65536, 8192) + + require.ErrorContains(t, err, "parse stored machine config") + require.Empty(t, path) +} diff --git a/cmd/cartesi-rollups-machine-tool/replay_dave.go b/cmd/cartesi-rollups-machine-tool/replay_dave.go new file mode 100644 index 000000000..ea8571346 --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/replay_dave.go @@ -0,0 +1,158 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "context" + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/cartesi/rollups-node/internal/model" +) + +//go:embed replay_dave.lua +var replayDaveLua string + +func replayDaveInputs(ctx context.Context, opts replayOptions, tmp string, inputs []*model.Input) error { + manifest, err := writeReplayInputManifest(tmp, inputs) + if err != nil { + return err + } + script := filepath.Join(tmp, "replay-dave.lua") + if err := os.WriteFile(script, []byte(replayDaveLua), 0600); err != nil { + return fmt.Errorf("write Dave replay Lua script: %w", err) + } + + args := []string{ + script, + "--template", opts.Template, + "--store", opts.Store, + "--inputs-manifest", manifest, + "--marchid", strconv.FormatUint(model.CartesiMachineMarchID, 10), + } + return runCommandWithEnv(ctx, opts.Lua, replayDaveLuaEnv(opts), args...) +} + +func writeReplayInputManifest(tmp string, inputs []*model.Input) (string, error) { + paths, err := writeReplayInputFiles(tmp, inputs) + if err != nil { + return "", err + } + manifest := filepath.Join(tmp, "inputs.txt") + var contents strings.Builder + for _, path := range paths { + contents.WriteString(path) + contents.WriteByte('\n') + } + if err := os.WriteFile(manifest, []byte(contents.String()), 0600); err != nil { + return "", fmt.Errorf("write Dave replay input manifest: %w", err) + } + return manifest, nil +} + +func writeReplayInputFiles(tmp string, inputs []*model.Input) ([]string, error) { + paths := make([]string, len(inputs)) + for i, input := range inputs { + path := filepath.Join(tmp, fmt.Sprintf("input-%d.bin", i)) + if err := os.WriteFile(path, input.RawData, 0600); err != nil { + return nil, fmt.Errorf("write replay input %d: %w", i, err) + } + paths[i] = path + } + return paths, nil +} + +func replayDaveLuaEnv(opts replayOptions) []string { + sdkRoot := opts.CartesiSDKRoot + if sdkRoot == "" { + sdkRoot = detectCartesiSDKRoot(opts.CartesiMachine) + } + if sdkRoot == "" { + return nil + } + + luaPath := filepath.Join(sdkRoot, "share", "lua", "5.4", "?.lua") + luaCPath := filepath.Join(sdkRoot, "lib", "lua", "5.4", "?.so") + env := []string{ + "LUA_PATH_5_4=" + prependLuaSearchPath(os.Getenv("LUA_PATH_5_4"), luaPath), + "LUA_CPATH_5_4=" + prependLuaSearchPath(os.Getenv("LUA_CPATH_5_4"), luaCPath), + "LUA_PATH=" + prependLuaSearchPath(os.Getenv("LUA_PATH"), luaPath), + "LUA_CPATH=" + prependLuaSearchPath(os.Getenv("LUA_CPATH"), luaCPath), + } + if os.Getenv("CARTESI_IMAGES_PATH") == "" { + env = append(env, "CARTESI_IMAGES_PATH="+filepath.Join(sdkRoot, "share", "cartesi-machine", "images")) + } + return env +} + +func prependLuaSearchPath(current string, entry string) string { + if current == "" { + return entry + ";;" + } + if luaSearchPathContains(current, entry) { + return current + } + return entry + ";" + current +} + +func luaSearchPathContains(path string, entry string) bool { + for _, part := range strings.Split(path, ";") { + if part == entry { + return true + } + } + return false +} + +func detectCartesiSDKRoot(cartesiMachine string) string { + path, err := exec.LookPath(cartesiMachine) + if err != nil { + path = cartesiMachine + } + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + + if root := detectCartesiSDKRootFromFile(path); root != "" { + return root + } + for _, root := range cartesiSDKFallbackRoots { + if fileExists(filepath.Join(root, "share", "lua", "5.4", "cartesi.lua")) { + return root + } + } + return "" +} + +var ( + cartesiSDKFallbackRoots = []string{"/opt/cartesi"} + cartesiMachineLuaPattern = regexp.MustCompile(`["']([^"']*/share/lua/5\.4/cartesi-machine\.lua)["']`) + cartesiLuaPathPattern = regexp.MustCompile(`["']?([^"'\s;]*/share/lua/5\.4)/\?\.lua`) +) + +func detectCartesiSDKRootFromFile(path string) string { + raw, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return "" + } + text := string(raw) + if match := cartesiMachineLuaPattern.FindStringSubmatch(text); len(match) == 2 { + return strings.TrimSuffix(match[1], "/share/lua/5.4/cartesi-machine.lua") + } + if match := cartesiLuaPathPattern.FindStringSubmatch(text); len(match) == 2 { + return strings.TrimSuffix(match[1], "/share/lua/5.4") + } + return "" +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/cmd/cartesi-rollups-machine-tool/replay_dave.lua b/cmd/cartesi-rollups-machine-tool/replay_dave.lua new file mode 100644 index 000000000..f8c8f1b94 --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/replay_dave.lua @@ -0,0 +1,109 @@ +-- (c) Cartesi and individual authors (see AUTHORS) +-- SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +local cartesi = require("cartesi") + +local function usage(message) + if message then io.stderr:write(message, "\n") end + io.stderr:write( + "usage: replay_dave.lua --template --store --inputs-manifest --marchid \n" + ) + os.exit(2) +end + +local function parse_args(args) + local opts = {} + local i = 1 + while i <= #args do + local key = args[i] + local value = args[i + 1] + if key == "--template" then + opts.template = value + elseif key == "--store" then + opts.store = value + elseif key == "--inputs-manifest" then + opts.inputs_manifest = value + elseif key == "--marchid" then + opts.marchid = value + else + usage("unknown argument: " .. tostring(key)) + end + if not value then usage("missing value for " .. tostring(key)) end + i = i + 2 + end + if not opts.template then usage("missing --template") end + if not opts.store then usage("missing --store") end + if not opts.inputs_manifest then usage("missing --inputs-manifest") end + if not opts.marchid then usage("missing --marchid") end + return opts +end + +local function read_all(path) + local file = assert(io.open(path, "rb")) + return assert(file:read("*a")) +end + +local function read_input_paths(path) + local inputs = {} + for line in io.lines(path) do + if line ~= "" then inputs[#inputs + 1] = line end + end + return inputs +end + +local function run_until_manual_yield(machine) + while true do + local break_reason = machine:run(math.maxinteger) + if break_reason == cartesi.BREAK_REASON_YIELDED_MANUALLY then + return + elseif break_reason == cartesi.BREAK_REASON_YIELDED_AUTOMATICALLY then + machine:receive_cmio_request() + elseif break_reason == cartesi.BREAK_REASON_HALTED then + error("machine halted before a manual yield") + elseif break_reason == cartesi.BREAK_REASON_FAILED then + error("machine failed before a manual yield") + elseif break_reason ~= cartesi.BREAK_REASON_YIELDED_SOFTLY then + error("unexpected machine break reason: " .. tostring(break_reason)) + end + end +end + +local function ensure_manual_yield(machine) + if machine:read_reg("iflags_Y") == 0 then + run_until_manual_yield(machine) + end +end + +local function advance_one(machine, input_path, input_number) + local checkpoint = machine:get_root_hash() + machine:send_cmio_response(cartesi.HTIF_YIELD_REASON_ADVANCE_STATE, read_all(input_path), checkpoint) + run_until_manual_yield(machine) + + local _, reason, data = machine:receive_cmio_request() + if reason == cartesi.HTIF_YIELD_MANUAL_REASON_RX_REJECTED then + error(string.format("Dave replay input %d was rejected", input_number)) + elseif reason == cartesi.HTIF_YIELD_MANUAL_REASON_TX_EXCEPTION then + error(string.format("Dave replay input %d raised an exception", input_number)) + elseif reason ~= cartesi.HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED then + error(string.format("Dave replay input %d ended with unexpected yield reason %s", input_number, tostring(reason))) + end + if #data ~= 32 then + error(string.format("Dave replay input %d returned an invalid outputs hash length: %d", input_number, #data)) + end +end + +local opts = parse_args(arg) +assert( + cartesi.MARCHID == tonumber(opts.marchid), + string.format("Cartesi Lua module MARCHID %s does not match required MARCHID %s (module: %s)", + tostring(cartesi.MARCHID), tostring(opts.marchid), tostring(package.searchpath("cartesi", package.cpath))) +) +local machine = cartesi.machine(opts.template) +ensure_manual_yield(machine) + +for index, input_path in ipairs(read_input_paths(opts.inputs_manifest)) do + advance_one(machine, input_path, index - 1) +end + +machine:get_root_hash() +machine:store(opts.store) diff --git a/cmd/cartesi-rollups-machine-tool/replay_dave_test.go b/cmd/cartesi-rollups-machine-tool/replay_dave_test.go new file mode 100644 index 000000000..d20cf4d66 --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/replay_dave_test.go @@ -0,0 +1,212 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReplayDaveInputs_PassesPinnedMarchID(t *testing.T) { + tmp := t.TempDir() + arguments := filepath.Join(tmp, "arguments") + lua := filepath.Join(tmp, "lua") + //nolint:gosec // This test-only executable records the real replay call arguments. + require.NoError(t, os.WriteFile(lua, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$REPLAY_ARGUMENTS\"\n"), 0700)) + t.Setenv("REPLAY_ARGUMENTS", arguments) + opts := replayOptions{Lua: lua, Template: "template with spaces", Store: "stored machine"} + + require.NoError(t, replayDaveInputs(t.Context(), opts, tmp, nil)) + + raw, err := os.ReadFile(arguments) + require.NoError(t, err) + require.Equal(t, []string{ + filepath.Join(tmp, "replay-dave.lua"), + "--template", opts.Template, + "--store", opts.Store, + "--inputs-manifest", filepath.Join(tmp, "inputs.txt"), + "--marchid", strconv.FormatUint(model.CartesiMachineMarchID, 10), + }, strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")) + embedded, err := os.ReadFile(filepath.Join(tmp, "replay-dave.lua")) + require.NoError(t, err) + require.Equal(t, replayDaveLua, string(embedded)) +} + +func TestCartesiSDKFallbackRoots_OnlyCurrentInstall(t *testing.T) { + t.Parallel() + + require.Equal(t, []string{"/opt/cartesi"}, cartesiSDKFallbackRoots) +} + +func TestReplayDaveLua_ChecksMarchIDBeforeLoad(t *testing.T) { + t.Parallel() + + check := strings.Index(replayDaveLua, "assert(\n cartesi.MARCHID == tonumber(opts.marchid),") + load := strings.Index(replayDaveLua, "cartesi.machine(opts.template)") + require.NotEqual(t, -1, check, "the embedded script must assert numeric module identity") + require.Greater(t, load, check, "refuse an incompatible module before loading the template") + require.Contains(t, replayDaveLua, `tostring(cartesi.MARCHID), tostring(opts.marchid)`) + require.Contains(t, replayDaveLua, `package.searchpath("cartesi", package.cpath)`) +} + +func TestReplayDaveLua_RealModuleMarchID(t *testing.T) { + lua, err := exec.LookPath("lua5.4") + if err != nil { + t.Skip("lua5.4 is unavailable") + } + env := append(os.Environ(), replayDaveLuaEnv(replayOptions{CartesiMachine: "cartesi-machine"})...) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + probe := exec.CommandContext(ctx, lua, "-e", `local c=require("cartesi"); +io.write(tostring(c.MARCHID), "\n", tostring(package.searchpath("cartesi", package.cpath)), "\n")`) + probe.Env = env + output, err := probe.CombinedOutput() + if err != nil && strings.Contains(string(output), "module 'cartesi' not found") { + t.Skipf("cartesi Lua module is unavailable: %s", output) + } + require.NoError(t, err, "%s", output) + module := strings.Split(strings.TrimSpace(string(output)), "\n") + require.Len(t, module, 2) + require.Equal(t, "22", module[0], "this fixture requires the pinned emulator module") + require.NotEqual(t, "nil", module[1]) + + tmp := t.TempDir() + script := filepath.Join(tmp, "replay.lua") + require.NoError(t, os.WriteFile(script, []byte(replayDaveLua), 0600)) + // Keep the real module and embedded admission code. Stop only at machine creation. + shim := filepath.Join(tmp, "check.lua") + require.NoError(t, os.WriteFile(shim, []byte(`local cartesi=require("cartesi") +cartesi.machine=function(template) + assert(template == "unused-template") + io.write("REPLAY_MACHINE_CREATION_REACHED\n") + os.exit(0) +end +local script=table.remove(arg, 1) +dofile(script) +`), 0600)) + for _, marchID := range []string{"22", "21"} { + t.Run(marchID, func(t *testing.T) { + cmd := exec.CommandContext(ctx, lua, shim, script, + "--template", "unused-template", "--store", "unused-store", + "--inputs-manifest", "unused-inputs", "--marchid", marchID) + cmd.Env = env + output, err := cmd.CombinedOutput() + if marchID == "22" { + require.NoError(t, err, "%s", output) + require.Equal(t, "REPLAY_MACHINE_CREATION_REACHED\n", string(output)) + return + } + require.Error(t, err, "an incompatible module must be refused before machine creation") + require.Contains(t, string(output), "module MARCHID 22") + require.Contains(t, string(output), "required MARCHID 21") + require.Contains(t, string(output), module[1]) + require.NotContains(t, string(output), "REPLAY_MACHINE_CREATION_REACHED") + }) + } +} + +func TestDetectCartesiSDKRootFromFile_WrapperWithCartesiMachineLua_ReturnsRoot(t *testing.T) { + t.Parallel() + + sdkRoot := filepath.Join(t.TempDir(), "cartesi-sdk") + luaDir := filepath.Join(sdkRoot, "share", "lua", "5.4") + wrapper := filepath.Join(t.TempDir(), "cartesi-machine") + contents := `#!/bin/sh +export LUA_PATH_5_4="` + luaDir + `/?.lua;${LUA_PATH_5_4:-;}" +lua5.4 "` + filepath.Join(luaDir, "cartesi-machine.lua") + `" "$@" +` + require.NoError(t, os.WriteFile(wrapper, []byte(contents), 0600)) + + assert.Equal(t, sdkRoot, detectCartesiSDKRootFromFile(wrapper)) +} + +func TestPrependLuaSearchPath_EmptyPath_KeepsLuaDefaultFallback(t *testing.T) { + t.Parallel() + + assert.Equal(t, "/opt/cartesi-sdk21/share/lua/5.4/?.lua;;", + prependLuaSearchPath("", "/opt/cartesi-sdk21/share/lua/5.4/?.lua")) +} + +func TestPrependLuaSearchPath_ExistingPath_PrependsWithoutDroppingExisting(t *testing.T) { + t.Parallel() + + got := prependLuaSearchPath("/custom/?.lua", "/opt/cartesi-sdk21/share/lua/5.4/?.lua") + + assert.Equal(t, "/opt/cartesi-sdk21/share/lua/5.4/?.lua;/custom/?.lua", got) +} + +func TestPrependLuaSearchPath_AlreadyPresent_DoesNotDuplicate(t *testing.T) { + t.Parallel() + + path := "/opt/cartesi-sdk21/share/lua/5.4/?.lua;/custom/?.lua" + + assert.Equal(t, path, prependLuaSearchPath(path, "/opt/cartesi-sdk21/share/lua/5.4/?.lua")) +} + +func TestReplayDaveLuaEnv_AutoDetectsSDKRootFromCartesiMachineWrapper(t *testing.T) { + sdkRoot := filepath.Join(t.TempDir(), "cartesi-sdk") + luaDir := filepath.Join(sdkRoot, "share", "lua", "5.4") + wrapper := filepath.Join(t.TempDir(), "cartesi-machine") + contents := `#!/bin/sh +export LUA_PATH_5_4="` + luaDir + `/?.lua;${LUA_PATH_5_4:-;}" +export LUA_CPATH_5_4="` + filepath.Join(sdkRoot, "lib", "lua", "5.4") + `/?.so;${LUA_CPATH_5_4:-;}" +lua5.4 "` + filepath.Join(luaDir, "cartesi-machine.lua") + `" "$@" +` + require.NoError(t, os.WriteFile(wrapper, []byte(contents), 0600)) + t.Setenv("LUA_PATH_5_4", "/custom/?.lua") + t.Setenv("LUA_CPATH_5_4", "") + t.Setenv("LUA_PATH", "") + t.Setenv("LUA_CPATH", "") + t.Setenv("CARTESI_IMAGES_PATH", "") + + env := replayDaveLuaEnv(replayOptions{CartesiMachine: wrapper}) + envByKey := envMap(env) + + assert.Equal(t, filepath.Join(luaDir, "?.lua")+";/custom/?.lua", envByKey["LUA_PATH_5_4"]) + assert.Equal(t, filepath.Join(sdkRoot, "lib", "lua", "5.4", "?.so")+";;", envByKey["LUA_CPATH_5_4"]) + assert.Equal(t, filepath.Join(luaDir, "?.lua")+";;", envByKey["LUA_PATH"]) + assert.Equal(t, filepath.Join(sdkRoot, "share", "cartesi-machine", "images"), envByKey["CARTESI_IMAGES_PATH"]) +} + +func TestWriteReplayInputManifest_WritesPayloadsAndManifest(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + inputs := []*model.Input{ + {RawData: []byte{0x01, 0x02}}, + {RawData: []byte("cartesi")}, + } + + manifest, err := writeReplayInputManifest(tmp, inputs) + require.NoError(t, err) + + raw, err := os.ReadFile(manifest) + require.NoError(t, err) + paths := strings.Split(strings.TrimSpace(string(raw)), "\n") + require.Len(t, paths, len(inputs)) + for i, path := range paths { + payload, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, inputs[i].RawData, payload) + } +} + +func envMap(env []string) map[string]string { + result := make(map[string]string, len(env)) + for _, item := range env { + key, value, _ := strings.Cut(item, "=") + result[key] = value + } + return result +} diff --git a/cmd/cartesi-rollups-machine-tool/replay_store_test.go b/cmd/cartesi-rollups-machine-tool/replay_store_test.go new file mode 100644 index 000000000..5d66ce0eb --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/replay_store_test.go @@ -0,0 +1,161 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const replayStoredRootOffset = int64(0x60) + +// The guest accepts one input and changes registers and RX memory. The reference +// uses a separate real machine, so reading its root cannot refresh the replay. +const replayStoreFixtureLua = `local cartesi = require("cartesi") +local function hex(data) + return (data:gsub(".", function(c) return string.format("%02x", string.byte(c)) end)) +end +local mode, directory = arg[1], arg[2] +if mode == "create" then + local machine = cartesi.machine({ + processor = {registers = {pc = 0x80000000}}, + ram = {length = 4096}, + cmio = {rx_buffer = {}, tx_buffer = {}}, + }) + machine:write_memory(0x80000000, string.pack(" = cartesi.machine(directory) + local input = assert(io.open(arg[3], "rb")) + local data = assert(input:read("*a")) + local checkpoint = machine:get_root_hash() + machine:send_cmio_response(cartesi.HTIF_YIELD_REASON_ADVANCE_STATE, data, checkpoint) + assert(machine:run(math.maxinteger) == cartesi.BREAK_REASON_YIELDED_MANUALLY) + local command, reason, response = machine:receive_cmio_request() + assert(command == cartesi.HTIF_YIELD_CMD_MANUAL) + assert(reason == cartesi.HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED) + assert(#response == 32) + io.write(hex(machine:get_root_hash()), "\n") +elseif mode == "reload" then + local machine = cartesi.machine(directory) + io.write(hex(machine:get_root_hash()), "\n") +else + error("unknown fixture mode") +end +` + +func TestReplayDaveLua_StoreKeepsFinalRoot(t *testing.T) { + lua, err := exec.LookPath("lua5.4") + if err != nil { + t.Skip("lua5.4 is unavailable") + } + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + opts := replayOptions{Lua: lua, CartesiMachine: "cartesi-machine"} + env := append(os.Environ(), replayDaveLuaEnv(opts)...) + probe := exec.CommandContext(ctx, lua, "-e", `local c=require("cartesi"); print(c.MARCHID)`) + probe.Env = env + output, err := probe.CombinedOutput() + if err != nil && strings.Contains(string(output), "module 'cartesi' not found") { + t.Skipf("cartesi Lua module is unavailable: %s", output) + } + require.NoError(t, err, "%s", output) + require.Equal(t, "22", strings.TrimSpace(string(output))) + + tmp := t.TempDir() + fixture := filepath.Join(tmp, "fixture.lua") + require.NoError(t, os.WriteFile(fixture, []byte(replayStoreFixtureLua), 0600)) + opts.Template, opts.Store = filepath.Join(tmp, "template"), filepath.Join(tmp, "replayed") + templateRoot := runReplayStoreFixture(ctx, t, env, fixture, "create", opts.Template) + require.Equal(t, templateRoot, readReplayStoredRoot(t, opts.Template)) + templateFiles := replayArchiveHashes(t, opts.Template) + input := []byte("one accepted input") + inputPath := filepath.Join(tmp, "input.bin") + require.NoError(t, os.WriteFile(inputPath, input, 0600)) + finalRoot := runReplayStoreFixture(ctx, t, env, fixture, "reference", opts.Template, inputPath) + require.NotEqual(t, templateRoot, finalRoot, "the fixture must change the machine state") + + require.NoError(t, replayDaveInputs(ctx, opts, tmp, []*model.Input{{RawData: input}})) + // Inspect persisted metadata before any reload can recompute dirty nodes. + storedRoot := readReplayStoredRoot(t, opts.Store) + assert.Equal(t, finalRoot, storedRoot, "stored SHT header root must match the final replay root before reload") + // Keep this diagnostic after a failed header assertion: reload can conceal it. + reloadedRoot := runReplayStoreFixture(ctx, t, env, fixture, "reload", opts.Store) + assert.Equal(t, finalRoot, reloadedRoot) + unchanged := assert.Equal(t, templateFiles, replayArchiveHashes(t, opts.Template), "source template must remain intact") + t.Logf("template_root=%x reference_final_root=%x raw_stored_root=%x reloaded_root=%x template_unchanged=%t", + templateRoot, finalRoot, storedRoot, reloadedRoot, unchanged) +} + +func runReplayStoreFixture(ctx context.Context, t *testing.T, env []string, fixture string, args ...string) [32]byte { + t.Helper() + cmd := exec.CommandContext(ctx, "lua5.4", append([]string{fixture}, args...)...) //nolint:gosec // Only private test fixtures. + cmd.Env = env + output, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", output) + decoded, err := hex.DecodeString(strings.TrimSpace(string(output))) + require.NoError(t, err) + var root [32]byte + require.Len(t, decoded, len(root)) + copy(root[:], decoded) + return root +} + +func readReplayStoredRoot(t *testing.T, directory string) [32]byte { + t.Helper() + file, err := os.Open(filepath.Join(directory, "hash_tree.sht")) + require.NoError(t, err) + defer func() { assert.NoError(t, file.Close()) }() + var root [32]byte + n, err := file.ReadAt(root[:], replayStoredRootOffset) + require.NoError(t, err) + require.Equal(t, len(root), n) + return root +} + +func replayArchiveHashes(t *testing.T, directory string) map[string][32]byte { + t.Helper() + root, err := os.OpenRoot(directory) + require.NoError(t, err) + defer func() { assert.NoError(t, root.Close()) }() + hashes := make(map[string][32]byte) + err = fs.WalkDir(root.FS(), ".", func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + raw, err := root.ReadFile(path) + if err != nil { + return err + } + hashes[path] = sha256.Sum256(raw) + return nil + }) + require.NoError(t, err) + return hashes +} diff --git a/cmd/cartesi-rollups-machine-tool/secret_output_test.go b/cmd/cartesi-rollups-machine-tool/secret_output_test.go new file mode 100644 index 000000000..609481899 --- /dev/null +++ b/cmd/cartesi-rollups-machine-tool/secret_output_test.go @@ -0,0 +1,18 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/test/tooling/command" +) + +func TestSecretCommandProcess(t *testing.T) { + command.Child(t, main, newRootCommand()) +} + +func TestSecretCommandHelp(t *testing.T) { + command.CheckHelp(t, "machine-tool") +} diff --git a/cmd/cartesi-rollups-node/main.go b/cmd/cartesi-rollups-node/main.go index d82391541..fa1d60541 100644 --- a/cmd/cartesi-rollups-node/main.go +++ b/cmd/cartesi-rollups-node/main.go @@ -4,66 +4,14 @@ package main import ( - "context" - "log/slog" "os" - "os/signal" - "syscall" - "time" - "github.com/cartesi/rollups-node/internal/node" - "github.com/cartesi/rollups-node/internal/node/config" - "github.com/lmittmann/tint" - "github.com/mattn/go-isatty" -) - -var ( - // Should be overridden during the final release build with ldflags - // to contain the actual version number - buildVersion = "devel" + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-node/root" ) func main() { - startTime := time.Now() - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - config := config.FromEnv() - - // setup log - opts := &tint.Options{ - Level: config.LogLevel, - AddSource: config.LogLevel == slog.LevelDebug, - NoColor: !config.LogPretty || !isatty.IsTerminal(os.Stdout.Fd()), - TimeFormat: "2006-01-02T15:04:05.000", // RFC3339 with milliseconds and without timezone - } - handler := tint.NewHandler(os.Stdout, opts) - logger := slog.New(handler) - slog.SetDefault(logger) - slog.Info("Starting the Cartesi Rollups Node", "version", buildVersion, "config", config) - - // create the node supervisor - supervisor, err := node.Setup(ctx, config, "") + err := root.Cmd.Execute() if err != nil { - slog.Error("Node exited with an error", "error", err) - os.Exit(1) - } - - // logs startup time - ready := make(chan struct{}, 1) - go func() { - select { - case <-ready: - duration := time.Since(startTime) - slog.Info("Node is ready", "after", duration) - case <-ctx.Done(): - } - }() - - // start supervisor - if err := supervisor.Start(ctx, ready); err != nil { - slog.Error("Node exited with an error", "error", err) os.Exit(1) } } diff --git a/cmd/cartesi-rollups-node/root/root.go b/cmd/cartesi-rollups-node/root/root.go new file mode 100644 index 000000000..4b81d57ee --- /dev/null +++ b/cmd/cartesi-rollups-node/root/root.go @@ -0,0 +1,253 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "context" + "math/big" + + "github.com/cartesi/rollups-node/internal/advancer" + "github.com/cartesi/rollups-node/internal/claimer" + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/inspect" + "github.com/cartesi/rollups-node/internal/jsonrpc" + "github.com/cartesi/rollups-node/internal/manager" + "github.com/cartesi/rollups-node/internal/prt" + "github.com/cartesi/rollups-node/internal/validator" + "github.com/cartesi/rollups-node/internal/version" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/spf13/cobra" +) + +var ( + logLevel string + logColor bool + logLevelAdvancer string + logLevelClaimer string + logLevelEvmReader string + logLevelJsonrpc string + logLevelPrt string + logLevelValidator string + defaultBlockString string + blockchainHTTPEndpoint string + databaseConnection string + evmReaderPollInterval string + advancerPollInterval string + validatorPollInterval string + claimerPollInterval string + prtPollInterval string + maxStartupTime string + enableInspect bool + enableJsonrpc bool + enableSubmission bool + enableMachineHashCheck bool + jsonrpcAPIAddress string + inspectAddress string + telemetryAddress string + machinelogLevel string + cfg *config.NodeConfig + maxBlockRange uint64 +) + +var Cmd = &cobra.Command{ + Use: "cartesi-rollups-" + config.ServiceNode, + Short: "Runs cartesi-rollups-" + config.ServiceNode, + Long: "Runs cartesi-rollups-" + config.ServiceNode + " in standalone mode.\n\n" + + "Use one node deployment per database and one active process per service role.\n" + + "Use one submitting process per signer. Stop the current process before starting its replacement.", + RunE: run, + Version: version.BuildVersion, +} + +func init() { + flags := Cmd.Flags() + + config.SetDefaults() + + cli.AddFlagStrVarP(flags, &defaultBlockString, "default-block", "d", config.BLOCKCHAIN_DEFAULT_BLOCK, + "Default block to be used when fetching new blocks.\n"+ + "One of 'latest', 'safe', 'finalized'; provisional observations have no reorg rollback") + cli.AddFlagStrVar(flags, &jsonrpcAPIAddress, "jsonrpc-address", config.JSONRPC_API_ADDRESS, + "Jsonrpc API service address and port") + cli.AddFlagStrVar(flags, &inspectAddress, "inspect-address", config.INSPECT_ADDRESS, + "Inspect service address and port") + cli.AddFlagStrVar(flags, &telemetryAddress, "telemetry-address", config.NODE_TELEMETRY_ADDRESS, + "Health check and metrics address and port") + cli.AddFlagStrVar(flags, &logLevel, "log-level", config.LOG_LEVEL, + "Log level: debug, info, warn or error") + cli.AddFlagBoolVar(flags, &logColor, "log-color", config.LOG_COLOR, + "Tint the logs (colored output)") + cli.AddFlagStrVar(flags, &logLevelAdvancer, "log-level-advancer", config.LOG_LEVEL_ADVANCER, + "Override log level for the advancer service (default: inherit --log-level)") + cli.AddFlagStrVar(flags, &logLevelClaimer, "log-level-claimer", config.LOG_LEVEL_CLAIMER, + "Override log level for the claimer service (default: inherit --log-level)") + cli.AddFlagStrVar(flags, &logLevelEvmReader, "log-level-evm-reader", config.LOG_LEVEL_EVM_READER, + "Override log level for the evm-reader service (default: inherit --log-level)") + cli.AddFlagStrVar(flags, &logLevelJsonrpc, "log-level-jsonrpc-api", config.LOG_LEVEL_JSONRPC_API, + "Override log level for the jsonrpc-api service (default: inherit --log-level)") + cli.AddFlagStrVar(flags, &logLevelPrt, "log-level-prt", config.LOG_LEVEL_PRT, + "Override log level for the prt service (default: inherit --log-level)") + cli.AddFlagStrVar(flags, &logLevelValidator, "log-level-validator", config.LOG_LEVEL_VALIDATOR, + "Override log level for the validator service (default: inherit --log-level)") + cli.AddFlagStrVar(flags, &databaseConnection, "database-connection", config.DATABASE_CONNECTION, + "Database connection string in the URL format\n(eg.: 'postgres://user:password@hostname:port/database') ") + cli.AddFlagStrVar(flags, &blockchainHTTPEndpoint, "blockchain-http-endpoint", config.BLOCKCHAIN_HTTP_ENDPOINT, + "Blockchain HTTP endpoint") + cli.AddFlagStrVar(flags, &evmReaderPollInterval, "evm-reader-poll-interval", config.EVM_READER_POLLING_INTERVAL, + "EVM reader poll interval") + cli.AddFlagStrVar(flags, &advancerPollInterval, "advancer-poll-interval", config.ADVANCER_POLLING_INTERVAL, + "Advancer poll interval") + cli.AddFlagStrVar(flags, &validatorPollInterval, "validator-poll-interval", config.VALIDATOR_POLLING_INTERVAL, + "Validator poll interval") + cli.AddFlagStrVar(flags, &claimerPollInterval, "claimer-poll-interval", config.CLAIMER_POLLING_INTERVAL, + "Claimer poll interval") + cli.AddFlagStrVar(flags, &prtPollInterval, "prt-poll-interval", config.PRT_POLLING_INTERVAL, + "PRT poll interval") + cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, + "Maximum startup time in seconds") + cli.AddFlagBoolVar(flags, &enableInspect, "inspect-enabled", config.FEATURE_INSPECT_ENABLED, + "Enable or disable the inspect service") + cli.AddFlagBoolVar(flags, &enableJsonrpc, "jsonrpc-enabled", config.FEATURE_JSONRPC_API_ENABLED, + "Enable or disable the jsonrpc api service") + cli.AddFlagBoolVar(flags, &enableMachineHashCheck, "machine-hash-check", config.FEATURE_MACHINE_HASH_CHECK_ENABLED, + "Enable or disable machine hash check (DO NOT USE IN PRODUCTION)") + cli.AddFlagBoolVar(flags, &enableSubmission, "claim-submission", config.FEATURE_CLAIM_SUBMISSION_ENABLED, + "Enable or disable claim submission (reader mode)") + cli.AddFlagStrVar(flags, &machinelogLevel, "machine-log-level", config.JSONRPC_MACHINE_LOG_LEVEL, + "Remote Machine log level: trace, debug, info, warning, error, fatal") + cli.AddFlagUint64Var(flags, &maxBlockRange, "max-block-range", config.BLOCKCHAIN_MAX_BLOCK_RANGE, + "Maximum number of blocks in a single query. large queries will be split automatically. Zero for unlimited.") + + // TODO: validate on preRunE + Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + var err error + cfg, err = config.LoadNodeConfig() + if err != nil { + return err + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) (runErr error) { + ctx, cancel := context.WithTimeout(context.Background(), cfg.MaxStartupTime) + defer cancel() + + // Create shared components + + name := config.ServiceNode + logger := service.NewLogger(name, cfg.LogLevel, cfg.LogColor) + // Return errors to Cobra only after all resource cleanup has completed. + defer func() { cli.LogErr(logger, runErr) }() + cmd.SilenceUsage = true + + var claimerSigner, prtSigner ethutil.TransactOptsFactory + var err error + if cfg.FeatureClaimSubmissionEnabled { + claimerSigner, prtSigner, err = auth.GetNodeTransactOptsFactories(ctx, new(big.Int).SetUint64(cfg.BlockchainId)) + if err != nil { + return err + } + } + + repo, err := cli.OpenRepository(ctx, cfg.DatabaseConnection) + if err != nil { + return err + } + defer repo.Close() + + // create Machine Manager using the same logger used by Advancer + advCfg := cfg.ToAdvancerConfig() + advLogger := service.NewLogger(config.ServiceAdvancer, advCfg.LogLevel, advCfg.LogColor) + machineManager := manager.NewMachineManager( + repo, + advLogger, + cfg.FeatureMachineHashCheckEnabled, + cfg.AdvancerInputBatchSize, + ) + defer machineManager.Close() + + // Create factories of services + + factories := []service.FactoryFunction{ + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + return evmreader.Create(ctx, &evmreader.CreateInfo{ + Config: *cfg.ToEvmreaderConfig(), + Repository: repo, + }) + }, + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + adv, err := advancer.Create(ctx, &advancer.CreateInfo{ + Config: *advCfg, + Logger: advLogger, + Repository: repo, + Machines: machineManager, + Supervisor: sup, + }) + return adv, err + }, + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + return validator.Create(ctx, &validator.CreateInfo{ + Config: *cfg.ToValidatorConfig(), + Repository: repo, + }) + }, + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + return claimer.Create(ctx, &claimer.CreateInfo{ + Config: *cfg.ToClaimerConfig(), + Repository: repo, + TransactOptsFactory: claimerSigner, + }) + }, + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + return prt.Create(ctx, &prt.CreateInfo{ + Config: *cfg.ToPrtConfig(), + Repository: repo, + TransactOptsFactory: prtSigner, + }) + }, + } + + if cfg.FeatureInspectEnabled { + factories = append(factories, + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + return inspect.Create(ctx, &inspect.CreateInfo{ + Config: *advCfg, + Repository: repo, + Machines: machineManager, + }) + }, + ) + } + if cfg.FeatureJsonrpcApiEnabled { + factories = append(factories, + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + cfg := jsonrpc.CreateInfo{ + Config: *cfg.ToJsonrpcConfig(), + Repository: repo, + } + return jsonrpc.Create(ctx, &cfg) + }, + ) + } + + logger.Info("Created", "config", cfg) + + supCfg := &service.SupervisorConfigs{ + BaseConfigs: service.BaseConfigs{Name: name, Logger: logger}, + EnableSignalHandling: true, + TelemetryAddress: cfg.NodeTelemetryAddress, + Factories: factories, + } + sup, err := service.NewSupervisor(ctx, supCfg) + if err != nil { + return err + } + return sup.Serve() +} diff --git a/cmd/cartesi-rollups-node/secret_output_test.go b/cmd/cartesi-rollups-node/secret_output_test.go new file mode 100644 index 000000000..46d873b52 --- /dev/null +++ b/cmd/cartesi-rollups-node/secret_output_test.go @@ -0,0 +1,19 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-node/root" + "github.com/cartesi/rollups-node/test/tooling/command" +) + +func TestSecretCommandProcess(t *testing.T) { + command.Child(t, main, root.Cmd) +} + +func TestSecretCommandHelp(t *testing.T) { + command.CheckHelp(t, "node") +} diff --git a/cmd/cartesi-rollups-prt/main.go b/cmd/cartesi-rollups-prt/main.go new file mode 100644 index 000000000..0ed159118 --- /dev/null +++ b/cmd/cartesi-rollups-prt/main.go @@ -0,0 +1,17 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-prt/root" +) + +func main() { + err := root.Cmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/cmd/cartesi-rollups-prt/root/root.go b/cmd/cartesi-rollups-prt/root/root.go new file mode 100644 index 000000000..ad1b7a7a8 --- /dev/null +++ b/cmd/cartesi-rollups-prt/root/root.go @@ -0,0 +1,86 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "context" + "log/slog" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/prt" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/version" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/spf13/cobra" +) + +var ( + logLevel string + logColor bool + databaseConnection string + defaultBlockString string + pollInterval string + maxStartupTime string + telemetryAddress string + cfg *config.PrtConfig +) + +var Cmd = &cobra.Command{ + Use: "cartesi-rollups-" + config.ServicePrt, + Short: "Runs cartesi-rollups-" + config.ServicePrt, + Long: "Runs cartesi-rollups-" + config.ServicePrt + " in standalone mode.\n\n" + + "Use one node deployment per database and one active process per service role.\n" + + "Use one submitting process per signer. Stop the current process before starting its replacement.", + RunE: run, + Version: version.BuildVersion, +} + +func init() { + flags := Cmd.Flags() + + config.SetDefaults() + + cli.AddFlagStrVar(flags, &telemetryAddress, "telemetry-address", config.PRT_TELEMETRY_ADDRESS, + "Health check and metrics address and port") + cli.AddFlagStrVar(flags, &logLevel, "log-level", config.LOG_LEVEL, + "Log level: debug, info, warn or error") + cli.AddFlagBoolVar(flags, &logColor, "log-color", config.LOG_COLOR, + "Tint the logs (colored output)") + cli.AddFlagStrVar(flags, &databaseConnection, "database-connection", config.DATABASE_CONNECTION, + "Database connection string in the URL format\n(eg.: 'postgres://user:password@hostname:port/database') ") + cli.AddFlagStrVar(flags, &pollInterval, "poll-interval", config.PRT_POLLING_INTERVAL, + "Poll interval") + cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, + "Maximum startup time in seconds") + + cli.AddFlagStrVarP(flags, &defaultBlockString, "default-block", "d", config.BLOCKCHAIN_DEFAULT_BLOCK, + "Block policy: latest, safe or finalized") + + // TODO: validate on preRunE + Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + var err error + cfg, err = config.LoadPrtConfig() + if err != nil { + return err + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) error { + return cli.RunSingleService(cli.SingleServiceOptions{ + Command: cmd, + Name: config.ServicePrt, + LogLevel: cfg.LogLevel, + LogColor: cfg.LogColor, + MaxStartupTime: cfg.MaxStartupTime, + DatabaseConnection: cfg.DatabaseConnection, + TelemetryAddress: cfg.PrtTelemetryAddress, + Create: func(ctx context.Context, logger *slog.Logger, repo repository.Repository) (service.SupervisedService, error) { + return prt.Create(ctx, &prt.CreateInfo{Config: *cfg, Logger: logger, Repository: repo}) + }, + }) +} diff --git a/cmd/cartesi-rollups-prt/secret_output_test.go b/cmd/cartesi-rollups-prt/secret_output_test.go new file mode 100644 index 000000000..f5696d6c1 --- /dev/null +++ b/cmd/cartesi-rollups-prt/secret_output_test.go @@ -0,0 +1,19 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-prt/root" + "github.com/cartesi/rollups-node/test/tooling/command" +) + +func TestSecretCommandProcess(t *testing.T) { + command.Child(t, main, root.Cmd) +} + +func TestSecretCommandHelp(t *testing.T) { + command.CheckHelp(t, "prt") +} diff --git a/cmd/cartesi-rollups-validator/main.go b/cmd/cartesi-rollups-validator/main.go new file mode 100644 index 000000000..51f3111a2 --- /dev/null +++ b/cmd/cartesi-rollups-validator/main.go @@ -0,0 +1,17 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "os" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-validator/root" +) + +func main() { + err := root.Cmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/cmd/cartesi-rollups-validator/root/root.go b/cmd/cartesi-rollups-validator/root/root.go new file mode 100644 index 000000000..305e787ed --- /dev/null +++ b/cmd/cartesi-rollups-validator/root/root.go @@ -0,0 +1,80 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package root + +import ( + "context" + "log/slog" + + "github.com/cartesi/rollups-node/internal/cli" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/validator" + "github.com/cartesi/rollups-node/internal/version" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/spf13/cobra" +) + +var ( + logLevel string + logColor bool + databaseConnection string + pollInterval string + maxStartupTime string + telemetryAddress string + cfg *config.ValidatorConfig +) + +var Cmd = &cobra.Command{ + Use: "cartesi-rollups-" + config.ServiceValidator, + Short: "Runs cartesi-rollups-" + config.ServiceValidator, + Long: "Runs cartesi-rollups-" + config.ServiceValidator + " in standalone mode", + RunE: run, + Version: version.BuildVersion, +} + +func init() { + flags := Cmd.Flags() + + config.SetDefaults() + + cli.AddFlagStrVar(flags, &telemetryAddress, "telemetry-address", config.VALIDATOR_TELEMETRY_ADDRESS, + "Health check and metrics address and port") + cli.AddFlagStrVar(flags, &logLevel, "log-level", config.LOG_LEVEL, + "Log level: debug, info, warn or error") + cli.AddFlagBoolVar(flags, &logColor, "log-color", config.LOG_COLOR, + "Tint the logs (colored output)") + cli.AddFlagStrVar(flags, &databaseConnection, "database-connection", config.DATABASE_CONNECTION, + "Database connection string in the URL format\n(eg.: 'postgres://user:password@hostname:port/database') ") + cli.AddFlagStrVar(flags, &pollInterval, "poll-interval", config.VALIDATOR_POLLING_INTERVAL, + "Poll interval") + cli.AddFlagStrVar(flags, &maxStartupTime, "max-startup-time", config.MAX_STARTUP_TIME, + "Maximum startup time in seconds") + + // TODO: validate on preRunE + Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + var err error + cfg, err = config.LoadValidatorConfig() + if err != nil { + return err + } + return nil + } +} + +func run(cmd *cobra.Command, args []string) error { + return cli.RunSingleService(cli.SingleServiceOptions{ + Command: cmd, + Name: config.ServiceValidator, + LogLevel: cfg.LogLevel, + LogColor: cfg.LogColor, + MaxStartupTime: cfg.MaxStartupTime, + DatabaseConnection: cfg.DatabaseConnection, + TelemetryAddress: cfg.ValidatorTelemetryAddress, + Create: func(ctx context.Context, logger *slog.Logger, repo repository.Repository) (service.SupervisedService, error) { + return validator.Create(ctx, &validator.CreateInfo{Config: *cfg, Logger: logger, Repository: repo}) + }, + }) +} diff --git a/cmd/cartesi-rollups-validator/secret_output_test.go b/cmd/cartesi-rollups-validator/secret_output_test.go new file mode 100644 index 000000000..f7312ee4b --- /dev/null +++ b/cmd/cartesi-rollups-validator/secret_output_test.go @@ -0,0 +1,19 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "testing" + + "github.com/cartesi/rollups-node/cmd/cartesi-rollups-validator/root" + "github.com/cartesi/rollups-node/test/tooling/command" +) + +func TestSecretCommandProcess(t *testing.T) { + command.Child(t, main, root.Cmd) +} + +func TestSecretCommandHelp(t *testing.T) { + command.CheckHelp(t, "validator") +} diff --git a/cmd/gen-devnet/anvil.go b/cmd/gen-devnet/anvil.go deleted file mode 100644 index 6f7c9fb2a..000000000 --- a/cmd/gen-devnet/anvil.go +++ /dev/null @@ -1,24 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -import ( - "strconv" - - "github.com/cartesi/rollups-node/internal/services" -) - -func newAnvilService(anvilStateFilePath string) services.CommandService { - var s services.CommandService - s.Name = "anvil" - s.HealthcheckPort, _ = strconv.Atoi(ANVIL_HTTP_PORT) - s.Path = "anvil" - - s.Args = append(s.Args, "--host", ANVIL_IP_ADDR) - s.Args = append(s.Args, "--dump-state", anvilStateFilePath) - s.Args = append(s.Args, "--state-interval", "1") - s.Args = append(s.Args, "--silent") - - return s -} diff --git a/cmd/gen-devnet/deployer.go b/cmd/gen-devnet/deployer.go deleted file mode 100644 index 25ef0fc22..000000000 --- a/cmd/gen-devnet/deployer.go +++ /dev/null @@ -1,120 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -import ( - "context" - "fmt" - "os/exec" - "strings" - - "github.com/cartesi/rollups-node/pkg/addresses" - "github.com/cartesi/rollups-node/pkg/ethutil" - "github.com/ethereum/go-ethereum/common" -) - -const ( - CONTRACT_OWNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" - SALT = "0x0000000000000000000000000000000000000000000000000000000000000000" -) - -func deploy(ctx context.Context, - rollupsContractsPath string, - hash string) (DeploymentInfo, error) { - var depInfo DeploymentInfo - - fmt.Printf("deployer: deploying %v\n", rollupsContractsPath) - err := deployRollupsContracts(ctx, rollupsContractsPath) - if err != nil { - return depInfo, fmt.Errorf("could not deploy rollups-contracts: %v", err) - } - - depInfo, err = createApplication(ctx, hash) - if err != nil { - return depInfo, fmt.Errorf("could not create Application: %v", err) - } - - return depInfo, nil -} - -// Create a Rollups Application by calling the necessary factories -func createApplication(ctx context.Context, hash string) (DeploymentInfo, error) { - var depInfo DeploymentInfo - - // Create the Authority/History pair - contractAddresses, err := createContracts(ctx, - common.Address.Hex(addresses.GetTestBook().AuthorityHistoryPairFactory), - "newAuthorityHistoryPair(address,bytes32)(address,address)", - CONTRACT_OWNER_ADDRESS, - SALT) - if err != nil { - return DeploymentInfo{}, fmt.Errorf("could not create authority/history pair: %v", err) - } - - depInfo.AuthorityAddress = contractAddresses[0] - depInfo.HistoryAddress = contractAddresses[1] - - // Create the Application, passing the address of the newly created Authority - contractAddresses, err = createContracts(ctx, - common.Address.Hex(addresses.GetTestBook().CartesiDAppFactory), - "newApplication(address,address,bytes32,bytes32)(address)", - depInfo.AuthorityAddress, - CONTRACT_OWNER_ADDRESS, - hash, - SALT) - if err != nil { - return DeploymentInfo{}, fmt.Errorf("could not create application: %v", err) - } - - depInfo.ApplicationAddress = contractAddresses[0] - - return depInfo, nil -} - -// Call a contract factory, passing a factory function to be executed. -// Returns the resulting contract address(es) and the corresponding -// block number. -// -// Warning: a second call to a contract with the same arguments will fail. -func createContracts(ctx context.Context, - args ...string) ([]string, error) { - commonArgs := []string{"--rpc-url", RPC_URL} - commonArgs = append(commonArgs, args...) - - var contractAddresses []string - // Calculate the resulting deterministic address(es) - castCall := exec.CommandContext(ctx, - "cast", - "call") - castCall.Args = append(castCall.Args, commonArgs...) - var outStrBuilder strings.Builder - castCall.Stdout = &outStrBuilder - err := castCall.Run() - if err != nil { - return contractAddresses, fmt.Errorf("command failed %v: %v", castCall.Args, err) - } - contractAddresses = strings.Fields(outStrBuilder.String()) - - // Perform actual transaction on the contract - castSend := exec.CommandContext(ctx, - "cast", - "send", - "--json", - "--mnemonic", - ethutil.FoundryMnemonic) - castSend.Args = append(castSend.Args, commonArgs...) - outStrBuilder.Reset() - castSend.Stdout = &outStrBuilder - err = castSend.Run() - if err != nil { - return contractAddresses, fmt.Errorf("command failed %v: %v", castSend.Args, err) - } - - if VerboseLog { - fmt.Printf("deployer: command: %s\n", castSend.Args) - fmt.Printf("deployer: output: %s\n", outStrBuilder.String()) - } - - return contractAddresses, nil -} diff --git a/cmd/gen-devnet/main.go b/cmd/gen-devnet/main.go deleted file mode 100644 index 936a03bef..000000000 --- a/cmd/gen-devnet/main.go +++ /dev/null @@ -1,183 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -import ( - "context" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/cartesi/rollups-node/internal/services" - "github.com/spf13/cobra" -) - -const ( - CMD_NAME = "gen-devnet" - ANVIL_IP_ADDR = "0.0.0.0" - ANVIL_HTTP_PORT = "8545" - RPC_URL = "http://" + ANVIL_IP_ADDR + ":" + ANVIL_HTTP_PORT - USER_FILE_MODE = 0664 -) - -var Cmd = &cobra.Command{ - Use: CMD_NAME, - Short: "Generates a devnet for testing", - Long: `Generates a devnet to be used for testing. -It uses a previously generated Cartesi Machine snapshot -and deploys an Application based on its hash file. - -Set CARTESI_LOG_LEVEL to debug for extensive logging.`, - Run: run, -} - -var ( - VerboseLog bool - anvilStatePath string - deploymentInfoPath string - hashFile string - rollupsContractsPath string -) - -func init() { - // Default path based on submodule location for rollups-contracts 1.2 - Cmd.Flags().StringVarP(&rollupsContractsPath, - "rollups-contracts-hardhat-path", - "r", - "rollups-contracts/onchain/rollups", - "path for the hardhat project used to deploy rollups-contracts") - - Cmd.Flags().StringVarP(&hashFile, - "template-hash-file", - "t", - "", - "path for a Cartesi Machine template hash file") - cobra.CheckErr(Cmd.MarkFlagRequired("template-hash-file")) - - Cmd.Flags().StringVarP(&anvilStatePath, - "anvil-state-file", - "a", - "./anvil_state.json", - "path for the resulting anvil state file") - - Cmd.Flags().StringVarP(&deploymentInfoPath, - "deployment-info-file", - "d", - "./deployment.json", - "path for saving the deployment information") - - Cmd.Flags().BoolVarP(&VerboseLog, - "verbose", - "v", - false, - "enable verbose logging") -} - -func main() { - err := Cmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func run(cmd *cobra.Command, args []string) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - rollupsContractsPath, err := filepath.Abs(rollupsContractsPath) - if err != nil { - fmt.Printf("%s: %v does not exist\n", CMD_NAME, err) - return - } - - hash, err := readMachineHash(hashFile) - if err != nil { - fmt.Printf("%s: failed to read hash: %v\n", CMD_NAME, err) - return - } - - // Supervisor handles only Anvil - ready := make(chan struct{}, 1) - go func() { - var s []services.Service - - s = append(s, newAnvilService(anvilStatePath)) - - supervisor := newSupervisorService(s) - if err := supervisor.Start(ctx, ready); err != nil { - fmt.Printf("%s: %v", supervisor.Name, err) - cancel() - } - }() - - // Deploy rollups-contracts and create Cartesi Application - go func() { - defer cancel() - - select { - case <-ready: - depInfo, err := deploy(ctx, rollupsContractsPath, hash) - if err != nil { - fmt.Printf("%s: deployment failed. %v\n", CMD_NAME, err) - return - } - - anvilStatePath, err := filepath.Abs(anvilStatePath) - if err != nil { - fmt.Printf("%s: unable to get path for %s: %v\n", - CMD_NAME, - anvilStatePath, - err) - } else { - fmt.Printf("%s: anvil state saved to %s\n", - CMD_NAME, - anvilStatePath) - } - - jsonInfo, err := json.MarshalIndent(depInfo, "", "\t") - if err != nil { - fmt.Printf("%s: failed to parse deployment info. %v\n", CMD_NAME, err) - } else { - deploymentInfoPath, err := filepath.Abs(deploymentInfoPath) - if err == nil { - err = os.WriteFile(deploymentInfoPath, []byte(jsonInfo), USER_FILE_MODE) - if err == nil { - fmt.Printf("%s: deployment information saved to %s\n", - CMD_NAME, - deploymentInfoPath) - } - } - if err != nil { - fmt.Printf("%s: unable to save deployment information to %s: %v\n", - CMD_NAME, - deploymentInfoPath, - err) - } - } - - case <-ctx.Done(): - } - }() - - <-ctx.Done() -} - -// Read template machine hash from file -func readMachineHash(hashPath string) (string, error) { - data, err := os.ReadFile(hashPath) - if err != nil { - return "", fmt.Errorf("error reading %v (%v)", hashPath, err) - } - - return hex.EncodeToString(data), nil -} - -func newSupervisorService(s []services.Service) services.SupervisorService { - return services.SupervisorService{ - Name: CMD_NAME + "-supervisor", - Services: s, - } -} diff --git a/cmd/gen-devnet/rollups.go b/cmd/gen-devnet/rollups.go deleted file mode 100644 index 889bbd915..000000000 --- a/cmd/gen-devnet/rollups.go +++ /dev/null @@ -1,75 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" -) - -const ( - PROJECT_NAME = "rollups-contracts" - ZIP_FILE = PROJECT_NAME + ".tar.gz" -) - -// Deploy rollups-contracts to a local anvil instance -func deployRollupsContracts(ctx context.Context, - rollupsContractsPath string) error { - - _, err := os.Stat(filepath.Join(rollupsContractsPath, "hardhat.config.ts")) - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%s does not contain a hardhat project: %v", rollupsContractsPath, err) - } - - tmpDir, err := os.MkdirTemp("", "") - if err != nil { - return fmt.Errorf("failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - if err := copyContracts(ctx, rollupsContractsPath, tmpDir); err != nil { - return fmt.Errorf("failed to copy contracts: %v", err) - } - - if err := deployContracts(ctx, tmpDir); err != nil { - return fmt.Errorf("failed to deploy contracts: %v", err) - } - return nil -} - -// Copy only the rollups hardhat project -func copyContracts(ctx context.Context, srcDir string, destDir string) error { - cmd := exec.CommandContext(ctx, "cp", "-pr", srcDir, destDir) - if err := cmd.Run(); err != nil { - return fmt.Errorf("command '%v' failed with %v", cmd.String(), err) - } - return nil -} - -// Deploy rollups-contracts by using its own deployment script -func deployContracts(ctx context.Context, execDir string) error { - cmdDir := execDir + "/rollups" - - cmd := exec.CommandContext(ctx, "yarn", "install") - cmd.Dir = cmdDir - if err := cmd.Run(); err != nil { - return fmt.Errorf("command '%v' failed with %v", cmd.String(), err) - } - - cmd = exec.CommandContext(ctx, "yarn", "deploy:development") - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, "RPC_URL="+RPC_URL) - cmd.Dir = cmdDir - if VerboseLog { - cmd.Stdout = os.Stdout - } - if err := cmd.Run(); err != nil { - return fmt.Errorf("command '%v' failed with %v", cmd.String(), err) - } - return nil -} diff --git a/cmd/gen-devnet/types.go b/cmd/gen-devnet/types.go deleted file mode 100644 index 954a05882..000000000 --- a/cmd/gen-devnet/types.go +++ /dev/null @@ -1,10 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -type DeploymentInfo struct { - AuthorityAddress string `json:"CARTESI_CONTRACTS_AUTHORITY_ADDRESS"` - HistoryAddress string `json:"CARTESI_CONTRACTS_HISTORY_ADDRESS"` - ApplicationAddress string `json:"CARTESI_CONTRACTS_APPLICATION_ADDRESS"` -} diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go deleted file mode 100644 index 5f3dfd4df..000000000 --- a/cmd/gen-docs/main.go +++ /dev/null @@ -1,26 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// This binary generates part of the node documentation automatically. - -//go:generate go run . -package main - -import ( - "log" - - "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root" - "github.com/spf13/cobra/doc" -) - -func main() { - generateCartesiRollupsCliDocs() -} - -func generateCartesiRollupsCliDocs() { - err := doc.GenMarkdownTree(root.Cmd, "../../docs/cli") - if err != nil { - log.Fatalf("failed to gen cartesi-rollups-cli docs: %v", err) - } - log.Print("generated docs for cartesi-rollups-cli") -} diff --git a/compose.individual-services.yaml b/compose.individual-services.yaml new file mode 100644 index 000000000..461cbd367 --- /dev/null +++ b/compose.individual-services.yaml @@ -0,0 +1,211 @@ +x-env: &env + CARTESI_LOG_LEVEL: info + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint + CARTESI_BLOCKCHAIN_ID: 31337 + CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: ${CARTESI_BLOCKCHAIN_DEFAULT_BLOCK:-latest} + CARTESI_EVM_READER_POLLING_INTERVAL: 1 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xF8f6dFA3A3e40C10926839FFd8a221C87C8b2ccD" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" + CARTESI_DATABASE_CONNECTION_FILE: /run/secrets/database_connection + CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 + +services: + ethereum_provider: + image: cartesi/rollups-node-devnet:devel + networks: + - devnet + ports: + - 8545:8545 + + database: + image: postgres:18-alpine + shm_size: 128mb + networks: + - devnet + ports: + - 5432:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d rollupsdb || exit 1"] + interval: 3s + timeout: 3s + retries: 5 + environment: + POSTGRES_PASSWORD: password + POSTGRES_DB: rollupsdb + + migration: + image: cartesi/rollups-node:devel + command: cartesi-rollups-cli db init + depends_on: + database: + condition: service_healthy + networks: + - devnet + restart: "no" + secrets: + - database_connection + environment: + <<: *env + + evmreader: + image: cartesi/rollups-node:devel + command: cartesi-rollups-evm-reader + depends_on: + database: + condition: service_healthy + ethereum_provider: + condition: service_healthy + networks: + - devnet + ports: + - "10001:10001" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10001/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - auth_mnemonic + - blockchain_http_endpoint + - database_connection + environment: + <<: *env + + advancer: + image: cartesi/rollups-node:devel + init: true + command: cartesi-rollups-advancer + depends_on: + database: + condition: service_healthy + ethereum_provider: + condition: service_healthy + volumes: + - node_data:/var/lib/cartesi-rollups-node/data + networks: + - devnet + ports: + - "10002:10002" # Supervisor + - "10012:10012" # Inspect Service + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10002/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - auth_mnemonic + - database_connection + environment: + <<: *env + + validator: + image: cartesi/rollups-node:devel + command: cartesi-rollups-validator + depends_on: + database: + condition: service_healthy + networks: + - devnet + ports: + - "10003:10003" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10003/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - database_connection + environment: + <<: *env + + claimer: + image: cartesi/rollups-node:devel + command: cartesi-rollups-claimer + depends_on: + database: + condition: service_healthy + ethereum_provider: + condition: service_healthy + networks: + - devnet + ports: + - "10004:10004" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10004/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - auth_mnemonic + - blockchain_http_endpoint + - database_connection + environment: + <<: *env + + prt: + image: cartesi/rollups-node:devel + command: cartesi-rollups-prt + depends_on: + database: + condition: service_healthy + ethereum_provider: + condition: service_healthy + networks: + - devnet + ports: + - "10006:10006" # Supervisor + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10006/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - auth_mnemonic + - blockchain_http_endpoint + - database_connection + environment: + <<: *env + + jsonrpc-api: + image: cartesi/rollups-node:devel + command: cartesi-rollups-jsonrpc-api + depends_on: + database: + condition: service_healthy + networks: + - devnet + ports: + - "10005:10005" # Supervisor + - "10011:10011" # Jsonrpc API service + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:10005/readyz"] + interval: 1s + timeout: 1s + retries: 5 + secrets: + - auth_mnemonic + - blockchain_http_endpoint + - database_connection + environment: + <<: *env +volumes: + node_data: + +networks: + devnet: + +secrets: + auth_mnemonic: + file: test/secrets/auth_mnemonic.txt + blockchain_http_endpoint: + file: test/secrets/blockchain_http_endpoint.txt + database_connection: + file: test/secrets/database_connection.txt diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..cd7fa9c52 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,94 @@ +x-env: &env + CARTESI_LOG_LEVEL: info + CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE: /run/secrets/blockchain_http_endpoint + CARTESI_BLOCKCHAIN_ID: 31337 + CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: ${CARTESI_BLOCKCHAIN_DEFAULT_BLOCK:-latest} + CARTESI_EVM_READER_POLLING_INTERVAL: 1 + CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C" + CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS: "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9" + CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS: "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9" + CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS: "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F" + CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: "0x9e6866A965dC5f99f95EF6B0d8399dad18eEf98b" + CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS: "0xF8f6dFA3A3e40C10926839FFd8a221C87C8b2ccD" + CARTESI_DEVNET_WITHDRAWAL_OUTPUT_BUILDER_ADDRESS: "0xB4D253c7a110241561B3eD6d632846dF7d4e9Af7" + CARTESI_DATABASE_CONNECTION_FILE: /run/secrets/database_connection + CARTESI_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: 0 + CARTESI_PRT_AUTH_MNEMONIC_FILE: /run/secrets/auth_mnemonic + CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: 6 + +services: + ethereum_provider: + image: cartesi/rollups-node-devnet:devel + networks: + - devnet + ports: + - 8545:8545 + + database: + image: postgres:18-alpine + shm_size: 128mb + networks: + - devnet + ports: + - 5432:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d rollupsdb || exit 1"] + interval: 3s + timeout: 3s + retries: 5 + environment: + POSTGRES_PASSWORD: password + POSTGRES_DB: rollupsdb + + migration: + image: cartesi/rollups-node:devel + command: cartesi-rollups-cli db init + depends_on: + database: + condition: service_healthy + networks: + - devnet + secrets: + - database_connection + restart: "no" + environment: + <<: *env + + node: + image: cartesi/rollups-node:devel + init: true + command: cartesi-rollups-node + depends_on: + database: + condition: service_healthy + ethereum_provider: + condition: service_healthy + volumes: + - node_data:/var/lib/cartesi-rollups-node/data + networks: + - devnet + ports: + - "10000:10000" # Supervisor + - "10011:10011" # Jsonrpc API service + - "10012:10012" # Inspect Service + secrets: + - auth_mnemonic + - blockchain_http_endpoint + - database_connection + environment: + <<: *env + +volumes: + node_data: + +networks: + devnet: + +secrets: + auth_mnemonic: + file: test/secrets/auth_mnemonic.txt + blockchain_http_endpoint: + file: test/secrets/blockchain_http_endpoint.txt + database_connection: + file: test/secrets/database_connection.txt diff --git a/control.template b/control.template new file mode 100644 index 000000000..b9099ff40 --- /dev/null +++ b/control.template @@ -0,0 +1,13 @@ +Package: cartesi-rollups-node +Source: cartesi-rollups-node +Version: ARG_VERSION +Homepage: https://docs.cartesi.io/cartesi-rollups/ +Architecture: ARG_ARCH +Maintainer: Node Reference Unit +Provides: cartesi-rollups-node +Depends: cartesi-machine-emulator (>= 0.22.0), cartesi-machine-emulator (<< 0.23.0) +Section: net +Priority: optional +Multi-Arch: no +Description: The Cartesi Rollups Node is the reference implementation the Cartesi Rollups solution. It provides the infrastructure for running Cartesi Rollups applications. + diff --git a/dev/gen-docs/main.go b/dev/gen-docs/main.go new file mode 100644 index 000000000..a5b2a520b --- /dev/null +++ b/dev/gen-docs/main.go @@ -0,0 +1,43 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// This binary generates part of the node documentation automatically. + +//go:generate go run . +package main + +import ( + "log" + "os" + + advancer_root "github.com/cartesi/rollups-node/cmd/cartesi-rollups-advancer/root" + cli_root "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/root" + evmreader_root "github.com/cartesi/rollups-node/cmd/cartesi-rollups-evm-reader/root" + node_root "github.com/cartesi/rollups-node/cmd/cartesi-rollups-node/root" + validator_root "github.com/cartesi/rollups-node/cmd/cartesi-rollups-validator/root" + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" + "github.com/spf13/pflag" +) + +func main() { + // Workaround for hidden flags + cli_root.Cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { f.Hidden = false }) + generateDocs("cli", cli_root.Cmd) + generateDocs("evm-reader", evmreader_root.Cmd) + generateDocs("advancer", advancer_root.Cmd) + generateDocs("validator", validator_root.Cmd) + generateDocs("node", node_root.Cmd) +} + +func generateDocs(suffix string, cmd *cobra.Command) { + dir := "docs/" + suffix + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("failed to create directory %s: %v", dir, err) + } + err := doc.GenMarkdownTree(cmd, dir) + if err != nil { + log.Fatalf("failed to gen %s docs: %v", suffix, err) + } + log.Printf("generated docs for %s", suffix) +} diff --git a/dev/licenses-generate.sh b/dev/licenses-generate.sh new file mode 100755 index 000000000..07164f758 --- /dev/null +++ b/dev/licenses-generate.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# (c) Cartesi and individual authors (see AUTHORS) +# SPDX-License-Identifier: Apache-2.0 (see LICENSE) +# +# Write THIRD_PARTY_LICENSES.md to stdout. Invoked by `make THIRD_PARTY_LICENSES.md`. +# +# go-licenses only sees Go modules, and only the ones reachable for the platform +# it happens to run under. Redirecting its report straight over the notices file +# therefore drops both the hand-written entries and any module that is only +# linked on another target. This script keeps that knowledge outside the +# generated file: +# +# 1. run the report once per released target and cgo mode, and merge the +# results, +# 2. drop the entry go-licenses emits for this module itself, +# 3. apply the corrections listed in dev/licenses-overrides.tsv, +# 4. prepend the hand-written entries in dev/licenses-manual.md. + +set -euo pipefail + +MODULE=github.com/cartesi/rollups-node +TEMPLATE=dev/licenses.tpl +MANUAL=dev/licenses-manual.md +OVERRIDES=dev/licenses-overrides.tsv + +# The platforms binaries are released for. The notices file describes what is +# distributed, and the .deb and the container image are both Linux. +TARGETS="linux/amd64 linux/arm64" + +# The machine artifacts are built with cgo and the other artifacts without it, +# and the two builds of a dependency may reach different packages. go-ethereum, +# for one, links libsecp256k1 with cgo and a pure Go secp256k1 without it. +CGO_MODES="1 0" + +# Other go-licenses versions classify some licences differently. CI installs +# this version too. +GO_LICENSES_VERSION=v1.6.0 +build_info=$(go version -m "$(command -v go-licenses)" 2>/dev/null) || build_info= +if ! grep -Eq "^[[:space:]]+mod[[:space:]]+github.com/google/go-licenses[[:space:]]+$GO_LICENSES_VERSION([[:space:]]|$)" \ + <<<"$build_info"; then + echo "licenses-generate: go-licenses $GO_LICENSES_VERSION is required" >&2 + exit 1 +fi + +# go-licenses recognises the standard library by its GOROOT, which is the GOROOT +# of the Go that built it unless GOROOT is set. Use the GOROOT of the go command, +# so that a go-licenses built by another Go installation works too. +GOROOT=$(go env GOROOT) +export GOROOT + +# Collect the reports before processing them, so that a go-licenses failure +# stops the script here. +reports=$( + for target in $TARGETS; do + for cgo in $CGO_MODES; do + GOOS="${target%/*}" GOARCH="${target#*/}" CGO_ENABLED=$cgo \ + go-licenses report --template "$TEMPLATE" ./... || exit 1 + done + done +) + +cat "$MANUAL" + +# Flatten each entry to a single "name version license url" record. +printf '%s\n' "$reports" | + awk -v self="$MODULE" ' + /^## / { name = substr($0, 4); next } + /^\* Version: / { version = substr($0, 12); next } + /^\* License: / { + link = substr($0, 12) + split_at = index(link, "](") + license = substr(link, 2, split_at - 2) + url = substr(link, split_at + 2, length(link) - split_at - 2) + if (name != self) + printf "%s\t%s\t%s\t%s\n", name, version, license, url + next + } + ' | + # A module reached by more than one target is listed once: deduplicate on + # the module name, which also restores the generator's own ordering. + LC_ALL=C sort -t "$(printf '\t')" -k1,1 -u | + # Render the records back as Markdown, applying the overrides. + awk -F'\t' -v overrides="$OVERRIDES" ' + BEGIN { + while ((getline line < overrides) > 0) { + if (line ~ /^#/ || line ~ /^[ \t]*$/) + continue + split(line, field, "\t") + override_license[field[1]] = field[2] + override_url[field[1]] = field[3] + override_note[field[1]] = field[4] + } + } + { + name = $1; version = $2; license = $3; url = $4; note = "" + if (name in override_license) { + license = override_license[name] + url = override_url[name] + note = override_note[name] + gsub(/\{version\}/, version, url) + gsub(/\{version\}/, version, note) + applied[name] = 1 + } + # go-licenses writes Unknown when it cannot classify a licence, or + # when it cannot resolve the URL, as happens without network access. + if (license == "Unknown" || url == "Unknown") { + printf "licenses-generate: unknown licence or URL for %s\n", name > "/dev/stderr" + failed = 1 + } + printf "\n## %s\n\n", name + printf "* Name: %s\n* Version: %s\n* License: [%s](%s)\n", name, version, license, url + if (note != "") + printf "* Note: %s\n", note + } + END { + for (name in override_license) { + if (!(name in applied)) { + printf "licenses-generate: no entry matches the override for %s\n", name > "/dev/stderr" + failed = 1 + } + } + exit failed + } + ' diff --git a/dev/licenses-manual.md b/dev/licenses-manual.md new file mode 100644 index 000000000..d0a6a2521 --- /dev/null +++ b/dev/licenses-manual.md @@ -0,0 +1,48 @@ +# Third Party Licenses + +This file lists the third party components distributed with the Cartesi Rollups Node. +Regenerate it with `make THIRD_PARTY_LICENSES.md` — never edit it directly. + +The entries immediately below cover components that are not Go modules and are therefore +invisible to `go-licenses`; they are maintained by hand in `dev/licenses-manual.md`. +Everything after them is generated from the module graph, with the corrections listed in +`dev/licenses-overrides.tsv` applied. + +The project's own code, including all contributions to it, is available under the [Apache License, Version 2.0](LICENSE). +This code is also offered under the [GNU Lesser General Public License, Version 3](licenses/LGPL-3.0.txt), making it dual-licensed: you may use it independently in other applications under either license, at your option. + +Two components reach users under the GNU Lesser General Public License v3.0: go-ethereum, +statically linked into every binary, and the Cartesi Machine emulator, loaded at run time. +The node as a whole is subject to LGPL-3.0 when distributed with go-ethereum. +The LGPL-3.0 is written as a set of additional permissions on top of the GPL-3.0. Both texts +are in [licenses/LGPL-3.0.txt](licenses/LGPL-3.0.txt) and +[licenses/GPL-3.0.txt](licenses/GPL-3.0.txt). + +The emulator is a dynamic library in its own package. A recipient can replace it without a new +build of the node. go-ethereum is statically linked. The complete source of this project is in +this repository under Apache-2.0. To build the node against a different go-ethereum, add a +`replace` directive for `github.com/ethereum/go-ethereum` to `go.mod` and run `make build`. + +The Go bindings under `pkg/contracts/` are generated from the release artifacts of +cartesi/rollups-contracts and cartesi/dave, pinned and checksum-verified by the Makefile. Both +repositories are Apache-2.0. They are listed below for attribution. + +## github.com/cartesi/machine-emulator + +* Name: github.com/cartesi/machine-emulator +* Version: 0.22.0 +* License: [LGPL-3.0](https://github.com/cartesi/machine-emulator/blob/v0.22.0/COPYING) + +## github.com/cartesi/rollups-contracts + +* Name: github.com/cartesi/rollups-contracts +* Version: 3.0.0-alpha.10 +* License: [Apache-2.0](https://github.com/cartesi/rollups-contracts/blob/v3.0.0-alpha.10/LICENSE) +* Note: source of the generated contract bindings under pkg/contracts/. + +## github.com/cartesi/dave + +* Name: github.com/cartesi/dave +* Version: 3.0.0-alpha.6 +* License: [Apache-2.0](https://github.com/cartesi/dave/blob/v3.0.0-alpha.6/LICENSE) +* Note: source of the generated PRT contract bindings under pkg/contracts/. diff --git a/dev/licenses-overrides.tsv b/dev/licenses-overrides.tsv new file mode 100644 index 000000000..ee63bbdfc --- /dev/null +++ b/dev/licenses-overrides.tsv @@ -0,0 +1,17 @@ +# Corrections applied to the entries go-licenses derives from the module graph, +# re-applied on every regeneration: +# - go-licenses resolves a licence from the file at the module root, which is +# wrong for modules that licence parts of their tree differently; +# - it resolves the directory of a nested major version, such as /v2, with a +# network request, and without network access it picks the wrong one; +# - when a module directory has no licence file, the module zip carries the +# licence at the repository root, but go-licenses links the missing file; +# - it does not see the C code that a package compiles with cgo. +# +# Tab-separated: module name, licence name, licence URL, note. {version} in the URL +# and in the note is replaced with the resolved module version. The note is +# optional. The script fails when a line matches no entry. +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 Apache-2.0 https://github.com/aws/aws-sdk-go-v2/blob/internal/endpoints/{version}/internal/endpoints/v2/LICENSE.txt +github.com/decred/dcrd/dcrec/secp256k1/v4 ISC https://github.com/decred/dcrd/blob/dcrec/secp256k1/{version}/LICENSE +github.com/ethereum/go-ethereum LGPL-3.0 https://github.com/ethereum/go-ethereum/blob/{version}/COPYING.LESSER only the library packages are used, that is, everything outside cmd/, and those are LGPL-3.0. The GPL-3.0 COPYING file at the module root covers go-ethereum's own binaries, which this project does not build or distribute. +github.com/ethereum/go-ethereum/crypto/secp256k1 BSD-3-Clause https://github.com/ethereum/go-ethereum/blob/{version}/crypto/secp256k1/LICENSE built with cgo, this package also compiles the bundled libsecp256k1 C library, which is under the [MIT licence](https://github.com/ethereum/go-ethereum/blob/{version}/crypto/secp256k1/libsecp256k1/COPYING) diff --git a/dev/licenses.tpl b/dev/licenses.tpl new file mode 100644 index 000000000..0344e5786 --- /dev/null +++ b/dev/licenses.tpl @@ -0,0 +1,7 @@ +{{ range . }} +## {{ .Name }} + +* Name: {{ .Name }} +* Version: {{ .Version }} +* License: [{{ .LicenseName }}]({{ .LicenseURL }}) +{{ end -}} diff --git a/dev/list-integration-tests/main.go b/dev/list-integration-tests/main.go new file mode 100644 index 000000000..076ac2c5a --- /dev/null +++ b/dev/list-integration-tests/main.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// list-integration-tests reads go list -json output and lists top-level tests +// without compiling the integration package or loading its native dependencies. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "os" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +func main() { + if err := listTests(os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func listTests(input io.Reader, output io.Writer) error { + decoder := json.NewDecoder(input) + for { + var pkg struct { + Dir string + TestGoFiles []string + XTestGoFiles []string + } + if err := decoder.Decode(&pkg); errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return fmt.Errorf("read Go package metadata: %w", err) + } + for _, name := range append(pkg.TestGoFiles, pkg.XTestGoFiles...) { + path := filepath.Join(pkg.Dir, name) + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return fmt.Errorf("parse integration test: %w", err) + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || fn.Name.Name == "TestMain" { + continue + } + suffix, test := strings.CutPrefix(fn.Name.Name, "Test") + next, _ := utf8.DecodeRuneInString(suffix) + if test && !unicode.IsLower(next) { + if _, err := fmt.Fprintln(output, fn.Name.Name); err != nil { + return fmt.Errorf("write integration test name: %w", err) + } + } + } + } + } +} diff --git a/dev/list-integration-tests/main_test.go b/dev/list-integration-tests/main_test.go new file mode 100644 index 000000000..20c338e82 --- /dev/null +++ b/dev/list-integration-tests/main_test.go @@ -0,0 +1,75 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestListTestsUsesGoSelectedFiles(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "native_test.go": `package integration +import ( + "testing" + _ "github.com/cartesi/rollups-node/internal/advancer" +) +type Suite struct{} +func TestNative(t *testing.T) {} +func Test(t *testing.T) {} +func TestÉpoch(t *testing.T) {} +func TestMain(m *testing.M) {} +func Testhelper() {} +func BenchmarkNative(b *testing.B) {} +func (s *Suite) TestMethod() {} +`, + "external_test.go": `package integration_test +import "testing" +func TestExternal(t *testing.T) {} +`, + "inactive_test.go": "not valid Go: this file was excluded by go list", + } + for name, source := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + } + metadata, err := json.Marshal(map[string]any{ + "Dir": dir, "TestGoFiles": []string{"native_test.go"}, "XTestGoFiles": []string{"external_test.go"}, + }) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if err := listTests(bytes.NewReader(metadata), &output); err != nil { + t.Fatal(err) + } + want := "TestNative\nTest\nTestÉpoch\nTestExternal\n" + if output.String() != want { + t.Fatalf("got %q, want %q", output.String(), want) + } +} + +func TestListTestsRejectsReadErrors(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "invalid_test.go"), []byte("invalid Go"), 0o600); err != nil { + t.Fatal(err) + } + for _, scenario := range []struct{ name, input string }{ + {"metadata", "not JSON"}, + {"missing file", `{"Dir":"` + dir + `","TestGoFiles":["missing_test.go"]}`}, + {"invalid syntax", `{"Dir":"` + dir + `","TestGoFiles":["invalid_test.go"]}`}, + } { + t.Run(scenario.name, func(t *testing.T) { + if err := listTests(strings.NewReader(scenario.input), &bytes.Buffer{}); err == nil { + t.Fatal("expected discovery error") + } + }) + } +} diff --git a/dev/migrate/main.go b/dev/migrate/main.go new file mode 100644 index 000000000..294bceec8 --- /dev/null +++ b/dev/migrate/main.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "log/slog" + "net/url" + "os" + "time" + + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" +) + +func main() { + var s *schema.Schema + var err error + + dbConnString, ok := os.LookupEnv("CARTESI_DATABASE_CONNECTION") + if !ok || dbConnString == "" { + slog.Error("Error: CARTESI_DATABASE_CONNECTION not defined") + os.Exit(1) + } + + uri, err := url.Parse(dbConnString) + if err != nil { + slog.Error("Failed to parse database connection.", "error", err) + os.Exit(1) + } + + for i := 0; i < 5; i++ { + s, err = schema.New(uri.String()) + if err == nil { + break + } + if i == 4 { + slog.Error("Failed to connect to database.", "error", err) + os.Exit(1) + } + slog.Warn("Connection to database failed. Trying again.", "PostgresEndpoint", uri.Redacted()) + time.Sleep(5 * time.Second) // wait before retrying + } + defer s.Close() + + err = s.Upgrade() + if err != nil { + slog.Error("Error while upgrading database schema", "error", err) + os.Exit(1) + } + + version, err := s.ValidateVersion() + if err != nil { + slog.Error("Error while validating database schema version", "error", err) + os.Exit(1) + } + + slog.Info("Database Schema successfully Updated.", "version", version) +} diff --git a/dev/test-endpoint-lint.sh b/dev/test-endpoint-lint.sh new file mode 100644 index 000000000..7803c9b24 --- /dev/null +++ b/dev/test-endpoint-lint.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# (c) Cartesi and individual authors (see AUTHORS) +# SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +set -euo pipefail + +# Run from the repository root, like the other Makefile lint checks. +golangci-lint run --enable-only forbidigo ./test/lint/testdata/approved + +output=$(mktemp) +trap 'rm -f "$output"' EXIT +if golangci-lint run --enable-only forbidigo ./test/lint/testdata/forbidden >"$output" 2>&1; then + echo "Expected endpoint lint rules to reject the forbidden fixture." >&2 + exit 1 +fi + +# Check every constructor's diagnostic and source location. A compilation or +# tooling failure, or a rule narrowed to DialContext, must not pass. +for identifier in 'endpoint.Raw' 'rpcAlias.Dial' 'rpcAlias.DialHTTP' 'rpcAlias.DialOptions' \ + 'rpcAlias.DialContext' 'ethAlias.Dial' 'ethAlias.DialContext' \ + 'factoryAlias.NewRepositoryFromConnectionString'; do + if ! diagnostic=$(grep -F "use of \`$identifier\` forbidden" "$output") || + ! grep -Eq 'forbidden.go:[0-9]+:[0-9]+: use of' <<<"$diagnostic"; then + cat "$output" >&2 + echo "Missing endpoint lint diagnostic for $identifier." >&2 + exit 1 + fi +done diff --git a/dev/tools.go b/dev/tools.go new file mode 100644 index 000000000..3b8dbeee4 --- /dev/null +++ b/dev/tools.go @@ -0,0 +1,12 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build tools + +package main + +import ( + _ "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen" + // Import the Jet CLI tool for code generation + _ "github.com/go-jet/jet/v2/cmd/jet" +) diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..e366085fe --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,6 @@ +cli/ +evm-reader/ +advancer/ +node/ +validator/ +/config.md diff --git a/docs/.markdownlint.yaml b/docs/.markdownlint.yaml deleted file mode 100644 index 368dc72f4..000000000 --- a/docs/.markdownlint.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Configuration related to docs generated automatically -# by https://pkg.go.dev/github.com/spf13/cobra/doc. -MD010: false -MD012: false -MD013: false -MD040: false -MD041: false diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index dfbc95c35..000000000 --- a/docs/architecture.md +++ /dev/null @@ -1,144 +0,0 @@ -# Internal Architecture - -This page describes the Rollups Node internal architecture. -Developers don't need to understand this architecture to develop applications for the Cartesi Rollups. -This documentation helps developers who want to modify the Rollups Node source code. - -## Components - -![Components](images/components.svg) - -The diagram above presents the internal and external components of the Node. -The teal boxes represent the internal components which are maintained by the Node unit. -The cyan and white boxes represent external components. -Some external components such as the Redis and Server Manager run inside the node as supervisioned processes. -On the other hand, the Postgres database and the Ethereum node run outside the Node. -The person who runs the Rollups Node should set up the unsupervisioned components and pass the corresponding configuration variables to the Node. - -### Node Supervisor - -The Node Supervisor is a supervisor written in Go that manages the internal components. -The supervisor starts those components as subprocesses and ensures they behave correctly. -The internal components are several Rust microservices, Redis, the Server Manager, and the Cartesi Machine. - -The Node Supervisor is also an HTTP reverse proxy for the internal components. -It exposes the external HTTP endpoints and redirects the call to the corresponding component. - -### State Server - -The State Server listens and processes blockchain events. -It is a generic service that can listen to any event; it is up to the state-fold client to decide which events should be processed. -The Rust crate [`state-server`][state-server] and the [state-fold][state-fold] library contain the source code for this service. - -[state-server]: ../offchain/state-server/ -[state-fold]: https://github.com/cartesi/state-fold - -### Dispatcher - -The Dispatcher reads the inputs from the blockchain and publishes them to the Broker. -It uses the State Server to read the input events from the base layer. -The Dispatcher also decides when the epoch should be closed. -The Rust crate [`dispatcher`][dispatcher] contains the source code for this service. - -[dispatcher]: ../offchain/dispatcher/ - -### Advance Runner - -The Advance Runner is a proxy between the Server Manager and the Broker. -It reads advance-state inputs from the Broker, sends them to the Server Manager, and publishes the outputs, proofs, and claims to the Broker. -The Rust crate [`advance-runner`][advance-runner] contains the source code for this service. - -[advance-runner]: ../offchain/advance-runner/ - -### Indexer - -The Indexer reads the inputs, outputs, and proofs from the Broker and stores them in the Postgres database. -The Rust crate [`indexer`][indexer] contains the source code for this service. - -[indexer]: ../offchain/indexer/ - -### GraphQL Server - -The GraphQL Server implements the Rollups Node GraphQL API. -When it receives a query, the GraphQL Server reads the inputs, outputs, and proofs from the Postgres database. -The Rust crate [`graphql-server`][graphql-server] contains the source code for this service. - -[graphql-server]: ../offchain/graphql-server/ - - -### Authority Claimer - -The Authority Claimer reads the claims from the Broker and submits them to the base layer. -The Rust crate [`authority-claimer`][authority-claimer] contains the source code for this service. - -[authority-claimer]: ../offchain/authority-claimer/ - -### Inspect Server - -The Inspect Server implements the inspect HTTP API and calls the Server Manager. -The Rust crate [`inspect-server`][inspect-server] contains the source code for this service. - -[inspect-server]: ../offchain/inspect-server/ - - -### Redis (Broker) - -The Rollups Node uses [Redis Streams][redis-streams] as the message broker to handle communication between some of the internal components. -The Broker contains streams for inputs, outputs, proofs, and claims. -The Rust crate [`rollups-events`][rollups-events] contains the definition of these streams. - -[redis-streams]: https://redis.io/docs/data-types/streams/ -[rollups-events]: ../offchain/rollups-events/ - -### Server Manager - -The Server Manager manages the Cartesi Machine. -It provides a high-level API to process advance-state and inspect-state inputs and computes the proofs to validate outputs. -The source code for the server-manager is on [another repository][server-manager-repo]. - -[server-manager-repo]: https://github.com/cartesi/server-manager - -## Communication - -![Communication](images/communication.svg) - -The diagram above illustrates the communication between the Rollups Node components. -The following subsections explain how the data flows through the Node. - -### Processing Advance-State Inputs - -Using the State Server, the Dispatcher reads the inputs and publishes them to the inputs stream in the Broker. -The Advance Runner listens to the inputs stream; as it receives inputs, it sends them to the Server Manager. -The Server Manager sends the inputs to the Cartesi Machine and obtains the outputs. -The Advance Runner waits until the Server Manager finishes processing the input, obtains the outputs, and publishes them in the outputs stream in the Broker. -The Indexer listens to the inputs and outputs streams and stores these events in the Postgres database. - -### Querying the Rollups State - -The application front end may query the rollup state from the Node using the GraphQL API. -The Node Supervisor receives the GraphQL request and forwards it to the GraphQL server. -The GraphQL server processes the request and fetches the data from the Postgres database. -Each call returns the fetched data in the response. - -### Processing Inspect-State Inputs - -The application front-end sends an inspect-state request to the Rollups Node. -The Node Supervisor receives this requests and forwards this request to the Inspect Server. -The Inspect Server translates this request from HTTP to gRPC, making another request to the Server Manager. -The Server Manager sends the inspect-state input to the Cartesi Machine and obtains the reports. -All the services return these reports as the response to the corresponding call. - -### Submitting Claims - -After the amount of time configured as the epoch size passes, the Node closes an epoch. -Note that the Node only closes an Epoch if there were advance-state inputs in this epoch. - -When it is time to close an epoch, the Dispatcher publishes an event to the inputs stream in the Broker. -The Advance Runner listens to this event and asks the Server Manager to close the current epoch. -The Server Manager computes the output proofs and the epoch claim and returns them to the advance runner. -The Advance Runner publishes the proofs to the outputs stream and the claims to the claims stream on the Broker. - -The Authority Claimer listens to the claims stream from the Broker and submits the claim to the Consensus smart contract in the base layer. - -The Indexer listens to the outputs stream and stores the proofs in the Postgres database. -At this point, the application front end may query the GraphQL API to fetch these proofs. diff --git a/docs/cli/cartesi-rollups-cli.md b/docs/cli/cartesi-rollups-cli.md deleted file mode 100644 index 2b3763a82..000000000 --- a/docs/cli/cartesi-rollups-cli.md +++ /dev/null @@ -1,27 +0,0 @@ -## cartesi-rollups-cli - -Command line interface for Cartesi Rollups - -### Synopsis - -This command line interface provides functionality to help develop and debug the -Cartesi Rollups node. - -### Options - -``` - -h, --help help for cartesi-rollups-cli -``` - -### SEE ALSO - -* [cartesi-rollups-cli execute](cartesi-rollups-cli_execute.md) - Executes a voucher -* [cartesi-rollups-cli increase-time](cartesi-rollups-cli_increase-time.md) - Increases evm time of the current machine -* [cartesi-rollups-cli inspect](cartesi-rollups-cli_inspect.md) - Calls inspect API -* [cartesi-rollups-cli mine](cartesi-rollups-cli_mine.md) - Mine blocks -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API -* [cartesi-rollups-cli run-deps](cartesi-rollups-cli_run-deps.md) - Run node dependencies with Docker -* [cartesi-rollups-cli save-snapshot](cartesi-rollups-cli_save-snapshot.md) - Saves the testing Cartesi machine snapshot to the designated folder -* [cartesi-rollups-cli send](cartesi-rollups-cli_send.md) - Send a rollups input to the Ethereum node -* [cartesi-rollups-cli validate](cartesi-rollups-cli_validate.md) - Validates a notice - diff --git a/docs/cli/cartesi-rollups-cli_execute.md b/docs/cli/cartesi-rollups-cli_execute.md deleted file mode 100644 index 50071af9c..000000000 --- a/docs/cli/cartesi-rollups-cli_execute.md +++ /dev/null @@ -1,32 +0,0 @@ -## cartesi-rollups-cli execute - -Executes a voucher - -``` -cartesi-rollups-cli execute [flags] -``` - -### Examples - -``` -# Executes voucher 5 from input 6: -cartesi-rollups-cli execute --voucher-index 5 --input-index 6 -``` - -### Options - -``` - --account uint32 account index used to sign the transaction (default: 0) - --address-book string if set, load the address book from the given file; else, use test addresses - --eth-endpoint string ethereum node JSON-RPC endpoint (default "http://localhost:8545") - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for execute - --input-index int index of the input - --mnemonic string mnemonic used to sign the transaction (default "test test test test test test test test test test test junk") - --voucher-index int index of the voucher -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/cli/cartesi-rollups-cli_increase-time.md b/docs/cli/cartesi-rollups-cli_increase-time.md deleted file mode 100644 index cf47abc11..000000000 --- a/docs/cli/cartesi-rollups-cli_increase-time.md +++ /dev/null @@ -1,27 +0,0 @@ -## cartesi-rollups-cli increase-time - -Increases evm time of the current machine - -``` -cartesi-rollups-cli increase-time [flags] -``` - -### Examples - -``` -# Increases evm time by one day (86400 seconds): -cartesi-rollups-cli increase-time -``` - -### Options - -``` - --anvil-endpoint string anvil address used to send to the request (default "http://localhost:8545") - -h, --help help for increase-time - --time int The amount of time to increase in the evm, in seconds (default 86400) -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/cli/cartesi-rollups-cli_inspect.md b/docs/cli/cartesi-rollups-cli_inspect.md deleted file mode 100644 index f4563a499..000000000 --- a/docs/cli/cartesi-rollups-cli_inspect.md +++ /dev/null @@ -1,27 +0,0 @@ -## cartesi-rollups-cli inspect - -Calls inspect API - -``` -cartesi-rollups-cli inspect [flags] -``` - -### Examples - -``` -# Makes a request with "hi" encoded as hex: -cartesi-rollups-cli inspect --payload 0x$(printf "hi" | xxd -p) -``` - -### Options - -``` - -h, --help help for inspect - --inspect-endpoint string address used to connect to the inspect api (default "http://localhost:10000/") - --payload string input payload hex-encoded starting with 0x -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/cli/cartesi-rollups-cli_mine.md b/docs/cli/cartesi-rollups-cli_mine.md deleted file mode 100644 index ac3d41645..000000000 --- a/docs/cli/cartesi-rollups-cli_mine.md +++ /dev/null @@ -1,28 +0,0 @@ -## cartesi-rollups-cli mine - -Mine blocks - -``` -cartesi-rollups-cli mine [flags] -``` - -### Examples - -``` -# Mine 10 blocks with a 5-second interval between them: -cartesi-rollups-cli mine --number-of-blocks 10 --block-interval 5 -``` - -### Options - -``` - --block-interval int interval, in seconds, between the timestamps of each block (default 1) - --eth-endpoint string ethereum node JSON-RPC endpoint (default "http://localhost:8545") - -h, --help help for mine - --number-of-blocks int number of blocks to mine (default 1) -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/cli/cartesi-rollups-cli_read.md b/docs/cli/cartesi-rollups-cli_read.md deleted file mode 100644 index 0c6ba614e..000000000 --- a/docs/cli/cartesi-rollups-cli_read.md +++ /dev/null @@ -1,22 +0,0 @@ -## cartesi-rollups-cli read - -Read the node state from the GraphQL API - -### Options - -``` - -h, --help help for read -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups -* [cartesi-rollups-cli read input](cartesi-rollups-cli_read_input.md) - Reads an input -* [cartesi-rollups-cli read inputs](cartesi-rollups-cli_read_inputs.md) - Reads inputs ordered by index -* [cartesi-rollups-cli read notice](cartesi-rollups-cli_read_notice.md) - Reads a notice -* [cartesi-rollups-cli read notices](cartesi-rollups-cli_read_notices.md) - Reads all notices. If an input is specified, reads all notices from that input -* [cartesi-rollups-cli read report](cartesi-rollups-cli_read_report.md) - Reads a report -* [cartesi-rollups-cli read reports](cartesi-rollups-cli_read_reports.md) - Reads all reports. If an input is specified, reads all reports from that input -* [cartesi-rollups-cli read voucher](cartesi-rollups-cli_read_voucher.md) - Reads a voucher -* [cartesi-rollups-cli read vouchers](cartesi-rollups-cli_read_vouchers.md) - Reads all vouchers. If an input is specified, reads all vouchers from that input - diff --git a/docs/cli/cartesi-rollups-cli_read_input.md b/docs/cli/cartesi-rollups-cli_read_input.md deleted file mode 100644 index 035035b12..000000000 --- a/docs/cli/cartesi-rollups-cli_read_input.md +++ /dev/null @@ -1,27 +0,0 @@ -## cartesi-rollups-cli read input - -Reads an input - -``` -cartesi-rollups-cli read input [flags] -``` - -### Examples - -``` -# Read specific input from GraphQL: -cartesi-rollups-cli read input --index 5 -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for input - --index int index of the input -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_read_inputs.md b/docs/cli/cartesi-rollups-cli_read_inputs.md deleted file mode 100644 index 6ed91febd..000000000 --- a/docs/cli/cartesi-rollups-cli_read_inputs.md +++ /dev/null @@ -1,26 +0,0 @@ -## cartesi-rollups-cli read inputs - -Reads inputs ordered by index - -``` -cartesi-rollups-cli read inputs [flags] -``` - -### Examples - -``` -# Read inputs from GraphQL: -cartesi-rollups-cli read inputs -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for inputs -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_read_notice.md b/docs/cli/cartesi-rollups-cli_read_notice.md deleted file mode 100644 index 8f498a460..000000000 --- a/docs/cli/cartesi-rollups-cli_read_notice.md +++ /dev/null @@ -1,28 +0,0 @@ -## cartesi-rollups-cli read notice - -Reads a notice - -``` -cartesi-rollups-cli read notice [flags] -``` - -### Examples - -``` -# Read notice 5 from input 6: -cartesi-rollups-cli read notice --notice-index 5 --input-index 6 -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for notice - --input-index int index of the input - --notice-index int index of the notice -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_read_notices.md b/docs/cli/cartesi-rollups-cli_read_notices.md deleted file mode 100644 index d59ff1faa..000000000 --- a/docs/cli/cartesi-rollups-cli_read_notices.md +++ /dev/null @@ -1,27 +0,0 @@ -## cartesi-rollups-cli read notices - -Reads all notices. If an input is specified, reads all notices from that input - -``` -cartesi-rollups-cli read notices [flags] -``` - -### Examples - -``` -# Read all notices: -cartesi-rollups-cli read notices -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for notices - --input-index int index of the input (default -1) -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_read_report.md b/docs/cli/cartesi-rollups-cli_read_report.md deleted file mode 100644 index ee9050b4d..000000000 --- a/docs/cli/cartesi-rollups-cli_read_report.md +++ /dev/null @@ -1,28 +0,0 @@ -## cartesi-rollups-cli read report - -Reads a report - -``` -cartesi-rollups-cli read report [flags] -``` - -### Examples - -``` -# Read report 5 from input 6: -cartesi-rollups-cli read report --report-index 5 --input-index 6 -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for report - --input-index int index of the input - --report-index int index of the report -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_read_reports.md b/docs/cli/cartesi-rollups-cli_read_reports.md deleted file mode 100644 index 9b46b9e38..000000000 --- a/docs/cli/cartesi-rollups-cli_read_reports.md +++ /dev/null @@ -1,27 +0,0 @@ -## cartesi-rollups-cli read reports - -Reads all reports. If an input is specified, reads all reports from that input - -``` -cartesi-rollups-cli read reports [flags] -``` - -### Examples - -``` -# Read all reports: -cartesi-rollups-cli read reports -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for reports - --input-index int index of the input (default -1) -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_read_voucher.md b/docs/cli/cartesi-rollups-cli_read_voucher.md deleted file mode 100644 index c47bb0020..000000000 --- a/docs/cli/cartesi-rollups-cli_read_voucher.md +++ /dev/null @@ -1,28 +0,0 @@ -## cartesi-rollups-cli read voucher - -Reads a voucher - -``` -cartesi-rollups-cli read voucher [flags] -``` - -### Examples - -``` -# Read voucher 5 from input 6: -cartesi-rollups-cli read voucher --voucher-index 5 --input-index 6 -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for voucher - --input-index int index of the input - --voucher-index int index of the voucher -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_read_vouchers.md b/docs/cli/cartesi-rollups-cli_read_vouchers.md deleted file mode 100644 index 304483177..000000000 --- a/docs/cli/cartesi-rollups-cli_read_vouchers.md +++ /dev/null @@ -1,27 +0,0 @@ -## cartesi-rollups-cli read vouchers - -Reads all vouchers. If an input is specified, reads all vouchers from that input - -``` -cartesi-rollups-cli read vouchers [flags] -``` - -### Examples - -``` -# Read all vouchers: -cartesi-rollups-cli read vouchers -``` - -### Options - -``` - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for vouchers - --input-index int index of the input (default -1) -``` - -### SEE ALSO - -* [cartesi-rollups-cli read](cartesi-rollups-cli_read.md) - Read the node state from the GraphQL API - diff --git a/docs/cli/cartesi-rollups-cli_run-deps.md b/docs/cli/cartesi-rollups-cli_run-deps.md deleted file mode 100644 index fca0efb30..000000000 --- a/docs/cli/cartesi-rollups-cli_run-deps.md +++ /dev/null @@ -1,33 +0,0 @@ -## cartesi-rollups-cli run-deps - -Run node dependencies with Docker - -``` -cartesi-rollups-cli run-deps [flags] -``` - -### Examples - -``` -# Run all deps: -cartesi-rollups-cli run-deps -``` - -### Options - -``` - --devnet-block-time string Devnet mining block time (default "1") - --devnet-docker-image string Devnet docker image name (default "cartesi/rollups-node-devnet:devel") - --devnet-mapped-port string Devnet local listening port number (default "8545") - --devnet-no-mining Devnet disable mining - -h, --help help for run-deps - --postgres-docker-image string Postgress docker image name (default "postgres:16-alpine") - --postgres-mapped-port string Postgres local listening port number (default "5432") - --postgres-password string Postgres password (default "password") - -v, --verbose verbose logs -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/cli/cartesi-rollups-cli_save-snapshot.md b/docs/cli/cartesi-rollups-cli_save-snapshot.md deleted file mode 100644 index 0491c090d..000000000 --- a/docs/cli/cartesi-rollups-cli_save-snapshot.md +++ /dev/null @@ -1,28 +0,0 @@ -## cartesi-rollups-cli save-snapshot - -Saves the testing Cartesi machine snapshot to the designated folder - -``` -cartesi-rollups-cli save-snapshot [flags] -``` - -### Examples - -``` -# Save the default Rollups Echo Application snapshot: -cartesi-rollups-cli save-snapshot -``` - -### Options - -``` - --dest-dir string directory where to store the Cartesi Machine snapshot to be used by the local Node (default "./machine-snapshot") - --docker-image string Docker image containing the Cartesi Machine snapshot to be used (default "cartesi/rollups-node-snapshot:devel") - -h, --help help for save-snapshot - --temp-container-name string Name of the temporary container needed to extract the machine snapshot files (default "temp-machine") -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/cli/cartesi-rollups-cli_send.md b/docs/cli/cartesi-rollups-cli_send.md deleted file mode 100644 index 64930fdd2..000000000 --- a/docs/cli/cartesi-rollups-cli_send.md +++ /dev/null @@ -1,30 +0,0 @@ -## cartesi-rollups-cli send - -Send a rollups input to the Ethereum node - -``` -cartesi-rollups-cli send [flags] -``` - -### Examples - -``` -# Send the string "hi" encoded as hex: -cartesi-rollups-cli send --payload 0x$(printf "hi" | xxd -p) -``` - -### Options - -``` - --account uint32 account index used to sign the transaction (default: 0) - --address-book string if set, load the address book from the given file; else, use test addresses - --eth-endpoint string ethereum node JSON-RPC endpoint (default "http://localhost:8545") - -h, --help help for send - --mnemonic string mnemonic used to sign the transaction (default "test test test test test test test test test test test junk") - --payload string input payload hex-encoded starting with 0x -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/cli/cartesi-rollups-cli_validate.md b/docs/cli/cartesi-rollups-cli_validate.md deleted file mode 100644 index de2a430a8..000000000 --- a/docs/cli/cartesi-rollups-cli_validate.md +++ /dev/null @@ -1,30 +0,0 @@ -## cartesi-rollups-cli validate - -Validates a notice - -``` -cartesi-rollups-cli validate [flags] -``` - -### Examples - -``` -# Validates notice 5 from input 6: -cartesi-rollups-cli validate --notice-index 5 --input-index 6 -``` - -### Options - -``` - --address-book string if set, load the address book from the given file; else, use test addresses - --eth-endpoint string ethereum node JSON-RPC endpoint (default "http://localhost:8545") - --graphql-endpoint string address used to connect to graphql (default "http://localhost:10000/graphql") - -h, --help help for validate - --input-index int index of the input - --notice-index int index of the notice -``` - -### SEE ALSO - -* [cartesi-rollups-cli](cartesi-rollups-cli.md) - Command line interface for Cartesi Rollups - diff --git a/docs/config.md b/docs/config.md deleted file mode 100644 index 23ad1cc4f..000000000 --- a/docs/config.md +++ /dev/null @@ -1,254 +0,0 @@ - - - -# Node Configuration - -The node is configurable through environment variables. -(There is no other way to configure it.) - -This file documents the configuration options. - - - -## `CARTESI_AUTH_AWS_KMS_KEY_ID` - -If set, the node will use the AWS KMS service with this key ID to sign transactions. - -Must be set alongside `CARTESI_AUTH_AWS_KMS_REGION`. - -* **Type:** `string` - -## `CARTESI_AUTH_AWS_KMS_REGION` - -An AWS KMS Region. - -Must be set alongside `CARTESI_AUTH_AWS_KMS_KEY_ID`. - -* **Type:** `string` - -## `CARTESI_AUTH_KIND` - -One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". - -* **Type:** `AuthKind` -* **Default:** `"mnemonic"` - -## `CARTESI_AUTH_MNEMONIC` - -The node will use the private key generated from this mnemonic to sign transactions. - -* **Type:** `string` - -## `CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX` - -When using mnemonics to sign transactions, -the node will use this account index to generate the private key. - -* **Type:** `int` -* **Default:** `"0"` - -## `CARTESI_AUTH_MNEMONIC_FILE` - -The node will use the private key generated from the mnemonic contained in this file -to sign transactions. - -* **Type:** `string` - -## `CARTESI_AUTH_PRIVATE_KEY` - -The node will use this private key to sign transactions. - -* **Type:** `string` - -## `CARTESI_AUTH_PRIVATE_KEY_FILE` - -The node will use the private key contained in this file to sign transactions. - -* **Type:** `string` - -## `CARTESI_BLOCKCHAIN_BLOCK_TIMEOUT` - -Block subscription timeout in seconds. - -* **Type:** `int` -* **Default:** `"60"` - -## `CARTESI_BLOCKCHAIN_FINALITY_OFFSET` - -The node assumes that blocks offseted by N from the current block have reached finality -(N is the read depth). - -* **Type:** `int` -* **Default:** `"10"` - -## `CARTESI_BLOCKCHAIN_HTTP_ENDPOINT` - -HTTP endpoint for the blockchain RPC provider. - -* **Type:** `string` - -## `CARTESI_BLOCKCHAIN_ID` - -An unique identifier representing a blockchain network. - -* **Type:** `uint64` - -## `CARTESI_BLOCKCHAIN_IS_LEGACY` - -If set to true the node will send transactions using the legacy gas fee model -(instead of EIP-1559). - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_BLOCKCHAIN_WS_ENDPOINT` - -WebSocket endpoint for the blockchain RPC provider. - -* **Type:** `string` - -## `CARTESI_CONTRACTS_APPLICATION_ADDRESS` - -Address of the DApp's contract. - -* **Type:** `string` - -## `CARTESI_CONTRACTS_AUTHORITY_ADDRESS` - -Address of the Authority contract. - -* **Type:** `string` - -## `CARTESI_CONTRACTS_HISTORY_ADDRESS` - -Address of the History contract. - -* **Type:** `string` - -## `CARTESI_CONTRACTS_INPUT_BOX_ADDRESS` - -Address of the InputBox contract. - -* **Type:** `string` - -## `CARTESI_CONTRACTS_INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER` - -The deployment block for the input box contract. -The node will begin to read blockchain events from this block. - -* **Type:** `int64` - -## `CARTESI_EXPERIMENTAL_SERVER_MANAGER_BYPASS_LOG` - -When enabled, prints server-manager output to stdout and stderr directly. -All other log configurations are ignored. - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_ENABLED` - -When enabled, the node does not start Redis. -It must be configured with an external Redis endpoint. - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_REDIS_ENDPOINT` - -External Redis endpoint for the node when running in the experimental sunodo validator mode. - -* **Type:** `string` - -## `CARTESI_FEATURE_DISABLE_CLAIMER` - -If set to true, the authority-claimer service is disabled. - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_FEATURE_DISABLE_MACHINE_HASH_CHECK` - -If set to true, the node will *not* check whether the Cartesi machine hash from -the snapshot matches the hash in the Application contract. - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_FEATURE_HOST_MODE` - -If set to true, the node will run in host mode. - -In host mode, computations will not be performed by the cartesi machine. -You should only use host mode for development and debugging! - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_FEATURE_READER_MODE_ENABLED` - -If set to true, the node will not generate any claims. - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_HTTP_ADDRESS` - -HTTP address for the node. - -* **Type:** `string` -* **Default:** `"127.0.0.1"` - -## `CARTESI_HTTP_PORT` - -HTTP port for the node. -The node will also use the 20 ports after this one for internal services. - -* **Type:** `int` -* **Default:** `"10000"` - -## `CARTESI_LOG_LEVEL` - -One of "debug", "info", "warn", "error". - -* **Type:** `LogLevel` -* **Default:** `"info"` - -## `CARTESI_LOG_PRETTY` - -If set to true, the node will add colors to its log output. - -* **Type:** `bool` -* **Default:** `"false"` - -## `CARTESI_POSTGRES_ENDPOINT` - -Postgres endpoint in the 'postgres://user:password@hostname:port/database' format. - -If not set, or set to empty string, will defer the behaviour to the PG driver. -See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. - -It is also possible to set the endpoint without a password and load it from Postgres' passfile. -See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) -for more information. - -* **Type:** `string` -* **Default:** `""` - -## `CARTESI_EPOCH_LENGTH` - -Length of a rollups epoch in blocks. - -At the end of each epoch, the node will send claims to the blockchain. - -* **Type:** `uint64` -* **Default:** `"7200"` - -## `CARTESI_SNAPSHOT_DIR` - -Path to the directory with the cartesi-machine snapshot that will be loaded by the node. - -* **Type:** `string` diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index 89a924456..000000000 --- a/docs/development.md +++ /dev/null @@ -1,144 +0,0 @@ -# Node Development - -This page contains instructions for developers who want to run the Rollups Node from source. - -## Cloning submodules - -Before building and running the Node, you should download the submodules with the command below. - -```sh -make submodules -``` - -## Generating files - -All the necessary auto-generated files are already committed to the project, but if you wish to generate them again, you can run the command below. - -```sh -make generate -``` - -## Running with Docker - -Running the Node with Docker is the fastest way to get started because it doesn't require you to install other dependencies. - -### Building the Docker images - -Before running the Node, you must build the required Docker images. -These images include the Rollups Node, an Ethereum development node, and a test Cartesi application. -To build those images, run the command below. - -```sh -make docker-build -``` - -### Running the Node - -After building the Docker images, run the command below to start the Node and its external dependencies. - -```sh -make docker-run -``` - -### Connecting to Sepolia testnet - -Connecting the Node to the Sepolia testnet is possible instead of running a local Ethereum development node. -First, you need to set the environment variables `RPC_HTTP_URL` and `RPC_WS_URL` with the URLs of the RPC provider. -For instance, if you are using Alchemy, you should set the variables to `https://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY` and `wss://eth-sepolia.g.alchemy.com/v2/$ALCHEMY_API_KEY`. -Then, run the command below to start the Node. - -```sh -make docker-run-sepolia -``` - -## Run Node Natively - -This section explains how to run the Rollups Node natively to facilitate development. -We still recommend using Docker to build dependencies such as the Cartesi Machine snapshot and to run external services like the Postgres database and the Ethereum development node. - -### Install Dependencies - -- [Docker](https://docs.docker.com/engine/install/) -- [Redis](https://redis.io/docs/install/install-redis/) -- [Cartesi Machine](https://github.com/cartesi/machine-emulator) -- [Server Manager](https://github.com/cartesi/server-manager) -- [Golang](https://go.dev/doc/install) -- [Rust](https://www.rust-lang.org/tools/install) - -Building the Cartesi Machine and the Server Manager from source is advised. -Make sure you use the same versions used by the Rollups Node and that Server Manager is in your `$PATH`. - -The Rollups Node uses the latest versions of Go and Rust. -Make sure these versions are up to date in your system. - -### Build Dependencies - -Execute the command below to build the Cartesi Machine test snapshot and the devnet. - -```sh -make docker-build-deps -``` - -### Build Rust services - -Before running, build the Rust services in the `offchain` directory with the following commands. - -```sh -cd ./offchain -cargo build -``` - -The binaries will be on `./offchain/target/debug`; add this to your `$PATH`. - -### Save Machine Snapshot - -The Rollups Node needs a Cartesi Machine snapshot of an application. -Use this command to save the snapshot of an echo application to your local file system. - -```sh -go run ./cmd/cartesi-rollups-cli/ save-snapshot -``` - -### Start Dependencies - -Once built, start the dependencies. -On a shell, execute the following command: - -```sh -go run ./cmd/cartesi-rollups-cli/ run-deps -``` - -### Run - -The Node is configurable via environment variables. -Open another shell and execute the following command to quick-start with default values. - -```sh -source ./setup_env.sh -``` - -Finally, start the Node in the same shell by executing. - -```sh -go run ./cmd/cartesi-rollups-node/ -``` - -Optionally, you can build the Go binary and use it directly. - -```sh -go build ./cmd/cartesi-rollups-node -./cartesi-rollups-node -``` - -## Interacting with the Node - -The Node repository contains a command-line tool to interact with the Node. -This tool is meant for the Node developers to test and debug it. -This tool is **not** a general-purpose tool to interact with the Cartesi Rollups. -To run the tool, run the command below. - -```sh -go run ./cmd/cartesi-rollups-cli -``` - -The page [`cartesi-rollups-cli`](cli/cartesi-rollups-cli.md) contains the documentation for all available options. diff --git a/docs/http-posture.md b/docs/http-posture.md new file mode 100644 index 000000000..704d94098 --- /dev/null +++ b/docs/http-posture.md @@ -0,0 +1,489 @@ +# HTTP Deployment Posture + +**Audience:** operators running the Cartesi rollups-node in production. +**Applies to:** inbound HTTP controls introduced in 2.0.0-alpha.12. + +This document describes the HTTP-facing surfaces of the node, how they are +protected in-process, and the deployment posture operators are expected to +provide around them. The node assumes a **trusted network boundary** — the +in-process controls are a defense-in-depth layer, not a substitute for +operator-side network policy. + +## The three HTTP surfaces + +| Surface | Default address | Purpose | Per-request cost | +| --- | --- | --- | --- | +| **Telemetry** (`/livez`, `/readyz`) | `:10000` | Orchestrator health checks | Trivial — a boolean check and a short response | +| **JSON-RPC API** (`/rpc`) | `:10011` | Read-only query interface | Up to 1 MiB body; one list operation, or a batch with a cumulative list limit of 10000 items; DB queries | +| **Inspect** (`/inspect/{dapp}`) | `:10012` | Machine state query without advancing | Up to 2 MiB body, Cartesi Machine fork + execution | + +Telemetry is cheap by design — orchestrators (Kubernetes, Docker, +systemd health checks) hammer it intentionally. JSON-RPC and inspect +are the expensive surfaces and are the targets of the hardening package. + +## Bind defaults + +All three services bind to `:PORT` (all interfaces) by default. This is +required for Docker container port publishing and for typical reverse-proxy +front-ending patterns where the node listens on an in-container interface +the proxy can reach. + +**On startup, the node logs a warning** for every HTTP service that binds +to an unspecified address (`:PORT`, `0.0.0.0:PORT`, or `[::]:PORT`): + +```text +WRN HTTP service bound to all interfaces; restrict access via firewall or reverse proxy + service=inspect addr=:10012 +``` + +This warning is expected under Docker and Compose. In bare-metal +deployments without a reverse proxy, consider overriding the addresses to +loopback only: + +```bash +CARTESI_INSPECT_ADDRESS=127.0.0.1:10012 +CARTESI_JSONRPC_API_ADDRESS=127.0.0.1:10011 +CARTESI_TELEMETRY_ADDRESS=127.0.0.1:10000 +``` + +## Recommended deployment posture + +The node's HTTP surfaces are designed to sit **behind a reverse proxy** +(nginx, Caddy, Traefik, Envoy, or a cloud API gateway). The proxy is +expected to provide: + +- **TLS termination.** The node speaks plain HTTP only. +- **Per-IP rate limiting.** The node has no IP-awareness; the proxy's + `limit_req`/`limit_conn`-equivalent primitives own that responsibility. +- **Authentication and authorization.** The node's HTTP endpoints are + unauthenticated; do not expose them to untrusted clients without a + proxy that enforces auth. +- **Connection and request caps at the network layer.** The in-process + admission control (described below) is a second line of defense, not + a replacement for proxy-level limits. + +For internal-only deployments without external exposure: + +- Bind to loopback or a private-network interface. +- Use a firewall (`iptables`, `nftables`, cloud security groups) to + restrict source addresses. +- Treat `inspect` as a sensitive execution surface — it can run machine + code on demand and should stay internal whenever possible. + +**Browser exposure.** CORS is **disabled by default** on both JSON-RPC +and inspect. No `Access-Control-Allow-Origin` header is emitted unless +the operator explicitly configures an origin allowlist via +`CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS` or +`CARTESI_INSPECT_CORS_ALLOWED_ORIGINS`. When configured, only +exact-match origins are reflected — wildcard (`*`) is never used. + +For production browser exposure, prefer handling CORS at the reverse +proxy (nginx, Caddy, Envoy) rather than in the node. + +Note that `http://localhost:3000` and `http://127.0.0.1:3000` are +**distinct origins** in the browser's same-origin policy. If you bind +the node to `127.0.0.1` but your frontend dev server runs at +`http://localhost:3000`, you must allowlist the origin the browser +sees (e.g. `http://localhost:3000`), not the bind address. + +CORS configuration is read at startup and is not reloadable. Changing +allowed origins requires a process restart. + +## Timeout baselines + +Each HTTP surface uses a named preset from `pkg/service` (exported as +`DefaultInspectOptions`, `DefaultTelemetryOptions`, `DefaultJSONRPCOptions`). +All five fields are set on every server. + +| Field | Inspect | Telemetry | JSON-RPC | +| --- | --- | --- | --- | +| `ReadHeaderTimeout` | 10s | 5s | 10s | +| `ReadTimeout` | 30s | 10s | 30s | +| `WriteTimeout` | 600s | 10s | 30s | +| `IdleTimeout` | 60s | 60s | 60s | +| `MaxHeaderBytes` | 64 KiB | 16 KiB | 64 KiB | + +**Inspect `WriteTimeout` = 600s** is a process-health backstop. The actual +per-request deadline is set structurally in `Inspector.ServeHTTP` as +`InspectMaxDeadline + 30s` (the 30-second headroom covers response +serialization). The HTTP `WriteTimeout` prevents leaked goroutines from +holding a connection forever but never participates in normal request +lifecycle. + +### Per-request deadline enforcement + +Each inspect request receives a `context.WithTimeout` set to the +application's `InspectMaxDeadline + 30s` (the 30 seconds cover JSON +response serialization and wire delivery). This deadline is set in +`Inspector.ServeHTTP` after resolving the application, before invoking +the Cartesi Machine. The machine manager sets a nested +`context.WithTimeout(ctx, InspectMaxDeadline)` for the machine execution +itself; Go context nesting means the shorter deadline always wins. + +This structural approach eliminates the need for coordinating +`WriteTimeout` with `InspectMaxDeadline` — operators configure per-app +deadlines without worrying about HTTP-layer timeouts. + +## Admission control + +Both expensive HTTP surfaces (JSON-RPC and inspect) have an **in-process +concurrency gate** that fails fast when the number of in-flight requests +exceeds a configured limit. This bounds goroutine count, per-request +memory, and backend contention even under a flood. + +### Configuration + +| Env var | Default | Scope | +| --- | --- | --- | +| `CARTESI_INSPECT_MAX_INFLIGHT` | `64` | Inspect service only | +| `CARTESI_JSONRPC_MAX_INFLIGHT` | `64` | JSON-RPC service only | + +Each surface has its **own independent budget**. A flood on one +surface cannot starve the other. Telemetry is never gated — its +per-request cost is too low to justify admission. + +A value of `0` disables admission on that surface. Backpressure then +falls back to: + +- Inspect: the per-application Cartesi Machine semaphore (blocking, not + fail-fast; deeper in the request path). +- JSON-RPC: the PostgreSQL connection pool (blocking). + +### JSON-RPC batch work budget + +Admission counts HTTP requests, while a JSON-RPC batch can contain up to 100 +operations. To keep one admitted batch from buying substantially more row-fetch +work than one maximal list request, the service applies a protocol-level budget +before dispatch: + +- The sum of the effective `limit` values across all list entries in a batch + must not exceed 10000. +- An omitted or zero `limit` counts as the default of 50. A value above the + per-list maximum is capped to 10000 before it is added. +- If the sum exceeds 10000, the whole batch is rejected before any handler or + database query runs. The response is one JSON-RPC error object with code + `-31004` and message `Batch list item limit exceeded`. +- Non-list entries do not consume this work budget. A single request retains + the existing per-list maximum of 10000. + +This restores the row-fetch bound that existed before batch support: one +admission slot can fetch at most as many rows as one maximal list call. It does +not bound `COUNT(*)` cost, which is independent of `limit`. It also does not +meter `offset` traversal cost; that cost is bounded by the size of the filtered +set rather than by the numeric `offset` value, but PostgreSQL may still have to +scan and discard the matching rows before the requested page. Selective +filters, the pending-output partial index, proxy rate limiting, and PostgreSQL +capacity planning remain important. + +### Rejection semantics + +When admission rejects a request: + +```text +HTTP/1.1 503 Service Unavailable +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Retry-After: 1-3 (jittered) + +service at capacity (request_id=) +``` + +- **Silent by default** — the rejection is not logged per-request + (logging every rejection during a flood would amplify the flood). +- The `Retry-After` header carries a jittered value in `[1, 3]` seconds + to desynchronize retrying clients and prevent thundering-herd pile-ups. + Clients and proxies may or may not honor it. +- Operators observe saturation via the monotonic `Rejected()` counter + exposed on the `SemaphoreAdmission` instance. Wiring into a metrics + backend is out of scope for this release; see the rollups-node bug + taxonomy for when that becomes urgent. + +Note that the 503 response is `text/plain`, not a JSON-RPC error +envelope. JSON-RPC 2.0 client SDKs will treat this as a transport-level +error, not a protocol-level one. This is deliberate: admission rejection +happens before the request reaches the JSON-RPC handler, so a transport +error is the correct signal. + +### Tuning guidance + +The defaults (64 in-flight per surface) are conservative and fit a +single-node deployment on typical hardware. Consider raising the limit +when: + +- The reverse proxy is already doing per-IP rate limiting and the + admission gate is hitting the ceiling on legitimate traffic. +- DB / machine capacity can sustain more concurrent work and the + `Rejected()` counter is non-trivial under normal load. + +Consider lowering when: + +- Memory pressure from buffered request bodies is observable. +- Concurrent machine forks are slowing legitimate inspects to the point + of cascading timeouts. + +## Request bodies + +| Surface | Cap | Enforcement | +| --- | --- | --- | +| Telemetry | — | No request body | +| JSON-RPC | **1 MiB** | `http.MaxBytesReader` in the handler | +| Inspect | **2 MiB** | `http.MaxBytesReader` in the handler (matches the Cartesi Machine CMIO RX buffer) | + +Oversized bodies are rejected with: + +```text +HTTP/1.1 413 Request Entity Too Large +Content-Type: text/plain; charset=utf-8 + +Payload too large +``` + +The connection is then force-closed by the stdlib, so clients cannot +pipeline additional requests on the same connection. This behavior +depends on the internal `responseWriterTap.Unwrap()` cooperating with +`http.MaxBytesReader`; see the hardening v3 plan for the design note. + +**Worst-case request and response memory under saturation.** +Each admitted request pins its body buffer for the full request lifetime +(up to `InspectMaxDeadline + 30s` for inspect (typically ~210s with the default +180s deadline), 30s for JSON-RPC). At default concurrency this means +`CARTESI_INSPECT_MAX_INFLIGHT × 2 MiB = 128 MiB` for inspect and +`CARTESI_JSONRPC_MAX_INFLIGHT × 1 MiB = 64 MiB` for JSON-RPC request bodies. + +The dominant JSON-RPC term is response buffering: each of the 64 admitted +requests has a 10 MiB response budget, for up to `64 × 10 MiB = 640 MiB` of +response buffers under saturation, or approximately 704 MiB including request +bodies. The budget is enforced while encoding the response, after repository +rows and decoded response objects have already been materialized; that working +set is additional and is not bounded by the 10 MiB serialized-response limit. +Operators should size process RAM headroom accordingly, on top of machine +state, database connections, and other working memory. + +## PostgreSQL pool sizing + +The node uses `pgxpool` for database access. The pool's default maximum +connection count is `max(4, runtime.NumCPU())` unless overridden in the +connection URL. On a typical 4-core container this means 4 connections +shared across every service. + +**Rule of thumb:** set `pool_max_conns` to at least +`CARTESI_JSONRPC_MAX_INFLIGHT + steady-state writer services`. In +standalone mode the writer services (EVM Reader, Advancer, Validator, +Claimer, PRT, plus overhead) account for roughly 6-8 connections, so a +conservative floor is `64 + 8 = 72`. Override via the connection URL: + +```bash +CARTESI_DATABASE_CONNECTION="postgres://user:pass@db:5432/rollups?pool_max_conns=72&pool_max_conn_lifetime=30m" +``` + +**Fail-fast vs. fail-slow.** Admission rejects are visible and +immediate (503 with `Retry-After`). Exceeding the pool capacity is +invisible and slow — handlers that passed admission block inside +`pgxpool.Acquire` while holding admission permits, causing latency +degradation with no obvious signal to the operator. Sizing the pool to +match the admission limit prevents this silent backpressure. A future +release may emit a startup warning when the sum of admission limits +exceeds the configured pool size. + +Note that the inspect surface also queries the database (one +`GetApplication` call per request) and shares the same underlying pool, +so both hardened HTTP surfaces compete for connections. + +## Request IDs + +Every response from inspect, JSON-RPC, and telemetry includes an +`X-Request-ID` header. The middleware enforces: + +- **Validation:** upstream `X-Request-ID` is trusted only if it matches + `^[A-Za-z0-9._:=/+-]{1,128}$`. Values outside that charset or longer + than 128 characters are **discarded** and a fresh UUIDv4 is generated + in their place. This prevents log injection via `\n` / `\r` and caps + header cardinality. +- **Generation:** when no upstream ID is present (or the upstream value + was rejected), the node generates a UUIDv4 via `github.com/google/uuid`. +- **Propagation:** the chosen ID is placed on the request context and + echoed on the response `X-Request-ID` header. Error log lines from the + handler include the ID as a structured field. + +This lets operators correlate a single request across: + +- The reverse proxy's access log (if it assigns IDs upstream). +- The rollups-node's structured log output. +- The client's error response (see next section). + +## Internal errors + +When a handler hits an unexpected error that it can't express as a +domain-level status code, it responds with: + +```text +HTTP/1.1 500 Internal Server Error +Content-Type: text/plain; charset=utf-8 + +Internal server error (request_id=) +``` + +The original Go `error` value is **never** written to the response body. +Its full content — message, wrapped chain, stack if available — is +logged at ERR level with the request ID as a structured field. Operators +triage a user's 500 report by grepping logs for the request ID they +reported. + +As with admission 503s, the 500 response is `text/plain`, not a JSON-RPC +error envelope. JSON-RPC client SDKs will surface these as transport +errors, which is the correct signal for a server-side fault. + +## Panic recovery + +Every HTTP handler chain is wrapped in a panic-recovery middleware: + +- **If the handler panics before any byte has been written**, the + middleware catches the panic, logs the value and stack trace at ERR + level with the request ID, and writes a generic 500 (same format as + "Internal errors" above). +- **If the handler panics after bytes have been flushed**, the + middleware cannot safely write a 500 without producing a corrupt + response (stitching a `500 Internal Server Error` onto a started 200 + would lie to the client and trigger Go's "superfluous WriteHeader" + warning). Instead, the middleware re-panics with `http.ErrAbortHandler` + — the stdlib's documented sentinel for "abort this connection + silently". The client observes a truncated response and connection + drop, which is the honest signal. +- **Panics whose value is already `http.ErrAbortHandler` are re-panicked + unchanged.** This preserves the stdlib contract for handlers that + intentionally use the sentinel to abort without logging. + +## Known limitations + +1. **Two-layer admission on inspect.** Inspect requests pass through two + independent concurrency gates: the HTTP-global admission gate (default + 64 in-flight, configured via `CARTESI_INSPECT_MAX_INFLIGHT`) and a + per-application machine semaphore (`MaxConcurrentInspects`, default + 10). Both gates are fail-fast (`TryAcquire`): when either is full the + request is rejected immediately with 503 and the caller's admission + permit is released. This means one saturated application does **not** + starve others — its excess requests fail at the per-app gate and free + HTTP-global capacity for other apps. Operators should be aware that + both layers return 503; the HTTP-global gate includes a `Retry-After` + header while the per-app gate does not. + +2. **Single-replica assumption.** The admission budget (default 64 per + surface) is per-process. Multi-replica deployments behind a load + balancer have an effective budget of `replicas × CARTESI_*_MAX_INFLIGHT`. + The default 64 is sized for a single-node deployment on typical + hardware; operators should not assume it represents a global limit. + +## Outbound endpoint credentials + +`CARTESI_BLOCKCHAIN_HTTP_ENDPOINT`, `CARTESI_JSONRPC_API_URL`, and +`CARTESI_INSPECT_URL` require an `http` or `https` URL with a DNS name or IP address. +IPv4 and bracketed IPv6 addresses are supported, for example +`http://127.0.0.1:10011/rpc` and `http://[::1]:10011/rpc`. +WebSocket URLs and IPC paths are rejected. Configure the final endpoint +directly: outbound clients do not follow redirects, including 307 and 308. +HTTP 101 upgrade streams are closed; these clients support HTTP API responses. + +Help, usage, and generated command documentation show the defaults declared +in the binary, independently of environment or configuration values. Sensitive +configuration errors omit the supplied value, including a secret accidentally +placed in a `_FILE` variable instead of a filename. + +Outbound clients restore URL credentials, paths, queries, and Basic authentication +only in the HTTP transport. Connection diagnostics and retry logs expose the +scheme and host, with fixed descriptions for DNS, connection, timeout and TLS +certificate failures. Non-2xx response bodies are replaced with safe diagnostics; +arbitrary provider messages are discarded. Numeric RPC codes and the fixed +`nonce too low` classification are retained for complete, bounded error bodies. +The retrying Ethereum client can lose that phrase after exhausting retries on +5xx responses; this also occurred before the fix. The inspect client retains +the node's exact fixed inspect failure messages. Inspect operations append +their path and query to the configured base URL. + +Non-2xx diagnostic reads are limited to 64 KiB; larger or incomplete bodies produce +a generic failure while retaining the HTTP status. Interrupted reads retain +their safe cancellation or timeout cause after status-based retry decisions. +An HTTP 400 body interrupted by the client's request timeout is not retried. +Successful response buffering +remains unlimited. Unexpected 2xx statuses +other than HTTP 200 are errors in the JSON-RPC API client, and their bodies are +omitted from diagnostics. The Ethereum and JSON-RPC API clients decode the first +JSON response value, preserving their existing acceptance of trailing data. The +Ethereum envelope check still rejects malformed outer field types safely. + +The Ethereum constructors own their protected HTTP client; a supplied +`rpc.WithHTTPClient` option is superseded. Other RPC options still apply. The +JSON-RPC client keeps its existing public `URL` configuration field, which may +contain credentials; use returned diagnostics when displaying failures. +The protected inspect constructor accepts ordinary `*http.Client` overrides; +it rejects arbitrary custom HTTP doers whose transport cannot be protected. + +The JSON-RPC API and inspect CLI send a fresh local UUID in `X-Request-ID` and +print that ID on failure. If the request reached the node, use the ID to find +the matching JSON-RPC dispatch/error or inspect error log. Failures before +JSON-RPC request construction have no request ID. Response IDs and +arbitrary error bodies are not displayed; a provider can put a credential there. +Successful CLI output is unchanged. + +For example, a provider connection failure exposes only the public address: + +```text +HTTP request failed for https://provider.example: connection refused +``` + +A node API internal failure includes the locally generated correlation ID: + +```text +RPC Error -32603: Internal server error (request_id=) +``` + +Connection diagnostics use these fixed labels: + +| Label | Meaning | +| --- | --- | +| `proxy connection failed` | The connection to the configured proxy failed. | +| `context canceled` | The request was canceled. | +| `context deadline exceeded` | The request context expired. | +| `timeout` | A network operation timed out. | +| `DNS lookup failed` | Hostname resolution failed. | +| `connection refused` / `connection reset` | The destination refused or reset the connection. | +| `unknown certificate authority` | The TLS certificate authority is not trusted. | +| `certificate hostname mismatch` | The certificate does not match the hostname. | +| `certificate verification failed` / `TLS handshake failed` | TLS validation or negotiation failed. | +| `unexpected EOF` / `connection closed` | A response or connection ended unexpectedly. | +| `empty response` | An RPC decoder received no JSON response. | + +Valid Ethereum 2xx and JSON-RPC API HTTP-200 payloads retain their content, including provider error +messages and result data. A provider can reflect a credential in that content +or in a result that later fails to decode. Hostnames are also visible, including +provider subdomains that serve as credentials. These protections do not remove +secrets from process environments, inherited child environments, or command-line +arguments visible through process listings. + +Owner-mismatch failure reasons omit raw provider error messages. Other application +failure reasons retain contract revert-data hex as operational evidence. Hex is +reversible: provider-supplied data can therefore reach the public `application.reason` +field, and that field is not universally secret-free. + +## Non-goals + +The following are explicitly **out of scope** for the inbound HTTP hardening +package. If you need them, add them at the reverse proxy or via +follow-up work. + +- TLS termination inside the node. +- Authentication / authorization on HTTP endpoints. +- Per-IP rate limiting. +- Per-application fairness inside the admission gate (the gate is + global per HTTP surface). +- Global cross-service admission (inspect and JSON-RPC have independent + budgets). +- Admission on telemetry. +- Wiring `SemaphoreAdmission.Rejected()` into a metrics backend. +- Flipping bind defaults to loopback. +- Exposing `net/http/pprof`. + +## Related documentation + +- `docs/config.md` — generated reference for every `CARTESI_*` + environment variable, including `CARTESI_INSPECT_MAX_INFLIGHT` and + `CARTESI_JSONRPC_MAX_INFLIGHT`. diff --git a/docs/images/advance.svg b/docs/images/advance.svg deleted file mode 100644 index d6791b9f5..000000000 --- a/docs/images/advance.svg +++ /dev/null @@ -1,899 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/communication.svg b/docs/images/communication.svg deleted file mode 100644 index 61c95a451..000000000 --- a/docs/images/communication.svg +++ /dev/null @@ -1,1136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/components.svg b/docs/images/components.svg deleted file mode 100644 index bed1edf1a..000000000 --- a/docs/images/components.svg +++ /dev/null @@ -1,1078 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/inspect.svg b/docs/images/inspect.svg deleted file mode 100644 index 8b0d88145..000000000 --- a/docs/images/inspect.svg +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/overview.svg b/docs/images/overview.svg deleted file mode 100644 index d7af75098..000000000 --- a/docs/images/overview.svg +++ /dev/null @@ -1,649 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/validate.svg b/docs/images/validate.svg deleted file mode 100644 index b7ad498ca..000000000 --- a/docs/images/validate.svg +++ /dev/null @@ -1,910 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/release.md b/docs/release.md deleted file mode 100644 index 51d61444c..000000000 --- a/docs/release.md +++ /dev/null @@ -1,92 +0,0 @@ -# Release process - -This page explains how to create a release of the Rollups Node by showing each step that should be performed, from updating the change log and testing the changes to announcing a release. - -## Pre-release procedure - -Before starting the release process, we must confirm all project dependencies have been updated for the new release. - -All related tasks are described in the [update-dependencies issue template](https://github.com/cartesi/rollups-node/blob/main/.github/ISSUE_TEMPLATE/update-dependencies.md). - -## Pull latest changes - -On your local machine, make sure to pull the latest changes made to the `main` branch. - -## Perform tests - -Execute all end-to-end tests and whatever specific tests concerning the changes made at the release (let's use `X.Y.Z` as the release version number). - -If necessary, fix any issues with the team by following the normal development workflow. - -Once all tests pass and the `main` branch is up-to-date, proceed to the next step. - -## Update the [`CHANGELOG`](../CHANGELOG.md) - -Confirm the code changes listed under `Unreleased` are accurate, and then: - -- in the beginning of the file, replace the heading `Unreleased` with the version number of the new release, followed by the current date (`YYYY-MM-DD`); -- at the end of the file, create a link to the differences from the new release to the previous one; -- update the link to the differences related to the `Unreleased` changes. - -Commit the changes, push them to the repository and create a corresponding pull request. - -> [A sample commit](https://github.com/cartesi/rollups-node/commit/16ba20137a1575b98d069500168261cccb2e0eb2) may be found in the repository. - -## Create the release tag - -Once the pull request is approved, merge it and update your local copy of the `main` branch. - -Then, create a new tag for the release based on the `main` branch as follows: - -```shell -git tag -a -s vX.Y.Z -``` - -Make sure to add a message to the tag commit as follows: - -```txt -Release X.Y.Z -``` - -Then, push the tag to th repository: - -```sh -git push origin vX.Y.Z -``` - -## Check CI results - -Check if the CI actions associated with the release pass. - -After that, pull the docker image generated by the CI to make sure it's been pushed to Docker hub as follows: - -```shell -docker pull cartesi/rollups-node:X.Y.Z -``` - -## Update release information - -Go to the [Releases page](https://github.com/cartesi/rollups-node/releases) and edit the current release by: - -- unchecking the `Set as a pre-release`; -- checking the `Set as the latest release`. - -Remember to save the changes after that. - -## Announce the release - -Once the release information is updated, announce it on the appropriate communication channels. -The announcement should contain: - -- the release number; -- a link to the tag/release; -- instructions on how to pull the corresponding docker image from Docker hub. - -## Update/create a maintenance branch - -Either create a maintenance branch for any major or minor release and name it as `release/X.Y.Z` or update an existing maintenance branch if the release is a patch release. - -## Prepare for the next release - -Last but not least, prepare the [`CHANGELOG`](../CHANGELOG.md) for the next release by creating a new heading (`## [Unreleased]`) in the beginning of the document. -Create a pull request based on the `main` branch and merge it once it's approved. diff --git a/docs/transaction-delivery.md b/docs/transaction-delivery.md new file mode 100644 index 000000000..82dd11446 --- /dev/null +++ b/docs/transaction-delivery.md @@ -0,0 +1,615 @@ + + + +# Transaction delivery + +The node submits transactions to advance application consensus and recover its +PRT bonds. Claimer handles Authority and Quorum. PRT handles DaveConsensus. +Each role has its own signer and sender. Within each role, an observer and a +publisher run independently. + +The observer stores protocol facts in PostgreSQL, using `finalized` by default. The publisher reads +those committed facts and checks current contract state before sending. A slow +observation does not stop checks of a transaction already being published. +RPC acceptance does not prove that an action executed or changed protocol state. + +The database stores no signed transactions or delivery journal. After a restart, +the node reconstructs useful work from protocol facts and contract state. This +design supports new deployments. This pre-release changes the initial database +schema to retain the epoch where execution or root validation stopped. Use a +fresh database and re-ingest the chain when upgrading an existing deployment; +there is no migration or inferred cutoff for an already populated database. +Changes to a database's registered chain, observation policy, submission mode +or signer are not supported. + +## Prepare a deployment + +- Set `CARTESI_BLOCKCHAIN_DEFAULT_BLOCK` to `latest`, `safe` or `finalized`. + The node defaults to `finalized`. All contributing services must use the same + policy. The provisional modes, `latest` and `safe`, have no rollback after a + reorg: stored inputs or protocol facts can become invalid, and an operator may + need to stop the node and rebuild its database. A rebuild cannot undo a claim + or bond payment that already executed. Startup reports the lack of rollback. + The sender keeps only its current transaction and releases that record when the + latest mined nonce advances. It does this for every observation policy. `latest` + makes no mandatory finalized read. `pending` is unsupported. +- Give Claimer and PRT different, code-free EOA addresses. Do not attach pending + EIP-7702 authorizations to these accounts. +- Run one submitting process per signer. Independent deployments do not + coordinate through their databases. Do not send from an active node's signer + with the CLI or another wallet. If another program accidentally uses the + wallet, the node reconciles nonce changes and retries at its polling interval. + Lost-publication races with advancing nonces produce rate-limited warnings. A warning does + not prove another program is using the signer. Stopping the competing program + does not withdraw transactions it already sent. Those can keep occupying the + nonce until mined, replaced or dropped from the provider's pool. Stop the submitting service before using + a wallet to replace a pending transaction. Inclusion is not guaranteed during + continued competition. +- Use one node deployment per database and one active process per service role. + A split deployment runs its different service roles against that database. + Stop the current processes completely before starting replacements. Use a + stop-first rollout, for example Kubernetes + [Recreate](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#recreate-deployment). +- Use a provider that supports canonical block-hash account reads and contract + calls through EIP-1898, and gas estimation at a block number. + Estimation sizes gas; exact hash-pinned simulation authorizes publication. + Use one consistent RPC endpoint for the contributing + services; its replicas must give coherent state and log results. + It must serve state at the selected observation head + and historical state, logs and headers for catch-up. Open Claimer epochs need + state at their last processed block, even when the policy is `latest`. Retention + must cover the oldest unreconciled epoch or cursor, including downtime and + computation backlog. Verify the provider's actual query support; there is no + universal block-count cutoff. PRT counter baselines + come from committed database events; they do not require state at an old cursor. + Input scans and Claimer observation can still require older contract state. + In particular, Claimer reads accepted-claim counts at an open claim's epoch end; + a provider that prunes that state can stop observation until archival state is available. + +## Use a development chain + +Development and local environments use `latest` because their chains do not +reorganize. Both integration targets accept `DEFAULT_BLOCK=latest`, `safe` or +`finalized`. The default is `latest`; an inherited +`CARTESI_BLOCKCHAIN_DEFAULT_BLOCK` is preserved unless `DEFAULT_BLOCK` is explicit. +The selector applies the same observation policy to all contributing services: + +```sh +make integration-test-local DEFAULT_BLOCK=finalized +make integration-test-with-compose DEFAULT_BLOCK=finalized +``` + +`SHARD` and `NODE_TOPOLOGY` keep selecting the tests and deployment topology. +Local tests use your running Anvil with its existing finality settings. Compose +tests with `finalized` use a 64-block finality lag; other policies use the ordinary +devnet settings. CI adds two finalized cells that select three delivery suites: + +```sh +make integration-test-with-compose DEFAULT_BLOCK=finalized SHARD=delivery-finalized NODE_TOPOLOGY=all +``` + +The focused profile stays within the normal job budget. Selecting every suite +with `finalized` takes longer and can exceed that budget. Compose CI cells that +select the basic standalone suites also run the short-deadline delivery fixtures. +On the host, run those separately with `make delivery-test`. + +`eval "$(make env)"` exports the development settings directly. Use +`eval "$(make env DEFAULT_BLOCK=finalized)"` to select finalized observation for +other local commands. Both development Compose files also default to `latest` +and accept the full node setting as an environment override. + +Use a fresh database when changing policy; changing a database's saved policy +is unsupported. The node's unconfigured default remains `finalized`. The selected policy controls stored protocol facts. Publication checks and +in-memory transaction retirement use latest mined state in all three modes. + +## Connect PostgreSQL + +The observer and publisher use the ordinary repository pool. +The database coordinates protocol facts within one deployment; it does not +coordinate wallets used by independent deployments. + +Repository operations used by delivery have a two-minute client deadline, +including connection acquisition and database work. A shorter caller deadline +takes precedence. Deletion respects the CLI's explicit timeout and has a short +lock-wait limit. A failed or timed-out operation leaves the affected work for a +later polling iteration. Shutdown cancels outstanding work. + +Configure PostgreSQL's +[idle transaction timeout](https://www.postgresql.org/docs/17/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT) +and [TCP dead-connection detection](https://www.postgresql.org/docs/17/runtime-config-connection.html#GUC-TCP-KEEPALIVES-IDLE) +for the node's connections. A client deadline cannot guarantee that a partitioned +server receives cancellation or releases transaction locks promptly. The idle +transaction timeout covers idle transactions; active statements need an +appropriate server-side statement timeout. Allow enough time for expected +observation commits and deletion work when choosing these server limits. + +A large PRT observation interval can hold its application's row for seconds. +The EVM Reader and application removal use the same row. Removal can report a +lock timeout while observation commits; retry it after that transaction finishes. +Each refresh pass can repeat these locks and database writes; this cost is not +limited to discovering a new tournament. + +## Publish a transaction + +Each publisher asks its planner for **Publish**, **Wait**, or **Cancel**: + +- Publish supplies a useful protocol action and its transaction request. +- Wait keeps the current transaction while a read or protocol waiting period is incomplete. +- Cancel requires positive evidence that the current action is obsolete. It asks + for a zero-value self-transfer at that nonce. It cannot revoke an already sent transaction. + +The sender reads one coherent latest block and the signer's mined nonce. It +checks the service publication guard, obtains a fresh fee quote, estimates gas at that block number, +and simulates the exact +sender, target, calldata, value, gas and fees at that block hash. Bond recovery +also has to return true. A successful gas estimate alone does not authorize a send. + +After signing, the sender checks the service guard and current eligibility again. A +normal new block does not require another signature. A changed nonce, orphaned +preparation block, failed read or obsolete action prevents that POST. A failed +read does not authorize cancelling or replacing unrelated useful work. + +The sender stores signed bytes before POST. It reuses a valid current envelope +when a fresh quote cannot clear its replacement threshold. It has no application, epoch, deadline or consensus rules; +the Claimer and PRT planners own those decisions. Each role keeps its own +publisher loop and in-memory sender. + +Each HTTP request and each signing operation is bounded by +`CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT`. KMS HTTP requests also use this timeout. +The KMS request receives the caller's deadline and cancellation directly. +A pass can contain many requests and take longer than this timeout. Its finite +candidate budget limits work, not elapsed time. Each delivery database operation +has its own client deadline and also receives service cancellation. These limits +do not impose one deadline on an entire observation or publication pass. + +## Choose a fee ceiling + +`CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS` is optional. Its value is a positive decimal +integer in wei. It caps dynamic `maxFeePerGas` and legacy `gasPrice`. Without it, +there is no operator price ceiling. It does not bound total expenditure. + +For dynamic transactions, the sender uses a fresh quoted tip and a cap based on +the current base fee. It clamps the cap to the optional ceiling and the tip to +that cap. After a price refusal, it retries once with the tip equal to the actual +cap, without increasing the cap. This fallback can pay the full cap as the +price per gas, rather than the usual base fee plus quoted tip. + +Legacy mode uses a fresh suggested gas price. Its one price-refusal retry uses +the greater of a ten-percent increase, rounded up, or one additional wei, then +applies the optional ceiling. + +For the same current action, the sender reuses valid bytes while a fresh quote +cannot increase both fee fields enough to replace them. For cancellation or a +different selected action, it raises the quote to at least the cached bid's +ten-percent replacement step, with a strict increase of at least one wei. +The optional ceiling still applies. Once that replacement is retained, ordinary +polling reuses it; polling alone does not keep increasing the bid. + +If that replacement step exceeds the ceiling, the sender tries the fresh capped +quote. The earlier copy might have left the pool. A definite refusal preserves +the old record. This can restore progress after eviction, but cannot force a +surviving older bid out of the pool above the configured ceiling. +While that copy remains, a pass can make two refused attempts and two signing +requests. No transaction fee is spent for a refused POST, but KMS requests can cost money. + +A cached cap must cover the current base fee plus the smaller of the cached tip +and the fresh quoted tip. Merely covering the base fee can leave a zero effective +tip. In that case, or when cached gas is insufficient, the sender repairs the +envelope using the same bounded replacement step. + +Retained bytes do not prove that the pool admitted them. After a definite +replacement-underpriced reply for the same action, the one retry uses the fresh +quote, including its full-cap tip. This can displace an unknown older bid after +restart and a lost reply. An ambiguous or mixed transport failure keeps its +uncertain result and does not authorize this price retry. +In legacy mode on a base-fee chain, a retained gas price must exceed the base fee. +A full-cap retry's tip also sets the minimum tip for later replacements at that nonce. + +These rules do not guarantee replacing an arbitrary older transaction. If the +fresh quote cannot clear a provider's replacement threshold, the old transaction +can remain pending. Check the stalled-nonce warning's reply and the INFO +`Transaction publication` entry for that nonce. Its `fee_cap` and `tip` show the +attempted quote. Compare them with the configured ceiling. The operator may need +to fund the signer, increase the ceiling or use another provider. +Lowering the ceiling cannot cancel an already sent transaction or reduce its price. +Private relays need a verified release policy; public-pool progress assumptions +do not establish that every relay will release obsolete work. + +These replacement steps target the supported pool profile; another provider can +require a larger bump. A fresh market quote can exceed the minimum step. +There is no reserved cancellation fee headroom or wallet reservation. +The sender checks the current +transaction's maximum gas cost plus value against the current balance. A join +uses the contract's required bond; there is no configurable join-bond cap. +The funding check before estimation uses only a minimum gas allowance. Its +reported amount is a lower bound; the final padded allowance can require more funds. + +Ordinary contract calls add the greater of fifty percent or 150,000 gas to the +estimate. A value-bearing join adds the greater of the full estimate or 150,000 +gas, to cover the pairing branch after a peer joins first. An action can supply +a minimum gas allowance, such as bond recovery with its payment callback. +The estimate can raise that allowance to cover chain-specific posting costs. +A neutral self-transfer uses its estimated gas without the contract-call padding. +An unavailable estimator can reuse the same retained request's gas only when its +exact simulation and fresh checks succeed. New or changed work, including a new +cancellation, waits for estimation to recover. Known estimator refusals retain +their cause and are reported as `gas estimate refused`. Check the named contract +condition, gas allowance, signer balance or provider capability. A transport +failure is reported as `gas estimate unavailable`. The sender does not invent a +large fallback allowance. +The sender bounds gas by the +block limit and its supported transaction limit. Unused gas is not normally +charged, but the larger limit increases the balance needed before inclusion. + +## Understand retries and restarts + +There is one preferred current-nonce transaction in memory. There is no signed +transaction journal, inventory of past nonces, receipt accounting pipeline or +cumulative application revert breaker. + +A lost reply leaves the outcome unknown. The sender keeps the preferred bytes +for a later pass. If a changed replacement is definitely refused, it preserves +the previous usable request. An ambiguous replacement can coexist with an older +transaction in the network; the cache does not list every distributed version. + +Latest mined nonce progress releases the cache, even when observation uses +`safe` or `finalized`. Inclusion consumes a nonce even when execution reverts. +The observer independently records protocol results at the selected policy. +RPC acceptance, an already-known reply and nonce progress do not prove that the +intended protocol action succeeded. + +Restart loses the cache. A reorg can restore a consumed nonce after its bytes +have been discarded. The planner reconstructs useful work from computed data +and current contract state. An older higher-nonce copy can execute after the +lower gap is filled. Duplicate or losing-race execution can cost gas; there is +no historical classifier or automatic cumulative spending stop. +The cost depends on the action, calldata and chain gas rules; there is no fixed +gas charge for every duplicate claim. + +After a restart with a transaction still pending, replacement-underpriced replies +can repeat until that copy is mined or a fresh quote clears its replacement threshold. +After a reorg to a shorter chain, publication can wait for the head to reach the +earlier preparation height. A failed read does not justify abandoning that record. + +A known obsolete current action triggers best-effort cancellation. A later useful +urgent action can replace that neutral request. Neither can unsend old bytes: +a value-carrying join can still win the race and transfer its bond after the +application is disabled or foreclosed. Treat this as residual publication risk, +not a cancellation guarantee. + +A provisional reorg can remove the tournament's deployment after join bytes +were sent. If those bytes execute on a branch without its code, the bond becomes +an ordinary ETH transfer: no join is recorded and recovery is not guaranteed. +The address might also contain a different tournament after reordered deployment. +A database rebuild cannot undo either payment. Observing creation at `finalized` +avoids this under the finality assumption; `latest` adds no hidden finalized read. + +With `latest` or `safe`, a recorded submission can disappear after a reorg. +The publisher can re-submit the same locally computed claim when current contract +state permits it. The observer refreshes matching canonical transaction and staging +metadata. This repairs delivery of that intent; it does not verify an orphaned input +history. Stored ACCEPTED rows, cursors or terminal health facts can still require a rebuild. +Fresh work is not rediscovered from ACCEPTED rows. + +Claimer uses one event-scan path to reconcile submissions and acceptance. It keeps +event transaction hashes for audit. Observation can wait for an application's next +page turn and require more event reads than a receipt shortcut. External acceptance +out of epoch order is valid; the observer identifies the epoch before comparing roots. + +Before acceptance, a submitting Quorum node records its own matching vote hash, +separately from the majority's staging block. A replacement canonical own vote can +repair that link. If no own vote survives, the hash can be absent even when peers +stage the claim. A reader without a local signer can record the staging event's +hash. After acceptance, the field can identify the acceptance transaction instead. +A Quorum vote cannot be withdrawn. A divergent result saved from `latest` or +`safe` is also provisional; it can require a database rebuild after a reorg. + +## Keep applications progressing + +Claim discovery rotates applications by ID, skipping applications without a row +in the requested claim source. Each application has its own epoch +position; another application's epoch numbers do not determine its turn. Each +turn inspects the oldest relevant row and a bounded later window before choosing +work. A waiting oldest row cannot hide all later work, and a busy application +moves behind the next application after its turn. + +A ready acceptance takes priority within the visited work. This is not a global +ordering across all applications. The sixteen-candidate pass budget bounds +planning attempts, not RPC count, inclusion delay or unlimited backlog capacity. +New arrivals do not reset the positions of applications already being visited. + +A failed preflight candidate gets a bounded cooldown and the publisher can try +another candidate. Normal protocol maturity waits stay quiet. Persistent named +refusals report the application and cause. A contract race after simulation can +still revert on chain and cost gas. The publisher has no application-wide +cumulative execution stop and provides no total spending guarantee. + +A useful current action keeps its nonce while simulation fails or required funds +are absent. Other applications on that role can wait behind it. A failed read +does not prove the action obsolete. PRT can still choose a verified earlier-deadline +join according to its priority rule. + +PRT discovers its bounded candidate list once per publication pass. A refused +preparation consumes that candidate's turn. Every selected action still receives +fresh protected validation before POST, including after a head change. + +## Size observation and deadline capacity + +Use the existing role polling interval, HTTP request timeout and +`CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE`. There are no delivery pass, candidate, +observation or urgency timeout settings. + +Each role normally starts a new nonce only after it observes the previous one +mined. Capacity is therefore limited by inclusion and polling. Rebroadcasts can +make several POSTs in one block; they do not increase useful nonce throughput. +On a chain with 12-second blocks, the ideal ceiling is 300 transactions per hour +per role, before polling, waiting, recovery and failed execution costs. A claim +can need submission and acceptance; a PRT epoch can also need a join and bond +recovery. Size the total action load across all applications below that capacity. +Applications with pending but immature work still take turns in claim rotation. + +PRT advances each tournament from its own saved block. When it discovers a new +child, it scans that child and any new descendants through the same endpoint. +That complete unit commits atomically. An existing child advances independently. +A failed tournament read does not roll back another tournament's completed unit. +The application's aggregate tournament height is diagnostic, not complete coverage. + +One dense tournament or newly discovered subtree can require substantial RPC, +memory and database work before its atomic observation commits. There is no internal +node-count or subtree-size ceiling. SQL statements are chunked within that transaction; +chunking does not cap its total work. Other known tournaments receive separate turns, +but a slow unit can delay the observer's next turn. The sender keeps one current transaction rather than an observation-history inventory. + +PRT can replace current protocol work with a ready join that has a strictly earlier +finite deadline. An untimed action has no deadline. The planner owns this choice; +there is no one-use takeover allowance or sticky neutral state. Replacement still +depends on current evidence, funds, provider policy and fees. + +Measure observation lag, provider latency, signing time and inclusion time against +the deployed tournament allowance. Independent loops and urgent replacement do not +guarantee meeting a deadline. + +## Observe Authority and Quorum claims + +Claimer checks `ClaimStaged` and `ClaimAccepted` events for each enabled +Authority or Quorum application. This check also runs in reader mode, after an +execution stop, and after foreclosure. It does not need a local epoch row. +An empty Authority or Quorum epoch has no local row. A claim that covers such an +epoch still needs a check. + +For a covered block B, the node first confirms that input discovery reached B +and that all local execution through B passed validation. It then compares both +the machine root and the cumulative outputs root. The last validated roots also +cover later empty epochs. Before the first input, the expected roots come from +the application template and the empty outputs tree. This requires a supported +template whose initial accepted yield declares that empty tree. A claim for a later epoch +alone does not prove a mismatch. + +A proved mismatch emits `Divergent on-chain claim observed` at ERROR level, with +`source=independent_claim_observer`. The record gives the application, covered +block, both on-chain roots, both validated roots, phase and staging deadline. +The node then confirms a durable `DIVERGED` or `CORRUPTED` status before it marks +that claim as checked. An existing integrity finding keeps its first reason. + +Without enough local evidence, the node emits +`Independent claim verification is incomplete` at WARN level. The record gives +the cause, the number of pending claims, an example and its acceptance threshold. +Warnings use a stable cause class per application. Changing block numbers or +queue order does not reset the rate limit. This warning does not prove a false +claim. RPC and database failures retain the work. A failed claim check moves to +the end of the pending order so that other claims can be checked. A proved +mismatch remains a barrier until its durable status is confirmed. + +The private `application.last_claim_check_block` column records the completed +prefix of claim checks. Pending claims hold this checkpoint before their source +block. Discovery can continue in memory beyond that point. A restart reads the +chain again from the checkpoint, with no limit based on claim age. The private +`epoch.claim_validated` marker preserves completed validation after an epoch +becomes `CLAIM_FORECLOSED`; proof bytes alone do not set this marker. Neither +column adds a JSON-RPC field or permits publication of a foreclosed epoch. + +The provider must serve canonical headers, logs and contract state at the oldest +claim checkpoint needed for replay. A pending claim can hold that checkpoint +for an unlimited time, including after a different claim caused `DIVERGED`. +Use a provider with the required historical state. Missing state stops discovery +for the affected application and causes a warning; it is not an empty history. +The scanner reduces the block range when the sum of staged and accepted event +deltas exceeds an internal target of 256 events. One block is indivisible and must be read whole, +even above that target. A long replay or a dense block can delay new discovery +and local lifecycle observation. The target does not give a fixed time or memory +limit. The separate publisher can still check its current transaction. A repeated +head skips event discovery but still permits local comparison and status retries. + +An unaccepted claim can leave the pending set only when the event stream is +complete through block d and `isForeclosed()` is true at that exact finalized +block hash. A newer head or the recorded foreclosure height alone is not proof. +No transaction receipt is required. This retirement assumes that application +foreclosure is permanent. It is disabled under `latest` and `safe`. Accepted +claims still need comparison. + +Run the Claimer service for this protection, including on reader or guardian +nodes with claim submission disabled. Disabling or removing an application +stops its protection and alerts. It can also stop observation needed for +withdrawals. + +| Signal | Required action | +| --- | --- | +| ERROR with `independent_claim_mismatch` | Check both roots and the named application. Use the guardian procedure before the acceptance threshold when time remains. | +| WARN with pending local validation | Check input discovery and local execution. A pending claim is not a confirmed mismatch. | +| WARN with an RPC or database failure | Restore the dependency. Check that the provider serves state at the retained checkpoint and range endpoints. | + +Detection before acceptance needs enough time for observation and validation. +A zero staging period, node downtime or unavailable evidence can remove that +opportunity. The node reports pending verification; it does not declare a false +claim without validated evidence or promise a guardian response deadline. + +## Execution stops and integrity findings + +An execution stop (`GUEST_EXCEPTION`, `MACHINE_HALTED`, `MCYCLE_OVERFLOW` or +`UNEXPECTED_YIELD`) or `INVALID_OUTPUTS_ROOT` at epoch P stops further execution. +An enabled application can still validate and complete a valid, complete epoch +before P: Authority/Quorum submit or accept, and PRT join, stage or accept. +An invalid root found in an earlier epoch moves P back, including after restart. +The earlier work still needs valid proofs, signer authorization, time for +inclusion, and all foreclosure and protocol checks. This does not provide active +dispute defense or discovery of every other party's claim. + +With `finalized` observation, a stored `CLAIM_STAGED` row that reads `UNSTAGED` +once the latest head reaches its recorded staging block causes publication to +wait. A warning identifies the application, epoch, recorded staging block and +latest block, at most once per minute for each application and epoch. The latest +read does not itself establish finality. Check that the provider serves coherent +views of the registered chain. A held transaction keeps its signer occupied; +other applications sharing that signer can also wait. The warning does not +cancel or replace that transaction. + +The observer checks for this contradiction after the configured view reaches the +staging block plus the application's staging period, and only in submitting mode. +If it sees the contradiction then, a healthy application becomes `FAILED`. +Restore a coherent endpoint for the registered chain before re-enabling. If the +persisted history no longer matches that chain, rebuild and re-ingest into a +fresh database; do not edit individual saved facts to force progress. +An application already stopped by execution or an invalid root becomes +`CORRUPTED`, which also stops foreclosure drain. Re-enabling cannot clear +`CORRUPTED`: correct the cause and re-ingest into a fresh database. +Recorded foreclosure can instead terminalize the claim as `CLAIM_FORECLOSED` +before this diagnosis. If the views disagree, the observer may not see the same +contradiction. There is no guaranteed diagnosis deadline. + +The new integrity reason replaces the earlier reason. The database retains the +earliest known stop's typed epoch boundary in `application.problem_epoch_index`, +`problem_kind` and `problem_input_index`; logs provide further context if retained. +Those columns are not separate JSON-RPC response fields. The current reason is +not a full history of previous diagnostics. + +With `latest` or `safe`, the publisher can repair a vanished stage by resubmitting. +A claim recorded only as `CLAIM_SUBMITTED` has no persisted staging fact and +remains eligible for that repair even with `finalized`. Transactions distributed +earlier, independent actors and changes after the pre-send check can still affect +the chain. + +An unknown consensus-contract claim status is refused. A healthy application can +become `FAILED`; an application stopped at P keeps its status. If a transaction +is already held, this refusal can also hold up other applications on the signer. +Restore a supported contract/provider and inspect the held transaction; restarting +does not revoke already distributed bytes. If the validator cannot build a PRT +commitment proof under a stop, it retries the epoch, which can keep foreclosure +drain pending. + +The independent Authority/Quorum check still discovers claims at or after P. +It warns when the covered execution cannot be validated. Foreclosure can mark +their local epochs `CLAIM_FORECLOSED`; the private marker retains earlier +validated evidence for comparison. PRT leaves an epoch pending when it has seen +a claim transaction but has no validated local claim to compare, so drain can +remain pending. Neither case authorizes publication of that epoch. Earlier +validated epochs retain their normal reconciliation. + +Known limitations: + +- An `OutputExecuted` event names an output index and bytes, but not the accepted + cumulative root proving them. After an execution stop, different bytes can + cause a false `CORRUPTED` diagnosis when a later invalid root proves them. + This can also happen after an earlier invalid-root finding has moved P back, + because the application status may still name the execution stop. +- A machine can be released before a closed epoch of a foreclosed application + has been finalized. +- The output reader can stall on an executed output index that is absent locally. + +## Disable and remove an application + +Disable stops observation, fresh action discovery, useful replacements and bond +recovery at the next iteration. Work already in progress can finish. A transaction +already sent can still execute. Neutral nonce maintenance can still spend fees. +Disabling also stops protection alerts. +`FAILED`, `DIVERGED` and `CORRUPTED` prevent useful publication and can trigger +best-effort cancellation of current work, including when `FAILED` is recoverable. +A missing stop boundary or a failed read is inconclusive: that evidence alone +cannot justify cancelling or replacing retained work. An independently verified +priority replacement still follows the planner's normal rules. +PRT bond recovery is an exception: an enabled application's recoverable bond can +still be returned when its on-chain payee is the configured signer, even when the +application's health is not OK. Disabling the application stops this recovery too. + +Removal requires a disabled application and `--allow-abandonment`. This flag +acknowledges that removal ends protection and alerts, deletes the stored state, +and abandons outstanding work. `--yes` skips only the confirmation prompt. +The command does not scan the blockchain or classify outstanding obligations. +Normal observers, transaction preflight and bond recovery keep their own chain +reads; this simplification affects only application removal. + +```sh +cartesi-rollups-cli app remove my-app --allow-abandonment +cartesi-rollups-cli app remove my-app --allow-abandonment --yes --timeout 10m +``` + +The timeout bounds deletion, including lock acquisition. The repository checks +that the application is still disabled when it deletes the row. Observation +transactions preserve their application-row locks and atomic cursor updates. +Removal cannot revoke transactions already sent or in progress; those may still +execute. Check outstanding claims and bonds before deliberately abandoning them. + +Retain any required tournament and payee identities for later operator recovery. +Anyone can recover a bond to its on-chain payee. Automatic recovery uses only the +configured signer. The node has no signer registry or manual-recovery CLI. +For a join that executes after removal, the tournament's permissionless +`tryRecoveringBond()` method pays its recorded recipient once the bond is +recoverable. An operator must initiate this recovery after removal. + +The former `CARTESI_CLAIMER_MAX_ACCEPT_ATTEMPTS` setting has been removed. +It is ignored; there is no cumulative application revert breaker. + +## Respond to operational signals + +| Signal | Action | +| --- | --- | +| Waiting for observer registration; service unready | Restore database access or start the contributing observer with compatible chain and observation settings; the service retries | +| Protected RPC read failed or unsupported | Restore a compatible provider; the next iteration retries protected reads | +| Stalled nonce or frozen head | Check provider progress, fees and inclusion | +| Named gas, balance or price limit | Inspect the required and available amounts | +| Claim publication waiting for funding or a policy limit | Check the named gas, balance or price limit. A funding amount before estimation is a lower bound | +| Same action still needed after nonce advancement | Check contract state and accidental wallet sharing. This symptom does not prove another publisher exists or that our transaction reverted | +| Claim or PRT publication lost a race while signer nonce advanced | Check other users of this signer and provider consistency; stop competing senders and inspect pending transactions | +| Replacement underpriced for a new action | A pre-restart or foreign transaction can already occupy the nonce. Stopping its sender does not remove it; wait for inclusion or replace it with the submitting service stopped | +| Gas estimate unavailable | Restore the estimator; new work waits, while suitable retained bytes can still be simulated and reused | +| Gas estimate refused | Check the returned cause: contract state, gas allowance, signer balance or provider support. This is not necessarily an estimator outage | +| Join near its deadline | Check observation lag, backlog, signing and inclusion time | +| Claim candidate refused; retrying after cooldown | Check the named signer against the Authority owner or Quorum validator membership | +| Claim or PRT publication deferred | Check the named cause; recoverable failures produce rate-limited warnings and retry | +| PRT transaction could not publish; planning will retry | Check the named action and cause. For a join, compare the remaining deadline with signing and inclusion time | +| PRT application action could not be planned; other applications can continue | Check the named application's read or planning failure; other applications retain their turns | +| PRT cannot publish consensus action with the registered contracts | Check the named contract link or geometry; staging and acceptance for that application are paused | +| Action prefixed with "cancel obsolete" | The node is trying to replace a sent action; check funds, the fee ceiling and pool replacement policy | +| PRT cannot join a tournament | Check the named geometry, registration link or missed join window; protection for that epoch is unavailable | +| Recorded submission or provisional staging is absent from the current chain | Check provider consistency or a provisional reorg; same-claim delivery can recover, but invalidated inputs require a rebuild | +| Input observation is inconclusive | The count and logs disagree; the cursor stays unchanged and the next poll retries. Restore a coherent provider if failures persist | +| Finality or call-content contradiction | Stop and investigate; do not edit saved facts to force progress | +| Claim publication waiting: recorded staging contradicts latest view | Check endpoint/chain consistency and compare the named blocks. The observer can wait for staging maturity; a held transaction also holds the shared signer | +| Application FAILED: getClaim returned UNSTAGED for recorded CLAIM_STAGED | Restore the correct coherent endpoint before re-enabling. If saved history no longer matches the chain, re-ingest into a fresh database | +| Application CORRUPTED: getClaim returned UNSTAGED for recorded CLAIM_STAGED | Correct the cause and re-ingest into a fresh database. Re-enabling cannot clear CORRUPTED | + +Alert on sustained publication-deferred, could-not-publish, waiting-for-funding, +stalled-nonce or frozen-head warnings, and on join-deadline or cannot-join warnings. +A single recovered read failure or normal shutdown cancellation does not need an +operator alert. Readiness alone does not detect every persistent publication +refusal. + +A protected-state contradiction stops the affected publisher and makes it +unready. The process stays up so it can report the fault. The stop is held in +memory; a restart clears it. First verify the provider and saved evidence, because +restarting does not repair a real contradiction. + +With `latest` or `safe`, a reorg can remove a saved claim or tournament fact. +Saved cursors can then be ahead of the canonical chain. A provider disagreement +can cause similar symptoms, so check the canonical chain first. If stored facts +came from an orphaned branch, stop the node and rebuild its database before +resuming submission. Do not edit individual facts or the registered policy to +force progress. + +Readiness reports hard prerequisites. It does not prove that every application is +progressing or that a deadline will be met. Monitor the progress warnings as well. +A near-deadline join warning requires an identified current join. Before a root +has complete committed observation, planning can wait at Debug level without +that warning. Monitor epoch and tournament observation progress separately. +The sender reads no receipts: it cannot report each included revert or its gas +cost. A repeated-action warning is only a symptom that the protocol still needs +that action after nonce advancement. diff --git a/docs/upgrade_to_1_5.md b/docs/upgrade_to_1_5.md deleted file mode 100644 index f14503994..000000000 --- a/docs/upgrade_to_1_5.md +++ /dev/null @@ -1,135 +0,0 @@ - -# Upgrade guide for Cartesi Rollups Node `v1.5.0` - -Release `v1.5.0` brings a change in the way the Cartesi Rollups Node closes epochs. -They are now closed based on block numbers intead of timestamps. - -> [!WARNING] -> This release contains a **BREAKING CHANGE** that fixes issue [#432](https://github.com/cartesi/rollups-node/issues/432), where epochs may be closed wrongly between restarts of the Cartesi Rollups Node, eventually triggering a `ClaimMismatch` error, which causes the Cartesi Rollups Node to abrubtly shut down. - -> [!IMPORTANT] -> It's highly recommended that application owners upgrade their instances of the Cartesi Rollups Node to `v1.5.0`, had their applications being affected by the issue above or not. - -## What has changed - -The way the Cartesi Rollups Node closes epochs has changed starting on `v1.5.0`. -This procedure is now based on the `CARTESI_EPOCH_LENGTH` environment variable instead of `CARTESI_EPOCH_DURATION`. - -The value of `CARTESI_EPOCH_LENGTH` (blocks) may be derived from `CARTESI_EPOCH_DURATION` (seconds) as follows: - -`CARTESI_EPOCH_LENGTH = CARTESI_EPOCH_DURATION/BLOCK_TIME` - -Where `BLOCK_TIME` corresponds to the duration it takes to generate a block in the target network. - -> [!TIP] -> Suppose a block is created every `12` seconds and `CARTESI_EPOCH_DURATION` is set to `86400` seconds (24 hours). -> -> So, `CARTESI_EPOCH_LENGTH = 86400/12 = 7200` - -Check the [`CHANGELOG`](../CHANGELOG.md) and the [Rollups Node configuration](./config.md) for more details. - -## How to upgrade - -Application owners may decide to redeploy all necessary contracts and upgrade their instances of the Cartesi Rollups Node to release `v1.5.0`. -This is the simplest way to perform the upgrade. - -In order to do so, just update the application configuration considering [what has changed](#what-has-changed) and upgrade the version of the Cartesi Rollups Node as usual. - -For more details about how to deploy an application, please refer to the [deployment process](https://docs.cartesi.io/cartesi-rollups/1.3/deployment/introduction/#deployment-process) options available in the Cartesi documentation. - -> [!CAUTION] -> A redeployment will create a new instance of the application from scratch. -> All previous inputs, outputs and claims will remain associated to the previous application address, including any funds locked in the application contract. - -Alternatively, the _Authority_ owner may choose to replace the _History_ being used by the application's _Authority_. -This process allows inputs, outputs and locked funds to remain unchanged, but is a little bit more involved. -It is described in the next section. - -> [!NOTE] -> A new _History_ will contain no claims. -> Once the Rollups Node is restarted with a new _History_, all previous claims will be submitted again, one-by-one, by the configured _Authority_, incurring in extra cost for the _Authority_ owner. - -## Steps to replace an Application's _History_ - -> [!CAUTION] -> Instances of the [History](https://github.com/cartesi/rollups-contracts/blob/v1.2.0/onchain/rollups/contracts/history/History.sol) contract from [rollups-contracts v1.2.0](https://github.com/cartesi/rollups-contracts/releases/tag/v1.2.0) may be used simultaneously by several applications through their associated _Authority_ instance. -> Application owners must consider that and exercise care when performing the steps listed below. - -To keep the application inputs, before performing the upgrade to version `v1.5.0`, proceed as described in the sub-sections below. - -### 1. Instantiate a new _History_ - -This is a two-step process. -First, the address of the new _History_ instance should be calculated. - -> [!NOTE] -> It's recommended to use the deterministic deployment functions available in the Rollups Contracts. - -> [!IMPORTANT] -> All commands below assume that the following environment variables have been previous defined: -> -> - `SALT`: a random 32-byte value to be used by the deterministic deployment functions -> - `RPC_URL`: the RPC endpoint to be used -> - `MNEMONIC`: mnemonic phrase for the _Authority_ owner's wallet (other wallet options may be used) -> - `HISTORY_FACTORY_ADDRESS`: address of a valid _HistoryFactory_ instance -> - `AUTHORITY_ADDRESS`: address of the _Authority_ instance being used by the application - -> [!TIP] -> A _HistoryFactory_ is deployed at `0x1f158b5320BBf677FdA89F9a438df99BbE560A26` for all supported networks (Ethereum, Optimism, Arbitrum, Base and their respective Sepolia-based tesnets). - -In order to calculate the address of a new history contract, one can call the view function [`calculateHistoryAddress(address,bytes32)`](https://github.com/cartesi/rollups-contracts/blob/e8a2d82bc51167086e7928b9a6aced0d62c96cf8/onchain/rollups/contracts/history/HistoryFactory.sol#L35-L38). -This can be done with the the help of Foundry's [cast](https://book.getfoundry.sh/reference/cast/) as follows: - -```shell -cast call \ - --trace --verbose \ - $HISTORY_FACTORY_ADDRESS \ - "calculateHistoryAddress(address,bytes32)(address)" \ - $AUTHORITY_ADDRESS \ - $SALT \ - --rpc-url "$RPC_URL" -``` - -Assuming the command execution is successfull, it should print out the address of the to-be-created History contract. You may store this address in the environment variable `NEW_HISTORY_ADDRESS`, to be used later on. - -After that, the new instance of _History_ may be created by calling function [`newHistory(address,bytes32)`](https://github.com/cartesi/rollups-contracts/blob/e8a2d82bc51167086e7928b9a6aced0d62c96cf8/onchain/rollups/contracts/history/HistoryFactory.sol#L24-L27) as follows: - -```shell -cast send \ - --json \ - --mnemonic "$MNEMONIC" \ - $HISTORY_FACTORY_ADDRESS \ - "newHistory(address,bytes32)(History)" \ - $AUTHORITY_ADDRESS \ - $SALT \ - --rpc-url "$RPC_URL" -``` - -> [!NOTE] -> `cast send` will fail if the `History` type is not known to `cast` at the time of the execution. -> In such case, it's safe to replace `History` with `address` as return type to `newHistory()` and perform the call again. - -> [!NOTE] -> A `cast send` command may fail due to a gas [estimation failure](https://github.com/foundry-rs/foundry/issues/3093#issuecomment-1251616792), which can be circumvented by providing gas constraints to the command with parameter `gas-limit` (e.g.,`--gas-limit 7000000`). - -### 2. Replace the _History_ - -> [!IMPORTANT] -> The command below assumes that the environment variables set in the previous step are available, as well as `NEW_HISTORY_ADDRESS`, which must contain the address of the new _History_. - -To replace the _History_ used by the _Authority_, call [`setHistory(address)`](https://github.com/cartesi/rollups-contracts/blob/e8a2d82bc51167086e7928b9a6aced0d62c96cf8/onchain/rollups/contracts/consensus/authority/Authority.sol#L65) as follows: - -```shell -cast send \ - --json \ - --mnemonic "$MNEMONIC" \ - "$AUTHORITY_ADDRESS" \ - "setHistory(address)" \ - "$NEW_HISTORY_ADDRESS" \ - --rpc-url "$RPC_URL" -``` - -After the _History_ replacement is complete, one must update `CARTESI_CONTRACTS_HISTORY_ADDRESS` with the new _History_ address in the application configuration and proceed with the upgrade of the Cartesi Rollups Node as usual. -More details about the release may be found in the [CHANGELOG](../CHANGELOG.md). - -Once the Cartesi Rollups Node is started, it will relay all existing inputs to be processed by the application, calculate the new epochs and send the respective claims to the new _History_ as a result, all based on the updated configuration. diff --git a/go.mod b/go.mod index 9665613a9..631f19bab 100644 --- a/go.mod +++ b/go.mod @@ -1,116 +1,111 @@ module github.com/cartesi/rollups-node -go 1.22.5 +go 1.27.3 require ( - github.com/ethereum/go-ethereum v1.14.7 - github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.9.0 - github.com/testcontainers/testcontainers-go v0.32.0 - github.com/tyler-smith/go-bip32 v1.0.0 + github.com/BurntSushi/toml v1.6.0 + github.com/aws/aws-sdk-go-v2 v1.47.5 + github.com/aws/aws-sdk-go-v2/config v1.33.12 + github.com/aws/aws-sdk-go-v2/service/kms v1.62.1 + github.com/ethereum/go-ethereum v1.17.10 + github.com/go-jet/jet/v2 v2.16.1 + github.com/golang-migrate/migrate/v4 v4.20.1 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/holiman/uint256 v1.3.2 + github.com/jackc/pgx/v5 v5.11.0 + github.com/lmittmann/tint v1.2.1 + github.com/oapi-codegen/oapi-codegen/v2 v2.8.1 + github.com/oapi-codegen/runtime v1.8.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.12.1 github.com/tyler-smith/go-bip39 v1.1.0 -) - -require github.com/BurntSushi/toml v1.4.0 - -require ( - github.com/Khan/genqlient v0.7.0 - github.com/deepmap/oapi-codegen/v2 v2.2.0 - github.com/lmittmann/tint v1.0.5 - github.com/mattn/go-isatty v0.0.20 - github.com/oapi-codegen/runtime v1.1.1 - golang.org/x/sync v0.7.0 - golang.org/x/text v0.16.0 + golang.org/x/crypto v0.58.0 + golang.org/x/sync v0.24.0 + golang.org/x/text v0.43.0 ) require ( - dario.cat/mergo v1.0.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect - github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.12.4 // indirect - github.com/agnivade/levenshtein v1.1.1 // indirect - github.com/alexflint/go-arg v1.4.3 // indirect - github.com/alexflint/go-scalar v1.2.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/Microsoft/go-winio v0.6.4 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20261028124501-05a55ac303ba // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/bits-and-blooms/bitset v1.13.0 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.3 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/consensys/bavard v0.1.13 // indirect - github.com/consensys/gnark-crypto v0.12.1 // indirect - github.com/containerd/containerd v1.7.19 // indirect - github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect - github.com/cpuguy83/dockercfg v0.3.1 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect - github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/deckarep/golang-set/v2 v2.6.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v27.0.3+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/ethereum/c-kzg-4844 v1.0.2 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/getkin/kin-openapi v0.124.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 // indirect + github.com/aws/smithy-go v1.28.7 // indirect + github.com/bits-and-blooms/bitset v1.27.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.22.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect + github.com/deckarep/golang-set/v2 v2.9.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.8 // indirect + github.com/fjl/jsonw v0.1.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/getkin/kin-openapi v0.152.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/go-openapi/jsonpointer v1.0.2 // indirect + github.com/go-sql-driver/mysql v1.10.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/holiman/uint256 v1.3.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/invopop/yaml v0.2.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mmcloughlin/addchain v0.4.0 // indirect - github.com/moby/docker-image-spec v1.3.1 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // 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/perimeterx/marshmallow v1.1.5 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.14.3 // indirect + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.3 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgtype v1.14.4 // indirect + github.com/jackc/pgx/v4 v4.18.3 // indirect + github.com/jackc/puddle/v2 v2.2.3 // indirect + github.com/lib/pq v1.12.3 // indirect + github.com/mattn/go-sqlite3 v1.14.52 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oasdiff/yaml v0.1.3 // indirect + github.com/oasdiff/yaml3 v0.0.16 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect - github.com/shirou/gopsutil/v3 v3.24.5 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/supranational/blst v0.3.12 // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.8.0 // indirect - github.com/vektah/gqlparser/v2 v2.5.16 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/stretchr/objx v0.8.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/supranational/blst v0.3.17 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect - go.opentelemetry.io/otel v1.28.0 // indirect - go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect - golang.org/x/mod v0.19.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/tools v0.22.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect - google.golang.org/grpc v1.65.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + go.mongodb.org/mongo-driver v1.17.10 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/tools v0.52.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - rsc.io/tmplfunc v0.0.3 // indirect ) diff --git a/go.sum b/go.sum index d78b2335a..21a1e3116 100644 --- a/go.sum +++ b/go.sum @@ -1,410 +1,687 @@ -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/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= -github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= -github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc= -github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw= -github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc= -github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec/go.mod h1:CD8UlnlLDiqb36L110uqiP2iSflVjx9g/3U9hCI4q2U= -github.com/Khan/genqlient v0.7.0 h1:GZ1meyRnzcDTK48EjqB8t3bcfYvHArCUUvgOwpz1D4w= -github.com/Khan/genqlient v0.7.0/go.mod h1:HNyy3wZvuYwmW3Y7mkoQLZsa/R5n5yIRajS1kPBvSFM= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.12.4 h1:Ev7YUMHAHoWNm+aDSPzc5W9s6E2jyL1szpVDJeZ/Rr4= -github.com/Microsoft/hcsshim v0.12.4/go.mod h1:Iyl1WVpZzr+UkzjekHZbV8o5Z9ZkxNGx6CtY2Qg/JVQ= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= +github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20261028124501-05a55ac303ba h1:aPtCt3+FWnpjRPGx2SMWREtz29yIIomvxQn7R1bXMjg= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20261028124501-05a55ac303ba/go.mod h1:uO5tzF6zbEbGmzjjDjqrYuAdGqqGJYYYSGijnBePEeg= +github.com/RaduBerinde/axisds v0.1.0 h1:YItk/RmU5nvlsv/awo2Fjx97Mfpt4JfgtEVAGPrLdz8= +github.com/RaduBerinde/axisds v0.1.0/go.mod h1:UHGJonU9z4YYGKJxSaC6/TNcLOBptpmM5m2Cksbnw0Y= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 h1:bsU8Tzxr/PNz75ayvCnxKZWEYdLMPDkUgticP4a4Bvk= +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54/go.mod h1:0tr7FllbE9gJkHq7CVeeDDFAFKQVy5RnCSSNBOvdqbc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= -github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= -github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= -github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= -github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= -github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= -github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= -github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= -github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/aws/aws-sdk-go-v2 v1.47.5 h1:5yA+A+BiXbDq1ZLbYwPViwhEGDMdR0MEhL8Jh2LnLS4= +github.com/aws/aws-sdk-go-v2 v1.47.5/go.mod h1:iNIkogrRvFwm+1OQIUOP2lPBtlXZboOIUWiCa+pHEMw= +github.com/aws/aws-sdk-go-v2/config v1.33.12 h1:IV1YARaXahwCqUIyP8IlWsP85Lil+5ozTozDQXPumRk= +github.com/aws/aws-sdk-go-v2/config v1.33.12/go.mod h1:h51R06bzMVwNBRZOX5ylHhNDnkB/azZeq/LvxM6q2pQ= +github.com/aws/aws-sdk-go-v2/credentials v1.20.12 h1:6P7Q7yH/GIwHg6MLKv3ySsBqaCoE1z+C0OTzYMenhEY= +github.com/aws/aws-sdk-go-v2/credentials v1.20.12/go.mod h1:Kw/SaoW/wc7IqJErqXJIzKo8b7Aelxfbuc6XlbrRnsc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 h1:iXjy2SFhGYNB1jWZBhQqq8HbhsbPF23w+/zbMvPXsTg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5/go.mod h1:k0RUiBnyXU0AXRJ9tJmY2jQke6XUIX2PNVnWtvgaPtc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 h1:gzIWc3DTPfPbWKrQnmL6BV6WD7NPD1htxzgdk/bQ/Z4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8/go.mod h1:vvUA5cDAZWGo4R14FZPZfXfJCmgsafOWEq78Kg0D+ug= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 h1:PWv+vG8aCLS8qd+HPdYhK7Vees90aKHWFFQ1AGYD/8E= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8/go.mod h1:xxPZEpflYHJA12366ZGyq66z9dHPCoeCIu99Jck+MPw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 h1:WOp5De9iWdEHDPh2Kb0tBP3vGBm/dVAuRGh4a4tqduo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8/go.mod h1:z8W8YgfhbWl62XqcTtK+CxPrE+iHGRVUOtrrbIOp7ZY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 h1:MCIR6iZaCKO4UE0hUvoWWLI6JVjppopV64JfAJaMjng= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23/go.mod h1:pphWFS+z0MmIPivj8c/6bATd1Z+Av7Ust75DPdEFsPA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 h1:+PMpWlJYOn2tm7sm6OZ/ZLd4jStl17T7wjNapd1Rczs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8/go.mod h1:cnG3xZLGQL4c/bt8pjdzlFtZsW7epBBuNS04a5xMjdo= +github.com/aws/aws-sdk-go-v2/service/kms v1.62.1 h1:NGtJiVt3s5b/4sJia4EnSQKUsKS9cncwznihdAzsaYI= +github.com/aws/aws-sdk-go-v2/service/kms v1.62.1/go.mod h1:YVEFrNMhrD35Z6qdvzvpGHDbmFmhZ/Nf58Ubv9dROVU= +github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 h1:3vu/eUqh0rjh7Zy4N4YKcpZX4Aud+zinB3VsLhhlQFw= +github.com/aws/aws-sdk-go-v2/service/signin v1.12.0/go.mod h1:wxAk0D2oVBSjuXEIzvcKxTZfM90zticdAbh/VD42L0Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 h1:kIEpD5dUry7VET3Ybl7zj7PPHRB4lb52G9iTz+a1Tqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.39.0/go.mod h1:UGsEYgWz4Gi+46vDQ5zkFLqUgJziyOE2SXjZjgmfmHc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 h1:WfFev+HgTodggcNon5HieiLxxwWwb8Br8R2+nDkixvk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0/go.mod h1:gJSdIY6YUzDvqQLcMtPVZgBRHrFwzHryvpE8Igzbxps= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 h1:BVjKrtRUtSYKztBCCfE1oAB+d3ViKcW2cKZfserOqMQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.6/go.mod h1:eSukFLeLkPMm+1OxDR+F0joauZdyBkF47Ow7MqjZiJw= +github.com/aws/smithy-go v1.28.7 h1:h/n1SHrwVtaDYLz8mR1/Kq4Xh4RaFbFW7bOYckg9mfE= +github.com/aws/smithy-go v1.28.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= -github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.27.0 h1:0s3FwmJRlxOqUsmQK+a8/HcPMgPu8jM7KrE/y/X7d+8= +github.com/bits-and-blooms/bitset v1.27.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs= -github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= -github.com/btcsuite/btcd/btcec/v2 v2.3.3 h1:6+iXlDKE8RMtKsvK0gshlXIuPbyWM/h84Ensb7o3sC0= -github.com/btcsuite/btcd/btcec/v2 v2.3.3/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e h1:0XBUw73chJ1VYSsfvcPvVT7auykAJce9FpRr10L6Qhw= -github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b h1:SHlYZ/bMx7frnmeqCu+xm0TCxXLzX3jQIVuFbnFGtFU= +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b/go.mod h1:Gq51ZeKaFCXk6QwuGM0w1dnaOqc/F5zKT2zA9D6Xeac= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= -github.com/cockroachdb/pebble v1.1.1 h1:XnKU22oiCLy2Xn8vp1re67cXg4SAasg/WDt1NtcRFaw= -github.com/cockroachdb/pebble v1.1.1/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/pebble/v2 v2.1.4 h1:j9wPgMDbkErFdAKYFGhsoCcvzcjR+6zrJ4jhKtJ6bOk= +github.com/cockroachdb/pebble/v2 v2.1.4/go.mod h1:Reo1RTniv1UjVTAu/Fv74y5i3kJ5gmVrPhO9UtFiKn8= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 h1:IJ+uNItEm0qx9FE2AgIc1PMsCUtk8nbSIzhQE1t5GWw= +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ= -github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= -github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M= -github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY= -github.com/containerd/containerd v1.7.19 h1:/xQ4XRJ0tamDkdzrrBAUy/LE5nCcxFKdBm4EcPrSMEE= -github.com/containerd/containerd v1.7.19/go.mod h1:h4FtNYUUMB4Phr6v+xG89RYKj9XccvbNSCKjdufCrkc= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= -github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c h1:uQYC5Z1mdLRPrZhHjHxufI8+2UG/i25QG92j0Er9p6I= -github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJZH3SzKCqnz5VT0q/DyIG/tvu/dZk+VIfXicupJs= -github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI= -github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/consensys/gnark-crypto v0.22.0 h1:RYH7FX1/zkmNrSiABo70HAqew41SRUsoluKundObo3o= +github.com/consensys/gnark-crypto v0.22.0/go.mod h1:HL8XflRG4cvau1PEG3CWBTD2kkzUzdlqZF6BBgy57F8= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 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/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= -github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= -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.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/deepmap/oapi-codegen/v2 v2.2.0 h1:FW4f7C0Xb6EaezBSB3GYw2QGwHD5ChDflG+3xSZBdvY= -github.com/deepmap/oapi-codegen/v2 v2.2.0/go.mod h1:L4zUv7ULYDtYSb/aYk/xO3OYcQU6BoU/0viULkbi2DE= -github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= -github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.9.0 h1:prva4eP9UysWagLyKrtn074ughi0NnkIf0A4M5yOCKI= +github.com/deckarep/golang-set/v2 v2.9.0/go.mod h1:EWknQXbs0mcFpat2QOoXV0Ee57cD+w6ZEN76BR2JVrM= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.0.3+incompatible h1:aBGI9TeQ4MPlhquTQKq9XbK79rKFVwXNUAYz9aXyEBE= -github.com/docker/docker v27.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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= 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/c-kzg-4844 v1.0.2 h1:8tV84BCEiPeOkiVgW9mpYBeBUir2bkCNVqxPwwVeO+s= -github.com/ethereum/c-kzg-4844 v1.0.2/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= -github.com/ethereum/go-ethereum v1.14.7 h1:EHpv3dE8evQmpVEQ/Ne2ahB06n2mQptdwqaMNhAT29g= -github.com/ethereum/go-ethereum v1.14.7/go.mod h1:Mq0biU2jbdmKSZoqOj29017ygFrMnB5/Rifwp980W4o= -github.com/ethereum/go-verkle v0.1.1-0.20240306133620-7d920df305f0 h1:KrE8I4reeVvf7C1tm8elRjj4BdscTYzz/WAbYyf/JI4= -github.com/ethereum/go-verkle v0.1.1-0.20240306133620-7d920df305f0/go.mod h1:D9AJLVXSyZQXJQVk8oh1EwjISE+sJTn2duYIZC0dy3w= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fjl/memsize v0.0.2 h1:27txuSD9or+NZlnOWdKUxeBzTAUkWCVh+4Gf2dWFOzA= -github.com/fjl/memsize v0.0.2/go.mod h1:VvhXpOYNQvB+uIk2RvXzuaQtkQJzzIx6lSBe1xv7hi0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.8 h1:oQ48q/TMe2SKU8qBE3N7e4/HlG3EpJftom6EsPQgJ58= +github.com/ethereum/c-kzg-4844/v2 v2.1.8/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.17.10 h1:b3WO+xux3pItpm13kqT5Xddv01zOxBFZxQgk/Ate4/M= +github.com/ethereum/go-ethereum v1.17.10/go.mod h1:7ruvgGPsxGXtaSFJQKkSGwbKghz8V/G1xuDVY8NqQQM= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/fjl/jsonw v0.1.0 h1:V3MyR79fjLpn/+bMgvegdGUIhoJOzjmqWcKDgcOmY1I= +github.com/fjl/jsonw v0.1.0/go.mod h1:2KMLevM6FXEJnfhtk7naXu9vZdVfOma1GlnGdPRlumU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +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/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= -github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M= -github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= +github.com/getkin/kin-openapi v0.152.0 h1:XRVo77lMRyOGMHf1paMu9lxX3wzuQDZeprssZJnz/x8= +github.com/getkin/kin-openapi v0.152.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-jet/jet/v2 v2.16.1 h1:gbU2gGVv/Nn0eiUsnSxO8ABIQLjx4oUzMb1uO/3sUtg= +github.com/go-jet/jet/v2 v2.16.1/go.mod h1:MILY2HK89zo7ODycULCCWLnwNklbMJkw4L5notrLpIA= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/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-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8= +github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8= +github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho= +github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU= +github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM= +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.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -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/golang/snappy v1.0.1-0.20260716114414-9ae09f520e93 h1:GpQQr4L8jsBtJSURCDqQboOdgpVMU6vR9REjc8nR4Qc= +github.com/golang/snappy v1.0.1-0.20260716114414-9ae09f520e93/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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/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/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= +github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= -github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4= -github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= -github.com/holiman/uint256 v1.3.0 h1:4wdcm/tnd0xXdu7iS3ruNvxkWwrb4aeBQv19ayYn8F4= -github.com/holiman/uint256 v1.3.0/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= -github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/influxdata/influxdb-client-go/v2 v2.4.0 h1:HGBfZYStlx3Kqvsv1h2pJixbCl/jhnFtxpKFAv9Tu5k= +github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs= +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8= +github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= +github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao= +github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= -github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= -github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= -github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= -github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lmittmann/tint v1.2.1 h1:zOadah+f+nSm3jZM044jOsqh0+vSfgG/d9Ota9a/TLE= +github.com/lmittmann/tint v1.2.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= +github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 h1:0lgqHvJWHLGW5TuObJrfyEi6+ASTKDBWikGvPqy9Yiw= +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= -github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= -github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= -github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= -github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.1 h1:7gjJwSzbXBUhTJFa6bd4ZpVXIF2Xjwei/Ni1LW4fztk= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.1/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oapi-codegen/runtime v1.8.0 h1:RJzEgQSJQADEPaG1cYuSCKYai/cCVRE2xnkrqhJs/3c= +github.com/oapi-codegen/runtime v1.8.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.3 h1:TdwO8e1G1F+bN++LzHj6IWExcuqMjQ1xK8KRYWTffWE= +github.com/oasdiff/yaml v0.1.3/go.mod h1:vxzaFi+b+l+FWC/2NZMm+yD4DzyzwPCwaeLj1x1XfMg= +github.com/oasdiff/yaml3 v0.0.16 h1:NlDF/1oIOhmOvrEb9eH6Sbuubp3gZNS4A+MKST5/p0Q= +github.com/oasdiff/yaml3 v0.0.16/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/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/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 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/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= +github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= +github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= +github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= +github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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-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.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= -github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= -github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= -github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= -github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -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/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= -github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.8.0 h1:rm9oMkeprG/wHifkZP4EuDww9S0mvEaQ/p9cXTNT4cw= +github.com/stretchr/objx v0.8.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/supranational/blst v0.3.12 h1:Vfas2U2CFHhniv2QkUm2OVa1+pGTdqtpqm9NnhUUbZ8= -github.com/supranational/blst v0.3.12/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/supranational/blst v0.3.17 h1:OyduggShfN3CWEDdrqChEUZyt1iIsVAFApTKSzqoxAo= +github.com/supranational/blst v0.3.17/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/testcontainers/testcontainers-go v0.32.0 h1:ug1aK08L3gCHdhknlTTwWjPHPS+/alvLJU/DRxTD/ME= -github.com/testcontainers/testcontainers-go v0.32.0/go.mod h1:CRHrzHLQhlXUsa5gXjTOfqIEJcrK5+xMDmBr/WMI88E= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= -github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= -github.com/tyler-smith/go-bip32 v1.0.0 h1:sDR9juArbUgX+bO/iblgZnMPeWY1KZMUC2AFUJdv5KE= -github.com/tyler-smith/go-bip32 v1.0.0/go.mod h1:onot+eHknzV4BVPwrzqY5OoVpyCvnwD7lMawL5aQupE= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= -github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= -github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= -github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= -github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= -go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= -go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= -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.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= -go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= -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.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= -go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= -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-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.mongodb.org/mongo-driver v1.17.10 h1:kdAgQvu8TROXZpSkJQd5wzfaNCCrMbpZyKFtQ6qkPCE= +go.mongodb.org/mongo-driver v1.17.10/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= -golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-20190813141303-74dc4d7220e7/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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/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.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= -google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw= -google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= -google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= -google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +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.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +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.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/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.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54= -launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= -rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= -rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/internal/advancer/advancer.go b/internal/advancer/advancer.go new file mode 100644 index 000000000..42a73d1c4 --- /dev/null +++ b/internal/advancer/advancer.go @@ -0,0 +1,658 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/manager" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/repository" +) + +var ( + ErrInvalidMachines = errors.New("machines must not be nil") + ErrInvalidRepository = errors.New("repository must not be nil") + + ErrNoApp = errors.New("no machine for application") + ErrNoInputs = errors.New("no inputs") +) + +// AdvancerRepository defines the repository interface needed by the Advancer service +type AdvancerRepository interface { + prtprotection.Repository + ListEpochs( + ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool, + ) ([]*Epoch, uint64, error) + ListInputs( + ctx context.Context, nameOrAddress string, f repository.InputFilter, p repository.Pagination, descending bool, + ) ([]*Input, uint64, error) + GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) + StoreAdvanceResult(ctx context.Context, appID int64, ar *AdvanceResult) error + UpdateEpochInputsProcessed(ctx context.Context, nameOrAddress string, epochIndex uint64, proof *StateProof) error + UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error + GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) + UpdateInputSnapshotURI(ctx context.Context, appID int64, inputIndex uint64, snapshotURI string) error + GetLastSnapshot(ctx context.Context, nameOrAddress string) (*Input, error) + GetLastProcessedInput(ctx context.Context, appAddress string) (*Input, error) +} + +func getUnprocessedEpochs(ctx context.Context, er AdvancerRepository, address string) ([]*Epoch, uint64, error) { + f := repository.EpochFilter{Status: []EpochStatus{EpochStatus_Open, EpochStatus_Closed}} + return er.ListEpochs(ctx, address, f, repository.Pagination{}, false) +} + +// getUnprocessedInputs retrieves inputs that haven't been processed yet with pagination support. +func getUnprocessedInputs( + ctx context.Context, + repo AdvancerRepository, + appAddress string, + epochIndex uint64, + batchSize uint64, +) ([]*Input, uint64, error) { + f := repository.InputFilter{Status: Pointer(InputCompletionStatus_None), EpochIndex: &epochIndex} + return repo.ListInputs(ctx, appAddress, f, repository.Pagination{Limit: batchSize}, false) +} + +// Step performs one processing cycle of the advancer. +// It updates machines, processes one batch of inputs per application in round-robin +// order, and returns whether any application had work remaining. +// +// Per-app errors are accumulated so that a failure in one application does not block +// processing of other healthy applications. Cancellation stops new application work +// and preserves errors already collected during the cycle. +// +// The returned boolean indicates whether any app successfully processed inputs and +// potentially has more work. Callers use this to decide whether to re-tick immediately +// (via the Reschedule channel) or wait for the next timer/event. +func (s *Service) Step(ctx context.Context) (bool, error) { + // Check for context cancellation or shutdown in progress. This + // prevents starting new work while the machine manager is being torn down. + if err := ctx.Err(); err != nil { + return false, err + } + + // Update the machine manager with any new or disabled applications + updateErr := s.machineManager.UpdateMachines(ctx) + if updateErr != nil && !manager.IsOnlyApplicationFailurePersistenceErrors(updateErr) { + return false, updateErr + } + + // Get all applications with active machines (returned sorted by ID). + apps := s.machineManager.Applications() + if len(apps) == 0 { + return false, updateErr + } + anyWork := false + errs := []error{updateErr} + for _, app := range apps { + if err := ctx.Err(); err != nil { + return false, errors.Join(append(errs, err)...) + } + hadWork, err := s.stepApp(ctx, app) + if err != nil { + errs = append(errs, err) + // Context errors (cancellation or timeout) mean no further apps will + // succeed. Stop now, but retain earlier application failures. + if ctx.Err() != nil { + return false, errors.Join(errs...) + } + continue + } + if hadWork { + anyWork = true + } + } + + return anyWork, errors.Join(errs...) +} + +// stepApp processes unprocessed epochs for a single application, returning +// whether more work may remain. It enforces a global per-app budget of +// inputBatchSize inputs across all epochs, so no single app can monopolize +// the tick even when it has many small epochs. +func (s *Service) stepApp(ctx context.Context, app *Application) (bool, error) { + appAddress := app.IApplicationAddress.String() + if !s.machineManager.HasMachine(app.ID) { + return false, fmt.Errorf("%w: %d", ErrNoApp, app.ID) + } + + epochs, _, err := getUnprocessedEpochs(ctx, s.repository, appAddress) + if err != nil { + return false, err + } + + budgetRemaining := s.inputBatchSize + for _, epoch := range epochs { + moreInputs, processed, terminal, err := s.processEpochInputs( + ctx, app, epoch.Index, budgetRemaining, + ) + if err != nil { + return false, err + } + + budgetRemaining -= processed + if terminal { + // The terminal result itself is durable, but its epoch remains CLOSED: + // there is no accepted state from which to publish a validity proof. + return false, nil + } + + // More inputs remain in this epoch — reschedule immediately. + if moreInputs { + return true, nil + } + + // All inputs in this epoch processed. Finalize if closed. + if epoch.Status == EpochStatus_Closed { + if err := s.finalizeEpoch(ctx, app, epoch); err != nil { + return false, err + } + } + + // Budget exhausted — yield so other apps get a turn. + if budgetRemaining == 0 { + return true, nil + } + } + + return false, nil +} + +// finalizeEpoch verifies all inputs are processed, handles snapshot/proof +// bookkeeping, and marks the epoch as inputs-processed in the database. +func (s *Service) finalizeEpoch(ctx context.Context, app *Application, epoch *Epoch) error { + allProcessed, err := s.isAllEpochInputsProcessed(app, epoch) + if err != nil { + return err + } + if !allProcessed { + return nil + } + + proof, err := s.prepareEpochPublication(ctx, app, epoch) + if err != nil { + return err + } + + appAddress := app.IApplicationAddress.String() + if err := s.repository.UpdateEpochInputsProcessed(ctx, appAddress, epoch.Index, proof); err != nil { + if errors.Is(err, repository.ErrEpochForeclosed) { + s.Logger.Info("Epoch was foreclosed before state-proof publication; discarding obsolete publication", + "application", app.Name, "epoch_index", epoch.Index) + return nil + } + return fmt.Errorf( + "publishing state proof for application %s epoch %d: %w", + app.Name, + epoch.Index, + err, + ) + } + + s.Logger.Info("Epoch updated to Inputs Processed", + "application", app.Name, "epoch_index", epoch.Index) + return nil +} + +// processEpochInputs fetches and processes up to `budget` unprocessed inputs +// for an epoch. Returns whether more unprocessed inputs remain in this epoch +// and the number of inputs actually processed. +func (s *Service) processEpochInputs( + ctx context.Context, app *Application, epochIndex uint64, budget uint64, +) (bool, uint64, bool, error) { + appAddress := app.IApplicationAddress.String() + inputs, total, err := getUnprocessedInputs(ctx, s.repository, appAddress, epochIndex, budget) + if err != nil { + return false, 0, false, err + } + if len(inputs) == 0 { + return false, 0, false, nil + } + s.Logger.Debug("Processing inputs", + "application", app.Name, "epoch_index", epochIndex, + "count", len(inputs), "total", total) + processed, terminal, err := s.processInputs(ctx, app, inputs) + if err != nil { + return false, processed, false, err + } + // More work remains if total exceeds what we just processed. + return total > processed, processed, terminal, nil +} + +func (s *Service) isAllEpochInputsProcessed(app *Application, epoch *Epoch) (bool, error) { + // epoch has no inputs + if epoch.InputIndexLowerBound == epoch.InputIndexUpperBound { + return true, nil + } + machine, exists := s.machineManager.GetMachine(app.ID) + if !exists { + return false, fmt.Errorf("%w: %d", ErrNoApp, app.ID) + } + if machine.ProcessedInputs() == epoch.InputIndexUpperBound { + return true, nil + } + return false, nil +} + +// processInputs stores the completed prefix of inputs and stops immediately +// after a terminal completion. Rejected inputs are nonterminal and therefore +// do not truncate the batch. +func (s *Service) processInputs( + ctx context.Context, + app *Application, + inputs []*Input, +) (uint64, bool, error) { + // Skip if there are no inputs to process + if len(inputs) == 0 { + return 0, false, nil + } + + // Get the machine instance for this application + machine, exists := s.machineManager.GetMachine(app.ID) + if !exists { + return 0, false, fmt.Errorf("%w: %d", ErrNoApp, app.ID) + } + + var processed uint64 + // Process each input sequentially + for _, input := range inputs { + // Check for context cancellation before processing each input + if err := ctx.Err(); err != nil { + return processed, false, err + } + + s.Logger.Info("Processing input", + "application", app.Name, + "epoch", input.EpochIndex, + "index", input.Index) + + // Advance the machine with this input + result, err := machine.Advance(ctx, input.RawData, input.EpochIndex, input.Index, app.IsDaveConsensus()) + input.RawData = nil // allow GC to collect payload while batch continues + if err != nil { + // Cancellation of this service context is the normal shutdown path, + // so it does not change the application's status. A returned error + // that happens to include context.Canceled is still a failure while + // the service context remains active. + if errors.Is(ctx.Err(), context.Canceled) { + s.Logger.Debug("Advance stopped because the service is shutting down", + "application", app.Name, + "index", input.Index, + "error", err) + return processed, false, err + } + + // Anything else, including a deadline, is an execution failure. + s.Logger.Error("Error executing advance", + "application", app.Name, + "index", input.Index, + "error", err) + + s.markApplicationFailed(ctx, app, err.Error()) + + // Eagerly close the machine to release the child process. + // The app has failed, so no further operations will succeed. + // Skip if the runtime was already destroyed inside the manager. + if !errors.Is(err, manager.ErrMachineClosed) { + if closeErr := machine.Close(); closeErr != nil { + s.Logger.Warn("Failed to close machine after advance error", + "application", app.Name, + "error", closeErr) + } + } + + return processed, false, err + } + // log advance result hashes + s.Logger.Info("Processing input finished", + "application", app.Name, + "epoch", result.EpochIndex, + "index", result.InputIndex, + "status", result.Status, + "outputs", len(result.Outputs), + "reports", len(result.Reports), + "periodic_state_hashes", len(result.PeriodicStateHashes), + "padding_repetitions", result.PaddingRepetitions, + ) + + // Store the result in the database + err = s.repository.StoreAdvanceResult(ctx, input.EpochApplicationID, result) + if err != nil { + stopLogger := s.Logger.With("origin_application", app.Name, "origin_address", app.IApplicationAddress, + "origin_epoch", input.EpochIndex) + var errCause string + switch { + case errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err): + errCause = "canceled advance result persistence" + // Shutdown interrupted persistence after the machine advanced. + // Discard that runtime; restart must recover from persisted state. + s.Logger.Debug("Advance result persistence canceled during shutdown; closing machine", + "application", app.Name, "index", input.Index, "error", err) + case errors.Is(err, repository.ErrApplicationNotRunnable): + errCause = "application status race" + // Another service durably fenced this application after the + // machine began its advance. Discard only this stale runtime; the + // database already contains the authoritative app-local outcome. + s.Logger.Warn("Advance result lost an application status race; closing stale machine", + "application", app.Name, + "epoch", input.EpochIndex, + "index", input.Index, + "error", err) + default: + errCause = "its advance result was not confirmed saved; service shutdown is still required" + // Advance has already changed the live machine, but the transaction + // did not confirm that its result was saved. The database may still + // show this input as pending. Reusing this machine could then execute + // the input again from the wrong state, so StoreAdvanceResult is not + // retried against this live machine. + s.Logger.Error( + "Could not confirm that the advance result was saved; "+ + "the live machine has already advanced, so services will stop; "+ + "after the node is restarted, execution will use persisted state", + "application", app.Name, + "epoch", input.EpochIndex, + "index", input.Index, + "error", err) + if app.IsDaveConsensus() { + prtprotection.ReportUnavailable(s.Logger.With("epoch_index", input.EpochIndex), app, err) + } + persistenceFailure := fmt.Errorf("unconfirmed advance result for %s: %w", app.Name, err) + prtprotection.ReportGlobalUnavailable(stopLogger, persistenceFailure) + s.supervisor.Fatal(persistenceFailure) + } + + // Try to close the machine now so the already-advanced runtime cannot + // be used again. Cancel services even if Close fails. After the node + // is restarted, the machine is rebuilt from persisted state, and the + // database decides whether this input is still pending and needs a + // safe retry. + closeErr := machine.Close() + if closeErr != nil { + s.Logger.Error("Could not close the machine after "+errCause, + "application", app.Name, + "error", closeErr) + closeFailure := fmt.Errorf("close machine for %s after %s: %w", app.Name, errCause, closeErr) + prtprotection.ReportGlobalUnavailable(stopLogger, closeFailure) + s.supervisor.Fatal(closeFailure) + } + return processed, false, errors.Join(err, closeErr) + } + processed++ + if result.Status.IsTerminal() { + applicationStatus, _ := result.Status.TerminalApplicationStatus() + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, result.EpochIndex, + fmt.Errorf("execution terminated: %s", applicationStatus)) + s.Logger.Error("Application execution terminated", + "application", app.Name, + "address", app.IApplicationAddress, + "epoch", result.EpochIndex, + "input", result.InputIndex, + "completion_status", result.Status, + "application_status", applicationStatus, + ) + return processed, true, nil + } + + // Create a snapshot if needed + if result.Status == InputCompletionStatus_Accepted { + err = s.handleSnapshot(ctx, app, machine, input) + switch { + case err == nil: + // Snapshot created. + case errors.Is(ctx.Err(), context.Canceled): + s.Logger.Debug("Snapshot creation cancelled due to shutdown", + "application", app.Name, + "index", input.Index) + return processed, false, err + case errors.Is(err, manager.ErrMachineClosed): + s.Logger.Error("Snapshot failure destroyed the machine runtime", + "application", app.Name, + "index", input.Index, + "error", err) + s.markApplicationFailed(ctx, app, err.Error()) + return processed, false, err + default: + s.Logger.Error("Failed to create snapshot", + "application", app.Name, + "index", input.Index, + "error", err) + // Continue processing even if snapshot creation fails + } + } + } + + return processed, false, nil +} + +// markApplicationFailed persists FAILED or installs a local fence when the +// status write cannot be confirmed. Keeping those operations together prevents +// a failed application from being handed more work while durability is retried. +func (s *Service) markApplicationFailed(ctx context.Context, app *Application, reason string) { + if err := appstatus.SetFailed(ctx, s.Logger, s.repository, app, reason); err != nil { + s.machineManager.FenceApplicationFailure(app, reason) + s.Logger.Error( + "Could not persist FAILED application status; the application remains fenced until the write is retried", + "application", app.Name, + "db_error", err, + ) + } + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, 0, errors.New(reason)) +} + +func (s *Service) isEpochLastInput(ctx context.Context, app *Application, input *Input) (bool, error) { + if app == nil || input == nil { + return false, fmt.Errorf("application and input must not be nil") + } + // Get the epoch for this input + epoch, err := s.repository.GetEpoch(ctx, app.IApplicationAddress.String(), input.EpochIndex) + if err != nil { + return false, fmt.Errorf("failed to get epoch: %w", err) + } + + // Skip if the epoch is still open + if epoch.Status == EpochStatus_Open { + return false, nil + } + + // Check if this is the last input of the epoch + lastInput, err := s.repository.GetLastInput(ctx, app.IApplicationAddress.String(), input.EpochIndex) + if err != nil { + return false, fmt.Errorf("failed to get last input: %w", err) + } + + // If this is the last input and the epoch is closed, return true + if lastInput != nil && lastInput.Index == input.Index { + return true, nil + } + + return false, nil +} + +// prepareEpochPublication acquires a fresh proof from the resident accepted +// machine state for every epoch, including empty and rejection-only epochs, +// and performs any epoch-boundary snapshot before the proof is published. +func (s *Service) prepareEpochPublication( + ctx context.Context, + app *Application, + epoch *Epoch, +) (*StateProof, error) { + machine, exists := s.machineManager.GetMachine(app.ID) + if !exists { + return nil, fmt.Errorf("%w: %d", ErrNoApp, app.ID) + } + + proof, err := machine.StateProof(ctx) + if err != nil { + // If the runtime was destroyed (e.g., child process crashed), mark the + // app as failed to avoid an infinite retry loop. + if errors.Is(err, manager.ErrMachineClosed) { + s.markApplicationFailed(ctx, app, err.Error()) + } + return nil, fmt.Errorf("failed to get final state proof from machine: %w", err) + } + if !proof.IsComplete() { + return nil, fmt.Errorf( + "machine returned an incomplete final state proof: %w", + repository.ErrInvalidStateProof, + ) + } + + if epoch.InputIndexLowerBound == epoch.InputIndexUpperBound || + app.ExecutionParameters.SnapshotPolicy != SnapshotPolicy_EveryEpoch { + return proof, nil + } + + lastProcessedInput, err := s.repository.GetLastProcessedInput( + ctx, app.IApplicationAddress.String(), + ) + if err != nil { + return nil, fmt.Errorf("failed to get last input: %w", err) + } + if lastProcessedInput != nil { + if err := s.handleSnapshot(ctx, app, machine, lastProcessedInput); err != nil { + return nil, err + } + } + return proof, nil +} + +// handleSnapshot creates a snapshot based on the application's snapshot policy +func (s *Service) handleSnapshot(ctx context.Context, app *Application, machine manager.MachineInstance, input *Input) error { + policy := app.ExecutionParameters.SnapshotPolicy + + // Skip if snapshot policy is NONE + if policy == SnapshotPolicy_None { + return nil + } + + // For EVERY_INPUT policy, create a snapshot for every input + if policy == SnapshotPolicy_EveryInput { + return s.createSnapshot(ctx, app, machine, input) + } + + // For EVERY_EPOCH policy, check if this is the last input of the epoch + if policy == SnapshotPolicy_EveryEpoch { + // If this is the last input and the epoch is closed, create a snapshot + isLastInput, err := s.isEpochLastInput(ctx, app, input) + if err != nil { + return err + } + if isLastInput { + return s.createSnapshot(ctx, app, machine, input) + } + } + + return nil +} + +// createSnapshot creates a snapshot and updates the input record with the snapshot URI +func (s *Service) createSnapshot(ctx context.Context, app *Application, machine manager.MachineInstance, input *Input) error { + if input.SnapshotURI != nil { + s.Logger.Debug("Skipping snapshot, input already has a snapshot", + "application", app.Name, + "epoch", input.EpochIndex, + "input", input.Index, + "path", *input.SnapshotURI) + return nil + } + + // Generate a snapshot path with a simpler structure + // Use app name and input index only, avoiding deep directory nesting + snapshotName := fmt.Sprintf("%s_epoch%d_input%d", app.Name, input.EpochIndex, input.Index) + snapshotPath := filepath.Join(s.snapshotsDir, snapshotName) + + s.Logger.Info("Creating snapshot", + "application", app.Name, + "epoch", input.EpochIndex, + "input", input.Index, + "path", snapshotPath) + + // Guard against partial store by cleaning up before a retry + if _, err := os.Stat(snapshotPath); err == nil { + if err := s.removeSnapshot(snapshotPath, app.Name); err != nil { + return fmt.Errorf("failed to clean stale snapshot directory: %w", err) + } + } + + // Ensure the parent directory exists + if err := os.MkdirAll(s.snapshotsDir, 0755); err != nil { //nolint: mnd + return fmt.Errorf("failed to create snapshots directory: %w", err) + } + + // Create the snapshot + err := machine.CreateSnapshot(ctx, input.Index+1, snapshotPath) + if err != nil { + return err + } + + // Get previous snapshot BEFORE writing the new one so the query does not + // return the snapshot we just created — that would cause self-deletion. + previousSnapshot, err := s.repository.GetLastSnapshot(ctx, app.IApplicationAddress.String()) + if err != nil { + if errors.Is(err, context.Canceled) { + s.Logger.Debug("GetLastSnapshot cancelled due to shutdown", + "application", app.Name) + } else { + s.Logger.Error("Failed to get previous snapshot", + "application", app.Name, + "error", err) + // Continue even if we can't get the previous snapshot + } + } + + // Update the input record with the snapshot URI + input.SnapshotURI = &snapshotPath + + // Update the input's snapshot URI in the database + err = s.repository.UpdateInputSnapshotURI(ctx, input.EpochApplicationID, input.Index, snapshotPath) + if err != nil { + return fmt.Errorf("failed to update input snapshot URI: %w", err) + } + + // Remove previous snapshot if it exists + if previousSnapshot != nil && previousSnapshot.SnapshotURI != nil { + if err := s.removeSnapshot(*previousSnapshot.SnapshotURI, app.Name); err != nil { + s.Logger.Error("Failed to remove previous snapshot", + "application", app.Name, + "snapshot", *previousSnapshot.SnapshotURI, + "error", err) + // Continue even if we can't remove the previous snapshot + } + } + + return nil +} + +// removeSnapshot safely removes a previous snapshot +func (s *Service) removeSnapshot(snapshotPath string, appName string) error { + // Safety check: canonicalize paths to prevent directory traversal via ".." sequences + cleanPath := filepath.Clean(snapshotPath) + cleanDir := filepath.Clean(s.snapshotsDir) + if !strings.HasPrefix(cleanPath, cleanDir+string(filepath.Separator)) || + !strings.HasPrefix(filepath.Base(cleanPath), appName+"_") { + return fmt.Errorf("invalid snapshot path: %s", snapshotPath) + } + + s.Logger.Debug("Removing previous snapshot", "application", appName, "path", snapshotPath) + + // Check if the path exists before attempting to remove it + if _, err := os.Stat(snapshotPath); os.IsNotExist(err) { + s.Logger.Warn("Snapshot path does not exist, nothing to remove", + "application", appName, + "path", snapshotPath) + return nil + } + + // Use os.RemoveAll to remove the snapshot directory or file + return os.RemoveAll(snapshotPath) +} diff --git a/internal/advancer/advancer_test.go b/internal/advancer/advancer_test.go new file mode 100644 index 000000000..6df12d78a --- /dev/null +++ b/internal/advancer/advancer_test.go @@ -0,0 +1,2848 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + crand "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + mrand "math/rand" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/manager" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + pkgmachine "github.com/cartesi/rollups-node/pkg/machine" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/suite" +) + +func TestAdvancer(t *testing.T) { + suite.Run(t, new(AdvancerSuite)) +} + +type AdvancerSuite struct{ suite.Suite } + +type advancerLogCapture struct { + mu sync.Mutex + records []slog.Record +} + +func (h *advancerLogCapture) Enabled(context.Context, slog.Level) bool { return true } +func (h *advancerLogCapture) Handle(_ context.Context, record slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, record.Clone()) + return nil +} +func (h *advancerLogCapture) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *advancerLogCapture) WithGroup(string) slog.Handler { return h } + +func (h *advancerLogCapture) contains(level slog.Level, message string) bool { + h.mu.Lock() + defer h.mu.Unlock() + for _, record := range h.records { + if record.Level == level && record.Message == message { + return true + } + } + return false +} + +const defaultBatchSize = 500 + +func newMockAdvancerService(machineManager *MockMachineManager, repo *MockRepository) (*Service, error) { + return newMockAdvancerServiceWithBatchSize(machineManager, repo, defaultBatchSize) +} + +func newMockAdvancerServiceWithBatchSize( + machineManager *MockMachineManager, + repo *MockRepository, + batchSize uint64, +) (*Service, error) { + sup := newMockSupervisor() + s := &Service{ + inputBatchSize: batchSize, + machineManager: machineManager, + repository: repo, + supervisor: sup, + } + err := service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{}, s) + if err != nil { + return nil, err + } + return s, nil +} + +// testEnv bundles the components most tests need: a service, a single app's mock, +// the mock machine manager, and the mock repository. +type testEnv struct { + service *Service + supervisor *MockSupervisor + app *MockMachineImpl + mm *MockMachineManager + repo *MockRepository +} + +// setupOneApp creates a standard test environment with one application. +// The repository is empty; callers can configure it after the call. +func (s *AdvancerSuite) setupOneApp() testEnv { + mm := newMockMachineManager() + app := newMockMachine(1) + mm.Map[1] = newMockInstance(app) + repo := &MockRepository{} + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, defaultBatchSize) + s.Require().NoError(err) + return testEnv{ + service: svc, + supervisor: svc.supervisor.(*MockSupervisor), + app: app, + mm: mm, + repo: repo, + } +} + +func (s *AdvancerSuite) TestServiceInterface() { + s.Run("ServiceMethods", func() { + require := s.Require() + + machineManager := newMockMachineManager() + repository := &MockRepository{} + advancer, err := newMockAdvancerService(machineManager, repository) + require.NotNil(advancer) + require.Nil(err) + + // Test service interface methods + require.True(advancer.Ready()) + machineManager.PendingApplicationFailures = true + require.False(advancer.Ready()) + machineManager.PendingApplicationFailures = false + require.True(advancer.Ready()) + require.Equal(advancer.Name, advancer.String()) + + // Test Tick method + machineManager.Map[1] = newMockInstance(newMockMachine(1)) + repository.GetEpochsReturn = map[common.Address][]*Epoch{ + machineManager.Map[1].application.IApplicationAddress: {}, + } + _, tickErr := advancer.Tick(context.Background()) + require.NoError(tickErr) + + // Test Tick with error + repository.GetEpochsError = errors.New("list epochs error") + _, tickErr = advancer.Tick(context.Background()) + require.Error(tickErr) + require.Contains(tickErr.Error(), "list epochs error") + + // Stop must be called last to cleanly shut down the service. + advancer.supervisor.Stop() + }) +} + +func (s *AdvancerSuite) TestStep() { + s.Run("Ok", func() { + require := s.Require() + + machineManager := newMockMachineManager() + app1 := newMockMachine(1) + app2 := newMockMachine(2) + machineManager.Map[1] = newMockInstance(app1) + machineManager.Map[2] = newMockInstance(app2) + res0 := randomAdvanceResult(0) + res1 := randomAdvanceResult(1) + res2 := randomAdvanceResult(0) + + repository := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app1.Application.IApplicationAddress: { + &Epoch{Index: 0, Status: EpochStatus_Open}, + }, + app2.Application.IApplicationAddress: { + &Epoch{Index: 0, Status: EpochStatus_Open}, + }, + }, + GetInputsReturn: map[common.Address][]*Input{ + app1.Application.IApplicationAddress: { + newInput(app1.Application.ID, 0, 0, marshal(res0)), + newInput(app1.Application.ID, 0, 1, marshal(res1)), + }, + app2.Application.IApplicationAddress: { + newInput(app2.Application.ID, 0, 0, marshal(res2)), + }, + }, + } + + advancer, err := newMockAdvancerService(machineManager, repository) + require.NotNil(advancer) + require.Nil(err) + + _, err = advancer.Step(context.Background()) + require.Nil(err) + + require.Len(repository.StoredResults, 3) + }) + + s.Run("Error/UpdateEpochs", func() { + require := s.Require() + env := s.setupOneApp() + res0 := randomAdvanceResult(0) + + env.repo.GetEpochsReturn = map[common.Address][]*Epoch{ + env.app.Application.IApplicationAddress: { + {Index: 0, Status: EpochStatus_Closed}, + }, + } + env.repo.GetInputsReturn = map[common.Address][]*Input{ + env.app.Application.IApplicationAddress: { + newInput(env.app.Application.ID, 0, 0, marshal(res0)), + }, + } + env.repo.UpdateEpochsError = errors.New("update epochs error") + + _, err := env.service.Step(context.Background()) + require.Error(err) + require.Contains(err.Error(), "update epochs error") + require.Contains(err.Error(), env.app.Application.Name) + require.Contains(err.Error(), "epoch 0") + }) + + s.Run("Error/UpdateMachines", func() { + require := s.Require() + + machineManager := &MockMachineManager{ + Map: map[int64]*MockMachineInstance{}, + UpdateMachinesError: errors.New("update machines error"), + } + repository := &MockRepository{} + + advancer, err := newMockAdvancerService(machineManager, repository) + require.NotNil(advancer) + require.Nil(err) + + _, err = advancer.Step(context.Background()) + require.Error(err) + require.Contains(err.Error(), "update machines error") + }) + + s.Run("Error/UnconfirmedApplicationFailureStatus", func() { + require := s.Require() + persistenceErr := &manager.ApplicationFailurePersistenceError{ + ApplicationID: 7, + WriteErr: errors.New("status write unavailable"), + ReadErr: errors.New("status read unavailable"), + } + healthy := newMockMachine(8) + machineManager := &MockMachineManager{ + Map: map[int64]*MockMachineInstance{ + healthy.Application.ID: newMockInstance(healthy), + }, + UpdateMachinesError: persistenceErr, + PendingApplicationFailures: true, + } + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + healthy.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + healthy.Application.IApplicationAddress: { + newInput(healthy.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + }, + }, + } + advancer, err := newMockAdvancerService(machineManager, repo) + require.NoError(err) + + _, err = advancer.Step(context.Background()) + require.ErrorIs(err, manager.ErrApplicationFailureNotDurable) + require.Len(repo.StoredResults, 1, "the healthy application must still advance") + require.False(advancer.Ready()) + }) + + s.Run("Error/GetInputs", func() { + require := s.Require() + env := s.setupOneApp() + + env.repo.GetEpochsReturn = map[common.Address][]*Epoch{ + env.app.Application.IApplicationAddress: { + {Index: 0, Status: EpochStatus_Closed}, + }, + } + env.repo.GetInputsError = errors.New("get inputs error") + + _, err := env.service.Step(context.Background()) + require.Error(err) + require.Contains(err.Error(), "get inputs error") + }) + + s.Run("NoInputs", func() { + require := s.Require() + env := s.setupOneApp() + + env.repo.GetInputsReturn = map[common.Address][]*Input{ + env.app.Application.IApplicationAddress: {}, + } + + _, err := env.service.Step(context.Background()) + require.Nil(err) + require.Len(env.repo.StoredResults, 0) + }) + + s.Run("FailedAppDoesNotBlockOtherApps", func() { + require := s.Require() + + mm := newMockMachineManager() + app1 := newMockMachine(1) // will fail + app2 := newMockMachine(2) // should still be processed + mm.Map[1] = newMockInstance(app1) + mm.Map[2] = newMockInstance(app2) + res2 := randomAdvanceResult(0) + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app1.Application.IApplicationAddress: { + {Index: 0, Status: EpochStatus_Open}, + }, + app2.Application.IApplicationAddress: { + {Index: 0, Status: EpochStatus_Open}, + }, + }, + GetInputsReturn: map[common.Address][]*Input{ + app1.Application.IApplicationAddress: { + newInput(app1.Application.ID, 0, 0, []byte("advance error")), + }, + app2.Application.IApplicationAddress: { + newInput(app2.Application.ID, 0, 0, marshal(res2)), + }, + }, + } + + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + _, err = svc.Step(context.Background()) + // Step returns a combined error but the healthy app was still processed + require.Error(err) + require.Contains(err.Error(), "advance error") + require.Equal(1, repo.ApplicationStatusUpdates) + require.Equal(ApplicationStatus_Failed, repo.LastApplicationStatus) + + // app2's input was processed despite app1's failure + require.Len(repo.StoredResults, 1) + }) + + s.Run("LiveCycleLimitWriteFailureFencesAppWithoutBlockingHealthySibling", func() { + require := s.Require() + mm := newMockMachineManager() + limited := newMockMachine(1) + healthy := newMockMachine(2) + limitErr := fmt.Errorf( + "advance execution reached configured cycle limit: %w", + pkgmachine.ErrReachedLimitMcycle, + ) + limited.AdvanceError = limitErr + limited.Application.ConsensusType = Consensus_PRT + root := common.HexToAddress("0x777") + mm.Map[limited.Application.ID] = newMockInstance(limited) + mm.Map[healthy.Application.ID] = newMockInstance(healthy) + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + limited.Application.IApplicationAddress: {{ApplicationID: limited.Application.ID, + Index: 0, Status: EpochStatus_Closed, TournamentAddress: &root}}, + healthy.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + limited.Application.IApplicationAddress: { + newInput(limited.Application.ID, 0, 0, []byte("limited input")), + }, + healthy.Application.IApplicationAddress: { + newInput(healthy.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + }, + }, + UpdateApplicationStatusError: errors.New("FAILED write unavailable"), + TournamentReadError: errors.New("root metadata read unavailable"), + } + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + logs := &advancerLogCapture{} + svc.Logger = slog.New(logs) + + _, err = svc.Step(context.Background()) + + require.ErrorIs(err, pkgmachine.ErrReachedLimitMcycle) + require.Equal( + appstatus.NormalizeReason(limitErr.Error()), + mm.RecordedApplicationFailures[limited.Application.ID], + ) + require.False(svc.Ready(), "an unconfirmed FAILED write must fail readiness immediately") + require.Len(repo.StoredResults, 1, "the healthy sibling must still advance") + require.Equal(healthy.Application.ID, repo.StoredAppIDs[0]) + require.Equal([]common.Address{root}, repo.TournamentReads) + require.True(logs.contains(slog.LevelError, "PRT protection stopped")) + }) +} + +func (s *AdvancerSuite) TestGetUnprocessedInputs() { + s.Run("Success", func() { + require := s.Require() + + app1 := newMockMachine(1) + inputs := []*Input{ + newInput(app1.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + newInput(app1.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + } + + repo := &MockRepository{ + GetInputsReturn: map[common.Address][]*Input{ + app1.Application.IApplicationAddress: inputs, + }, + } + + result, count, err := getUnprocessedInputs( + context.Background(), repo, app1.Application.IApplicationAddress.String(), 0, 500) + require.Nil(err) + require.Equal(uint64(2), count) + require.Equal(inputs, result) + }) + + s.Run("Error", func() { + require := s.Require() + + app1 := newMockMachine(1) + repo := &MockRepository{ + GetInputsError: errors.New("list inputs error"), + } + + _, _, err := getUnprocessedInputs( + context.Background(), repo, app1.Application.IApplicationAddress.String(), 0, 500) + require.Error(err) + require.Contains(err.Error(), "list inputs error") + }) +} + +func (s *AdvancerSuite) TestProcess() { + s.Run("ApplicationStatusUpdate", func() { + require := s.Require() + env := s.setupOneApp() + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, []byte("advance error")), + } + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.Error(err) + require.Equal(1, env.repo.ApplicationStatusUpdates) + require.Equal(ApplicationStatus_Failed, env.repo.LastApplicationStatus) + require.NotNil(env.repo.LastApplicationStatusReason) + require.Equal("advance error", *env.repo.LastApplicationStatusReason) + }) + + s.Run("ApplicationStatusUpdateError", func() { + require := s.Require() + env := s.setupOneApp() + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, []byte("advance error")), + } + env.repo.UpdateApplicationStatusError = errors.New("update state error") + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.Error(err) + require.Contains(err.Error(), "advance error") + }) + + s.Run("IncompleteAdvanceMarksFailedWithoutCanonicalResult", func() { + interruptions := []struct { + name string + err error + }{ + {"InternalWatchdog", pkgmachine.ErrDeadlineExceeded}, + {"OutputLimit", pkgmachine.ErrOutputsLimitExceeded}, + {"ReportLimit", pkgmachine.ErrReportsLimitExceeded}, + {"PayloadLimit", pkgmachine.ErrPayloadLengthLimitExceeded}, + {"CycleLimit", pkgmachine.ErrReachedLimitMcycle}, + } + + for _, interruption := range interruptions { + s.Run(interruption.name, func() { + require := s.Require() + env := s.setupOneApp() + env.app.AdvanceError = interruption.err + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, []byte("input")), + } + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.ErrorIs(err, interruption.err) + require.Empty(env.repo.StoredResults) + require.Equal(1, env.repo.ApplicationStatusUpdates) + require.Equal(ApplicationStatus_Failed, env.repo.LastApplicationStatus) + require.Equal(1, env.mm.Map[env.app.Application.ID].closeCalls) + }) + } + }) + + s.Run("Ok", func() { + require := s.Require() + env := s.setupOneApp() + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + newInput(env.app.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + newInput(env.app.Application.ID, 0, 2, marshal(randomAdvanceResult(2))), + newInput(env.app.Application.ID, 0, 3, marshal(randomAdvanceResult(3))), + newInput(env.app.Application.ID, 1, 4, marshal(randomAdvanceResult(4))), + newInput(env.app.Application.ID, 1, 5, marshal(randomAdvanceResult(5))), + newInput(env.app.Application.ID, 2, 6, marshal(randomAdvanceResult(6))), + } + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.Nil(err) + require.Len(env.repo.StoredResults, 7) + }) + + for _, status := range []InputCompletionStatus{ + InputCompletionStatus_Exception, + InputCompletionStatus_MachineHalted, + InputCompletionStatus_Overflow, + InputCompletionStatus_UnexpectedYield, + InputCompletionStatus_InvalidOutputsRoot, + } { + s.Run("StopsAfterTerminal/"+status.String(), func() { + require := s.Require() + env := s.setupOneApp() + accepted := randomAdvanceResult(0) + terminal := randomAdvanceResult(1) + terminal.Status = status + terminal.Outputs = nil + terminal.Reports = [][]byte{[]byte("terminal report")} + if status == InputCompletionStatus_Exception { + terminal.ExceptionData = []byte("guest exception") + } + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(accepted)), + newInput(env.app.Application.ID, 0, 1, marshal(terminal)), + newInput(env.app.Application.ID, 0, 2, []byte("must not execute")), + } + + processed, stopped, err := env.service.processInputs( + context.Background(), env.app.Application, inputs, + ) + require.NoError(err) + require.Equal(uint64(2), processed) + require.True(stopped) + require.Len(env.repo.StoredResults, 2) + require.Equal(status, env.repo.StoredResults[1].Status) + require.Equal(terminal.Reports, env.repo.StoredResults[1].Reports) + require.Equal([]byte("must not execute"), inputs[2].RawData) + }) + } + + s.Run("RejectedDoesNotStopBatch", func() { + require := s.Require() + env := s.setupOneApp() + rejected := randomAdvanceResult(0) + rejected.Status = InputCompletionStatus_Rejected + rejected.Outputs = nil + rejected.Reports = [][]byte{[]byte("rejection report")} + accepted := randomAdvanceResult(1) + + processed, stopped, err := env.service.processInputs( + context.Background(), env.app.Application, []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(rejected)), + newInput(env.app.Application.ID, 0, 1, marshal(accepted)), + }, + ) + require.NoError(err) + require.Equal(uint64(2), processed) + require.False(stopped) + require.Len(env.repo.StoredResults, 2) + require.Equal(rejected.Reports, env.repo.StoredResults[0].Reports) + }) + + s.Run("Noop", func() { + s.Run("NoInputs", func() { + require := s.Require() + env := s.setupOneApp() + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, []*Input{}) + require.Nil(err) + }) + }) + + s.Run("Error", func() { + s.Run("ErrApp", func() { + require := s.Require() + env := s.setupOneApp() + invalidApp := Application{ID: 999} + inputs := randomInputs(1, 0, 3) + + _, _, err := env.service.processInputs(context.Background(), &invalidApp, inputs) + expected := fmt.Sprintf("%v: %v", ErrNoApp, invalidApp.ID) + require.EqualError(err, expected) + }) + + s.Run("Advance", func() { + require := s.Require() + env := s.setupOneApp() + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + newInput(env.app.Application.ID, 0, 1, []byte("advance error")), + newInput(env.app.Application.ID, 0, 2, []byte("unreachable")), + } + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.Error(err) + require.Contains(err.Error(), "advance error") + require.Len(env.repo.StoredResults, 1) + }) + + s.Run("StoreAdvance", func() { + require := s.Require() + env := s.setupOneApp() + pending := newInput( + env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0)), + ) + inputs := []*Input{ + pending, + newInput(env.app.Application.ID, 0, 1, []byte("unreachable")), + } + address := env.app.Application.IApplicationAddress + env.repo.GetInputsReturn = map[common.Address][]*Input{ + address: {pending}, + } + env.repo.StoreAdvanceError = errors.New("store-advance error") + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.Error(err) + require.Contains(err.Error(), "store-advance error") + require.NotNil(env.supervisor.FatalError.Load()) + require.ErrorIs(*env.supervisor.FatalError.Load(), env.repo.StoreAdvanceError) + require.ErrorContains(*env.supervisor.FatalError.Load(), env.app.Application.Name) + require.Empty(env.repo.StoredResults) + require.Empty(env.repo.StoredAppIDs) + require.Equal([]*Input{pending}, env.repo.GetInputsReturn[address], + "an atomic store failure must leave the input pending") + + require.True(env.supervisor.StopCalled.Load(), + "supervisor Stop should have been called") + }) + + s.Run("StoreAdvanceCommitResponseLost", func() { + require := s.Require() + env := s.setupOneApp() + pending := newInput( + env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0)), + ) + address := env.app.Application.IApplicationAddress + env.repo.GetInputsReturn = map[common.Address][]*Input{ + address: {pending}, + } + env.repo.StoreAdvanceCommitError = errors.New("commit response lost") + + _, _, err := env.service.processInputs( + context.Background(), env.app.Application, []*Input{pending}, + ) + require.ErrorContains(err, "commit response lost") + require.NotNil(env.supervisor.FatalError.Load()) + require.ErrorIs(*env.supervisor.FatalError.Load(), env.repo.StoreAdvanceCommitError) + require.Len(env.repo.StoredResults, 1) + require.Equal([]int64{env.app.Application.ID}, env.repo.StoredAppIDs) + require.Empty(env.repo.GetInputsReturn[address], + "a committed result and its input cursor must advance atomically") + require.True(env.supervisor.StopCalled.Load(), + "supervisor Stop should have been called") + require.Equal(1, env.mm.Map[env.app.Application.ID].closeCalls) + + unprocessed, _, listErr := getUnprocessedInputs( + context.Background(), env.repo, address.String(), pending.EpochIndex, 500, + ) + require.NoError(listErr) + require.Empty(unprocessed, + "a restart must not execute an input whose result already committed") + }) + + s.Run("StoreAdvanceApplicationNotRunnableIsAppLocal", func() { + require := s.Require() + env := s.setupOneApp() + pending := newInput( + env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0)), + ) + env.repo.StoreAdvanceError = fmt.Errorf( + "status changed: %w", repository.ErrApplicationNotRunnable) + + _, _, err := env.service.processInputs( + context.Background(), env.app.Application, []*Input{pending}, + ) + require.ErrorIs(err, repository.ErrApplicationNotRunnable) + require.False(env.supervisor.StopCalled.Load(), + "an application-local conflict must not stop unrelated services") + require.Equal(1, env.mm.Map[env.app.Application.ID].closeCalls) + require.Zero(env.repo.ApplicationStatusUpdates, + "the repository's durable status is already authoritative") + }) + }) + + s.Run("SnapshotFailureCannotLeaveSilentZombie", func() { + require := s.Require() + env := s.setupOneApp() + env.app.Application.ExecutionParameters.SnapshotPolicy = SnapshotPolicy_EveryInput + env.service.snapshotsDir = s.T().TempDir() + instance := env.mm.Map[env.app.Application.ID] + instance.createSnapshotError = errors.New("snapshot runtime crashed") + instance.destroyAfterSnapshotError = true + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + newInput(env.app.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + } + + processed, stopped, err := env.service.processInputs( + context.Background(), + env.app.Application, + inputs, + ) + require.ErrorIs(err, manager.ErrMachineClosed) + require.ErrorContains(err, "snapshot runtime crashed") + require.Equal(uint64(1), processed) + require.False(stopped) + require.Len(env.repo.StoredResults, 1) + require.Equal(1, instance.advanceCalls, + "the destroyed runtime must not be handed the next input") + require.Equal(1, env.repo.ApplicationStatusUpdates) + require.Equal(ApplicationStatus_Failed, env.repo.LastApplicationStatus) + }) +} + +// TestContextCancellation tests how the advancer handles context cancellation +func (s *AdvancerSuite) TestContextCancellation() { + s.Run("CancelDuringStep", func() { + require := s.Require() + env := s.setupOneApp() + env.repo.GetEpochsBlock = true + + ctx, cancel := context.WithCancel(context.Background()) + + // Start the Step operation in a goroutine + errCh := make(chan error) + go func() { + _, err := env.service.Step(ctx) + errCh <- err + }() + + // Cancel the context after a short delay + time.Sleep(10 * time.Millisecond) + cancel() + + // Check that the operation was canceled + select { + case err := <-errCh: + require.Error(err) + require.ErrorIs(err, context.Canceled) + require.Empty(env.repo.StoredResults) + require.Zero(env.repo.ApplicationStatusUpdates) + require.Zero(env.mm.Map[env.app.Application.ID].closeCalls) + case <-time.After(100 * time.Millisecond): + require.Fail("Step operation did not respect context cancellation") + } + }) + + s.Run("CancelDuringProcessInputs", func() { + require := s.Require() + env := s.setupOneApp() + env.app.AdvanceBlock = true + logs := &advancerLogCapture{} + env.service.Logger = slog.New(logs) + + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + } + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error) + go func() { + _, _, err := env.service.processInputs(ctx, env.app.Application, inputs) + errCh <- err + }() + + // Cancel the context after a short delay + time.Sleep(10 * time.Millisecond) + cancel() + + // Check that the operation was canceled + select { + case err := <-errCh: + require.Error(err) + require.ErrorIs(err, context.Canceled) + require.Empty(env.repo.StoredResults) + require.Zero(env.repo.ApplicationStatusUpdates) + require.Zero(env.mm.Map[env.app.Application.ID].closeCalls) + require.True(logs.contains( + slog.LevelDebug, + "Advance stopped because the service is shutting down", + )) + require.False(logs.contains(slog.LevelError, "Error executing advance")) + case <-time.After(100 * time.Millisecond): + require.Fail("processInputs operation did not respect context cancellation") + } + }) + + s.Run("DeadlineDuringProcessInputs", func() { + require := s.Require() + env := s.setupOneApp() + env.app.AdvanceBlock = true + logs := &advancerLogCapture{} + env.service.Logger = slog.New(logs) + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + _, _, err := env.service.processInputs(ctx, env.app.Application, inputs) + require.ErrorIs(err, context.DeadlineExceeded) + require.Empty(env.repo.StoredResults) + // The expired context prevents the immediate database write, so the + // application failure is queued for a later status-write retry. + require.Zero(env.repo.ApplicationStatusUpdates) + require.Equal( + context.DeadlineExceeded.Error(), + env.mm.RecordedApplicationFailures[env.app.Application.ID], + ) + require.True(env.mm.PendingApplicationFailures) + require.Equal(1, env.mm.Map[env.app.Application.ID].closeCalls) + require.True(logs.contains(slog.LevelError, "Error executing advance")) + require.False(logs.contains( + slog.LevelDebug, + "Advance stopped because the service is shutting down", + )) + }) + + for _, test := range []struct { + name string + err error + }{ + { + name: "LiveContextWithJoinedDeadlineAndCancellation", + err: errors.Join(context.DeadlineExceeded, context.Canceled), + }, + { + name: "LiveContextWithJoinedMachineFailureAndCancellation", + err: errors.Join(pkgmachine.ErrMachineInternal, context.Canceled), + }, + } { + s.Run(test.name, func() { + require := s.Require() + env := s.setupOneApp() + env.app.AdvanceError = test.err + logs := &advancerLogCapture{} + env.service.Logger = slog.New(logs) + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, []byte("input")), + } + + ctx := context.Background() + _, _, err := env.service.processInputs(ctx, env.app.Application, inputs) + + require.ErrorIs(err, context.Canceled) + require.NoError(ctx.Err(), "the caller context must remain active") + require.Empty(env.repo.StoredResults) + require.Equal(1, env.repo.ApplicationStatusUpdates) + require.Equal(ApplicationStatus_Failed, env.repo.LastApplicationStatus) + require.Equal(1, env.mm.Map[env.app.Application.ID].closeCalls) + require.True(logs.contains(slog.LevelError, "Error executing advance")) + require.False(logs.contains( + slog.LevelDebug, + "Advance stopped because the service is shutting down", + )) + }) + } +} + +// TestLargeNumberOfInputs how the advancer handles large volumes of inputs +func (s *AdvancerSuite) TestLargeNumberOfInputs() { + s.Run("LargeNumberOfInputs", func() { + require := s.Require() + env := s.setupOneApp() + + const inputCount = 10000 + inputs := make([]*Input, inputCount) + for i := range inputCount { + inputs[i] = newInput(env.app.Application.ID, 0, uint64(i), marshal(randomAdvanceResult(uint64(i)))) + } + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.Nil(err) + require.Len(env.repo.StoredResults, inputCount) + }) +} + +// TestErrorRecovery verifies that any store failure after a successful Advance() +// triggers node shutdown, because the machine and DB are now out of sync. +func (s *AdvancerSuite) TestErrorRecovery() { + s.Run("TransientStoreFailureTriggersShutdown", func() { + require := s.Require() + env := s.setupOneApp() + env.repo.StoreAdvanceFailCount = 1 + + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + } + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, inputs) + require.Error(err) + require.Contains(err.Error(), "temporary failure") + require.Empty(env.repo.StoredResults) + require.Empty(env.repo.StoredAppIDs) + require.True(env.supervisor.StopCalled.Load(), + "supervisor Stop should have been called") + require.Equal(1, env.mm.Map[env.app.Application.ID].closeCalls, + "the already-advanced machine must be closed before processInputs returns") + }) +} + +// TestContextCancelledBeforeProcessing verifies that when the context is +// already cancelled, processInputs returns the context error immediately +// without reaching the advance or store paths. +func (s *AdvancerSuite) TestContextCancelledBeforeProcessing() { + s.Run("ContextAlreadyCancelled", func() { + require := s.Require() + env := s.setupOneApp() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + inputs := []*Input{ + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + } + + _, _, err := env.service.processInputs(ctx, env.app.Application, inputs) + require.ErrorIs(err, context.Canceled) + }) +} + +// --------------------------------------------------------------------------- +// isAllEpochInputsProcessed tests +// --------------------------------------------------------------------------- + +func (s *AdvancerSuite) TestIsAllEpochInputsProcessed() { + s.Run("TrueWhenEpochHasNoInputs", func() { + require := s.Require() + env := s.setupOneApp() + + epoch := &Epoch{Index: 0, InputIndexLowerBound: 5, InputIndexUpperBound: 5} + result, perr := env.service.isAllEpochInputsProcessed(env.app.Application, epoch) + require.Nil(perr) + require.True(result) + }) + + s.Run("TrueWhenMachineProcessedAllInputs", func() { + require := s.Require() + env := s.setupOneApp() + env.mm.Map[1].machineImpl.processedInputs = 10 + + epoch := &Epoch{Index: 0, InputIndexLowerBound: 5, InputIndexUpperBound: 10} + result, perr := env.service.isAllEpochInputsProcessed(env.app.Application, epoch) + require.Nil(perr) + require.True(result) + }) + + s.Run("FalseWhenMoreInputsExist", func() { + require := s.Require() + env := s.setupOneApp() + env.mm.Map[1].machineImpl.processedInputs = 7 + + epoch := &Epoch{Index: 0, InputIndexLowerBound: 5, InputIndexUpperBound: 10} + result, perr := env.service.isAllEpochInputsProcessed(env.app.Application, epoch) + require.Nil(perr) + require.False(result) + }) + + s.Run("ErrorWhenNoMachineForApp", func() { + require := s.Require() + mm := newMockMachineManager() + repo := &MockRepository{} + svc, err := newMockAdvancerService(mm, repo) + require.Nil(err) + + app := &Application{ID: 999} + epoch := &Epoch{Index: 0, InputIndexLowerBound: 0, InputIndexUpperBound: 5} + _, perr := svc.isAllEpochInputsProcessed(app, epoch) + require.Error(perr) + require.ErrorIs(perr, ErrNoApp) + }) +} + +// --------------------------------------------------------------------------- +// isEpochLastInput tests +// --------------------------------------------------------------------------- + +func (s *AdvancerSuite) TestIsEpochLastInput() { + setupWithEpoch := func(epochStatus EpochStatus) (testEnv, *Application) { + env := s.setupOneApp() + env.repo.GetEpochReturn = &Epoch{Status: epochStatus} + return env, env.app.Application + } + + s.Run("TrueWhenLastInputInClosedEpoch", func() { + require := s.Require() + env, app := setupWithEpoch(EpochStatus_Closed) + + lastInput := repotest.NewInputBuilder().WithIndex(5).WithEpochIndex(0).Build() + env.repo.GetInputsReturn = map[common.Address][]*Input{ + app.IApplicationAddress: {lastInput}, + } + env.repo.GetLastInputReturn = lastInput + + input := repotest.NewInputBuilder().WithIndex(5).WithEpochIndex(0).Build() + result, err := env.service.isEpochLastInput(context.Background(), app, input) + require.Nil(err) + require.True(result) + }) + + s.Run("FalseWhenEpochIsOpen", func() { + require := s.Require() + env, app := setupWithEpoch(EpochStatus_Open) + + input := repotest.NewInputBuilder().WithIndex(3).WithEpochIndex(0).Build() + result, err := env.service.isEpochLastInput(context.Background(), app, input) + require.Nil(err) + require.False(result) + }) + + s.Run("FalseWhenNotLastInput", func() { + require := s.Require() + env, app := setupWithEpoch(EpochStatus_Closed) + + env.repo.GetLastInputReturn = repotest.NewInputBuilder().WithIndex(5).WithEpochIndex(0).Build() + + input := repotest.NewInputBuilder().WithIndex(3).WithEpochIndex(0).Build() + result, err := env.service.isEpochLastInput(context.Background(), app, input) + require.Nil(err) + require.False(result) + }) + + s.Run("ErrorWhenNilInput", func() { + require := s.Require() + env, app := setupWithEpoch(EpochStatus_Closed) + + _, err := env.service.isEpochLastInput(context.Background(), app, nil) + require.Error(err) + require.Contains(err.Error(), "must not be nil") + }) + + s.Run("ErrorWhenNilApplication", func() { + require := s.Require() + env, _ := setupWithEpoch(EpochStatus_Closed) + + input := repotest.NewInputBuilder().WithIndex(0).Build() + _, err := env.service.isEpochLastInput(context.Background(), nil, input) + require.Error(err) + require.Contains(err.Error(), "must not be nil") + }) + + s.Run("ErrorWhenGetEpochFails", func() { + require := s.Require() + env, app := setupWithEpoch(EpochStatus_Closed) + env.repo.GetEpochError = errors.New("get epoch error") + + input := repotest.NewInputBuilder().WithIndex(0).Build() + _, err := env.service.isEpochLastInput(context.Background(), app, input) + require.Error(err) + require.Contains(err.Error(), "get epoch error") + }) + + s.Run("ErrorWhenGetLastInputFails", func() { + require := s.Require() + env, app := setupWithEpoch(EpochStatus_Closed) + env.repo.GetLastInputError = errors.New("get last input error") + + input := repotest.NewInputBuilder().WithIndex(0).Build() + _, err := env.service.isEpochLastInput(context.Background(), app, input) + require.Error(err) + require.Contains(err.Error(), "get last input error") + }) +} + +// --------------------------------------------------------------------------- +// prepareEpochPublication tests +// --------------------------------------------------------------------------- + +func (s *AdvancerSuite) TestPrepareEpochPublication() { + s.Run("EveryEmptyEpochGetsFreshMachineProof", func() { + for _, index := range []uint64{0, 2} { + env := s.setupOneApp() + epoch := &Epoch{Index: index, Status: EpochStatus_Closed} + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, epoch, + ) + s.Require().NoError(err) + s.Require().Same(env.app.StateProofReturn, proof) + s.True(proof.IsComplete()) + } + }) + + s.Run("ProofError", func() { + env := s.setupOneApp() + env.app.StateProofError = errors.New("proof error") + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, &Epoch{}, + ) + s.Require().Nil(proof) + s.Require().ErrorContains(err, "proof error") + }) + + s.Run("IncompleteProof", func() { + env := s.setupOneApp() + env.app.StateProofReturn = &StateProof{} + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, &Epoch{}, + ) + s.Require().Nil(proof) + s.Require().ErrorIs(err, repository.ErrInvalidStateProof) + }) + + s.Run("MachineClosedMarksAppFailed", func() { + env := s.setupOneApp() + env.app.StateProofError = manager.ErrMachineClosed + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, &Epoch{}, + ) + s.Require().Nil(proof) + s.Require().ErrorIs(err, manager.ErrMachineClosed) + s.Equal(1, env.repo.ApplicationStatusUpdates) + s.Equal(ApplicationStatus_Failed, env.repo.LastApplicationStatus) + }) + + s.Run("MachineClosedWriteFailureQueuesDurableFence", func() { + env := s.setupOneApp() + env.app.StateProofError = manager.ErrMachineClosed + env.repo.UpdateApplicationStatusError = errors.New("FAILED write unavailable") + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, &Epoch{}, + ) + s.Require().Nil(proof) + s.Require().ErrorIs(err, manager.ErrMachineClosed) + s.Equal( + appstatus.NormalizeReason(manager.ErrMachineClosed.Error()), + env.mm.RecordedApplicationFailures[env.app.Application.ID], + ) + s.False(env.service.Ready()) + }) + + s.Run("NonEmptyEpochWithEveryEpochSnapshotPolicy", func() { + env := s.setupOneApp() + env.app.Application.ExecutionParameters.SnapshotPolicy = SnapshotPolicy_EveryEpoch + env.service.snapshotsDir = s.T().TempDir() + epoch := &Epoch{Index: 0, Status: EpochStatus_Closed, InputIndexUpperBound: 3} + lastInput := repotest.NewInputBuilder().WithIndex(2).WithEpochIndex(0). + WithStatus(InputCompletionStatus_Accepted).Build() + lastInput.EpochApplicationID = env.app.Application.ID + env.repo.GetLastProcessedInputReturn = lastInput + env.repo.GetLastInputReturn = lastInput + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, epoch, + ) + s.Require().NoError(err) + s.True(proof.IsComplete()) + s.True(env.repo.SnapshotURIUpdated) + }) + + s.Run("NonEmptyEpochNoSnapshotPolicy", func() { + env := s.setupOneApp() + env.app.Application.ExecutionParameters.SnapshotPolicy = SnapshotPolicy_None + epoch := &Epoch{Index: 0, Status: EpochStatus_Closed, InputIndexUpperBound: 3} + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, epoch, + ) + s.Require().NoError(err) + s.True(proof.IsComplete()) + s.False(env.repo.SnapshotURIUpdated) + }) + + s.Run("NoMachineReturnsError", func() { + mm := newMockMachineManager() + svc, err := newMockAdvancerService(mm, &MockRepository{}) + s.Require().NoError(err) + app := repotest.NewApplicationBuilder().Build() + app.ID = 999 + + proof, err := svc.prepareEpochPublication( + context.Background(), app, &Epoch{InputIndexUpperBound: 3}, + ) + s.Require().Nil(proof) + s.Require().ErrorIs(err, ErrNoApp) + }) + + s.Run("GetLastProcessedInputError", func() { + env := s.setupOneApp() + env.app.Application.ExecutionParameters.SnapshotPolicy = SnapshotPolicy_EveryEpoch + env.repo.GetLastProcessedInputError = errors.New("db connection lost") + + proof, err := env.service.prepareEpochPublication( + context.Background(), env.app.Application, + &Epoch{InputIndexUpperBound: 3}, + ) + s.Require().Nil(proof) + s.Require().ErrorContains(err, "db connection lost") + }) +} + +// --------------------------------------------------------------------------- +// handleSnapshot tests +// --------------------------------------------------------------------------- + +func (s *AdvancerSuite) TestHandleSnapshot() { + setupSnapshot := func(policy SnapshotPolicy) (testEnv, *MockMachineInstance) { + env := s.setupOneApp() + env.app.Application.ExecutionParameters.SnapshotPolicy = policy + env.service.snapshotsDir = s.T().TempDir() + instance := env.mm.Map[1] + return env, instance + } + + s.Run("NonePolicy", func() { + require := s.Require() + env, machine := setupSnapshot(SnapshotPolicy_None) + + input := repotest.NewInputBuilder().WithIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.handleSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.False(env.repo.SnapshotURIUpdated) + }) + + s.Run("EveryInputPolicy", func() { + require := s.Require() + env, machine := setupSnapshot(SnapshotPolicy_EveryInput) + + input := repotest.NewInputBuilder().WithIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.handleSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.True(env.repo.SnapshotURIUpdated) + }) + + s.Run("EveryEpochPolicyLastInput", func() { + require := s.Require() + env, machine := setupSnapshot(SnapshotPolicy_EveryEpoch) + env.repo.GetEpochReturn = &Epoch{Status: EpochStatus_Closed} + env.repo.GetLastInputReturn = repotest.NewInputBuilder().WithIndex(5).WithEpochIndex(0).Build() + + input := repotest.NewInputBuilder().WithIndex(5).WithEpochIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.handleSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.True(env.repo.SnapshotURIUpdated) + }) + + s.Run("EveryEpochPolicyNotLastInput", func() { + require := s.Require() + env, machine := setupSnapshot(SnapshotPolicy_EveryEpoch) + env.repo.GetEpochReturn = &Epoch{Status: EpochStatus_Closed} + env.repo.GetLastInputReturn = repotest.NewInputBuilder().WithIndex(5).WithEpochIndex(0).Build() + + input := repotest.NewInputBuilder().WithIndex(3).WithEpochIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.handleSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.False(env.repo.SnapshotURIUpdated) + }) + + s.Run("EveryEpochPolicyOpenEpoch", func() { + require := s.Require() + env, machine := setupSnapshot(SnapshotPolicy_EveryEpoch) + env.repo.GetEpochReturn = &Epoch{Status: EpochStatus_Open} + + input := repotest.NewInputBuilder().WithIndex(0).WithEpochIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.handleSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.False(env.repo.SnapshotURIUpdated) + }) +} + +// --------------------------------------------------------------------------- +// createSnapshot tests +// --------------------------------------------------------------------------- + +func (s *AdvancerSuite) TestCreateSnapshot() { + setupCreateSnapshot := func() (testEnv, *MockMachineInstance, string) { + env := s.setupOneApp() + env.app.Application.Name = "testapp" + env.app.Application.ExecutionParameters.SnapshotPolicy = SnapshotPolicy_EveryInput + tmpDir := s.T().TempDir() + env.service.snapshotsDir = tmpDir + instance := &MockMachineInstance{ + application: env.app.Application, + machineImpl: env.app, + } + return env, instance, tmpDir + } + + s.Run("Success", func() { + require := s.Require() + env, machine, tmpDir := setupCreateSnapshot() + + input := repotest.NewInputBuilder().WithIndex(3).WithEpochIndex(1).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.createSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.True(env.repo.SnapshotURIUpdated) + + require.NotNil(input.SnapshotURI) + expectedPath := filepath.Join(tmpDir, "testapp_epoch1_input3") + require.Equal(expectedPath, *input.SnapshotURI) + }) + + s.Run("SkipsIfAlreadyHasSnapshot", func() { + require := s.Require() + env, machine, _ := setupCreateSnapshot() + + existingPath := "/existing/snapshot" + input := repotest.NewInputBuilder().WithIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + input.SnapshotURI = &existingPath + + err := env.service.createSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.False(env.repo.SnapshotURIUpdated) + }) + + s.Run("RemovesPreviousSnapshot", func() { + require := s.Require() + env, machine, tmpDir := setupCreateSnapshot() + + prevPath := filepath.Join(tmpDir, "testapp_epoch0_input0") + require.Nil(os.MkdirAll(prevPath, 0755)) + env.repo.GetLastSnapshotReturn = &Input{SnapshotURI: &prevPath} + + input := repotest.NewInputBuilder().WithIndex(1).WithEpochIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.createSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + + _, statErr := os.Stat(prevPath) + require.True(os.IsNotExist(statErr)) + }) + + s.Run("OverwritesPartialPriorAttempt", func() { + + require := s.Require() + env, machine, tmpDir := setupCreateSnapshot() + + prevPath := filepath.Join(tmpDir, "testapp_epoch0_input0") + require.Nil(os.MkdirAll(prevPath, 0755)) + + input := repotest.NewInputBuilder().WithIndex(0).WithEpochIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.createSnapshot(context.Background(), env.app.Application, machine, input) + require.Nil(err) + require.NotNil(input.SnapshotURI) + require.Equal(prevPath, *input.SnapshotURI) + + // This works because the machine mock doesn't create directories + _, statErr := os.Stat(prevPath) + require.True(errors.Is(statErr, fs.ErrNotExist)) + }) + + s.Run("CreateSnapshotError", func() { + require := s.Require() + env, machine, _ := setupCreateSnapshot() + machine.createSnapshotError = errors.New("snapshot failed") + + input := repotest.NewInputBuilder().WithIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.createSnapshot(context.Background(), env.app.Application, machine, input) + require.Error(err) + require.Contains(err.Error(), "snapshot failed") + require.False(env.repo.SnapshotURIUpdated) + }) + + s.Run("MkdirAllError", func() { + require := s.Require() + + machineManager := newMockMachineManager() + app := newMockMachine(1) + app.Application.Name = "testapp" + machineManager.Map[1] = newMockInstance(app) + repository := &MockRepository{} + advancer, err := newMockAdvancerService(machineManager, repository) + require.Nil(err) + + // Create a read-only parent directory so MkdirAll fails + tmpDir := s.T().TempDir() + readonlyDir := filepath.Join(tmpDir, "readonly") + require.Nil(os.MkdirAll(readonlyDir, 0755)) + require.Nil(os.Chmod(readonlyDir, 0555)) + s.T().Cleanup(func() { os.Chmod(readonlyDir, 0755) }) //nolint:errcheck + advancer.snapshotsDir = filepath.Join(readonlyDir, "snapshots") + + input := repotest.NewInputBuilder().WithIndex(0).Build() + input.EpochApplicationID = app.Application.ID + + err = advancer.createSnapshot(context.Background(), app.Application, machineManager.Map[1], input) + require.Error(err) + require.Contains(err.Error(), "failed to create snapshots directory") + }) + + s.Run("UpdateSnapshotURIError", func() { + require := s.Require() + env, machine, _ := setupCreateSnapshot() + + env.repo.UpdateSnapshotURIError = errors.New("db error") + + input := repotest.NewInputBuilder().WithIndex(0).Build() + input.EpochApplicationID = env.app.Application.ID + + err := env.service.createSnapshot(context.Background(), env.app.Application, machine, input) + require.Error(err) + require.Contains(err.Error(), "db error") + }) +} + +// --------------------------------------------------------------------------- +// removeSnapshot tests +// --------------------------------------------------------------------------- + +func (s *AdvancerSuite) TestRemoveSnapshot() { + s.Run("RemovesExistingSnapshot", func() { + require := s.Require() + + tmpDir := s.T().TempDir() + advancer := &Service{snapshotsDir: tmpDir} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} + require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) + + // Create a snapshot directory + snapshotPath := filepath.Join(tmpDir, "myapp_epoch0_input0") + require.Nil(os.MkdirAll(snapshotPath, 0755)) + + err := advancer.removeSnapshot(snapshotPath, "myapp") + require.Nil(err) + + _, statErr := os.Stat(snapshotPath) + require.True(os.IsNotExist(statErr)) + }) + + s.Run("NonExistentPathIsNoop", func() { + require := s.Require() + + tmpDir := s.T().TempDir() + advancer := &Service{snapshotsDir: tmpDir} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} + require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) + + snapshotPath := filepath.Join(tmpDir, "myapp_epoch0_input0") + err := advancer.removeSnapshot(snapshotPath, "myapp") + require.Nil(err) + }) + + s.Run("RejectsDirectoryTraversal", func() { + require := s.Require() + + tmpDir := s.T().TempDir() + advancer := &Service{snapshotsDir: tmpDir} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} + require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) + + // Try to traverse outside snapshotsDir + maliciousPath := filepath.Join(tmpDir, "..", "outside", "myapp_evil") + err := advancer.removeSnapshot(maliciousPath, "myapp") + require.Error(err) + require.Contains(err.Error(), "invalid snapshot path") + }) + + s.Run("RejectsMismatchedAppName", func() { + require := s.Require() + + tmpDir := s.T().TempDir() + advancer := &Service{snapshotsDir: tmpDir} + serviceArgs := &service.TickServiceConfigs{BaseConfigs: service.BaseConfigs{Name: config.ServiceAdvancer}} + require.Nil(service.InitTickServiceTemplate(&advancer.TickServiceTemplate, serviceArgs, advancer)) + + snapshotPath := filepath.Join(tmpDir, "otherapp_epoch0_input0") + err := advancer.removeSnapshot(snapshotPath, "myapp") + require.Error(err) + require.Contains(err.Error(), "invalid snapshot path") + }) +} + +// --------------------------------------------------------------------------- +// Scheduling tests (starvation fix) +// --------------------------------------------------------------------------- + +// Starvation prevention — both apps get served in a single Step(). +func (s *AdvancerSuite) TestStarvationPrevention() { + require := s.Require() + + mm := newMockMachineManager() + appA := newMockMachine(1) // will have many inputs + appB := newMockMachine(2) // will have 1 input + mm.Map[1] = newMockInstance(appA) + mm.Map[2] = newMockInstance(appB) + + resA := make([]*Input, 20) + for i := range resA { + resA[i] = newInput(appA.Application.ID, 0, uint64(i), marshal(randomAdvanceResult(uint64(i)))) + } + resB := randomAdvanceResult(0) + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + appA.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + appB.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + appA.Application.IApplicationAddress: resA, + appB.Application.IApplicationAddress: { + newInput(appB.Application.ID, 0, 0, marshal(resB)), + }, + }, + } + + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, 5) + require.NoError(err) + + hadWork, err := svc.Step(context.Background()) + require.NoError(err) + require.True(hadWork, "should report more work remaining") + + // Verify both apps were served using per-app tracking. + var countA, countB int + for _, id := range repo.StoredAppIDs { + switch id { + case appA.Application.ID: + countA++ + case appB.Application.ID: + countB++ + } + } + require.GreaterOrEqual(countB, 1, "app B must be served (starvation-free)") + require.LessOrEqual(countA, 5, "app A should process at most batchSize (5) inputs") + require.LessOrEqual(len(repo.StoredResults), 6, // 5 for A + 1 for B + "should process at most batchSize per app") +} + +// Single-batch enforcement — processEpochInputs processes exactly one batch. +func (s *AdvancerSuite) TestSingleBatchEnforcement() { + require := s.Require() + + mm := newMockMachineManager() + app := newMockMachine(1) + mm.Map[1] = newMockInstance(app) + + inputs := make([]*Input, 20) + for i := range inputs { + inputs[i] = newInput(app.Application.ID, 0, uint64(i), marshal(randomAdvanceResult(uint64(i)))) + } + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + app.Application.IApplicationAddress: inputs, + }, + } + + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, 5) + require.NoError(err) + + hadWork, err := svc.Step(context.Background()) + require.NoError(err) + require.True(hadWork, "more work should remain") + + // Exactly one batch of 5 should have been processed, not all 20. + require.Equal(5, len(repo.StoredResults), + "should process exactly one batch (batchSize=5)") +} + +func (s *AdvancerSuite) TestTerminalInputStopsEpochAndFutureTicks() { + require := s.Require() + env := s.setupOneApp() + terminal := randomAdvanceResult(1) + terminal.Status = InputCompletionStatus_MachineHalted + terminal.Outputs = nil + terminal.Reports = [][]byte{[]byte("terminal report")} + address := env.app.Application.IApplicationAddress + env.repo.GetEpochsReturn = map[common.Address][]*Epoch{ + address: {{ + Index: 0, + Status: EpochStatus_Closed, + InputIndexLowerBound: 0, + InputIndexUpperBound: 3, + }}, + } + env.repo.GetInputsReturn = map[common.Address][]*Input{ + address: { + newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + newInput(env.app.Application.ID, 0, 1, marshal(terminal)), + newInput(env.app.Application.ID, 0, 2, []byte("must not execute")), + }, + } + + hadWork, err := env.service.Step(context.Background()) + require.NoError(err) + require.False(hadWork) + require.Len(env.repo.StoredResults, 2) + require.Equal(terminal.Reports, env.repo.StoredResults[1].Reports) + require.Zero(env.repo.EpochInputsProcessedCount, + "a terminal epoch must remain closed because it has no accepted terminal state") + + terminalAppStatus, ok := terminal.Status.TerminalApplicationStatus() + require.True(ok) + env.app.Application.Status = terminalAppStatus + hadWork, err = env.service.Step(context.Background()) + require.NoError(err) + require.False(hadWork) + require.Len(env.repo.StoredResults, 2, + "a terminal machine must not receive work on later ticks") +} + +// More-work signal accuracy. +func (s *AdvancerSuite) TestMoreWorkSignal() { + s.Run("TrueWhenInputsRemain", func() { + require := s.Require() + env := s.setupOneApp() + + inputs := make([]*Input, 10) + for i := range inputs { + inputs[i] = newInput(env.app.Application.ID, 0, uint64(i), + marshal(randomAdvanceResult(uint64(i)))) + } + env.repo.GetEpochsReturn = map[common.Address][]*Epoch{ + env.app.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + } + env.repo.GetInputsReturn = map[common.Address][]*Input{ + env.app.Application.IApplicationAddress: inputs, + } + + // With default batch size (500) > 10 inputs, single batch drains everything. + hadWork, err := env.service.Step(context.Background()) + require.NoError(err) + require.False(hadWork, "no more work when all inputs fit in one batch") + }) + + s.Run("FalseWhenEmpty", func() { + require := s.Require() + env := s.setupOneApp() + env.repo.GetEpochsReturn = map[common.Address][]*Epoch{ + env.app.Application.IApplicationAddress: {}, + } + + hadWork, err := env.service.Step(context.Background()) + require.NoError(err) + require.False(hadWork, "no work when no epochs exist") + }) +} + +// Round-robin cursor distributes work evenly. +func (s *AdvancerSuite) TestAllAppsProcessedEveryStep() { + require := s.Require() + + mm := newMockMachineManager() + apps := make([]*MockMachineImpl, 3) + for i := range apps { + apps[i] = &MockMachineImpl{ + Application: &Application{ + ID: int64(i + 1), + IApplicationAddress: randomAddress(), + }, + } + mm.Map[int64(i+1)] = newMockInstance(apps[i]) + } + + // Each app has many inputs. batchSize=1 so we process exactly 1 per app per Step. + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{}, + GetInputsReturn: map[common.Address][]*Input{}, + } + for _, app := range apps { + repo.GetEpochsReturn[app.Application.IApplicationAddress] = []*Epoch{ + {Index: 0, Status: EpochStatus_Open}, + } + inputs := make([]*Input, 10) + for j := range inputs { + inputs[j] = newInput(app.Application.ID, 0, uint64(j), + marshal(randomAdvanceResult(uint64(j)))) + } + repo.GetInputsReturn[app.Application.IApplicationAddress] = inputs + } + + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, 1) + require.NoError(err) + + // Each Step processes all 3 apps with 1 input each = 3 results per Step. + // After 2 steps: 6 results, 2 per app. + for range 2 { + _, err := svc.Step(context.Background()) + require.NoError(err) + } + + require.Equal(6, len(repo.StoredResults), + "2 steps * 3 apps * 1 input/app = 6 total results") +} + +// Cursor handles app removal gracefully. +func (s *AdvancerSuite) TestAppRemovalBetweenTicks() { + require := s.Require() + + mm := newMockMachineManager() + app1 := newMockMachine(1) + app2 := newMockMachine(2) + app3 := newMockMachine(3) + mm.Map[1] = newMockInstance(app1) + mm.Map[2] = newMockInstance(app2) + mm.Map[3] = newMockInstance(app3) + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app1.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + app2.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + app3.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + app1.Application.IApplicationAddress: { + newInput(app1.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + }, + app2.Application.IApplicationAddress: { + newInput(app2.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + }, + app3.Application.IApplicationAddress: { + newInput(app3.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + }, + }, + } + + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + // First step processes all 3 apps. + _, err = svc.Step(context.Background()) + require.NoError(err) + require.Equal(3, len(repo.StoredResults)) + + // Remove app2, add fresh inputs for remaining apps. + delete(mm.Map, 2) + repo.GetInputsReturn[app1.Application.IApplicationAddress] = []*Input{ + newInput(app1.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + } + repo.GetInputsReturn[app3.Application.IApplicationAddress] = []*Input{ + newInput(app3.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + } + + // Second step should not panic and should process apps 1 and 3. + _, err = svc.Step(context.Background()) + require.NoError(err) + require.Equal(5, len(repo.StoredResults), "3 from first step + 2 from second") +} + +// App addition between ticks is handled gracefully. +func (s *AdvancerSuite) TestAppAdditionBetweenTicks() { + require := s.Require() + + mm := newMockMachineManager() + app1 := newMockMachine(1) + app2 := newMockMachine(2) + mm.Map[1] = newMockInstance(app1) + mm.Map[2] = newMockInstance(app2) + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app1.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + app2.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + app1.Application.IApplicationAddress: { + newInput(app1.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + }, + app2.Application.IApplicationAddress: { + newInput(app2.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + }, + }, + } + + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + _, err = svc.Step(context.Background()) + require.NoError(err) + require.Equal(2, len(repo.StoredResults)) + + // Add app3 and fresh inputs. + app3 := newMockMachine(3) + mm.Map[3] = newMockInstance(app3) + repo.GetEpochsReturn[app3.Application.IApplicationAddress] = []*Epoch{ + {Index: 0, Status: EpochStatus_Open}, + } + repo.GetInputsReturn[app1.Application.IApplicationAddress] = []*Input{ + newInput(app1.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + } + repo.GetInputsReturn[app2.Application.IApplicationAddress] = []*Input{ + newInput(app2.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + } + repo.GetInputsReturn[app3.Application.IApplicationAddress] = []*Input{ + newInput(app3.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + } + + _, err = svc.Step(context.Background()) + require.NoError(err) + require.Equal(5, len(repo.StoredResults), "2 from first + 3 from second (all 3 apps)") +} + +// Epoch completion fires only after last batch. +func (s *AdvancerSuite) TestEpochCompletionTiming() { + require := s.Require() + + mm := newMockMachineManager() + app := newMockMachine(1) + app.processedInputs = 0 + mm.Map[1] = newMockInstance(app) + + inputs := make([]*Input, 10) + for i := range inputs { + inputs[i] = newInput(app.Application.ID, 0, uint64(i), marshal(randomAdvanceResult(uint64(i)))) + } + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app.Application.IApplicationAddress: { + {Index: 0, Status: EpochStatus_Closed, + InputIndexLowerBound: 0, InputIndexUpperBound: 10}, + }, + }, + GetInputsReturn: map[common.Address][]*Input{ + app.Application.IApplicationAddress: inputs, + }, + } + + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, 5) + require.NoError(err) + + // Step 1: processes 5 of 10 inputs. Epoch should NOT be marked complete. + hadWork, err := svc.Step(context.Background()) + require.NoError(err) + require.True(hadWork) + require.Equal(0, repo.EpochInputsProcessedCount, + "epoch should not be finalized after first batch") + + // Simulate machine having processed 10 inputs for the epoch completion check. + app.processedInputs = 10 + + // Step 2: processes remaining 5 inputs. Epoch should be marked complete. + _, err = svc.Step(context.Background()) + require.NoError(err) + require.Equal(10, len(repo.StoredResults)) +} + +// Zero apps returns (false, nil) without panic. +func (s *AdvancerSuite) TestZeroApps() { + require := s.Require() + + mm := newMockMachineManager() // empty + repo := &MockRepository{} + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + hadWork, err := svc.Step(context.Background()) + require.NoError(err) + require.False(hadWork) +} + +// Single epoch per round — only the first epoch is processed. +// Cross-epoch processing — all unprocessed epochs are visited in a single Step. +func (s *AdvancerSuite) TestCrossEpochProcessing() { + require := s.Require() + + mm := newMockMachineManager() + app := newMockMachine(1) + mm.Map[1] = newMockInstance(app) + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app.Application.IApplicationAddress: { + {Index: 0, Status: EpochStatus_Open}, + {Index: 1, Status: EpochStatus_Open}, + {Index: 2, Status: EpochStatus_Open}, + }, + }, + GetInputsReturn: map[common.Address][]*Input{ + app.Application.IApplicationAddress: { + newInput(app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + newInput(app.Application.ID, 1, 1, marshal(randomAdvanceResult(1))), + newInput(app.Application.ID, 2, 2, marshal(randomAdvanceResult(2))), + }, + }, + } + + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + hadWork, err := svc.Step(context.Background()) + require.NoError(err) + require.False(hadWork, "all work consumed") + // All three epochs' inputs should be processed in one step. + require.Equal(3, len(repo.StoredResults)) +} + +// Cross-epoch budget — even when each epoch has fewer inputs than batchSize, +// the total across epochs is capped so one app cannot monopolize the tick. +func (s *AdvancerSuite) TestCrossEpochBudget() { + require := s.Require() + + mm := newMockMachineManager() + app := newMockMachine(1) + mm.Map[1] = newMockInstance(app) + + // 5 epochs, each with 3 inputs = 15 total. batchSize=5. + // Without the budget, all 15 would be processed. With it, only 5. + var allInputs []*Input + epochs := make([]*Epoch, 5) + for e := uint64(0); e < 5; e++ { + epochs[e] = &Epoch{Index: e, Status: EpochStatus_Open} + for i := uint64(0); i < 3; i++ { + idx := e*3 + i + allInputs = append(allInputs, + newInput(app.Application.ID, e, idx, marshal(randomAdvanceResult(idx)))) + } + } + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app.Application.IApplicationAddress: epochs, + }, + GetInputsReturn: map[common.Address][]*Input{ + app.Application.IApplicationAddress: allInputs, + }, + } + + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, 5) + require.NoError(err) + + hadWork, err := svc.Step(context.Background()) + require.NoError(err) + require.True(hadWork, "more work remains across later epochs") + // With batchSize=5 and 3 inputs per epoch: epoch 0 (3) + epoch 1 (2 of 3) = 5. + // Budget exhausted, remaining epochs deferred. + require.LessOrEqual(len(repo.StoredResults), 5, + "should not process more inputs than batchSize across epochs") + require.GreaterOrEqual(len(repo.StoredResults), 3, + "should process at least one full epoch") +} + +// Deterministic ordering — same app set produces same order. +func (s *AdvancerSuite) TestDeterministicOrdering() { + require := s.Require() + + // Create apps with IDs in non-sorted order. + mm := newMockMachineManager() + for _, id := range []int64{30, 10, 20} { + impl := &MockMachineImpl{ + Application: &Application{ + ID: id, + IApplicationAddress: randomAddress(), + }, + } + mm.Map[id] = newMockInstance(impl) + } + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{}, + GetInputsReturn: map[common.Address][]*Input{}, + } + for _, inst := range mm.Map { + addr := inst.application.IApplicationAddress + repo.GetEpochsReturn[addr] = []*Epoch{{Index: 0, Status: EpochStatus_Open}} + repo.GetInputsReturn[addr] = []*Input{ + newInput(inst.application.ID, 0, 0, marshal(randomAdvanceResult(0))), + } + } + + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + _, err = svc.Step(context.Background()) + require.NoError(err) + + // Verify results were stored in sorted ID order (10, 20, 30). + require.Equal(3, len(repo.StoredResults)) + require.Equal([]int64{10, 20, 30}, repo.StoredAppIDs, + "apps should be processed in ascending ID order") +} + +// Self-wake fires on successful work. +func (s *AdvancerSuite) TestSelfWakeOnSuccess() { + require := s.Require() + + mm := newMockMachineManager() + app := newMockMachine(1) + mm.Map[1] = newMockInstance(app) + + inputs := make([]*Input, 10) + for i := range inputs { + inputs[i] = newInput(app.Application.ID, 0, uint64(i), marshal(randomAdvanceResult(uint64(i)))) + } + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + app.Application.IApplicationAddress: inputs, + }, + } + + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, 5) + require.NoError(err) + + // Call Tick() which internally calls Step() and signals reschedule. + reschedule, err := svc.Tick(context.Background()) + require.NoError(err) + + // The reschedule channel should have a pending signal. + require.True(reschedule, + "reschedule channel should have a pending signal after Tick with work") +} + +// No self-wake when idle. +func (s *AdvancerSuite) TestNoSelfWakeWhenIdle() { + require := s.Require() + + mm := newMockMachineManager() + app := newMockMachine(1) + mm.Map[1] = newMockInstance(app) + + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app.Application.IApplicationAddress: {}, + }, + } + + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + reschedule, err := svc.Tick(context.Background()) + require.NoError(err) + + require.False(reschedule, + "reschedule channel should be empty when no work exists") +} + +// No self-wake on error. +func (s *AdvancerSuite) TestNoSelfWakeOnError() { + require := s.Require() + + mm := &MockMachineManager{ + Map: map[int64]*MockMachineInstance{}, + UpdateMachinesError: errors.New("db unavailable"), + } + repo := &MockRepository{} + + svc, err := newMockAdvancerService(mm, repo) + require.NoError(err) + + reschedule, err := svc.Tick(context.Background()) + require.Error(err) + + require.False(reschedule, + "reschedule should NOT be signaled on error") +} + +// Progress in one application still requests another tick when another fails. +func (s *AdvancerSuite) TestPartialSuccessStillReschedules() { + require := s.Require() + + mm := newMockMachineManager() + app1 := newMockMachine(1) // will fail + app2 := newMockMachine(2) // will succeed with more work remaining + mm.Map[1] = newMockInstance(app1) + mm.Map[2] = newMockInstance(app2) + + // Give app2 more inputs than the batch size so stepApp signals "more work". + repo := &MockRepository{ + GetEpochsReturn: map[common.Address][]*Epoch{ + app1.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + app2.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}}, + }, + GetInputsReturn: map[common.Address][]*Input{ + app1.Application.IApplicationAddress: { + newInput(app1.Application.ID, 0, 0, []byte("advance error")), + }, + app2.Application.IApplicationAddress: { + newInput(app2.Application.ID, 0, 0, marshal(randomAdvanceResult(0))), + newInput(app2.Application.ID, 0, 1, marshal(randomAdvanceResult(1))), + }, + }, + } + + svc, err := newMockAdvancerServiceWithBatchSize(mm, repo, 1) + require.NoError(err) + + // Call Tick — app1 fails, app2 succeeds with more work remaining (batch limit hit). + // Tick should surface the error AND signal reschedule for app2's pending work. + reschedule, err := svc.Tick(context.Background()) + require.Error(err, "Tick should surface app1's error") + + // Reschedule SHOULD fire: app2 had work, and one failing app must not + // delay healthy apps by suppressing the reschedule signal. + require.True(reschedule, + "reschedule should be signaled when hadWork is true, even with errors") +} + +func newTestAdvancer(c *CreateInfo) (*Service, error) { + logger := service.NewLogger( + config.ServiceAdvancer, + c.Config.LogLevel, + c.Config.LogColor, + ) + c.Machines = manager.NewMachineManager( + c.Repository, + logger, + c.Config.FeatureMachineHashCheckEnabled, + c.Config.AdvancerInputBatchSize, + ) + c.Supervisor = newMockSupervisor() + svc, err := Create(context.Background(), c) + if err != nil { + return nil, err + } + return svc.(*Service), nil +} + +// --------------------------------------------------------------------------- +// Service.Create tests +// --------------------------------------------------------------------------- + +func (s *AdvancerSuite) TestServiceCreate() { + s.Run("NilRepository", func() { + require := s.Require() + c := &CreateInfo{} + c.Config.AdvancerInputBatchSize = 500 + svc, err := newTestAdvancer(c) + require.Error(err) + require.Nil(svc) + require.Contains(err.Error(), "nil") + }) + + s.Run("ZeroBatchSize", func() { + require := s.Require() + c := &CreateInfo{} + c.Config.AdvancerInputBatchSize = 0 + c.Repository = &MockFullRepository{} + svc, err := newTestAdvancer(c) + require.Error(err) + require.Nil(svc) + require.Contains(err.Error(), "batch size") + }) + + s.Run("CancelledContext", func() { + require := s.Require() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + c := &CreateInfo{} + c.Config.AdvancerInputBatchSize = 500 + c.Repository = &MockFullRepository{} + svc, err := Create(ctx, c) + require.Error(err) + require.Nil(svc) + }) +} + +// MockFullRepository satisfies the repository.Repository interface minimally +// for Create() validation tests. It panics on any actual DB call. +type MockFullRepository struct { + repository.Repository +} + +type MockMachineImpl struct { + Application *Application + AdvanceBlock bool + AdvanceError error + StateProofReturn *StateProof + StateProofError error + processedInputs uint64 +} + +func (mock *MockMachineImpl) Advance( + ctx context.Context, + input []byte, + _ uint64, + _ uint64, + _ bool, +) (*AdvanceResult, error) { + // If AdvanceBlock is true, block until context is canceled + if mock.AdvanceBlock { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Hour): // Long timeout to ensure we're waiting for cancellation + // This should never be reached in tests + return nil, errors.New("advance timeout without cancellation") + } + } + + // Check for context cancellation + if ctx.Err() != nil { + return nil, ctx.Err() + } + + // If there's a predefined error, return it + if mock.AdvanceError != nil { + return nil, mock.AdvanceError + } + + var res AdvanceResult + err := json.Unmarshal(input, &res) + if err != nil { + return nil, errors.New(string(input)) + } + return &res, nil +} + +func newMockMachine(id int64) *MockMachineImpl { + return &MockMachineImpl{ + Application: &Application{ + ID: id, + IApplicationAddress: randomAddress(), + }, + StateProofReturn: randomCompleteStateProof(), + } +} + +// newMockInstance creates a MockMachineInstance from a MockMachineImpl, ready to store in MockMachineManager.Map. +func newMockInstance(impl *MockMachineImpl) *MockMachineInstance { + return &MockMachineInstance{ + application: impl.Application, + machineImpl: impl, + } +} + +// ------------------------------------------------------------------------------------------------ + +type MockSupervisor struct { + StopCalled atomic.Bool + FatalError atomic.Pointer[error] +} + +func newMockSupervisor() *MockSupervisor { + return &MockSupervisor{} +} + +func (mock *MockSupervisor) Fatal(err error) { + mock.FatalError.CompareAndSwap(nil, &err) + mock.Stop() +} + +func (mock *MockSupervisor) Stop() bool { + return mock.StopCalled.CompareAndSwap(false, true) +} + +func (mock *MockSupervisor) String() string { return "mock-supervisor" } +func (mock *MockSupervisor) Logger() *slog.Logger { return slog.Default() } +func (mock *MockSupervisor) Alive() bool { return !mock.StopCalled.Load() } +func (mock *MockSupervisor) NotReady() []string { + if mock.StopCalled.Load() { + return []string{mock.String()} + } + return nil +} +func (mock *MockSupervisor) Serve() error { return nil } +func (mock *MockSupervisor) Close() {} + +type MockMachineManager struct { + Map map[int64]*MockMachineInstance + UpdateMachinesError error + PendingApplicationFailures bool + RecordedApplicationFailures map[int64]string +} + +func newMockMachineManager() *MockMachineManager { + return &MockMachineManager{ + Map: map[int64]*MockMachineInstance{}, + RecordedApplicationFailures: map[int64]string{}, + } +} + +func (mock *MockMachineManager) GetMachine(appID int64) (manager.MachineInstance, bool) { + instance, exists := mock.Map[appID] + if !exists { + return nil, false + } + return instance, true +} + +func (mock *MockMachineManager) UpdateMachines(_ context.Context) error { + if mock.UpdateMachinesError != nil { + return mock.UpdateMachinesError + } + for id, instance := range mock.Map { + status := instance.application.Status + if status != "" && status != ApplicationStatus_OK { + delete(mock.Map, id) + } + } + return nil +} + +func (mock *MockMachineManager) FenceApplicationFailure(app *Application, reason string) { + if mock.RecordedApplicationFailures == nil { + mock.RecordedApplicationFailures = map[int64]string{} + } + mock.RecordedApplicationFailures[app.ID] = appstatus.NormalizeReason(reason) + mock.PendingApplicationFailures = true +} + +func (mock *MockMachineManager) Applications() []*Application { + apps := make([]*Application, 0, len(mock.Map)) + for _, v := range mock.Map { + apps = append(apps, v.application) + } + sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID }) + return apps +} + +func (mock *MockMachineManager) HasMachine(appID int64) bool { + _, exists := mock.Map[appID] + return exists +} + +func (mock *MockMachineManager) HasPendingApplicationFailures() bool { + return mock.PendingApplicationFailures +} + +func (mock *MockMachineManager) Close() {} + +// MockMachineInstance is a test implementation of manager.MachineInstance +type MockMachineInstance struct { + application *Application + machineImpl *MockMachineImpl + createSnapshotError error + destroyAfterSnapshotError bool + closeError error + closeCalls int + advanceCalls int +} + +// Advance implements the MachineInstance interface for testing +func (m *MockMachineInstance) Advance( + ctx context.Context, input []byte, epochIndex uint64, index uint64, leafs bool, +) (*AdvanceResult, error) { + m.advanceCalls++ + return m.machineImpl.Advance(ctx, input, epochIndex, index, leafs) +} + +// Inspect implements the MachineInstance interface for testing +func (m *MockMachineInstance) Inspect(_ context.Context, _ []byte) (*manager.InspectResult, error) { + // Not used in advancer tests, but needed to satisfy the interface + return nil, nil +} + +// Application returns the application associated with this machine +func (m *MockMachineInstance) Application() *Application { + return m.application +} + +func (m *MockMachineInstance) ProcessedInputs() uint64 { + return m.machineImpl.processedInputs +} + +func (m *MockMachineInstance) StateProof(_ context.Context) (*StateProof, error) { + if m.machineImpl.StateProofError != nil { + return nil, m.machineImpl.StateProofError + } + return m.machineImpl.StateProofReturn, nil +} + +// CreateSnapshot implements the MachineInstance interface for testing +func (m *MockMachineInstance) CreateSnapshot(_ context.Context, _ uint64, _ string) error { + if m.createSnapshotError != nil && m.destroyAfterSnapshotError { + m.machineImpl.AdvanceError = manager.ErrMachineClosed + return errors.Join(manager.ErrMachineClosed, m.createSnapshotError) + } + return m.createSnapshotError +} + +// Retrieves the hash of the current machine state +func (m *MockMachineInstance) Hash(_ context.Context) ([32]byte, error) { + // Not used in advancer tests, but needed to satisfy the interface + return [32]byte{}, nil +} + +// Close implements the MachineInstance interface for testing +func (m *MockMachineInstance) Close() error { + m.closeCalls++ + return m.closeError +} + +// ------------------------------------------------------------------------------------------------ + +type MockRepository struct { + EpochFilters []repository.EpochFilter + Tournaments map[common.Address]*Tournament + TournamentReadError error + TournamentReads []common.Address + GetEpochsReturn map[common.Address][]*Epoch + GetEpochsError error + GetEpochsBlock bool + GetInputsReturn map[common.Address][]*Input + GetInputsError error + GetInputsBlock bool + StoreAdvanceHook func(context.Context) error + StoreAdvanceError error + StoreAdvanceCommitError error + StoreAdvanceFailCount int + UpdateApplicationStatusError error + UpdateEpochsError error + GetLastSnapshotReturn *Input + GetLastSnapshotError error + GetEpochReturn *Epoch + GetEpochError error + GetLastInputReturn *Input + GetLastInputError error + GetLastProcessedInputReturn *Input + GetLastProcessedInputError error + UpdateSnapshotURIError error + + StoredResults []*AdvanceResult + StoredAppIDs []int64 + ApplicationStatusUpdates int + LastApplicationStatus ApplicationStatus + LastApplicationStatusReason *string + PublishedStateProof *StateProof + SnapshotURIUpdated bool + EpochInputsProcessedCount int + + mu sync.Mutex +} + +func (mock *MockRepository) ListEpochs( + ctx context.Context, + nameOrAddress string, + filter repository.EpochFilter, + _ repository.Pagination, + _ bool, +) ([]*Epoch, uint64, error) { + mock.mu.Lock() + mock.EpochFilters = append(mock.EpochFilters, filter) + mock.mu.Unlock() + // Check for context cancellation + if ctx.Err() != nil { + return nil, 0, ctx.Err() + } + + // If GetEpochsBlock is true, block until context is canceled + if mock.GetEpochsBlock { + <-ctx.Done() + return nil, 0, ctx.Err() + } + + address := common.HexToAddress(nameOrAddress) + return mock.GetEpochsReturn[address], uint64(len(mock.GetEpochsReturn[address])), mock.GetEpochsError +} + +func (mock *MockRepository) ListInputs( + ctx context.Context, + nameOrAddress string, + f repository.InputFilter, + p repository.Pagination, + _ bool, +) ([]*Input, uint64, error) { + // Check for context cancellation + if ctx.Err() != nil { + return nil, 0, ctx.Err() + } + + // If GetInputsBlock is true, block until context is canceled + if mock.GetInputsBlock { + <-ctx.Done() + return nil, 0, ctx.Err() + } + + address := common.HexToAddress(nameOrAddress) + inputs := mock.GetInputsReturn[address] + + // Filter by epoch if specified (production code always sets this). + if f.EpochIndex != nil { + var filtered []*Input + for _, inp := range inputs { + if inp.EpochIndex == *f.EpochIndex { + filtered = append(filtered, inp) + } + } + inputs = filtered + } + + total := uint64(len(inputs)) + + // Apply pagination if a limit is set + if p.Limit > 0 { + start := p.Offset + if start >= total { + return nil, total, mock.GetInputsError + } + end := start + p.Limit + if end > total { + end = total + } + inputs = inputs[start:end] + } + + return inputs, total, mock.GetInputsError +} + +func (mock *MockRepository) StoreAdvanceResult( + ctx context.Context, + appID int64, + res *AdvanceResult, +) error { + if mock.StoreAdvanceHook != nil { + return mock.StoreAdvanceHook(ctx) + } + // Check for context cancellation + if ctx.Err() != nil { + return ctx.Err() + } + + // Thread-safe operations + mock.mu.Lock() + defer mock.mu.Unlock() + + // For temporary failure testing + if mock.StoreAdvanceFailCount > 0 { + mock.StoreAdvanceFailCount-- + return errors.New("temporary failure") + } + + // Model an atomic transaction failure before commit: neither the canonical + // result nor the pending-input cursor changes. + if mock.StoreAdvanceError != nil { + return mock.StoreAdvanceError + } + + mock.StoredResults = append(mock.StoredResults, res) + mock.StoredAppIDs = append(mock.StoredAppIDs, appID) + + // Simulate real behavior: processed inputs change status and are no longer + // returned by queries filtering for unprocessed (Status_None) inputs. + // This prevents infinite loops in batched fetching. + for addr, inputs := range mock.GetInputsReturn { + for i, inp := range inputs { + if inp.EpochApplicationID == appID && inp.Index == res.InputIndex { + newInputs := make([]*Input, 0, len(inputs)-1) + newInputs = append(newInputs, inputs[:i]...) + newInputs = append(newInputs, inputs[i+1:]...) + mock.GetInputsReturn[addr] = newInputs + break + } + } + } + + // Model a committed transaction whose success response was lost. The + // canonical result and pending-input cursor changed together. + return mock.StoreAdvanceCommitError +} + +func (mock *MockRepository) UpdateEpochInputsProcessed( + ctx context.Context, + _ string, + _ uint64, + proof *StateProof, +) error { + // Check for context cancellation + if ctx.Err() != nil { + return ctx.Err() + } + + mock.EpochInputsProcessedCount++ + mock.PublishedStateProof = proof + return mock.UpdateEpochsError +} + +func (mock *MockRepository) UpdateApplicationStatus(ctx context.Context, _ int64, status ApplicationStatus, reason *string) error { + // Check for context cancellation + if ctx.Err() != nil { + return ctx.Err() + } + + mock.ApplicationStatusUpdates++ + mock.LastApplicationStatus = status + mock.LastApplicationStatusReason = reason + return mock.UpdateApplicationStatusError +} + +func (mock *MockRepository) GetEpoch(ctx context.Context, _ string, _ uint64) (*Epoch, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if mock.GetEpochError != nil { + return nil, mock.GetEpochError + } + if mock.GetEpochReturn != nil { + return mock.GetEpochReturn, nil + } + return &Epoch{Status: EpochStatus_Closed}, nil +} + +func (mock *MockRepository) GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if mock.GetLastInputError != nil { + return nil, mock.GetLastInputError + } + if mock.GetLastInputReturn != nil { + return mock.GetLastInputReturn, nil + } + + address := common.HexToAddress(appAddress) + inputs := mock.GetInputsReturn[address] + if len(inputs) == 0 { + return nil, nil + } + + var lastInput *Input + for _, input := range inputs { + if input.EpochIndex == epochIndex && (lastInput == nil || input.Index > lastInput.Index) { + lastInput = input + } + } + + return lastInput, nil +} + +func (mock *MockRepository) GetLastProcessedInput(ctx context.Context, appAddress string) (*Input, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if mock.GetLastProcessedInputError != nil { + return nil, mock.GetLastProcessedInputError + } + if mock.GetLastProcessedInputReturn != nil { + return mock.GetLastProcessedInputReturn, nil + } + + address := common.HexToAddress(appAddress) + inputs := mock.GetInputsReturn[address] + if len(inputs) == 0 { + return nil, nil + } + + var lastInput *Input + for _, input := range inputs { + if input.Status != InputCompletionStatus_None && (lastInput == nil || input.Index > lastInput.Index) { + lastInput = input + } + } + + return lastInput, nil +} + +func (mock *MockRepository) UpdateInputSnapshotURI(ctx context.Context, _ int64, _ uint64, _ string) error { + if ctx.Err() != nil { + return ctx.Err() + } + mock.SnapshotURIUpdated = true + return mock.UpdateSnapshotURIError +} + +func (mock *MockRepository) GetLastSnapshot(ctx context.Context, _ string) (*Input, error) { + // Check for context cancellation + if ctx.Err() != nil { + return nil, ctx.Err() + } + + return mock.GetLastSnapshotReturn, mock.GetLastSnapshotError +} + +// ------------------------------------------------------------------------------------------------ + +func randomAddress() common.Address { + address := make([]byte, 20) + _, err := crand.Read(address) + if err != nil { + panic(err) + } + return common.BytesToAddress(address) +} + +func randomHash() common.Hash { + hash := make([]byte, 32) + _, err := crand.Read(hash) + if err != nil { + panic(err) + } + return common.BytesToHash(hash) +} + +func randomCompleteStateProof() *StateProof { + proof := &StateProof{ + TxBufferDataBlock: randomHash(), + MachineHash: randomHash(), + IflagsYDataBlock: randomHash(), + HtifTohostDataBlock: randomHash(), + } + for range StateProofSiblingCount { + proof.TxBufferProof = append(proof.TxBufferProof, randomHash()) + proof.IflagsYProof = append(proof.IflagsYProof, randomHash()) + proof.HtifTohostProof = append(proof.HtifTohostProof, randomHash()) + } + return proof +} + +func randomBytes() []byte { + size := mrand.Intn(100) + 1 + bytes := make([]byte, size) + _, err := crand.Read(bytes) + if err != nil { + panic(err) + } + return bytes +} + +func randomSliceOfBytes() [][]byte { + size := mrand.Intn(10) + 1 + slice := make([][]byte, size) + for i := range size { + slice[i] = randomBytes() + } + return slice +} + +func newInput(appID int64, epochIndex uint64, inputIndex uint64, data []byte) *Input { + return &Input{ + EpochApplicationID: appID, + EpochIndex: epochIndex, + Index: inputIndex, + RawData: data, + } +} + +func randomInputs(appID int64, epochIndex uint64, size int) []*Input { + slice := make([]*Input, size) + for i := range size { + slice[i] = newInput(appID, epochIndex, uint64(i), randomBytes()) + } + return slice +} + +func randomAdvanceResult(inputIndex uint64) *AdvanceResult { + res := &AdvanceResult{ + InputIndex: inputIndex, + Status: InputCompletionStatus_Accepted, + Outputs: randomSliceOfBytes(), + Reports: randomSliceOfBytes(), + StateProof: StateProof{ + TxBufferDataBlock: randomHash(), + MachineHash: randomHash(), + }, + } + return res +} + +func marshal(res *AdvanceResult) []byte { + data, err := json.Marshal(*res) + if err != nil { + panic(err) + } + return data +} + +func (s *AdvancerSuite) TestStoreAdvanceShutdownClassification() { + storageErr := errors.New("storage failed") + closeErr := errors.New("machine close failed") + for _, tc := range []struct { + name string + cancel bool + storeErr error + closeErr error + fatalErr error + }{ + {"shutdown cancellation", true, fmt.Errorf("store: %w", context.Canceled), nil, nil}, + {"independent cancellation", false, context.Canceled, nil, context.Canceled}, + {"storage failure", false, storageErr, nil, storageErr}, + {"storage failure during shutdown", true, storageErr, nil, storageErr}, + {"mixed storage failure during shutdown", true, errors.Join(context.Canceled, storageErr), nil, storageErr}, + {"nested storage failure during shutdown", true, + fmt.Errorf("store: %w", errors.Join(storageErr, context.Canceled)), nil, storageErr}, + {"deadline during shutdown", true, errors.Join(context.Canceled, context.DeadlineExceeded), nil, context.DeadlineExceeded}, + {"joined shutdown cancellation", true, errors.Join(context.Canceled, context.Canceled), nil, nil}, + {"close failure during shutdown", true, context.Canceled, closeErr, closeErr}, + } { + s.Run(tc.name, func() { + require := s.Require() + env := s.setupOneApp() + ctx, cancel := context.WithCancel(s.T().Context()) + defer cancel() + machine := env.mm.Map[env.app.Application.ID] + machine.closeError = tc.closeErr + env.repo.StoreAdvanceHook = func(storeCtx context.Context) error { + require.NoError(storeCtx.Err(), "cancellation must happen during persistence") + require.Equal(1, machine.advanceCalls) + if tc.cancel { + cancel() + } + return tc.storeErr + } + pending := newInput(env.app.Application.ID, 0, 0, marshal(randomAdvanceResult(0))) + address := env.app.Application.IApplicationAddress + env.repo.GetInputsReturn = map[common.Address][]*Input{address: {pending}} + processed, stopped, err := env.service.processInputs(ctx, env.app.Application, []*Input{ + pending, newInput(env.app.Application.ID, 0, 1, []byte("unreachable")), + }) + require.ErrorIs(err, tc.storeErr) + require.Zero(processed) + require.False(stopped) + require.Equal(1, machine.closeCalls) + require.Equal(1, machine.advanceCalls) + require.Empty(env.repo.StoredResults) + require.Equal([]*Input{pending}, env.repo.GetInputsReturn[address]) + fatal := env.supervisor.FatalError.Load() + if tc.fatalErr == nil { + require.Nil(fatal, "shutdown cancellation must not become fatal") + } else { + require.NotNil(fatal) + require.ErrorIs(*fatal, tc.fatalErr) + if tc.closeErr != nil { + require.ErrorIs(err, tc.closeErr) + require.NotErrorIs(*fatal, context.Canceled) + } + } + }) + } +} + +func (mock *MockRepository) GetTournament(_ context.Context, _ string, address string) (*Tournament, error) { + root := common.HexToAddress(address) + mock.mu.Lock() + defer mock.mu.Unlock() + mock.TournamentReads = append(mock.TournamentReads, root) + return mock.Tournaments[root], mock.TournamentReadError +} diff --git a/internal/advancer/determinism_test.go b/internal/advancer/determinism_test.go new file mode 100644 index 000000000..24d7082d5 --- /dev/null +++ b/internal/advancer/determinism_test.go @@ -0,0 +1,1010 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/manager" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/machine" + pkgservice "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const determinismWaitTimeout = 5 * time.Second + +var errDeterminismRuntimeClosed = errors.New("determinism runtime is closed") + +func TestProcessInputs_RetryFromNonzeroPredecessorMatchesUninterruptedResult(t *testing.T) { + tests := []struct { + name string + targetPayload []byte + wantStatus model.InputCompletionStatus + }{ + { + name: "accepted", + targetPayload: []byte("deposit:alice:17"), + wantStatus: model.InputCompletionStatus_Accepted, + }, + { + name: "rejected", + targetPayload: []byte("reject:malformed-deposit"), + wantStatus: model.InputCompletionStatus_Rejected, + }, + { + name: "exception", + targetPayload: []byte("exception:application-error"), + wantStatus: model.InputCompletionStatus_Exception, + }, + { + name: "machine halted", + targetPayload: []byte("halt:application-finished"), + wantStatus: model.InputCompletionStatus_MachineHalted, + }, + { + name: "mcycle overflow", + targetPayload: []byte("overflow:cycle-ceiling"), + wantStatus: model.InputCompletionStatus_Overflow, + }, + { + name: "unexpected yield", + targetPayload: []byte("unexpected-yield:unknown-reason"), + wantStatus: model.InputCompletionStatus_UnexpectedYield, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix, predecessor, target := determinismBaseline(t, tt.targetPayload) + requireDeterminismTarget(t, prefix, target, tt.targetPayload, tt.wantStatus) + + t.Run("pre-canceled calls do not fork", func(t *testing.T) { + testDeterminismEarlyInterruptions(t, prefix, target, tt.targetPayload) + }) + t.Run("caller cancellation after mutation", func(t *testing.T) { + testDeterminismCallerInterruptions( + t, prefix, target, tt.targetPayload, context.Canceled, 1, + ) + }) + t.Run("caller deadline reconstructs predecessor across retries", func(t *testing.T) { + testDeterminismCallerDeadlines( + t, prefix, predecessor, target, tt.targetPayload, 3, + ) + }) + t.Run("infrastructure retry reconstructs predecessor", func(t *testing.T) { + testDeterminismInfrastructureRetries( + t, prefix, predecessor, target, tt.targetPayload, 2, + ) + }) + for _, deadline := range []time.Duration{100 * time.Millisecond, 250 * time.Millisecond} { + t.Run("manager watchdog "+deadline.String(), func(t *testing.T) { + testDeterminismManagerWatchdog( + t, prefix, predecessor, target, tt.targetPayload, deadline, + ) + }) + } + }) + } +} + +func determinismBaseline( + t *testing.T, + targetPayload []byte, +) (*model.AdvanceResult, determinismMachineState, *model.AdvanceResult) { + t.Helper() + repo := &MockRepository{} + harness := newTemplateDeterminismHarness( + t, repo, determinismWaitTimeout, + determinismAdvanceSuccess, + determinismAdvanceSuccess, + ) + + require.NoError(t, harness.process(context.Background(), 0, []byte("deposit:prefix:11"))) + require.Len(t, repo.StoredResults, 1) + prefix := cloneDeterminismResult(repo.StoredResults[0]) + predecessor := harness.liveState(t) + require.Equal(t, uint64(1), predecessor.step) + + require.NoError(t, harness.process(context.Background(), 1, targetPayload)) + require.Len(t, repo.StoredResults, 2) + target := cloneDeterminismResult(repo.StoredResults[1]) + require.Equal(t, uint64(2), harness.instance.ProcessedInputs()) + if target.Status.IsTerminal() { + _, err := harness.instance.Hash(context.Background()) + require.ErrorIs(t, err, manager.ErrMachineClosed) + } else { + require.Equal(t, machine.Hash(target.MachineHash), harness.runtimeHash(t)) + } + return prefix, predecessor, target +} + +func testDeterminismEarlyInterruptions( + t *testing.T, + wantPrefix *model.AdvanceResult, + wantTarget *model.AdvanceResult, + targetPayload []byte, +) { + t.Helper() + repo := &MockRepository{} + harness := newTemplateDeterminismHarness( + t, repo, determinismWaitTimeout, + determinismAdvanceSuccess, + determinismAdvanceSuccess, + ) + require.NoError(t, harness.process(context.Background(), 0, []byte("deposit:prefix:11"))) + require.Equal(t, wantPrefix, repo.StoredResults[0]) + predecessor := harness.liveRuntime(t) + predecessorState := predecessor.snapshot() + forksBefore := harness.factory.forkCount() + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + require.ErrorIs(t, harness.process(canceled, 1, targetPayload), context.Canceled) + + expired, cancelDeadline := context.WithDeadline( + context.Background(), time.Now().Add(-time.Second), + ) + defer cancelDeadline() + require.ErrorIs(t, harness.process(expired, 1, targetPayload), context.DeadlineExceeded) + + require.Equal(t, forksBefore, harness.factory.forkCount(), + "an already-finished caller context must be rejected before Fork") + requireCanonicalPrefix(t, repo, wantPrefix) + require.Equal(t, uint64(1), harness.instance.ProcessedInputs()) + require.False(t, predecessor.isClosed()) + require.Equal(t, predecessorState, harness.liveState(t)) + + require.NoError(t, harness.process(context.Background(), 1, targetPayload)) + requireCanonicalHistory(t, repo, wantPrefix, wantTarget) + requireSuccessfulRetryState(t, harness, predecessor, wantTarget) +} + +func testDeterminismCallerInterruptions( + t *testing.T, + wantPrefix *model.AdvanceResult, + wantTarget *model.AdvanceResult, + targetPayload []byte, + wantErr error, + attempts int, +) { + t.Helper() + behaviors := []determinismAdvanceBehavior{determinismAdvanceSuccess} + for range attempts { + behaviors = append(behaviors, determinismAdvanceWaitForContext) + } + behaviors = append(behaviors, determinismAdvanceSuccess) + repo := &MockRepository{} + harness := newTemplateDeterminismHarness( + t, repo, determinismWaitTimeout, behaviors..., + ) + require.NoError(t, harness.process(context.Background(), 0, []byte("deposit:prefix:11"))) + require.Equal(t, wantPrefix, repo.StoredResults[0]) + predecessor := harness.liveRuntime(t) + predecessorState := predecessor.snapshot() + + for range attempts { + controlled := newDeterminismContext(context.Background()) + defer controlled.finish(wantErr) + errCh := make(chan error, 1) + go func() { errCh <- harness.process(controlled, 1, targetPayload) }() + + fork := harness.waitForMutation(t) + controlled.finish(wantErr) + require.ErrorIs(t, waitDeterminismError(t, errCh), wantErr) + requireCanonicalPrefix(t, repo, wantPrefix) + require.Equal(t, uint64(1), harness.instance.ProcessedInputs()) + require.Zero(t, repo.ApplicationStatusUpdates, + "caller-owned interruptions must not mark the application failed") + require.False(t, predecessor.isClosed(), + "caller interruption must leave the reusable predecessor open") + require.Equal(t, predecessorState, harness.liveState(t)) + requireDiscardedMutatedFork(t, fork, predecessorState) + } + + require.NoError(t, harness.process(context.Background(), 1, targetPayload)) + requireCanonicalHistory(t, repo, wantPrefix, wantTarget) + requireSuccessfulRetryState(t, harness, predecessor, wantTarget) +} + +func testDeterminismCallerDeadlines( + t *testing.T, + wantPrefix *model.AdvanceResult, + predecessor determinismMachineState, + wantTarget *model.AdvanceResult, + targetPayload []byte, + attempts int, +) { + t.Helper() + repo := repositoryWithDeterminismPrefix(wantPrefix) + + for range attempts { + harness := newSnapshotDeterminismHarness( + t, repo, predecessor, determinismWaitTimeout, + determinismAdvanceWaitForContext, + ) + controlled := newDeterminismContext(context.Background()) + errCh := make(chan error, 1) + go func() { errCh <- harness.process(controlled, 1, targetPayload) }() + + fork := harness.waitForMutation(t) + controlled.finish(context.DeadlineExceeded) + require.ErrorIs(t, waitDeterminismError(t, errCh), context.DeadlineExceeded) + requireCanonicalPrefix(t, repo, wantPrefix) + require.Equal(t, uint64(1), harness.instance.ProcessedInputs()) + require.Zero(t, repo.ApplicationStatusUpdates, + "the expired context prevents the immediate FAILED status write") + require.True(t, harness.provider.HasPendingApplicationFailures()) + require.Equal( + t, + context.DeadlineExceeded.Error(), + harness.provider.failureReason(harness.app.ID), + ) + require.Equal(t, predecessor, harness.factory.base.snapshot()) + require.True(t, harness.factory.base.isClosed(), + "a timed-out advance must close the changed live machine") + _, err := harness.factory.base.Fork(context.Background()) + require.ErrorIs(t, err, errDeterminismRuntimeClosed) + requireDiscardedMutatedFork(t, fork, predecessor) + } + + recovered := newSnapshotDeterminismHarness( + t, repo, predecessor, determinismWaitTimeout, determinismAdvanceSuccess, + ) + require.NoError(t, recovered.process(context.Background(), 1, targetPayload)) + requireCanonicalHistory(t, repo, wantPrefix, wantTarget) + requireSuccessfulRetryState(t, recovered, recovered.factory.base, wantTarget) +} + +func testDeterminismInfrastructureRetries( + t *testing.T, + wantPrefix *model.AdvanceResult, + predecessor determinismMachineState, + wantTarget *model.AdvanceResult, + targetPayload []byte, + attempts int, +) { + t.Helper() + repo := repositoryWithDeterminismPrefix(wantPrefix) + + for range attempts { + harness := newSnapshotDeterminismHarness( + t, repo, predecessor, determinismWaitTimeout, + determinismAdvanceInfrastructureFailure, + ) + err := harness.process(context.Background(), 1, targetPayload) + require.ErrorIs(t, err, machine.ErrMachineInternal) + requireCanonicalPrefix(t, repo, wantPrefix) + require.Equal(t, uint64(1), harness.instance.ProcessedInputs()) + require.Equal(t, predecessor, harness.factory.base.snapshot()) + require.True(t, harness.factory.base.isClosed(), + "advancer must close the failed live runtime") + _, err = harness.factory.base.Fork(context.Background()) + require.ErrorIs(t, err, errDeterminismRuntimeClosed) + requireDiscardedMutatedFork(t, harness.lastFork(t), predecessor) + } + require.Equal(t, attempts, repo.ApplicationStatusUpdates) + require.Equal(t, model.ApplicationStatus_Failed, repo.LastApplicationStatus) + + recovered := newSnapshotDeterminismHarness( + t, repo, predecessor, determinismWaitTimeout, determinismAdvanceSuccess, + ) + require.NoError(t, recovered.process(context.Background(), 1, targetPayload)) + requireCanonicalHistory(t, repo, wantPrefix, wantTarget) + requireSuccessfulRetryState(t, recovered, recovered.factory.base, wantTarget) +} + +func testDeterminismManagerWatchdog( + t *testing.T, + wantPrefix *model.AdvanceResult, + predecessor determinismMachineState, + wantTarget *model.AdvanceResult, + targetPayload []byte, + deadline time.Duration, +) { + t.Helper() + repo := repositoryWithDeterminismPrefix(wantPrefix) + harness := newSnapshotDeterminismHarness( + t, repo, predecessor, deadline, determinismAdvanceWaitForContext, + ) + parent := context.Background() + errCh := make(chan error, 1) + go func() { errCh <- harness.process(parent, 1, targetPayload) }() + + err := waitDeterminismError(t, errCh) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NoError(t, parent.Err(), "the manager watchdog, not the caller, must expire") + requireCanonicalPrefix(t, repo, wantPrefix) + require.Equal(t, uint64(1), harness.instance.ProcessedInputs()) + require.Equal(t, 1, repo.ApplicationStatusUpdates) + require.Equal(t, model.ApplicationStatus_Failed, repo.LastApplicationStatus) + require.Equal(t, predecessor, harness.factory.base.snapshot()) + require.True(t, harness.factory.base.isClosed()) + requireDiscardedMutatedFork(t, harness.lastFork(t), predecessor) + + recovered := newSnapshotDeterminismHarness( + t, repo, predecessor, determinismWaitTimeout, determinismAdvanceSuccess, + ) + require.NoError(t, recovered.process(context.Background(), 1, targetPayload)) + requireCanonicalHistory(t, repo, wantPrefix, wantTarget) + requireSuccessfulRetryState(t, recovered, recovered.factory.base, wantTarget) +} + +func requireDeterminismTarget( + t *testing.T, + prefix *model.AdvanceResult, + target *model.AdvanceResult, + targetPayload []byte, + wantStatus model.InputCompletionStatus, +) { + t.Helper() + require.Equal(t, uint64(7), target.EpochIndex) + require.Equal(t, uint64(1), target.InputIndex) + require.Equal(t, wantStatus, target.Status) + require.True(t, target.IsDaveConsensus) + require.Len(t, target.PeriodicStateHashes, 2, "a PRT result must retain its periodic state hashes") + require.Equal(t, machine.InputEntryCapacity-uint64(len(target.PeriodicStateHashes)), target.PaddingRepetitions) + + require.Equal(t, [][]byte{append([]byte("report:"), targetPayload...)}, target.Reports) + if wantStatus == model.InputCompletionStatus_Accepted { + require.True(t, target.IsComplete()) + require.Equal(t, [][]byte{append([]byte("output:"), targetPayload...)}, target.Outputs) + require.NotEqual(t, prefix.MachineHash, target.MachineHash) + require.NotEqual(t, prefix.TxBufferDataBlock, target.TxBufferDataBlock) + return + } + + require.Empty(t, target.Outputs, "outputs are canonical only for accepted inputs") + if wantStatus.IsTerminal() { + require.True(t, target.IsComplete(), + "a terminal result must preserve its actual post-run proof") + require.NotEqual(t, prefix.MachineHash, target.MachineHash, + "a terminal completion must preserve its post-run machine root") + require.NotEqual(t, prefix.TxBufferDataBlock, target.TxBufferDataBlock, + "a terminal completion must preserve its post-run TX buffer") + } else { + require.True(t, target.IsComplete()) + require.Equal(t, prefix.MachineHash, target.MachineHash, + "a rejected candidate must not replace the predecessor") + } + if !wantStatus.IsTerminal() { + require.Equal(t, prefix.TxBufferDataBlock, target.TxBufferDataBlock) + } + if !wantStatus.IsTerminal() { + require.Equal(t, prefix.StateProof, target.StateProof) + } +} + +func requireDiscardedMutatedFork( + t *testing.T, + fork *determinismRuntime, + predecessor determinismMachineState, +) { + t.Helper() + require.True(t, fork.isClosed(), "the mutated candidate must be discarded") + require.NotEqual(t, predecessor.machineHash, fork.snapshot().machineHash, + "the injected failure must happen after candidate state changes") + _, err := fork.Hash(context.Background()) + require.ErrorIs(t, err, errDeterminismRuntimeClosed) +} + +func requireSuccessfulRetryState( + t *testing.T, + harness *determinismHarness, + predecessor *determinismRuntime, + wantTarget *model.AdvanceResult, +) { + t.Helper() + require.Equal(t, uint64(2), harness.instance.ProcessedInputs()) + lastCandidate := harness.lastFork(t) + if wantTarget.Status.IsTerminal() { + _, err := harness.instance.Hash(context.Background()) + require.ErrorIs(t, err, manager.ErrMachineClosed) + require.True(t, predecessor.isClosed()) + require.True(t, lastCandidate.isClosed(), "the terminal candidate must be disposed") + return + } + require.Equal(t, machine.Hash(wantTarget.MachineHash), harness.runtimeHash(t)) + if wantTarget.Status == model.InputCompletionStatus_Accepted { + require.True(t, predecessor.isClosed()) + require.False(t, lastCandidate.isClosed(), "the state-producing candidate must be adopted") + return + } + require.False(t, predecessor.isClosed(), "rejection must keep the predecessor live") + require.True(t, lastCandidate.isClosed(), "the rejected candidate must be discarded") +} + +func requireCanonicalPrefix( + t *testing.T, + repo *MockRepository, + wantPrefix *model.AdvanceResult, +) { + t.Helper() + require.Len(t, repo.StoredResults, 1, + "an interrupted input must not add a canonical result") + require.Equal(t, wantPrefix, repo.StoredResults[0]) +} + +func requireCanonicalHistory( + t *testing.T, + repo *MockRepository, + wantPrefix *model.AdvanceResult, + wantTarget *model.AdvanceResult, +) { + t.Helper() + require.Equal(t, []*model.AdvanceResult{wantPrefix, wantTarget}, repo.StoredResults) +} + +func repositoryWithDeterminismPrefix(prefix *model.AdvanceResult) *MockRepository { + return &MockRepository{StoredResults: []*model.AdvanceResult{cloneDeterminismResult(prefix)}} +} + +func waitDeterminismError(t *testing.T, errCh <-chan error) error { + t.Helper() + select { + case err := <-errCh: + return err + case <-time.After(determinismWaitTimeout): + t.Fatal("advance did not finish before the test timeout") + return nil + } +} + +func cloneDeterminismResult(result *model.AdvanceResult) *model.AdvanceResult { + clone := *result + clone.TxBufferProof = append([][32]byte(nil), result.TxBufferProof...) + clone.IflagsYProof = append([][32]byte(nil), result.IflagsYProof...) + clone.HtifTohostProof = append([][32]byte(nil), result.HtifTohostProof...) + clone.Outputs = cloneDeterminismBytes(result.Outputs) + clone.Reports = cloneDeterminismBytes(result.Reports) + clone.ExceptionData = append([]byte(nil), result.ExceptionData...) + clone.PeriodicStateHashes = append([][32]byte(nil), result.PeriodicStateHashes...) + return &clone +} + +func cloneDeterminismBytes(values [][]byte) [][]byte { + if values == nil { + return nil + } + clone := make([][]byte, len(values)) + for i := range values { + clone[i] = append([]byte(nil), values[i]...) + } + return clone +} + +type determinismHarness struct { + app *model.Application + instance manager.MachineInstance + service *Service + factory *determinismRuntimeFactory + provider *determinismMachineProvider +} + +func newTemplateDeterminismHarness( + t *testing.T, + repo *MockRepository, + advanceDeadline time.Duration, + behaviors ...determinismAdvanceBehavior, +) *determinismHarness { + t.Helper() + return newDeterminismHarness( + t, repo, newDeterminismMachineState(), 0, advanceDeadline, behaviors..., + ) +} + +func newSnapshotDeterminismHarness( + t *testing.T, + repo *MockRepository, + predecessor determinismMachineState, + advanceDeadline time.Duration, + behaviors ...determinismAdvanceBehavior, +) *determinismHarness { + t.Helper() + return newDeterminismHarness( + t, repo, predecessor, 1, advanceDeadline, behaviors..., + ) +} + +func newDeterminismHarness( + t *testing.T, + repo *MockRepository, + start determinismMachineState, + processedInputs uint64, + advanceDeadline time.Duration, + behaviors ...determinismAdvanceBehavior, +) *determinismHarness { + t.Helper() + app := &model.Application{ + ID: 1, + Name: "deterministic-input-statuses", + IApplicationAddress: common.HexToAddress("0x1"), + ConsensusType: model.Consensus_PRT, + Enabled: true, + Status: model.ApplicationStatus_OK, + ProcessedInputs: processedInputs, + ExecutionParameters: model.ExecutionParameters{ + SnapshotPolicy: model.SnapshotPolicy_None, + AdvanceMaxDeadline: advanceDeadline, + InspectMaxDeadline: determinismWaitTimeout, + LoadDeadline: determinismWaitTimeout, + StoreDeadline: determinismWaitTimeout, + MaxConcurrentInspects: 1, + }, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + factory := newDeterminismRuntimeFactory(start, behaviors...) + instance, err := manager.NewMachineInstanceWithFactory( + context.Background(), app, processedInputs, logger, factory, + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, instance.Close()) }) + provider := &determinismMachineProvider{app: app, instance: instance} + service := &Service{ + TickServiceTemplate: pkgservice.TickServiceTemplate{ + BaseTemplate: pkgservice.BaseTemplate{ + Logger: logger, + }, + }, + inputBatchSize: 500, + machineManager: provider, + repository: repo, + } + return &determinismHarness{ + app: app, instance: instance, service: service, factory: factory, provider: provider, + } +} + +func (h *determinismHarness) process(ctx context.Context, index uint64, payload []byte) error { + _, _, err := h.service.processInputs(ctx, h.app, []*model.Input{{ + EpochApplicationID: h.app.ID, + EpochIndex: 7, + Index: index, + RawData: append([]byte(nil), payload...), + }}) + return err +} + +func (h *determinismHarness) waitForMutation(t *testing.T) *determinismRuntime { + t.Helper() + select { + case fork := <-h.factory.mutated: + return fork + case <-time.After(determinismWaitTimeout): + t.Fatal("candidate did not reach the injected interruption point") + return nil + } +} + +func (h *determinismHarness) liveRuntime(t *testing.T) *determinismRuntime { + t.Helper() + if h.instance.ProcessedInputs() == 0 { + return h.factory.base + } + return h.factory.acceptedRuntime(t) +} + +func (h *determinismHarness) liveState(t *testing.T) determinismMachineState { + t.Helper() + return h.liveRuntime(t).snapshot() +} + +func (h *determinismHarness) lastFork(t *testing.T) *determinismRuntime { + t.Helper() + return h.factory.lastFork(t) +} + +func (h *determinismHarness) runtimeHash(t *testing.T) machine.Hash { + t.Helper() + hash, err := h.instance.Hash(context.Background()) + require.NoError(t, err) + return hash +} + +type determinismAdvanceBehavior uint8 + +const ( + determinismAdvanceSuccess determinismAdvanceBehavior = iota + determinismAdvanceWaitForContext + determinismAdvanceInfrastructureFailure +) + +type determinismMachineState struct { + step uint64 + machineHash machine.Hash + outputsHash machine.Hash + checkpointHash machine.Hash +} + +func newDeterminismMachineState() determinismMachineState { + machineHash := determinismHash("base-machine") + outputsHash := determinismHash("base-outputs") + return determinismMachineState{ + machineHash: machineHash, + outputsHash: outputsHash, + } +} + +func (s determinismMachineState) clone() determinismMachineState { + return s +} + +type determinismRuntimeFactory struct { + mu sync.Mutex + behaviors []determinismAdvanceBehavior + next int + start determinismMachineState + base *determinismRuntime + forks []*determinismRuntime + accepted *determinismRuntime + mutated chan *determinismRuntime +} + +func newDeterminismRuntimeFactory( + start determinismMachineState, + behaviors ...determinismAdvanceBehavior, +) *determinismRuntimeFactory { + return &determinismRuntimeFactory{ + behaviors: append([]determinismAdvanceBehavior(nil), behaviors...), + start: start.clone(), + mutated: make(chan *determinismRuntime, len(behaviors)+1), + } +} + +func (f *determinismRuntimeFactory) CreateMachineRuntime( + ctx context.Context, + _ *model.Application, + _ *slog.Logger, +) (machine.Machine, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + f.mu.Lock() + defer f.mu.Unlock() + f.base = &determinismRuntime{factory: f, state: f.start.clone()} + return f.base, nil +} + +func (f *determinismRuntimeFactory) fork(state determinismMachineState) *determinismRuntime { + f.mu.Lock() + defer f.mu.Unlock() + behavior := determinismAdvanceSuccess + if f.next < len(f.behaviors) { + behavior = f.behaviors[f.next] + } + f.next++ + child := &determinismRuntime{ + factory: f, + behavior: behavior, + state: state.clone(), + } + f.forks = append(f.forks, child) + return child +} + +func (f *determinismRuntimeFactory) recordAccepted(runtime *determinismRuntime) { + f.mu.Lock() + defer f.mu.Unlock() + f.accepted = runtime +} + +func (f *determinismRuntimeFactory) acceptedRuntime(t *testing.T) *determinismRuntime { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.NotNil(t, f.accepted) + return f.accepted +} + +func (f *determinismRuntimeFactory) lastFork(t *testing.T) *determinismRuntime { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.NotEmpty(t, f.forks) + return f.forks[len(f.forks)-1] +} + +func (f *determinismRuntimeFactory) forkCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.forks) +} + +type determinismRuntime struct { + mu sync.Mutex + factory *determinismRuntimeFactory + behavior determinismAdvanceBehavior + state determinismMachineState + closed bool +} + +func (m *determinismRuntime) Fork(ctx context.Context) (machine.Machine, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := m.checkOpenLocked(ctx); err != nil { + return nil, err + } + return m.factory.fork(m.state), nil +} + +func (m *determinismRuntime) Hash(ctx context.Context) (machine.Hash, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := m.checkOpenLocked(ctx); err != nil { + return machine.Hash{}, err + } + return m.state.machineHash, nil +} + +func (m *determinismRuntime) StateProof(ctx context.Context) (*machine.StateProof, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := m.checkOpenLocked(ctx); err != nil { + return nil, err + } + var iflagsYData machine.Hash + iflagsYData[8] = 1 + var htifTohostData machine.Hash + htifTohostData[20] = 1 + htifTohostData[22] = 1 + htifTohostData[23] = 2 + return &machine.StateProof{ + MachineHash: m.state.machineHash, + IflagsYProof: determinismValidityLeaf("iflags-y", iflagsYData), + HtifTohostProof: determinismValidityLeaf("htif-tohost", htifTohostData), + TxBufferProof: determinismValidityLeaf("tx-buffer", m.state.outputsHash), + }, nil +} + +func (m *determinismRuntime) Advance( + ctx context.Context, + input []byte, + checkpointHash machine.Hash, + computeHashes bool, +) (*machine.AdvanceResponse, error) { + m.mu.Lock() + if err := m.checkOpenLocked(ctx); err != nil { + m.mu.Unlock() + return nil, err + } + if !computeHashes { + m.mu.Unlock() + return nil, errors.New("determinism test requires PRT input hash collection") + } + if len(input) == 0 { + m.mu.Unlock() + return nil, errors.New("determinism test input must not be empty") + } + + // The revert root rides with the advance request and must be the machine's + // pre-input root, proved by the instance before execution. A mismatch is a + // harness (or caller) bug, not a determinism scenario. + if checkpointHash != m.state.machineHash { + m.mu.Unlock() + return nil, errors.New("determinism test requires the checkpoint hash to equal the pre-input machine root") + } + previous := m.state.clone() + // Recording the request's own revert root is the first mutation of the + // candidate, exactly like the CMIO response. + m.state.checkpointHash = checkpointHash + status := machine.CompletionStatusAccepted + switch { + case bytes.HasPrefix(input, []byte("reject:")): + status = machine.CompletionStatusRejected + case bytes.HasPrefix(input, []byte("exception:")): + status = machine.CompletionStatusException + case bytes.HasPrefix(input, []byte("halt:")): + status = machine.CompletionStatusHalted + case bytes.HasPrefix(input, []byte("overflow:")): + status = machine.CompletionStatusOverflow + case bytes.HasPrefix(input, []byte("unexpected-yield:")): + status = machine.CompletionStatusUnexpectedYield + } + output := append([]byte("output:"), input...) + report := append([]byte("report:"), input...) + firstHash := determinismHash("trace-before", previous.machineHash[:], input) + finalHash := determinismHash("trace-after", firstHash[:], input) + m.state.step++ + m.state.machineHash = determinismHash( + "machine", previous.machineHash[:], checkpointHash[:], input, + ) + m.state.outputsHash = determinismHash("outputs", previous.outputsHash[:], output) + hashes := []machine.Hash{firstHash, finalHash} + response := &machine.AdvanceResponse{ + Status: status, + Reports: []machine.Report{report}, + PeriodicStateHashes: hashes, + PaddingRepetitions: machine.InputEntryCapacity - uint64(len(hashes)), + } + response.FinalStateEntry = model.FinalStateEntry(m.state.machineHash) + switch status { + case machine.CompletionStatusRejected: + response.FinalStateEntry = model.FinalStateEntry(checkpointHash) + case machine.CompletionStatusAccepted: + response.Outputs = []machine.Output{output} + case machine.CompletionStatusException: + response.ExceptionData = append([]byte{}, input...) + case machine.CompletionStatusUnknown, machine.CompletionStatusHalted, + machine.CompletionStatusOverflow, machine.CompletionStatusUnexpectedYield, machine.CompletionStatusInvalidOutputsRoot: + } + behavior := m.behavior + m.mu.Unlock() + + if behavior == determinismAdvanceWaitForContext { + m.factory.mutated <- m + } + + switch behavior { + case determinismAdvanceSuccess: + if status == machine.CompletionStatusAccepted { + m.factory.recordAccepted(m) + } + return response, nil + case determinismAdvanceWaitForContext: + <-ctx.Done() + return nil, ctx.Err() + case determinismAdvanceInfrastructureFailure: + return nil, machine.ErrMachineInternal + default: + return nil, errors.New("unknown determinism advance behavior") + } +} + +func (m *determinismRuntime) Inspect( + ctx context.Context, + _ []byte, +) (*machine.InspectResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := m.checkOpenLocked(ctx); err != nil { + return &machine.InspectResponse{}, err + } + return &machine.InspectResponse{Status: machine.CompletionStatusRejected}, nil +} + +func (m *determinismRuntime) Store(ctx context.Context, _ string) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.checkOpenLocked(ctx) +} + +func (m *determinismRuntime) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + m.closed = true + return nil +} + +func (m *determinismRuntime) Address() string { return "determinism-runtime" } + +func (m *determinismRuntime) checkOpenLocked(ctx context.Context) error { + if m.closed { + return errDeterminismRuntimeClosed + } + return ctx.Err() +} + +// snapshot is test-only diagnostics; unlike the machine interface it remains +// readable after Close so tests can prove which state was closed or discarded. +func (m *determinismRuntime) snapshot() determinismMachineState { + m.mu.Lock() + defer m.mu.Unlock() + return m.state.clone() +} + +func (m *determinismRuntime) isClosed() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.closed +} + +func determinismHash(label string, values ...[]byte) machine.Hash { + h := sha256.New() + _, _ = h.Write([]byte(label)) + for _, value := range values { + _, _ = h.Write(value) + } + var result machine.Hash + copy(result[:], h.Sum(nil)) + return result +} + +func determinismValidityLeaf(label string, dataBlock machine.Hash) machine.LeafProof { + const canonicalMachineProofDepth = 59 + siblings := make([]machine.Hash, canonicalMachineProofDepth) + for i := range siblings { + siblings[i] = determinismHash(label, dataBlock[:], []byte{byte(i)}) + } + return machine.LeafProof{DataBlock: dataBlock, Siblings: siblings} +} + +type determinismMachineProvider struct { + app *model.Application + instance manager.MachineInstance + mu sync.Mutex + failures map[int64]string +} + +func (p *determinismMachineProvider) GetMachine(appID int64) (manager.MachineInstance, bool) { + if appID != p.app.ID { + return nil, false + } + return p.instance, true +} + +func (p *determinismMachineProvider) Applications() []*model.Application { + return []*model.Application{p.app} +} + +func (p *determinismMachineProvider) UpdateMachines(context.Context) error { return nil } + +func (p *determinismMachineProvider) FenceApplicationFailure(app *model.Application, reason string) { + p.mu.Lock() + defer p.mu.Unlock() + if p.failures == nil { + p.failures = map[int64]string{} + } + p.failures[app.ID] = reason +} + +func (p *determinismMachineProvider) HasPendingApplicationFailures() bool { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.failures) != 0 +} + +func (p *determinismMachineProvider) failureReason(appID int64) string { + p.mu.Lock() + defer p.mu.Unlock() + return p.failures[appID] +} + +func (p *determinismMachineProvider) HasMachine(appID int64) bool { return appID == p.app.ID } + +func (p *determinismMachineProvider) Close() { + err := p.instance.Close() + if err != nil { + panic(err) + } +} + +// determinismContext makes cancellation and deadline propagation controllable +// without relying on scheduler timing or short wall-clock deadlines. +type determinismContext struct { + parent context.Context + done chan struct{} + once sync.Once + mu sync.RWMutex + err error +} + +func newDeterminismContext(parent context.Context) *determinismContext { + return &determinismContext{parent: parent, done: make(chan struct{})} +} + +func (c *determinismContext) Deadline() (time.Time, bool) { return time.Time{}, false } +func (c *determinismContext) Done() <-chan struct{} { return c.done } +func (c *determinismContext) Err() error { + c.mu.RLock() + defer c.mu.RUnlock() + return c.err +} +func (c *determinismContext) Value(key any) any { return c.parent.Value(key) } +func (c *determinismContext) finish(err error) { + c.once.Do(func() { + c.mu.Lock() + c.err = err + c.mu.Unlock() + close(c.done) + }) +} diff --git a/internal/advancer/foreclosed_publication_test.go b/internal/advancer/foreclosed_publication_test.go new file mode 100644 index 000000000..ab5c12ba2 --- /dev/null +++ b/internal/advancer/foreclosed_publication_test.go @@ -0,0 +1,37 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +// The mock manager supplies the runtime; manager tests cover its retention. +func (s *AdvancerSuite) TestForeclosedClosedEpochPublishesWithoutNewInput() { + env := s.setupOneApp() + app := env.app.Application + app.Status, app.ForecloseBlock, app.LastInputCheckBlock = model.ApplicationStatus_OK, 80, 80 + app.ProcessedInputs, env.app.processedInputs = 2, 2 + epoch := &model.Epoch{ApplicationID: app.ID, Index: 5, FirstBlock: 50, LastBlock: 59, + InputIndexLowerBound: 1, InputIndexUpperBound: 2, Status: model.EpochStatus_Closed} + env.repo.GetEpochsReturn = map[common.Address][]*model.Epoch{app.IApplicationAddress: {epoch}} + // There are no new inputs. If manager preserves the runtime, a subsequent + // Step can finish IP even after the first publication write was unavailable. + failure := errors.New("publication write unavailable") + env.repo.UpdateEpochsError = failure + _, err := env.service.Step(context.Background()) + s.Require().ErrorIs(err, failure) + s.Equal(1, env.repo.EpochInputsProcessedCount) + env.repo.UpdateEpochsError = nil + _, err = env.service.Step(context.Background()) + s.Require().NoError(err) + s.Equal(2, env.repo.EpochInputsProcessedCount) + s.Equal(env.app.StateProofReturn, env.repo.PublishedStateProof) + s.Empty(env.repo.StoredResults, "do not replay or advance another input to publish CLOSED") + s.Zero(env.repo.ApplicationStatusUpdates) +} diff --git a/internal/advancer/invalid_outputs_root_test.go b/internal/advancer/invalid_outputs_root_test.go new file mode 100644 index 000000000..03e02af29 --- /dev/null +++ b/internal/advancer/invalid_outputs_root_test.go @@ -0,0 +1,69 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +func (s *AdvancerSuite) TestInvalidOutputsRootStopsClaimPreparation() { + env := s.setupOneApp() + terminal := randomAdvanceResult(0) + terminal.Status = model.InputCompletionStatus_InvalidOutputsRoot + terminal.Outputs = nil + terminal.Reports = [][]byte{[]byte("invalid outputs root report")} + address := env.app.Application.IApplicationAddress + env.repo.GetEpochsReturn = map[common.Address][]*model.Epoch{ + address: {{ + Index: 0, Status: model.EpochStatus_Closed, + InputIndexLowerBound: 0, InputIndexUpperBound: 2, + }}, + } + env.repo.GetInputsReturn = map[common.Address][]*model.Input{ + address: { + newInput(env.app.Application.ID, 0, 0, marshal(terminal)), + newInput(env.app.Application.ID, 0, 1, []byte("must not execute")), + }, + } + + hadWork, err := env.service.Step(context.Background()) + s.Require().NoError(err) + s.False(hadWork) + s.Require().Len(env.repo.StoredResults, 1) + s.Equal(model.InputCompletionStatus_InvalidOutputsRoot, env.repo.StoredResults[0].Status) + s.Equal(terminal.Reports, env.repo.StoredResults[0].Reports) + s.Zero(env.repo.EpochInputsProcessedCount, "the epoch must not become claim work") + s.Zero(env.repo.ApplicationStatusUpdates, "the status belongs to the atomic input-result write") + + env.app.Application.Status = model.ApplicationStatus_InvalidOutputsRoot + hadWork, err = env.service.Step(context.Background()) + s.Require().NoError(err) + s.False(hadWork) + s.Len(env.repo.StoredResults, 1, "later ticks must not execute the next input") +} + +func (s *AdvancerSuite) TestInvalidOutputsRootStoreFailureStopsService() { + env := s.setupOneApp() + terminal := randomAdvanceResult(0) + terminal.Status = model.InputCompletionStatus_InvalidOutputsRoot + terminal.Outputs = nil + terminal.Reports = [][]byte{[]byte("invalid outputs root report")} + pending := newInput(env.app.Application.ID, 0, 0, marshal(terminal)) + env.repo.StoreAdvanceError = errors.New("terminal result write failed") + + _, _, err := env.service.processInputs(context.Background(), env.app.Application, []*model.Input{ + pending, + newInput(env.app.Application.ID, 0, 1, []byte("must not execute")), + }) + s.Require().ErrorIs(err, env.repo.StoreAdvanceError) + s.Require().NotNil(env.supervisor.FatalError.Load()) + s.ErrorIs(*env.supervisor.FatalError.Load(), env.repo.StoreAdvanceError) + s.True(env.supervisor.StopCalled.Load()) + s.Empty(env.repo.StoredResults) + s.Zero(env.repo.ApplicationStatusUpdates, "do not record a terminal status without its input evidence") +} diff --git a/internal/advancer/protection_test.go b/internal/advancer/protection_test.go new file mode 100644 index 000000000..7b5ff2476 --- /dev/null +++ b/internal/advancer/protection_test.go @@ -0,0 +1,175 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/manager" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func (s *AdvancerSuite) TestProtectionDeadlineConsumers() { + const ( + advanceFailure = "advance failure" + executionTerminal = "execution terminal" + invalidOutputsLength = "invalid outputs length" + proofFailure = "proof failure" + unconfirmedPersistence = "unconfirmed persistence" + ) + for _, scenario := range []string{ + advanceFailure, "snapshot failure", executionTerminal, + invalidOutputsLength, unconfirmedPersistence, proofFailure, + } { + s.Run(scenario, func() { + env := s.setupOneApp() + app := env.app.Application + app.ConsensusType = model.Consensus_PRT + app.Status = model.ApplicationStatus_OK + app.Enabled = true + var output bytes.Buffer + env.service.Logger = slog.New(slog.NewTextHandler(&output, nil)) + env.repo.Tournaments = make(map[common.Address]*model.Tournament) + var epochs []*model.Epoch + for index := uint64(0); index < 3; index++ { + root := common.Address{byte(index + 1)} + epoch := &model.Epoch{ApplicationID: app.ID, Index: index, + LastBlock: 100 + index, Status: model.EpochStatus_Closed, TournamentAddress: &root} + epochs = append(epochs, epoch) + env.repo.Tournaments[root] = &model.Tournament{ApplicationID: app.ID, EpochIndex: index, Address: root, + StartInstant: epoch.LastBlock, Allowance: 50} + } + env.repo.GetEpochsReturn = map[common.Address][]*model.Epoch{app.IApplicationAddress: epochs} + result := randomAdvanceResult(0) + result.EpochIndex = 1 + switch scenario { + case advanceFailure: + env.app.AdvanceError = errors.New("runtime failed") + env.repo.UpdateApplicationStatusError = errors.New("status write failed") + case "snapshot failure": + app.ExecutionParameters.SnapshotPolicy = model.SnapshotPolicy_EveryInput + env.service.snapshotsDir = s.T().TempDir() + instance := env.mm.Map[app.ID] + instance.createSnapshotError = errors.New("snapshot failed") + instance.destroyAfterSnapshotError = true + case executionTerminal: + result.Status = model.InputCompletionStatus_MachineHalted + case invalidOutputsLength: + result.Status = model.InputCompletionStatus_InvalidOutputsRoot + case unconfirmedPersistence: + env.repo.StoreAdvanceError = errors.New("commit outcome unavailable") + case proofFailure: + env.app.StateProofError = errors.New("machine instance is closed") + } + if scenario == proofFailure { + // A closed runtime is the automatic-stop branch of publication preparation. + env.app.StateProofError = manager.ErrMachineClosed + _, err := env.service.prepareEpochPublication(s.T().Context(), app, epochs[1]) + require.Error(s.T(), err) + } else { + _, _, err := env.service.processInputs(s.T().Context(), app, []*model.Input{newInput(app.ID, 1, 0, marshal(result))}) + if scenario == executionTerminal || scenario == invalidOutputsLength { + require.NoError(s.T(), err) + } else { + require.Error(s.T(), err) + } + } + logs := output.String() + switch scenario { + case unconfirmedPersistence: + require.Contains(s.T(), logs, "PRT protection deadline discovery unavailable") + require.Contains(s.T(), logs, "epoch_index=1") + require.Contains(s.T(), logs, "clock_available=false") + require.Empty(s.T(), env.repo.TournamentReads) + require.Empty(s.T(), env.repo.EpochFilters) + require.True(s.T(), env.supervisor.StopCalled.Load()) + case executionTerminal, invalidOutputsLength: + require.Len(s.T(), env.repo.TournamentReads, 2) + require.NotContains(s.T(), logs, "root_tournament="+epochs[0].TournamentAddress.Hex()) + require.Contains(s.T(), logs, "join_close_block=151") + require.Contains(s.T(), logs, "join_close_block=152") + default: + require.Len(s.T(), env.repo.TournamentReads, 3) + require.Equal(s.T(), 3, strings.Count(logs, "PRT protection stopped")) + require.Contains(s.T(), logs, "join_close_block=150") + require.Contains(s.T(), logs, "join_close_block=152") + } + if scenario == advanceFailure { + require.True(s.T(), env.mm.PendingApplicationFailures) + require.False(s.T(), env.service.Ready()) + } + }) + } +} + +func TestProtectionAlertDoesNotTurnShutdownIntoFailure(t *testing.T) { + mm := newMockMachineManager() + machine := newMockMachine(1) + machine.Application.ConsensusType = model.Consensus_PRT + mm.Map[1] = newMockInstance(machine) + repo := &MockRepository{} + svc, err := newMockAdvancerService(mm, repo) + require.NoError(t, err) + var output bytes.Buffer + svc.Logger = slog.New(slog.NewTextHandler(&output, nil)) + machine.AdvanceError = context.Canceled + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, _, err = svc.processInputs(ctx, machine.Application, []*model.Input{newInput(1, 0, 0, []byte("input"))}) + require.ErrorIs(t, err, context.Canceled) + require.NotContains(t, output.String(), "PRT protection") + require.Empty(t, repo.TournamentReads) + require.Zero(t, repo.ApplicationStatusUpdates) +} + +func (s *AdvancerSuite) TestFatalStopsReportAllPRTProtection() { + for _, consensus := range model.ConsensusAllValues { + for _, test := range []struct { + name string + statusRace, shutdown bool + }{ + {name: "ambiguous persistence"}, + {name: "close after status race", statusRace: true}, + {name: "close during shutdown", shutdown: true}, + } { + s.Run(consensus.String()+"/"+test.name, func() { + env := s.setupOneApp() + app := env.app.Application + app.ConsensusType = consensus + app.Name = "source application" + var output bytes.Buffer + env.service.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(s.T().Context()) + defer cancel() + switch { + case test.statusRace: + env.repo.StoreAdvanceError = repository.ErrApplicationNotRunnable + env.mm.Map[app.ID].closeError = errors.New("runtime close failed") + case test.shutdown: + env.repo.StoreAdvanceHook = func(context.Context) error { cancel(); return context.Canceled } + env.mm.Map[app.ID].closeError = errors.New("runtime close failed") + default: + env.repo.StoreAdvanceError = errors.New("commit outcome unavailable") + } + _, _, err := env.service.processInputs(ctx, app, []*model.Input{newInput(app.ID, 1, 0, marshal(randomAdvanceResult(0)))}) + require.Error(s.T(), err) + require.True(s.T(), env.supervisor.StopCalled.Load()) + require.Contains(s.T(), output.String(), "PRT global protection deadline inventory unavailable") + require.Contains(s.T(), output.String(), "affected_applications=\"all enabled PRT applications\"") + require.Contains(s.T(), output.String(), "clock_available=false") + require.Empty(s.T(), env.repo.EpochFilters) + require.Empty(s.T(), env.repo.TournamentReads) + require.Zero(s.T(), env.repo.ApplicationStatusUpdates) + }) + } + } +} diff --git a/internal/advancer/publication_test.go b/internal/advancer/publication_test.go new file mode 100644 index 000000000..6da0de6e5 --- /dev/null +++ b/internal/advancer/publication_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "log/slog" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *AdvancerSuite) TestFinalizeEpochPublicationOutcome() { + for _, test := range []struct { + name string + storeErr error + obsolete bool + }{ + {name: "published"}, + {name: "foreclosed during publication", storeErr: fmt.Errorf("state changed: %w", repository.ErrEpochForeclosed), obsolete: true}, + {name: "unexpected state", storeErr: repository.ErrNoUpdate}, + {name: "database failure", storeErr: errors.New("database write failed")}, + } { + s.Run(test.name, func() { + env := s.setupOneApp() + logs := &advancerLogCapture{} + env.service.Logger = slog.New(logs) + env.repo.UpdateEpochsError = test.storeErr + epoch := &Epoch{ApplicationID: env.app.Application.ID, Index: 2, Status: EpochStatus_Closed} + + err := env.service.finalizeEpoch(context.Background(), env.app.Application, epoch) + + if test.obsolete || test.storeErr == nil { + s.Require().NoError(err) + } else { + s.Require().ErrorIs(err, test.storeErr) + } + s.Equal(1, env.repo.EpochInputsProcessedCount, "make one publication attempt; do not retry a rejected write") + s.Equal(test.storeErr == nil, logs.contains(slog.LevelInfo, "Epoch updated to Inputs Processed")) + s.Equal(test.obsolete, logs.contains(slog.LevelInfo, + "Epoch was foreclosed before state-proof publication; discarding obsolete publication")) + s.Zero(env.repo.ApplicationStatusUpdates) + s.Equal(EpochStatus_Closed, epoch.Status, "the stale input object must not be used to reset stored state") + }) + } +} diff --git a/internal/advancer/service.go b/internal/advancer/service.go new file mode 100644 index 000000000..d1709f26f --- /dev/null +++ b/internal/advancer/service.go @@ -0,0 +1,137 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/manager" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/emulator" + "github.com/cartesi/rollups-node/pkg/machine" + "github.com/cartesi/rollups-node/pkg/service" +) + +// Service is the main advancer service that processes inputs through Cartesi machines +type Service struct { + service.TickServiceTemplate + supervisor service.Supervisor + inputBatchSize uint64 + snapshotsDir string + repository AdvancerRepository + machineManager manager.MachineProvider +} + +// CreateInfo contains the configuration for creating an advancer service +type CreateInfo struct { + Config config.AdvancerConfig + Logger *slog.Logger + Repository repository.Repository + Machines manager.MachineProvider + Supervisor service.Supervisor +} + +// Create initializes a new advancer service +func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { + err := ctx.Err() + if err != nil { + return nil, err // This returns context.Canceled or context.DeadlineExceeded. + } + // This is a process-wide compatibility invariant, not an application + // failure. Refuse advancer startup instead of retrying every application on + // every tick while reporting a misleading healthy service. + if err = machine.ValidateEmulatorComputationHashLimits(); err != nil { + return nil, fmt.Errorf("invalid machine execution constants: %w", err) + } + if err = machine.ValidateEmulatorMarchID(); err != nil { + return nil, fmt.Errorf("could not validate emulator architecture: %w", err) + } + + if c.Supervisor == nil { + return nil, fmt.Errorf("supervisor on advancer service Create is nil") + } + + s := &Service{ + supervisor: c.Supervisor, + snapshotsDir: c.Config.SnapshotsDir, + } + + s.repository = c.Repository + if s.repository == nil { + return nil, fmt.Errorf("repository on advancer service Create is nil") + } + + s.machineManager = c.Machines + if s.machineManager == nil { + return nil, fmt.Errorf("machine manager on advancer service Create is nil") + } + + s.inputBatchSize = c.Config.AdvancerInputBatchSize + if s.inputBatchSize == 0 { + return nil, fmt.Errorf("advancer input batch size must be greater than 0") + } + + tickCfg := &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: config.ServiceAdvancer, + Logger: c.Logger, + LogLevel: c.Config.LogLevel, + LogColor: c.Config.LogColor, + }, + PollInterval: c.Config.AdvancerPollingInterval, + } + err = service.InitTickServiceTemplate(&s.TickServiceTemplate, tickCfg, s) + if err != nil { + return nil, err + } + + identity, identityErr := machine.LinkedEmulatorIdentity() + version := emulator.GetVersion() + const semverComponentBase = 1000 + attrs := []any{ + "config", c.Config, + "version", fmt.Sprintf("%d.%d.%d", version/(semverComponentBase*semverComponentBase), + (version/semverComponentBase)%semverComponentBase, version%semverComponentBase), + "marchid", identity.RuntimeMarchID, + "uarch_pristine_state_hash", identity.UarchPristineStateHash.Hex(), + } + if identityErr != nil { + attrs = append(attrs, "identity_error", identityErr) + } + s.Logger.Info("Created", attrs...) + + return s, nil +} + +// Service interface implementation +func (s *Service) Ready() bool { + // This is a local fail-closed signal while application-failure durability + // is unresolved. It cannot atomically revoke work already selected by a + // separate process before that durable status becomes visible. + return s.machineManager != nil && !s.machineManager.HasPendingApplicationFailures() +} + +func (s *Service) Tick(ctx context.Context) (bool, error) { + // Signal reschedule whenever work was done, even if some apps errored. + // Failed apps are marked Failed and removed by the machine manager, + // so they won't cause amplified retries on the next tick. + // Without this, one failing app delays all healthy apps by a full poll interval. + hadWork, err := s.Step(ctx) + + if ctx.Err() != nil { + hadWork = false + // Only cancellation caused by service shutdown is quiet. Preserve + // independent failures, including those joined with cancellation. + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick canceled during shutdown", "error", err) + err = nil + } + } + return hadWork, err +} diff --git a/internal/advancer/shutdown_test.go b/internal/advancer/shutdown_test.go new file mode 100644 index 000000000..eb674df90 --- /dev/null +++ b/internal/advancer/shutdown_test.go @@ -0,0 +1,166 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package advancer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/require" +) + +func TestAdvancerTickCancellation(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + shutdown bool + cause error + quiet bool + }{ + {name: "active cancellation", cause: context.Canceled}, + {name: "active mixed failure", cause: errors.Join(context.Canceled, dbErr)}, + {name: "shutdown cancellation", shutdown: true, cause: context.Canceled, quiet: true}, + {name: "wrapped shutdown", shutdown: true, cause: fmt.Errorf("query: %w", context.Canceled), quiet: true}, + {name: "joined shutdown", shutdown: true, cause: errors.Join(context.Canceled, context.Canceled), quiet: true}, + {name: "shutdown database failure", shutdown: true, cause: dbErr}, + {name: "shutdown mixed failure", shutdown: true, cause: errors.Join(context.Canceled, dbErr)}, + {name: "shutdown nested failure", shutdown: true, cause: fmt.Errorf("query: %w", errors.Join(dbErr, context.Canceled))}, + {name: "active deadline", cause: context.DeadlineExceeded}, + {name: "shutdown deadline", shutdown: true, cause: context.DeadlineExceeded}, + {name: "shutdown mixed deadline", shutdown: true, cause: errors.Join(context.Canceled, context.DeadlineExceeded)}, + {name: "active missing machine", cause: ErrNoApp}, + {name: "shutdown missing machine", shutdown: true, cause: ErrNoApp}, + {name: "shutdown missing machine and failure", shutdown: true, cause: errors.Join(ErrNoApp, dbErr)}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := &MockRepository{} + mm := newMockMachineManager() + svc, err := newMockAdvancerService(mm, repo) + require.NoError(t, err) + svc.machineManager = &shutdownMachineManager{MockMachineManager: mm, update: func(context.Context) error { + if test.shutdown { + cancel() + } + return test.cause + }} + logs := &advancerLogCapture{} + svc.Logger = slog.New(logs) + + reschedule, err := svc.Tick(ctx) + require.False(t, reschedule) + if test.quiet { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.cause, "preserve the complete dependency error") + } + require.Equal(t, test.quiet, logs.contains(slog.LevelDebug, "Tick canceled during shutdown")) + require.Zero(t, repo.ApplicationStatusUpdates) + require.False(t, mm.PendingApplicationFailures) + }) + } +} + +func TestAdvancerStepPreservesEarlierFailures(t *testing.T) { + firstErr := errors.New("first application database failure") + for _, lastErr := range []error{nil, context.Canceled, errors.Join(context.Canceled, errors.New("second database failure"))} { + for _, method := range []string{"Step", "Tick"} { + t.Run(fmt.Sprintf("%s/%v", method, lastErr), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mm := newMockMachineManager() + for id := int64(1); id <= 3; id++ { + mm.Map[id] = newMockInstance(newMockMachine(id)) + } + baseRepo := &MockRepository{} + svc, err := newMockAdvancerService(mm, baseRepo) + require.NoError(t, err) + var calls []string + svc.repository = &shutdownEpochRepository{MockRepository: baseRepo, list: func(address string) error { + calls = append(calls, address) + if len(calls) == 1 { + return firstErr + } + cancel() + return lastErr + }} + run := svc.Step + if method == "Tick" { + run = svc.Tick + } + + reschedule, err := run(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstErr) + if lastErr != nil { + require.ErrorIs(t, err, lastErr) + } else { + require.ErrorIs(t, err, context.Canceled, "stop before the next application even after a successful read") + } + require.Equal(t, []string{ + mm.Map[1].application.IApplicationAddress.Hex(), + mm.Map[2].application.IApplicationAddress.Hex(), + }, calls, "do not dispatch another application after cancellation") + require.Zero(t, baseRepo.ApplicationStatusUpdates) + require.False(t, mm.PendingApplicationFailures) + }) + } + } +} + +func TestAdvancerServeReportsShutdownFailures(t *testing.T) { + for _, test := range []struct { + name string + cause error + quiet bool + }{ + {"cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"mixed database failure", errors.Join(context.Canceled, errors.New("database unavailable")), false}, + {"deadline", context.DeadlineExceeded, false}, + {"missing machine and failure", errors.Join(ErrNoApp, errors.New("database unavailable")), false}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mm := newMockMachineManager() + svc, err := newMockAdvancerService(mm, &MockRepository{}) + require.NoError(t, err) + svc.machineManager = &shutdownMachineManager{MockMachineManager: mm, update: func(context.Context) error { + cancel() + return test.cause + }} + logs := &advancerLogCapture{} + svc.Logger = slog.New(logs) + + require.ErrorIs(t, svc.Serve(ctx), context.Canceled) + require.Equal(t, !test.quiet, logs.contains(slog.LevelError, "Tick")) + }) + } +} + +type shutdownMachineManager struct { + *MockMachineManager + update func(context.Context) error +} + +func (m *shutdownMachineManager) UpdateMachines(ctx context.Context) error { + return m.update(ctx) +} + +type shutdownEpochRepository struct { + *MockRepository + list func(string) error +} + +func (r *shutdownEpochRepository) ListEpochs( + _ context.Context, address string, _ repository.EpochFilter, _ repository.Pagination, _ bool, +) ([]*model.Epoch, uint64, error) { + return nil, 0, r.list(address) +} diff --git a/internal/appstatus/appstatus.go b/internal/appstatus/appstatus.go new file mode 100644 index 000000000..96bb4e004 --- /dev/null +++ b/internal/appstatus/appstatus.go @@ -0,0 +1,260 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package appstatus + +import ( + "context" + "errors" + "fmt" + "log/slog" + + . "github.com/cartesi/rollups-node/internal/model" +) + +// maxReasonLength is the maximum length of a reason string stored in the +// database. The DB column is VARCHAR(4096); we leave margin to avoid +// constraint violations from deeply-nested error chains. +const maxReasonLength = 4000 + +// Repository is the minimal interface needed to update application status. +type Repository interface { + UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error +} + +// SetFailed marks an application as FAILED (recoverable). +// Use for runtime or internal computation errors that can be resolved by +// operator intervention (e.g., OOM kill, process crash, or a repaired software +// defect). The operator can re-enable the application after fixing the cause. +// +// Recovery assumptions — FAILED is safe to re-enable only when: +// - The failure does not prove that persisted state is inconsistent. +// - The last snapshot is consistent with the database state. +// - replay.Run will correctly verify inputs from the snapshot point. +// +// The reason parameter must be a pre-formatted string describing the failure. +// Existing terminal statuses are preserved. The four execution terminals are +// entered atomically by repository.StoreAdvanceResult. Other terminal findings +// must not become recoverable runtime failures. Returns the database error if +// the status update fails; returns nil +// on success or when an existing terminal status is preserved. +func SetFailed( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + reason string, +) error { + if app.Status.IsTerminal() { + logger.Debug("preserving existing terminal application status", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "current_status", app.Status, + "requested_status", ApplicationStatus_Failed) + return nil + } + return setApplicationStatus(ctx, logger, repo, app, ApplicationStatus_Failed, reason) +} + +// SetFailedf marks an application as FAILED with a formatted reason string. +// Returns the database error if the status update fails; returns nil on success. +// Unlike [SetDivergedf] and [SetCorruptedf], this intentionally returns nil on success because +// FAILED is recoverable — callers typically continue with their own error. +func SetFailedf( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + reasonFmt string, + args ...any, +) error { + return SetFailed(ctx, logger, repo, app, fmt.Sprintf(reasonFmt, args...)) +} + +// SetDiverged marks an application DIVERGED (terminal): the node's computed +// claim disagrees with what the chain accepted. The application produces no +// more claims, but observation continues (it is gated on the enabled flag), +// so withdrawals and other post-foreclosure events keep being indexed. +// +// The reason must be a pre-formatted string describing the disagreement. +// Always returns a non-nil error containing the reason because DIVERGED is +// terminal and callers should stop processing the application. +// +// Logging contract: both the reason and any DB write error are logged at +// ERROR level via slog before the function returns. Callers that don't need +// to propagate the failure upward (e.g. best-effort loops over multiple +// applications) may discard the returned error with `_ =` without losing +// operator visibility. +func SetDiverged( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + reason string, +) error { + return setTerminalStatus(ctx, logger, repo, app, ApplicationStatus_Diverged, reason) +} + +// SetDivergedf marks an application DIVERGED with a formatted reason string. +func SetDivergedf( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + reasonFmt string, + args ...any, +) error { + return SetDiverged(ctx, logger, repo, app, fmt.Sprintf(reasonFmt, args...)) +} + +// SetCorrupted marks an application CORRUPTED (terminal): local state is +// missing or inconsistent and cannot be resolved by restarting. As with +// DIVERGED, observation continues and only claim work stops. +// +// The reason must be a pre-formatted string. Always returns a non-nil error +// because CORRUPTED is terminal and callers should stop. +func SetCorrupted( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + reason string, +) error { + return setTerminalStatus(ctx, logger, repo, app, ApplicationStatus_Corrupted, reason) +} + +// SetCorruptedf marks an application CORRUPTED with a formatted reason string. +func SetCorruptedf( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + reasonFmt string, + args ...any, +) error { + return SetCorrupted(ctx, logger, repo, app, fmt.Sprintf(reasonFmt, args...)) +} + +// setTerminalStatus persists a terminal health status +// and returns a non-nil error containing the reason (joined with the DB error +// if the update failed), so callers always have a value to propagate or log. +func setTerminalStatus( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + status ApplicationStatus, + reason string, +) error { + reason = NormalizeReason(reason) + reasonErr := errors.New(reason) + + // A genuine integrity finding must veto earlier work reopened under an + // execution/root stop. Its reason replaces the earlier reason; the typed + // stop boundary is retained. The database trigger also preserves the first + // DIVERGED/CORRUPTED status and reason against stale callers. + integrityEscalation := (app.Status.IsExecutionTerminal() || app.Status == ApplicationStatus_InvalidOutputsRoot) && + (status == ApplicationStatus_Diverged || status == ApplicationStatus_Corrupted) + preserveExistingTerminal := app.Status.IsTerminal() && !integrityEscalation + if preserveExistingTerminal { + logger.Debug("preserving existing terminal application status", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "current_status", app.Status, + "requested_status", status) + return reasonErr + } + + dbErr := setApplicationStatus(ctx, logger, repo, app, status, reason) + if dbErr != nil { + return errors.Join(reasonErr, dbErr) + } + return reasonErr +} + +// NormalizeReason returns the exact reason representation persisted by status +// helpers. Callers that compare a later readback must normalize before keeping +// their expected value. +func NormalizeReason(reason string) string { + if len(reason) > maxReasonLength { + return reason[:maxReasonLength] + "... (truncated)" + } + return reason +} + +func setApplicationStatus( + ctx context.Context, + logger *slog.Logger, + repo Repository, + app *Application, + status ApplicationStatus, + reason string, +) error { + if status.IsExecutionTerminal() { + return fmt.Errorf( + "execution-terminal status %s must be written atomically by repository.StoreAdvanceResult", + status, + ) + } + reason = NormalizeReason(reason) + + switch status { + case ApplicationStatus_Failed: + logger.Warn("marking application as failed (recoverable)", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "reason", reason) + case ApplicationStatus_Diverged: + logger.Error("marking application as diverged (terminal)", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "reason", reason) + case ApplicationStatus_Corrupted: + logger.Error("marking application as corrupted (terminal)", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "reason", reason) + default: + logger.Error("marking application with unexpected status", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "status", status, + "reason", reason) + } + + err := repo.UpdateApplicationStatus(ctx, app.ID, status, &reason) + if err != nil { + logger.Error("failed to update application status", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "target_status", status, "error", err) + return err + } + + // Only update in-memory status when the DB write succeeds to keep + // the in-memory Application consistent with the database. + app.Status = status + app.Reason = &reason + if status == ApplicationStatus_Failed { + WarnBlockedForeclosure(logger, app) + } + return nil +} + +// WarnBlockedForeclosure explains why FAILED prevents foreclosure work from +// finishing. Call when failure or foreclosure is recorded, not on every tick. +func WarnBlockedForeclosure(logger *slog.Logger, app *Application) { + if app.Status != ApplicationStatus_Failed || app.ForecloseBlock == 0 { + return + } + var reason string + if app.Reason != nil { + reason = *app.Reason + } + logger.Warn("Application failure blocks foreclosure drain; repair the cause before clearing FAILED", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + "reason", reason, + "recovery_command", fmt.Sprintf("cartesi-rollups-cli app status %s enabled", app.IApplicationAddress.Hex())) +} diff --git a/internal/appstatus/appstatus_test.go b/internal/appstatus/appstatus_test.go new file mode 100644 index 000000000..f39cd5860 --- /dev/null +++ b/internal/appstatus/appstatus_test.go @@ -0,0 +1,550 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package appstatus + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +func TestFailedForeclosureDiagnostic(t *testing.T) { + for _, tc := range []struct { + name string + foreclosed bool + writeError error + }{ + {name: "failure after foreclosure", foreclosed: true}, + {name: "failure without foreclosure"}, + {name: "failure not recorded", foreclosed: true, writeError: errors.New("database unavailable")}, + } { + t.Run(tc.name, func(t *testing.T) { + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, nil)) + app := newTestApp() + if tc.foreclosed { + app.ForecloseBlock = 80 + } + err := SetFailed(t.Context(), logger, &mockRepo{err: tc.writeError}, app, "machine process crashed") + require.ErrorIs(t, err, tc.writeError) + if tc.foreclosed && tc.writeError == nil { + require.Contains(t, logs.String(), "failure blocks foreclosure drain") + require.Contains(t, logs.String(), "foreclose_block=80") + require.Contains(t, logs.String(), "machine process crashed") + require.Contains(t, logs.String(), "repair the cause before clearing FAILED") + } else { + require.NotContains(t, logs.String(), "failure blocks foreclosure drain") + } + }) + } +} + +func TestAppStatus(t *testing.T) { + suite.Run(t, new(AppStatusSuite)) +} + +type AppStatusSuite struct{ suite.Suite } + +func newTestApp() *Application { + return &Application{ + ID: 42, + Name: "test-app", + IApplicationAddress: common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), + IConsensusAddress: common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"), + IInputBoxAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Status: ApplicationStatus_OK, + } +} + +type mockRepo struct { + lastAppID int64 + lastStatus ApplicationStatus + lastReason *string + err error + callCount int +} + +func (m *mockRepo) UpdateApplicationStatus( + _ context.Context, + appID int64, + state ApplicationStatus, + reason *string, +) error { + m.callCount++ + m.lastAppID = appID + m.lastStatus = state + m.lastReason = reason + return m.err +} + +func (s *AppStatusSuite) TestSetFailed() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + app := newTestApp() + + err := SetFailed(context.Background(), logger, repo, app, "machine crashed: OOM") + + require.NoError(err) + require.Equal(1, repo.callCount) + require.Equal(int64(42), repo.lastAppID) + require.Equal(ApplicationStatus_Failed, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("machine crashed: OOM", *repo.lastReason) + + // Verify in-memory status was updated. + require.Equal(ApplicationStatus_Failed, app.Status) + require.NotNil(app.Reason) + require.Equal("machine crashed: OOM", *app.Reason) +} + +func (s *AppStatusSuite) TestSetFailedf() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + app := newTestApp() + + err := SetFailedf(context.Background(), logger, repo, app, + "epoch %d input %d: %s", 5, 42, "timeout") + + require.NoError(err) + require.Equal(1, repo.callCount) + require.Equal(ApplicationStatus_Failed, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("epoch 5 input 42: timeout", *repo.lastReason) + + // Verify in-memory status was updated. + require.Equal(ApplicationStatus_Failed, app.Status) +} + +func (s *AppStatusSuite) TestSetFailedPreservesTerminalStatus() { + for _, status := range []ApplicationStatus{ + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + } { + s.Run(status.String(), func() { + app := newTestApp() + app.Status = status + originalReason := "terminal reason" + app.Reason = &originalReason + repo := &mockRepo{} + + err := SetFailed( + context.Background(), slog.Default(), repo, app, "later runtime failure") + + s.Require().NoError(err) + s.Zero(repo.callCount) + s.Equal(status, app.Status) + s.Equal(originalReason, *app.Reason) + }) + } +} + +func (s *AppStatusSuite) TestRejectsExecutionTerminalTarget() { + for _, status := range []ApplicationStatus{ + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + } { + s.Run(status.String(), func() { + app := newTestApp() + repo := &mockRepo{} + + err := setApplicationStatus( + context.Background(), slog.Default(), repo, app, status, "terminal outcome") + + s.Require().ErrorContains(err, "must be written atomically by repository.StoreAdvanceResult") + s.Zero(repo.callCount) + s.Equal(ApplicationStatus_OK, app.Status) + }) + } +} + +func (s *AppStatusSuite) TestSetDiverged() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + app := newTestApp() + + err := SetDiverged(context.Background(), logger, repo, app, "hash mismatch: 0xaa != 0xbb") + + // SetDiverged always returns a non-nil error (DIVERGED is terminal) + require.Error(err) + require.Contains(err.Error(), "hash mismatch: 0xaa != 0xbb") + require.Equal(1, repo.callCount) + require.Equal(int64(42), repo.lastAppID) + require.Equal(ApplicationStatus_Diverged, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("hash mismatch: 0xaa != 0xbb", *repo.lastReason) + + // Verify in-memory status was updated. + require.Equal(ApplicationStatus_Diverged, app.Status) + require.NotNil(app.Reason) + require.Equal("hash mismatch: 0xaa != 0xbb", *app.Reason) +} + +func (s *AppStatusSuite) TestSetCorrupted() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + app := newTestApp() + + err := SetCorrupted(context.Background(), logger, repo, app, "machine snapshot missing") + + // SetCorrupted always returns a non-nil error (CORRUPTED is terminal) + require.Error(err) + require.Contains(err.Error(), "machine snapshot missing") + require.Equal(1, repo.callCount) + require.Equal(int64(42), repo.lastAppID) + require.Equal(ApplicationStatus_Corrupted, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("machine snapshot missing", *repo.lastReason) + + // Verify in-memory status was updated. + require.Equal(ApplicationStatus_Corrupted, app.Status) + require.NotNil(app.Reason) + require.Equal("machine snapshot missing", *app.Reason) +} + +func (s *AppStatusSuite) TestApplicationStopCanEscalateToIntegrityStop() { + for _, initial := range []ApplicationStatus{ApplicationStatus_GuestException, ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, ApplicationStatus_Failed} { + for _, requested := range []ApplicationStatus{ApplicationStatus_Diverged, ApplicationStatus_Corrupted} { + s.Run(initial.String()+"/"+requested.String(), func() { + app := newTestApp() + app.Status = initial + app.Reason = new("execution stopped") + repo := &mockRepo{} + err := setTerminalStatus(s.T().Context(), slog.Default(), repo, app, requested, "integrity finding") + s.Require().ErrorContains(err, "integrity finding") + s.Equal(1, repo.callCount) + s.Equal(requested, app.Status) + s.Equal("integrity finding", *app.Reason) + }) + } + } +} + +func (s *AppStatusSuite) TestIntegrityTerminalStatusWritesAreIdempotent() { + for _, current := range []ApplicationStatus{ + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + } { + s.Run(current.String(), func() { + app := newTestApp() + app.Status = current + originalReason := "first integrity finding" + app.Reason = &originalReason + repo := &mockRepo{} + + err := SetCorrupted( + context.Background(), slog.Default(), repo, app, "repeated finding") + s.Require().ErrorContains(err, "repeated finding") + s.Zero(repo.callCount) + s.Equal(current, app.Status) + s.Equal(originalReason, *app.Reason) + }) + } +} + +func (s *AppStatusSuite) TestSetFailedDBError() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + logger := slog.Default() + app := newTestApp() + + err := SetFailed(context.Background(), logger, repo, app, "process crashed") + + require.ErrorIs(err, dbErr) + require.Equal(1, repo.callCount) + require.Equal(ApplicationStatus_Failed, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("process crashed", *repo.lastReason) + + // In-memory status must NOT be updated on DB error to stay consistent. + require.Equal(ApplicationStatus_OK, app.Status) + require.Nil(app.Reason) +} + +func (s *AppStatusSuite) TestSetDivergedDBError() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + logger := slog.Default() + app := newTestApp() + + err := SetDiverged(context.Background(), logger, repo, app, "claim disagreement") + + require.ErrorIs(err, dbErr) + require.Contains(err.Error(), "claim disagreement") + require.Equal(1, repo.callCount) + require.Equal(ApplicationStatus_Diverged, repo.lastStatus) + + // In-memory status must NOT be updated on DB error to stay consistent. + require.Equal(ApplicationStatus_OK, app.Status) + require.Nil(app.Reason) +} + +func (s *AppStatusSuite) TestSetCorruptedDBError() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + logger := slog.Default() + app := newTestApp() + + err := SetCorrupted(context.Background(), logger, repo, app, "state corruption") + + require.ErrorIs(err, dbErr) + require.Contains(err.Error(), "state corruption") + require.Equal(1, repo.callCount) + require.Equal(ApplicationStatus_Corrupted, repo.lastStatus) + + // In-memory status must NOT be updated on DB error to stay consistent. + require.Equal(ApplicationStatus_OK, app.Status) + require.Nil(app.Reason) +} + +func (s *AppStatusSuite) TestReasonStoredExactly() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + + err := SetFailed(context.Background(), logger, repo, newTestApp(), "epoch 5 input 42: timeout") + + require.NoError(err) + require.NotNil(repo.lastReason) + require.Equal("epoch 5 input 42: timeout", *repo.lastReason) +} + +func (s *AppStatusSuite) TestSetDivergedf() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + app := newTestApp() + + err := SetDivergedf(context.Background(), logger, repo, app, + "epoch %d: hash mismatch %s != %s", 5, "0xaa", "0xbb") + + // SetDivergedf always returns a non-nil error (DIVERGED is terminal) + require.Error(err) + require.Contains(err.Error(), "epoch 5: hash mismatch 0xaa != 0xbb") + require.Equal(1, repo.callCount) + require.Equal(ApplicationStatus_Diverged, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("epoch 5: hash mismatch 0xaa != 0xbb", *repo.lastReason) + + // Verify in-memory status was updated (DB succeeded). + require.Equal(ApplicationStatus_Diverged, app.Status) + require.NotNil(app.Reason) + require.Equal("epoch 5: hash mismatch 0xaa != 0xbb", *app.Reason) +} + +func (s *AppStatusSuite) TestSetCorruptedf() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + app := newTestApp() + + err := SetCorruptedf(context.Background(), logger, repo, app, + "epoch %d: missing snapshot", 7) + + // SetCorruptedf always returns a non-nil error (CORRUPTED is terminal) + require.Error(err) + require.Contains(err.Error(), "epoch 7: missing snapshot") + require.Equal(1, repo.callCount) + require.Equal(ApplicationStatus_Corrupted, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("epoch 7: missing snapshot", *repo.lastReason) + + // Verify in-memory status was updated (DB succeeded). + require.Equal(ApplicationStatus_Corrupted, app.Status) + require.NotNil(app.Reason) + require.Equal("epoch 7: missing snapshot", *app.Reason) +} + +func (s *AppStatusSuite) TestSetDivergedfDBError() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + logger := slog.Default() + app := newTestApp() + + err := SetDivergedf(context.Background(), logger, repo, app, "reason: %s", "test") + + require.Error(err) + require.ErrorIs(err, dbErr) + require.Contains(err.Error(), "reason: test") + + // In-memory status must NOT be updated on DB error. + require.Equal(ApplicationStatus_OK, app.Status) + require.Nil(app.Reason) +} + +func (s *AppStatusSuite) TestSetCorruptedfDBError() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + logger := slog.Default() + app := newTestApp() + + err := SetCorruptedf(context.Background(), logger, repo, app, "reason: %s", "test") + + require.Error(err) + require.ErrorIs(err, dbErr) + require.Contains(err.Error(), "reason: test") + + // In-memory status must NOT be updated on DB error. + require.Equal(ApplicationStatus_OK, app.Status) + require.Nil(app.Reason) +} + +func (s *AppStatusSuite) TestSetFailedfDBError() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + logger := slog.Default() + + err := SetFailedf(context.Background(), logger, repo, newTestApp(), + "input %d: %s", 7, "crash") + + require.ErrorIs(err, dbErr) + require.Equal(1, repo.callCount) + require.Equal(ApplicationStatus_Failed, repo.lastStatus) + require.NotNil(repo.lastReason) + require.Equal("input 7: crash", *repo.lastReason) +} + +// captureHandler is an slog.Handler that records every emitted Record so +// tests can assert on log output. It is concurrency-safe enough for +// single-goroutine test scenarios. +type captureHandler struct { + records []slog.Record +} + +func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + h.records = append(h.records, r) + return nil +} +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +// findRecord returns the first record whose message equals msg, or nil. +func findRecord(records []slog.Record, msg string) *slog.Record { + for i := range records { + if records[i].Message == msg { + return &records[i] + } + } + return nil +} + +// attrValue extracts the value of a named attribute from a record, or nil. +func attrValue(r *slog.Record, key string) any { + var found any + r.Attrs(func(a slog.Attr) bool { + if a.Key == key { + found = a.Value.Any() + return false + } + return true + }) + return found +} + +// TestSetDivergedDBErrorLogsBothLines asserts the logging contract +// documented on SetDiverged: when the DB write fails, BOTH the "marking +// application as diverged" line and the "failed to update application +// status" line are emitted at ERROR level. This is the invariant that lets +// callers discard the returned error with `_ =` without losing operator +// visibility into the DB failure. +func (s *AppStatusSuite) TestSetDivergedDBErrorLogsBothLines() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + handler := &captureHandler{} + logger := slog.New(handler) + app := newTestApp() + + err := SetDiverged(context.Background(), logger, repo, app, "claim disagreement") + require.ErrorIs(err, dbErr) + + transition := findRecord(handler.records, "marking application as diverged (terminal)") + require.NotNil(transition, "transition log line must fire even on DB failure") + require.Equal(slog.LevelError, transition.Level) + require.Equal("claim disagreement", attrValue(transition, "reason")) + + dbFailure := findRecord(handler.records, "failed to update application status") + require.NotNil(dbFailure, "DB-failure log line must fire so operators see the persist error") + require.Equal(slog.LevelError, dbFailure.Level) + loggedErr, ok := attrValue(dbFailure, "error").(error) + require.True(ok, "error attr must be an error value") + require.ErrorIs(loggedErr, dbErr) +} + +// TestSetCorruptedDBErrorLogsBothLines mirrors the diverged logging contract +// for the CORRUPTED terminal status. +func (s *AppStatusSuite) TestSetCorruptedDBErrorLogsBothLines() { + require := s.Require() + dbErr := errors.New("db connection failed") + repo := &mockRepo{err: dbErr} + handler := &captureHandler{} + logger := slog.New(handler) + app := newTestApp() + + err := SetCorrupted(context.Background(), logger, repo, app, "state corruption") + require.ErrorIs(err, dbErr) + + transition := findRecord(handler.records, "marking application as corrupted (terminal)") + require.NotNil(transition, "transition log line must fire even on DB failure") + require.Equal(slog.LevelError, transition.Level) + require.Equal("state corruption", attrValue(transition, "reason")) + + dbFailure := findRecord(handler.records, "failed to update application status") + require.NotNil(dbFailure, "DB-failure log line must fire so operators see the persist error") + require.Equal(slog.LevelError, dbFailure.Level) + loggedErr, ok := attrValue(dbFailure, "error").(error) + require.True(ok, "error attr must be an error value") + require.ErrorIs(loggedErr, dbErr) +} + +func (s *AppStatusSuite) TestReasonTruncation() { + require := s.Require() + repo := &mockRepo{} + logger := slog.Default() + app := newTestApp() + + // Create a reason string longer than maxReasonLength + longReason := strings.Repeat("x", maxReasonLength+500) + + err := SetFailed(context.Background(), logger, repo, app, longReason) + + require.NoError(err) + require.NotNil(repo.lastReason) + require.LessOrEqual(len(*repo.lastReason), maxReasonLength+len("... (truncated)")) + require.True(strings.HasSuffix(*repo.lastReason, "... (truncated)")) + + // Short reasons should pass through unchanged + repo2 := &mockRepo{} + app2 := newTestApp() + err = SetFailed(context.Background(), logger, repo2, app2, "short reason") + require.NoError(err) + require.NotNil(repo2.lastReason) + require.Equal("short reason", *repo2.lastReason) +} diff --git a/internal/appstatus/invalid_outputs_root_test.go b/internal/appstatus/invalid_outputs_root_test.go new file mode 100644 index 000000000..7da8546fd --- /dev/null +++ b/internal/appstatus/invalid_outputs_root_test.go @@ -0,0 +1,32 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package appstatus + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" + "log/slog" + "testing" +) + +func TestInvalidOutputsRootAllowsOnlyIntegrityEscalation(t *testing.T) { + for _, requested := range []model.ApplicationStatus{model.ApplicationStatus_Failed, + model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted} { + t.Run(requested.String(), func(t *testing.T) { + app := newTestApp() + app.Status = model.ApplicationStatus_InvalidOutputsRoot + app.Reason = new("original root mismatch") + repo := &mockRepo{} + if requested == model.ApplicationStatus_Failed { + require.NoError(t, SetFailed(t.Context(), slog.Default(), repo, app, "later failure")) + require.Zero(t, repo.callCount) + require.Equal(t, model.ApplicationStatus_InvalidOutputsRoot, app.Status) + } else { + require.Error(t, setTerminalStatus(t.Context(), slog.Default(), repo, app, requested, "integrity finding")) + require.Equal(t, 1, repo.callCount) + require.Equal(t, requested, app.Status) + } + }) + } +} diff --git a/internal/claimer/accept.go b/internal/claimer/accept.go new file mode 100644 index 000000000..addf767d2 --- /dev/null +++ b/internal/claimer/accept.go @@ -0,0 +1,282 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" +) + +// acceptClaimsAndUpdateDatabase looks for ClaimAccepted events on chain and +// moves local epochs to CLAIM_ACCEPTED. +// +// Tick normally passes CLAIM_STAGED epochs here. Other reconciliation paths +// may move CLAIM_SUBMITTED or CLAIM_COMPUTED directly to CLAIM_ACCEPTED, and +// UpdateEpochWithAcceptedClaim accepts those source states. +// +// It returns the number of successful state changes and any errors. +func (s *Service) acceptClaimsAndUpdateDatabase( + ctx context.Context, + stagedEpochs map[int64]*model.Epoch, + apps map[int64]*model.Application, + defaultBlockNumber *big.Int, +) (int, error) { + return s.observeClaims(ctx, stagedEpochs, apps, defaultBlockNumber, s.processAcceptedClaimEvent) +} + +func (s *Service) processAcceptedClaimEvent( + ctx context.Context, + work claimWork, + defaultBlockNumber *big.Int, +) claimStepResult { + app := work.app + currEpoch := work.epoch + + if err := requireObservedStaging(currEpoch, defaultBlockNumber.Uint64()); err != nil { + return claimRetryLater(err) + } + + if err := s.checkConsensusForAddressChange(ctx, app, defaultBlockNumber); err != nil { + return claimDropped(err) + } + + _, currEvent, _, err := s.blockchain.findClaimAcceptedEventAndSucc( + ctx, app, currEpoch, currEpoch.LastBlock+1, defaultBlockNumber.Uint64(), + ) + if err != nil { + return claimDropped(err) + } + + if currEvent == nil || !claimAcceptedEventMatchesEpoch(app, currEpoch, currEvent) { + return claimNoProgress() + } + + s.Logger.Debug("Found ClaimAccepted Event", + "app", currEvent.AppContract, + "outputs_merkle_root", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), + "last_block", currEvent.LastProcessedBlockNumber.Uint64(), + ) + matches, ok := claimAcceptedEventMatches(app, currEpoch, currEvent) + if !ok { + return claimDropped(s.markMatcherPrecondFailure(ctx, app, currEpoch, "acceptClaimsAndUpdateDatabase")) + } + if !matches { + return claimDropped(s.markAcceptedDivergence(ctx, app, currEpoch, currEvent, "acceptClaimsAndUpdateDatabase")) + } + if s.defaultBlock.Provisional() { + claim, err := s.blockchain.getClaimStatus(ctx, app, currEpoch, defaultBlockNumber) + if err != nil { + return claimRetryLater(err) + } + if claim.Status != claimStatusAccepted || claim.StagedOutputsMerkleRoot != currEvent.OutputsMerkleRoot { + return claimRetryLater(txsender.ErrInconclusive) + } + if err := s.refreshStagedClaim(ctx, app, currEpoch, claim, defaultBlockNumber.Uint64()); err != nil { + return claimRetryLater(err) + } + } + s.Logger.Debug("Updating claim status to accepted", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), + "last_block", currEpoch.LastBlock, + ) + txHash := currEvent.Raw.TxHash + err = s.repository.UpdateEpochWithAcceptedClaim(ctx, currEpoch.ApplicationID, currEpoch.Index, &txHash) + if err != nil { + return claimDropped(err) + } + s.Logger.Info("Claim accepted", + "app", currEvent.AppContract, + "event_block_number", currEvent.Raw.BlockNumber, + "outputs_merkle_root", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), + "last_block", currEvent.LastProcessedBlockNumber.Uint64(), + "tx", txHash, + ) + return claimWorkCompleted(1) +} + +// observeStagedClaims checks committed staging facts against the configured view. +// The publisher collects ready accept +// candidates. Reader mode observes without constructing publication work. +func (s *Service) observeStagedClaims( + ctx context.Context, + stagedEpochs map[int64]*model.Epoch, + apps map[int64]*model.Application, + defaultBlockNumber *big.Int, +) (int, error) { + return s.observeClaims(ctx, stagedEpochs, apps, defaultBlockNumber, s.processStagedClaim) +} + +func (s *Service) processStagedClaim( + ctx context.Context, + work claimWork, + defaultBlockNumber *big.Int, +) claimStepResult { + app := work.app + currEpoch := work.epoch + + currentBlock, result, done := s.stagedClaimReadyForAccept(ctx, app, currEpoch, defaultBlockNumber) + if done { + return result + } + + // Read the claim state before sending acceptClaim. Use the same block + // number for all reads in this tick. + claim, err := s.blockchain.getClaimStatus(ctx, app, currEpoch, defaultBlockNumber) + if err != nil { + return claimRetryLater(fmt.Errorf("getClaim before acceptClaim (app=%v, epoch=%d): %w", + app.IApplicationAddress, currEpoch.Index, err)) + } + // A foreclosed application has no remaining on-chain path for a claim that + // is not already ACCEPTED, so terminalize it to CLAIM_FORECLOSED instead of + // running the generic accept/divergence handling (which would otherwise mark + // the app FAILED on an UNSTAGED reading and leave the drain behind a + // re-enable loop). + if app.ForecloseBlock != 0 && claim.Status != claimStatusAccepted { + return s.terminalizeForeclosedStagedClaim(ctx, app, currEpoch, currentBlock) + } + if result, done := s.handlePreAcceptClaimStatus(ctx, app, currEpoch, claim, currentBlock); done { + return result + } + return claimNoProgress() +} + +func (s *Service) stagedClaimReadyForAccept( + ctx context.Context, + app *model.Application, + currEpoch *model.Epoch, + defaultBlockNumber *big.Int, +) (uint64, claimStepResult, bool) { + if err := s.checkConsensusForAddressChange(ctx, app, defaultBlockNumber); err != nil { + return 0, claimRetryLater(err), true + } + + if currEpoch.StagedAtBlock == nil { + // Invariant: CLAIM_STAGED rows must have staged_at_block. The database + // CHECK should stop this from happening. + err := s.setApplicationCorrupted(ctx, app, + "epoch %d (%d) is CLAIM_STAGED but staged_at_block is nil", + currEpoch.Index, currEpoch.VirtualIndex) + return 0, claimRetryLater(err), true + } + + currentBlock := defaultBlockNumber.Uint64() + if err := requireObservedStaging(currEpoch, currentBlock); err != nil { + return currentBlock, claimRetryLater(err), true + } + if app.ForecloseBlock != 0 { + return currentBlock, claimNoProgress(), false + } + // The staging period has not passed yet. Try again in a later tick. + if currentBlock-*currEpoch.StagedAtBlock < app.ClaimStagingPeriod { + return currentBlock, claimNoProgress(), true + } + if !s.submissionEnabled { + // Reader mode does not send transactions. Wait until another party + // sends acceptClaim and we observe ClaimAccepted. + return currentBlock, claimNoProgress(), true + } + return currentBlock, claimNoProgress(), false +} + +func (s *Service) handlePreAcceptClaimStatus( + ctx context.Context, + app *model.Application, + currEpoch *model.Epoch, + claim iconsensus.IConsensusClaim, + currentBlock uint64, +) (claimStepResult, bool) { + switch claim.Status { + case claimStatusAccepted: // Another party accepted first; update our DB. + if s.defaultBlock.Provisional() { + if err := s.verifyClaimOutputsMatch(ctx, app, currEpoch, claim, "observeStagedClaims"); err != nil { + return claimRetryLater(err), true + } + if err := s.refreshStagedClaim(ctx, app, currEpoch, claim, currentBlock); err != nil { + return claimRetryLater(err), true + } + } + err := s.updateEpochAcceptedFromClaimStatus(ctx, app, currEpoch, claim, "observeStagedClaims") + if err != nil { + return claimRetryLater(err), true + } + s.Logger.Info("Claim accepted (front-run; observed via getClaim)", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), + "last_block", currEpoch.LastBlock, + ) + return claimWorkCompleted(1), true + case claimStatusUnstaged: + if s.defaultBlock.Provisional() { + return claimRetryLater(fmt.Errorf("%w: epoch %d staging is absent at provisional block %d", + txsender.ErrInconclusive, currEpoch.Index, currentBlock)), true + } + // A finalized view at or above the stored staging block contradicts + // the persisted history. Under an execution/root stop, FAILED cannot + // replace the terminal status; persist the integrity finding instead. + reason := fmt.Sprintf( + "getClaim returned UNSTAGED for epoch %d (%d) recorded as CLAIM_STAGED at block %d; "+ + "current block %d. Check the RPC endpoint and the registered chain's history; "+ + "rollback is unsupported.", + currEpoch.Index, currEpoch.VirtualIndex, *currEpoch.StagedAtBlock, currentBlock) + if app.Status.IsExecutionTerminal() || app.Status == model.ApplicationStatus_InvalidOutputsRoot { + reason += " This integrity finding is terminal and stops claim work and foreclosure drain. " + + "Rebuild and re-ingest into a fresh database after correcting the cause; re-enabling cannot clear CORRUPTED." + return claimRetryLater(appstatus.SetCorrupted(ctx, s.Logger, s.repository, app, reason)), true + } + reason += " Restore a coherent endpoint for the registered chain before re-enabling the application. " + + "If the persisted history no longer matches that chain, rebuild and re-ingest into a fresh database." + if ferr := appstatus.SetFailed(ctx, s.Logger, s.repository, app, reason); ferr != nil { + return claimRetryLater(fmt.Errorf("marking app FAILED on UNSTAGED pre-accept getClaim: %w", ferr)), true + } + return claimNoProgress(), true + case claimStatusStaged: + // Defense-in-depth invariant check. The chain says our claim is still + // STAGED, so its outputs root must match our local epoch. If it does + // not match, this node and the chain disagree about the claim data. + if vErr := s.verifyClaimOutputsMatch(ctx, app, currEpoch, claim, "observeStagedClaims"); vErr != nil { + return claimRetryLater(vErr), true + } + if s.defaultBlock.Provisional() { + if err := s.refreshStagedClaim(ctx, app, currEpoch, claim, currentBlock); err != nil { + return claimRetryLater(err), true + } + } + return claimNoProgress(), false + default: + // getClaim returned a ClaimStatus this node does not model (the enum is + // 0/1/2). This is not a transient hiccup: the value came from a typed + // view call, so the IConsensus contract may be newer than this node. + // SetFailed records a recoverable failure only while the application is + // nonterminal; it preserves ET/IOR. The publisher independently refuses + // this epoch with ErrUnsupported in either case. + if ferr := appstatus.SetFailedf(ctx, s.Logger, s.repository, app, + "getClaim returned unmodeled ClaimStatus %d for epoch %d (%d) — this node models "+ + "only 0/1/2. The IConsensus contract may be newer than this node supports; "+ + "upgrade the node or verify the contract before re-enabling.", + claim.Status, currEpoch.Index, currEpoch.VirtualIndex); ferr != nil { + return claimRetryLater(fmt.Errorf( + "marking app FAILED on unmodeled ClaimStatus %d: %w", claim.Status, ferr)), true + } + return claimNoProgress(), true + } +} + +func (s *Service) terminalizeForeclosedStagedClaim( + ctx context.Context, + app *model.Application, + currEpoch *model.Epoch, + observedBlock uint64, +) claimStepResult { + if ferr := s.forecloseClaim(ctx, app, currEpoch, observedBlock, "observeStagedClaims"); ferr != nil { + return claimRetryLater(ferr) + } + return claimWorkCompleted(1) +} diff --git a/internal/claimer/accept_test.go b/internal/claimer/accept_test.go new file mode 100644 index 000000000..a2236c156 --- /dev/null +++ b/internal/claimer/accept_test.go @@ -0,0 +1,490 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestAcceptClaimUsesCurrentEpochIdentity(t *testing.T) { + const ( + accepted = "accepted" + differentRoot = "different root" + notVisible = "not visible" + otherEpoch = "other epoch" + readError = "read error" + writeError = "write error" + ) + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + for _, mode := range []string{accepted, notVisible, otherEpoch, differentRoot, readError, writeError} { + t.Run(consensus.String()+"/"+mode, func(t *testing.T) { + s, repo, chain := newServiceMock(t) + app := makeApplication() + app.ConsensusType = consensus + previous := makeStagedEpoch(app, 1, 25) + current := makeStagedEpoch(app, 3, 45) + event := makeAcceptedEvent(app, current) + event.Raw.TxHash = common.HexToHash("0xabc") + var readErr, writeErr error + switch mode { + case notVisible: + event = nil + case otherEpoch: + event = makeAcceptedEvent(app, makeComputedEpoch(app, 2)) + case differentRoot: + event.OutputsMerkleRoot = common.HexToHash("0xbad") + case readError: + readErr = errors.New("event provider unavailable") + case writeError: + writeErr = errors.New("database unavailable") + } + chain.On("getConsensusAddress", mock.Anything, app, mock.Anything).Return(app.IConsensusAddress, nil).Once() + chain.On("findClaimAcceptedEventAndSucc", mock.Anything, app, current, current.LastBlock+1, uint64(100)). + Return(&iconsensus.IConsensus{}, event, makeAcceptedEvent(app, previous), readErr).Once() + if mode == accepted || mode == writeError { + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, current.Index, &event.Raw.TxHash). + Return(writeErr).Once() + } + if mode == differentRoot { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + } + count, err := s.acceptClaimsAndUpdateDatabase(t.Context(), makeEpochMap(current), + makeApplicationMap(app), big.NewInt(100)) + + switch mode { + case accepted: + require.NoError(t, err) + require.Equal(t, 1, count) + case notVisible, otherEpoch: + require.NoError(t, err) + require.Zero(t, count) + default: + require.Error(t, err) + require.Zero(t, count) + } + if mode != differentRoot { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + repo.AssertExpectations(t) + chain.AssertExpectations(t) + }) + } + } +} + +func TestConsensusAddressChangedOnAcceptedClaims(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + wrongConsensusAddress := app.IConsensusAddress + wrongConsensusAddress[0]++ + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(wrongConsensusAddress, nil). + Once() + r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil). + Once() + + _, err := m.acceptClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) +} + +func TestAcceptStagedFrontRunner(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusAccepted, currEpoch, stagedAt), nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, mock.Anything). + Return(nil).Once() + + transitions, err := m.observeStagedClaims(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 1, transitions) +} + +func TestAcceptStagedBroadcastsWhenClaimStillStaged(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + + transitions, err := m.observeStagedClaims(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Zero(t, transitions, "planning is not durable progress") + require.Nil(t, m.publisher, "observation does not construct publication state") +} + +func TestAcceptStagedFrontRunnerOutputsMismatchSetsDiverged(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + claim := makeClaimStatus(claimStatusAccepted, currEpoch, stagedAt) + claim.StagedOutputsMerkleRoot = common.HexToHash("0xdeadbeef") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(claim, nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + + transitions, err := m.observeStagedClaims(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, 0, transitions) +} + +// TestAcceptStagedUnmodeledClaimStatusFailsClosed verifies the open-enum guard +// on the getClaim path: a ClaimStatus this node does not model (here 3 — the +// enum is 0/1/2) must escalate the app to FAILED rather than skip silently +// every tick. No acceptClaim is broadcast. +func TestAcceptStagedUnmodeledClaimStatusFailsClosed(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(3, currEpoch, stagedAt), nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(nil).Once() + + transitions, err := m.observeStagedClaims(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Equal(t, 0, transitions) + // SetFailedf returns nil on success; the FAILED write is asserted by the mock. + assert.NoError(t, err) +} + +func TestAcceptStagedForeclosesForeclosedApp(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + m.submissionEnabled = false + endBlock := big.NewInt(52) + app := withForeclosed(makeApplication(), 51) + app.ClaimStagingPeriod = 100 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + // Chain reports STAGED (status 1) — non-foreclosed apps would + // produce an accept candidate. Foreclosed apps must not. + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, currEpoch.Index). + Return(nil).Once() + + ctx := context.Background() + transitions, err := m.observeStagedClaims( + ctx, makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.NoError(t, err) + assert.Equal(t, 1, transitions) + assert.Equal(t, model.EpochStatus_ClaimForeclosed, currEpoch.Status) +} + +// TestAcceptStagedForeclosesForeclosedAppOnUnstaged verifies that a foreclosed +// app whose chain claim reads UNSTAGED terminalizes the epoch to +// CLAIM_FORECLOSED rather than falling into the generic UNSTAGED handling that +// would mark the app FAILED (and never DIVERGED/CORRUPTED). The foreclosure +// guard fires before any health-status write, so the drain is not stuck behind +// a re-enable loop. +func TestAcceptStagedForeclosesForeclosedAppOnUnstaged(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(52) + app := withForeclosed(makeApplication(), 51) + app.ClaimStagingPeriod = 100 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + // Chain reports UNSTAGED (status 0). A non-foreclosed app would be marked + // FAILED here; a foreclosed app must terminalize the epoch instead. + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusUnstaged, currEpoch, stagedAt), nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, currEpoch.Index). + Return(nil).Once() + // CRITICAL: no UpdateApplicationStatus expectation — testify reports an + // unexpected call if the foreclosure guard fails and any FAILED/DIVERGED/ + // CORRUPTED write is attempted. + + ctx := context.Background() + transitions, err := m.observeStagedClaims( + ctx, makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.NoError(t, err) + assert.Equal(t, 1, transitions) + assert.Equal(t, model.EpochStatus_ClaimForeclosed, currEpoch.Status) + assert.Equal(t, model.ApplicationStatus_OK, app.Status, + "foreclosure terminalization must not change application health status") +} + +func TestAcceptStagedPeriodNotElapsed(t *testing.T) { + m, _, b := newServiceMock(t) + defer b.AssertExpectations(t) + + app := makeApplication() + app.ClaimStagingPeriod = 100 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + + endBlock := big.NewInt(60) // only 10 blocks elapsed; need 100. + transitions, err := m.observeStagedClaims(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 0, transitions) +} + +// TestAcceptStagedReaderMode — submissionEnabled=false; no acceptClaim tx +// is ever issued even when the period has elapsed. Caller waits for +// someone else to call acceptClaim (observed via the ClaimAccepted scan). +func TestAcceptStagedReaderMode(t *testing.T) { + m, _, b := newServiceMock(t) + defer b.AssertExpectations(t) + m.submissionEnabled = false + + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + endBlock := big.NewInt(100) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + + transitions, err := m.observeStagedClaims(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 0, transitions) +} + +// TestAcceptanceDivergence_QuorumStagedDoesNotRejectEpoch verifies that a +// divergent accepted claim observed after our claim is already staged halts the +// app without rewriting the epoch to CLAIM_REJECTED. Under Quorum this is an +// invariant violation, not the normal outvoted path. +func TestAcceptanceDivergence_QuorumStagedDoesNotRejectEpoch(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: common.HexToHash("0xbad"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "quorum_divergence_at_acceptance") + })). + Return(nil).Once() + + _, err := m.acceptClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Diverged, app.Status) + assert.Equal(t, model.EpochStatus_ClaimStaged, currEpoch.Status) +} + +func TestAcceptanceDivergence_QuorumComputedRejectsEpoch(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: common.HexToHash("0xbad"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("RejectEpochAndSetApplicationDiverged", mock.Anything, app.ID, currEpoch.Index, mock.MatchedBy(func(reason string) bool { + return strings.Contains(reason, "quorum_divergence_at_acceptance") + })). + Return(repository.RejectEpochAndDivergeResult{ + EpochRejected: true, + ApplicationDiverged: true, + }, nil).Once() + + _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Diverged, app.Status) + assert.Equal(t, model.EpochStatus_ClaimRejected, currEpoch.Status) +} + +func TestAcceptanceDivergence_AuthorityComputedSetsDivergedWithoutRejectingEpoch(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: common.HexToHash("0xbad"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "authority_divergence_at_acceptance") + })). + Return(nil).Once() + + _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Diverged, app.Status) + assert.Equal(t, model.EpochStatus_ClaimComputed, currEpoch.Status) +} + +func TestAcceptanceDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: common.HexToHash("0xbad"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + + _, err := m.acceptClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Diverged, app.Status) + assert.Equal(t, model.EpochStatus_ClaimStaged, currEpoch.Status) +} + +// TestStagingDivergenceReaderMode_Quorum — reader-mode parity: with +// submissionEnabled=false, a divergent ClaimStaged event still fires the +// same DIVERGED transition as in submit mode. No tx is ever issued (the +// stage's broadcast path is unconditionally skipped, so we don't even need + +func TestAcceptanceDivergenceReaderMode_Quorum(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + m.submissionEnabled = false + + endBlock := big.NewInt(100) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + divergent := &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: common.HexToHash("0xbad"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "quorum_divergence_at_acceptance") + })). + Return(nil).Once() + + _, err := m.acceptClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err, "acceptance divergence detection must fire in reader mode") + assert.Equal(t, model.EpochStatus_ClaimStaged, currEpoch.Status) +} diff --git a/internal/claimer/accepted_order_test.go b/internal/claimer/accepted_order_test.go new file mode 100644 index 000000000..324ab4cd1 --- /dev/null +++ b/internal/claimer/accepted_order_test.go @@ -0,0 +1,172 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +// Match claim events to the current epoch: +// the real claimerBlockchain event search runs against an in-process JSON-RPC +// server. A third party accepts epoch e+1 (block 40) before epoch e (block 45); +// observing e must return e's event, not the first acceptance after e.LastBlock. +// Oracle: AbstractConsensus emits ClaimAccepted(appContract, lastProcessedBlockNumber, ...) +// per epoch and acceptClaim is permissionless with no cross-epoch order. + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type acceptedOrderRPC struct { + byHash map[common.Hash]uint64 + logs []types.Log +} + +func (s *acceptedOrderRPC) GetBlockByNumber(_ context.Context, number rpc.BlockNumber, _ bool) *types.Header { + if number < 0 { + return deliveryHeader(100) + } + return deliveryHeader(uint64(number)) +} + +func (s *acceptedOrderRPC) count(block uint64) int64 { + var n int64 + for _, l := range s.logs { + if l.BlockNumber <= block { + n++ + } + } + return n +} + +func (s *acceptedOrderRPC) Call(_ context.Context, call struct { + Input hexutil.Bytes + Gas *hexutil.Uint64 +}, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + var n uint64 + if hash, ok := block.Hash(); ok { + known, found := s.byHash[hash] + if !found { + return nil, errors.New("unknown block hash") + } + n = known + } else if number, ok := block.Number(); ok && number >= 0 { + n = uint64(number) + } else { + return nil, errors.New("unsupported block selector") + } + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + return nil, err + } + method, err := parsed.MethodById(call.Input[:4]) + if err != nil { + return nil, errors.New("unexpected call") + } + switch method.Name { + case "getNumberOfAcceptedClaims", "getNumberOfStagedClaims", "getNumberOfSubmittedClaims": + return method.Outputs.Pack(big.NewInt(s.count(n))) + default: + return nil, errors.New("unexpected counter " + method.Name) + } +} + +type acceptedOrderFilter struct { + FromBlock *hexutil.Big `json:"fromBlock"` + ToBlock *hexutil.Big `json:"toBlock"` + Address []common.Address `json:"address"` + Topics [][]common.Hash `json:"topics"` +} + +func (s *acceptedOrderRPC) GetLogs(_ context.Context, arg acceptedOrderFilter) ([]types.Log, error) { + from, to := arg.FromBlock.ToInt().Uint64(), arg.ToBlock.ToInt().Uint64() + var out []types.Log + for _, l := range s.logs { + if l.BlockNumber >= from && l.BlockNumber <= to { + out = append(out, l) + } + } + return out, nil +} + +func TestClaimEventScansMatchCurrentEpochAfterLaterEpochFirst(t *testing.T) { + for _, name := range []string{"ClaimAccepted", "ClaimStaged", claimSubmittedEventName} { + for _, divergent := range []bool{false, true} { + rootCase := "matching roots" + if divergent { + rootCase = "different roots for current epoch" + } + t.Run(name+"/"+rootCase, func(t *testing.T) { + app, epoch := deliveryApplication(1, "Authority") + epoch.LastBlock = 20 + root := *epoch.TxBufferDataBlock + if divergent { + root[0] ^= 1 + } + api := &acceptedOrderRPC{byHash: map[common.Hash]uint64{}} + for n := uint64(0); n <= 100; n++ { + api.byHash[deliveryHeader(n).Hash()] = n + } + for i, event := range []struct { + epochBlock, block uint64 + }{{30, 40}, {epoch.LastBlock, 45}} { + e := *epoch + e.LastBlock = event.epochBlock + e.TxBufferDataBlock = &root + log := *claimEventLog(app, &e, name) + log.BlockNumber, log.BlockHash = event.block, deliveryHeader(event.block).Hash() + log.TxHash = common.BigToHash(big.NewInt(int64(i + 1))) + api.logs = append(api.logs, log) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", api)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + cb := &claimerBlockchain{client: client, backend: txsender.ProtectedClient{Client: client}, + observed: txsender.BlockRef{Number: 100, Hash: deliveryHeader(100).Hash()}} + var lastBlock *big.Int + var currentRoot common.Hash + var eventBlock uint64 + var matches, complete bool + switch name { + case "ClaimAccepted": + _, current, _, err := cb.findClaimAcceptedEventAndSucc(t.Context(), app, epoch, epoch.LastBlock+1, 100) + require.NoError(t, err) + require.NotNil(t, current) + lastBlock = current.LastProcessedBlockNumber + currentRoot, eventBlock = current.OutputsMerkleRoot, current.Raw.BlockNumber + matches, complete = claimAcceptedEventMatches(app, epoch, current) + case "ClaimStaged": + _, current, _, err := cb.findClaimStagedEventAndSucc(t.Context(), app, epoch, epoch.LastBlock+1, 100) + require.NoError(t, err) + require.NotNil(t, current) + lastBlock = current.LastProcessedBlockNumber + currentRoot, eventBlock = current.OutputsMerkleRoot, current.Raw.BlockNumber + matches, complete = claimStagedEventMatches(app, epoch, current) + case claimSubmittedEventName: + _, current, err := cb.findClaimSubmittedEventAndSucc(t.Context(), app, epoch, epoch.LastBlock+1, 100) + require.NoError(t, err) + require.NotEmpty(t, current) + lastBlock = current[0].LastProcessedBlockNumber + currentRoot, eventBlock = current[0].OutputsMerkleRoot, current[0].Raw.BlockNumber + matches, complete = claimSubmittedEventMatches(app, epoch, current[0]) + } + require.Equal(t, epoch.LastBlock, lastBlock.Uint64(), "skip the later epoch's earlier event") + require.Equal(t, uint64(45), eventBlock) + require.Equal(t, root, currentRoot, "a different claim for this epoch must remain visible") + require.True(t, complete) + require.Equal(t, !divergent, matches) + }) + } + } +} diff --git a/internal/claimer/anvil_acceptance_recovery_test.go b/internal/claimer/anvil_acceptance_recovery_test.go new file mode 100644 index 000000000..ff8643ac0 --- /dev/null +++ b/internal/claimer/anvil_acceptance_recovery_test.go @@ -0,0 +1,220 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package claimer + +import ( + "context" + "errors" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + "io" + "math/big" + "os" + "testing" + "time" +) + +// Faults affect the sender's provider boundary. Observation and every successful +// request use real Anvil state; repository selectors and writes use PostgreSQL. +type acceptanceFaultBackend struct { + txsender.ProtectedClient + callError error + loseReply bool + publications []*types.Transaction + failedCalls int +} + +func (b *acceptanceFaultBackend) Call(ctx context.Context, call ethereum.CallMsg, ref txsender.BlockRef) ([]byte, error) { + if b.callError != nil { + b.failedCalls++ + return nil, b.callError + } + return b.ProtectedClient.Call(ctx, call, ref) +} + +func (b *acceptanceFaultBackend) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + hash, err := b.ProtectedClient.Publish(ctx, raw) + if err != nil { + return hash, err + } + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return hash, err + } + b.publications = append(b.publications, tx) + if b.loseReply { + return common.Hash{}, io.ErrUnexpectedEOF + } + return hash, nil +} + +func TestAnvilAuthorityAcceptanceRecoversFromOperationalFaults(t *testing.T) { + if os.Getenv("CARTESI_CLAIMER_ANVIL_TEST") != "1" { + t.Skip("set CARTESI_CLAIMER_ANVIL_TEST=1 and the isolated capacity admin connection for this release fixture") + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + repo := newCapacityRepository(ctx, t) + client, signer, chainID := capacityChain(ctx, t) + _, err := repo.RegisterObserver(ctx, "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: chainID.Uint64(), + }) + require.NoError(t, err) + app, epoch := seedAcceptanceApplication(ctx, t, repo, client, signer) + s := newCapacityService(ctx, t, repo, client, signer, chainID) + backend := &acceptanceFaultBackend{ProtectedClient: txsender.ProtectedClient{Client: client}} + s.publisher.backend = backend + s.blockchain.(*claimerBlockchain).backend = backend + s.publisher.sender, err = txsender.New[claimAction](backend, ethutil.NewStaticTransactOptsFactory(signer), chainID.Uint64(), + txsender.Policy{MaxFee: big.NewInt(1_000_000_000_000)}, + s.guardClaimPublication, s.Logger) + require.NoError(t, err) + consensus, err := iconsensus.NewIConsensus(app.IConsensusAddress, client) + require.NoError(t, err) + tx, err := consensus.SubmitClaim(signer, app.IApplicationAddress, new(big.Int).SetUint64(epoch.LastBlock), + [32]byte(*epoch.MachineHash), consensusMachineValidityProof(mustEpochProof(t, epoch))) + require.NoError(t, err) + staged := anvilClaimMined(ctx, t, client, tx) + anvilClaimFinalized(ctx, t, client, staged.BlockNumber.Uint64()) + setAcceptanceBalance(ctx, t, client, signer.From, "0x0") + + // More passes than the old attempt limit must not disable the application. + for range 7 { + clearAcceptanceCooldowns(s) + tickAcceptance(ctx, t, s) + assertAcceptanceRunnable(ctx, t, repo, app) + } + require.Empty(t, backend.publications) + stored, err := repo.GetEpoch(ctx, app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimStaged, stored.Status, "Observe records the real staged claim") + setAcceptanceBalance(ctx, t, client, signer.From, "0x56bc75e2d63100000") + for _, code := range []int{429, 502} { + before := backend.failedCalls + backend.callError = rpc.HTTPError{StatusCode: code, Status: "injected preparation failure"} + for range 7 { + clearAcceptanceCooldowns(s) + tickAcceptance(ctx, t, s) + assertAcceptanceRunnable(ctx, t, repo, app) + } + require.GreaterOrEqual(t, backend.failedCalls-before, 7) + require.Empty(t, backend.publications) + } + + backend.callError = nil + backend.loseReply = true + clearAcceptanceCooldowns(s) + tickAcceptance(ctx, t, s) + require.Len(t, backend.publications, 1, "restored preparation publishes without manual enablement") + accept := backend.publications[0] + mined := anvilClaimMined(ctx, t, client, accept) + require.Equal(t, uint64(types.ReceiptStatusSuccessful), mined.Status) + anvilClaimFinalized(ctx, t, client, mined.BlockNumber.Uint64()) + for range 7 { + tickAcceptance(ctx, t, s) + assertAcceptanceRunnable(ctx, t, repo, app) + } + backend.loseReply = false + _, err = s.Tick(ctx) + require.NoError(t, err) + require.NoError(t, s.publisher.tick(ctx)) + assertAcceptanceRunnable(ctx, t, repo, app) + stored, err = repo.GetEpoch(ctx, app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimAccepted, stored.Status) + require.Len(t, backend.publications, 1, "recovery must not send a second acceptance") +} + +func tickAcceptance(ctx context.Context, t *testing.T, s *Service) { + t.Helper() + _, observeErr := s.Tick(ctx) + err := errors.Join(observeErr, s.publisher.tick(ctx)) + if err != nil { + require.False(t, txsender.HardFailure(err), "operational fault became a hard failure: %v", err) + t.Logf("operational wait: %v", err) + } +} + +func clearAcceptanceCooldowns(s *Service) { + // Advance only the fixture's wall-clock hints; do not wait minutes between + // faults or reset the current request or DB state. + for key := range s.publisher.cooldowns { + s.publisher.cooldowns[key] = time.Time{} + } +} + +func assertAcceptanceRunnable(ctx context.Context, t *testing.T, repo repository.Repository, app *model.Application) { + t.Helper() + stored, err := repo.GetApplication(ctx, app.Name) + require.NoError(t, err) + require.True(t, stored.Enabled) + require.Equal(t, model.ApplicationStatus_OK, stored.Status) +} + +func setAcceptanceBalance(ctx context.Context, t *testing.T, client *ethclient.Client, address common.Address, value string) { + t.Helper() + var result any + require.NoError(t, client.Client().CallContext(ctx, &result, "anvil_setBalance", address, value)) +} + +func seedAcceptanceApplication( + ctx context.Context, t *testing.T, repo repository.Repository, client *ethclient.Client, signer *bind.TransactOpts, +) (*model.Application, *model.Epoch) { + t.Helper() + factory, err := iauthorityfactory.NewIAuthorityFactory(anvilFixtureAddress( + "CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS", "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9"), client) + require.NoError(t, err) + salt := crypto.Keccak256Hash(signer.From.Bytes()) + consensus, err := factory.CalculateAuthorityAddress(&bind.CallOpts{Context: ctx}, signer.From, big.NewInt(1), new(big.Int), salt) + require.NoError(t, err) + tx, err := factory.NewAuthority0(signer, signer.From, big.NewInt(1), new(big.Int), salt) + require.NoError(t, err) + anvilClaimMined(ctx, t, client, tx) + appFactory, err := iapplicationfactory.NewIApplicationFactory(anvilFixtureAddress( + "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS", "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F"), client) + require.NoError(t, err) + inputBox := anvilFixtureAddress("CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C") + withdrawal := iapplicationfactory.WithdrawalConfig{} + address, err := appFactory.CalculateApplicationAddress( + &bind.CallOpts{Context: ctx}, consensus, signer.From, salt, inputBox, withdrawal, salt) + require.NoError(t, err) + tx, err = appFactory.NewApplication0(signer, consensus, signer.From, salt, inputBox, withdrawal, salt) + require.NoError(t, err) + deployed := anvilClaimMined(ctx, t, client, tx).BlockNumber.Uint64() + anvilClaimFinalized(ctx, t, client, deployed) + app := repotest.NewApplicationBuilder().WithName("acceptance-recovery").WithAddress(address). + WithEpochLength(1).WithClaimStagingPeriod(0).WithWithdrawalConfig(model.WithdrawalConfig{}).Build() + app.IConsensusAddress, app.IInputBoxAddress, app.TemplateHash = consensus, inputBox, salt + app.IInputBoxBlock, app.LastInputCheckBlock = deployed, deployed + app.ID, err = repo.CreateApplication(ctx, app, false) + require.NoError(t, err) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed). + WithBlocks(deployed, deployed).WithInputBounds(0, 0).Build() + require.NoError(t, repo.CreateEpochsAndInputs(ctx, app.Name, map[*model.Epoch][]*model.Input{epoch: nil}, deployed)) + proof := repotest.KeccakStateProof(salt) + require.NoError(t, repo.UpdateEpochInputsProcessed(ctx, app.Name, 0, &proof)) + epoch, err = repo.GetEpoch(ctx, app.Name, 0) + require.NoError(t, err) + repotest.AdvanceEpochStatus(ctx, t, repo, app.Name, epoch, model.EpochStatus_ClaimComputed) + epoch, err = repo.GetEpoch(ctx, app.Name, 0) + require.NoError(t, err) + return app, epoch +} diff --git a/internal/claimer/anvil_capacity_test.go b/internal/claimer/anvil_capacity_test.go new file mode 100644 index 000000000..482d85aa0 --- /dev/null +++ b/internal/claimer/anvil_capacity_test.go @@ -0,0 +1,282 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package claimer + +import ( + "context" + "fmt" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres" + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + "io" + "log/slog" + "math/big" + "net/url" + "os" + "sync" + "testing" + "time" +) + +const ( + capacityApplications = 16 + capacityPollInterval = 3 * time.Second // the configured Claimer default + capacityDispatchTime = 120 * time.Second +) + +type claimPublication struct { + hash common.Hash + nonce uint64 + at time.Time +} + +type capacityBackend struct { + txsender.ProtectedClient + mu sync.Mutex + publications map[common.Address]claimPublication +} + +func (b *capacityBackend) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + hash, err := b.ProtectedClient.Publish(ctx, raw) + if err != nil { + return hash, err + } + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return hash, err + } + if data := tx.Data(); len(data) >= 36 { + app := common.BytesToAddress(data[16:36]) + b.mu.Lock() + if _, recorded := b.publications[app]; !recorded { + b.publications[app] = claimPublication{hash: hash, nonce: tx.Nonce(), at: time.Now()} + } + b.mu.Unlock() + } + return hash, nil +} + +func (b *capacityBackend) snapshot() map[common.Address]claimPublication { + b.mu.Lock() + defer b.mu.Unlock() + result := make(map[common.Address]claimPublication, len(b.publications)) + for app, publication := range b.publications { + result[app] = publication + } + return result +} + +// This release fixture measures an initial burst with real PostgreSQL selectors, +// the production Serve loop and naturally produced Anvil blocks. +// A fresh database and random funded signer keep it independent of other tests. +func TestAnvilClaimerInitialSixteenApplicationCapacity(t *testing.T) { + if os.Getenv("CARTESI_CLAIMER_CAPACITY_TEST") != "1" { + t.Skip("set CARTESI_CLAIMER_CAPACITY_TEST=1 for the isolated PostgreSQL + Anvil capacity fixture") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + repo := newCapacityRepository(ctx, t) + client, signer, chainID := capacityChain(ctx, t) + _, err := repo.RegisterObserver(ctx, "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: chainID.Uint64(), + }) + require.NoError(t, err) + apps, deployment := seedCapacityApplications(ctx, t, repo, client, signer) + anvilClaimFinalized(ctx, t, client, deployment) + s := newCapacityService(ctx, t, repo, client, signer, chainID) + backend := &capacityBackend{ProtectedClient: txsender.ProtectedClient{Client: client}, + publications: make(map[common.Address]claimPublication)} + s.publisher.backend = backend + s.blockchain.(*claimerBlockchain).backend = backend + s.publisher.sender, err = txsender.New[claimAction](backend, ethutil.NewStaticTransactOptsFactory(signer), chainID.Uint64(), + txsender.Policy{MaxFee: big.NewInt(1_000_000_000_000)}, + s.guardClaimPublication, s.Logger) + require.NoError(t, err) + startHeader, err := client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + started := time.Now() + run, stop := context.WithCancel(ctx) + finished := make(chan error, 1) + go func() { finished <- s.Serve(run) }() + defer func() { + stop() + require.ErrorIs(t, <-finished, context.Canceled) + }() + require.Eventually(t, func() bool { return len(backend.snapshot()) == len(apps) }, capacityDispatchTime, 250*time.Millisecond, + "every initially ready application must receive its first publication without starvation") + publications := backend.snapshot() + var lastDispatch time.Duration + nonces := make(map[uint64]bool, len(apps)) + var lastReceipt uint64 + for _, app := range apps { + publication, ok := publications[app.IApplicationAddress] + require.True(t, ok, "application %s received no publication", app.Name) + latency := publication.at.Sub(started) + require.Less(t, latency, capacityDispatchTime) + lastDispatch = max(lastDispatch, latency) + require.False(t, nonces[publication.nonce], "distinct initial actions use distinct consumed nonces") + nonces[publication.nonce] = true + tx, _, err := client.TransactionByHash(ctx, publication.hash) + require.NoError(t, err) + receipt := anvilClaimMined(ctx, t, client, tx) + lastReceipt = max(lastReceipt, receipt.BlockNumber.Uint64()) + t.Logf("initial app=%s nonce=%d dispatch=%s receipt=%d gas_used=%d", app.Name, publication.nonce, + latency.Round(time.Millisecond), receipt.BlockNumber.Uint64(), receipt.GasUsed) + } + endHeader, err := client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + blocks := endHeader.Number.Uint64() - startHeader.Number.Uint64() + t.Logf("measured capacity: apps=%d poll=%s last_dispatch=%s natural_blocks=%d block_timestamp_span=%ds", + len(apps), capacityPollInterval, lastDispatch.Round(time.Millisecond), blocks, endHeader.Time-startHeader.Time) + anvilClaimFinalized(ctx, t, client, lastReceipt) + require.Eventually(t, func() bool { return capacityClaimsStaged(ctx, repo, apps) }, 30*time.Second, 250*time.Millisecond, + "Observe must project every finalized claim into PostgreSQL") + require.True(t, s.Ready()) +} + +func capacityClaimsStaged(ctx context.Context, repo repository.Repository, apps []*model.Application) bool { + for _, app := range apps { + epoch, err := repo.GetEpoch(ctx, app.Name, 0) + if err != nil || epoch == nil || epoch.Status != model.EpochStatus_ClaimStaged { + return false + } + } + return true +} + +func capacityChain(ctx context.Context, t *testing.T) (*ethclient.Client, *bind.TransactOpts, *big.Int) { + t.Helper() + endpoint := os.Getenv("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8545" + } + client, err := ethclient.DialContext(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(client.Close) + chainID, err := client.ChainID(ctx) + require.NoError(t, err) + return client, anvilClaimSigner(ctx, t, client, chainID), chainID +} + +func seedCapacityApplications( + ctx context.Context, t *testing.T, repo repository.Repository, client *ethclient.Client, signer *bind.TransactOpts, +) ([]*model.Application, uint64) { + t.Helper() + factory, err := iauthorityfactory.NewIAuthorityFactory(anvilFixtureAddress( + "CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS", "0xB4d29c86e36385b5321a453C34D288AEB0ad11f9"), client) + require.NoError(t, err) + salt := crypto.Keccak256Hash(signer.From.Bytes()) + stagingPeriod := big.NewInt(1_000_000) // acceptance cannot preempt this measured initial submission burst + consensus, err := factory.CalculateAuthorityAddress(&bind.CallOpts{Context: ctx}, signer.From, big.NewInt(1), stagingPeriod, salt) + require.NoError(t, err) + tx, err := factory.NewAuthority0(signer, signer.From, big.NewInt(1), stagingPeriod, salt) + require.NoError(t, err) + anvilClaimMined(ctx, t, client, tx) + appFactory, err := iapplicationfactory.NewIApplicationFactory(anvilFixtureAddress( + "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS", "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F"), client) + require.NoError(t, err) + inputBox := anvilFixtureAddress("CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C") + apps := make([]*model.Application, 0, capacityApplications) + var lastDeployment uint64 + for index := range capacityApplications { + appSalt := crypto.Keccak256Hash(salt.Bytes(), big.NewInt(int64(index)).Bytes()) + withdrawal := iapplicationfactory.WithdrawalConfig{} + address, err := appFactory.CalculateApplicationAddress( + &bind.CallOpts{Context: ctx}, consensus, signer.From, appSalt, inputBox, withdrawal, appSalt) + require.NoError(t, err) + tx, err := appFactory.NewApplication0(signer, consensus, signer.From, appSalt, inputBox, withdrawal, appSalt) + require.NoError(t, err) + receipt := anvilClaimMined(ctx, t, client, tx) + lastDeployment = receipt.BlockNumber.Uint64() + app := repotest.NewApplicationBuilder().WithName(fmt.Sprintf("capacity-%02d", index)).WithAddress(address). + WithEpochLength(1).WithClaimStagingPeriod(stagingPeriod.Uint64()).WithWithdrawalConfig(model.WithdrawalConfig{}).Build() + app.IConsensusAddress, app.IInputBoxAddress, app.TemplateHash = consensus, inputBox, appSalt + app.IInputBoxBlock, app.LastInputCheckBlock = lastDeployment, lastDeployment + app.ID, err = repo.CreateApplication(ctx, app, false) + require.NoError(t, err) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed). + WithBlocks(lastDeployment, lastDeployment).WithInputBounds(0, 0).Build() + require.NoError(t, repo.CreateEpochsAndInputs(ctx, app.Name, map[*model.Epoch][]*model.Input{epoch: nil}, lastDeployment)) + proof := repotest.KeccakStateProof(appSalt) + require.NoError(t, repo.UpdateEpochInputsProcessed(ctx, app.Name, 0, &proof)) + epoch, err = repo.GetEpoch(ctx, app.Name, 0) + require.NoError(t, err) + epoch.Status = model.EpochStatus_ClaimComputed + require.NoError(t, repo.UpdateEpochStatus(ctx, app.Name, epoch)) + apps = append(apps, app) + } + return apps, lastDeployment +} + +func newCapacityService( + ctx context.Context, t *testing.T, repo repository.Repository, client *ethclient.Client, + signer *bind.TransactOpts, chainID *big.Int, +) *Service { + t.Helper() + svc, err := Create(ctx, &CreateInfo{ + Config: config.ClaimerConfig{ + ClaimerPollingInterval: capacityPollInterval, BlockchainId: chainID.Uint64(), + BlockchainDefaultBlock: model.DefaultBlock_Finalized, FeatureClaimSubmissionEnabled: true, + BlockchainHttpRequestTimeout: 10 * time.Second, + BlockchainTxMaxFeePerGas: "1000000000000", + }, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), EthConn: client, Repository: repo, + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(signer), + }) + require.NoError(t, err) + return svc.(*Service) +} + +func newCapacityRepository(ctx context.Context, t *testing.T) repository.Repository { + t.Helper() + endpoint := os.Getenv("CARTESI_CLAIMER_CAPACITY_ADMIN_CONNECTION") + require.NotEmpty(t, endpoint, "CARTESI_CLAIMER_CAPACITY_ADMIN_CONNECTION explicitly authorizes a fresh isolated database") + admin, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, admin.Close(closeCtx)) + }) + name := fmt.Sprintf("claimer_capacity_%d", time.Now().UnixNano()) + _, err = admin.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize()) + require.NoError(t, err) + t.Cleanup(func() { + // DROP DATABASE waits for a cluster-wide checkpoint. Parallel schema + // fixtures can delay that checkpoint after every owned connection closed. + const dropTimeout = 30 * time.Second + dropCtx, cancel := context.WithTimeout(context.Background(), dropTimeout) + defer cancel() + _, err := admin.Exec(dropCtx, "DROP DATABASE "+pgx.Identifier{name}.Sanitize()) + require.NoError(t, err) + }) + isolated, err := url.Parse(endpoint) + require.NoError(t, err) + require.NotEmpty(t, isolated.Host, "the test database endpoint must be a PostgreSQL URL") + isolated.Path = "/" + name + migration, err := schema.New(isolated.String()) + require.NoError(t, err) + require.NoError(t, migration.Upgrade()) + migration.Close() + repo, err := postgres.NewPostgresRepository(ctx, isolated.String(), 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + return repo +} diff --git a/internal/claimer/anvil_delivery_test.go b/internal/claimer/anvil_delivery_test.go new file mode 100644 index 000000000..57c502083 --- /dev/null +++ b/internal/claimer/anvil_delivery_test.go @@ -0,0 +1,302 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package claimer + +import ( + "context" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + "math/big" + "os" + "testing" + "time" +) + +const anvilClaimFixtureTimeout = 2 * time.Minute + +// X12 runs against the installed alpha.10 factories with independently funded +// random accounts. It does not reset Anvil, revert snapshots, or use a node key. +func TestAnvilQuorumDecidingVoteGas(t *testing.T) { + if os.Getenv("CARTESI_CLAIMER_ANVIL_TEST") != "1" { + t.Skip("set CARTESI_CLAIMER_ANVIL_TEST=1 to run fresh-contract Anvil release fixtures") + } + ctx, cancel := context.WithTimeout(t.Context(), anvilClaimFixtureTimeout) + defer cancel() + endpoint := os.Getenv("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8545" + } + client, err := ethclient.DialContext(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(client.Close) + chainID, err := client.ChainID(ctx) + require.NoError(t, err) + quorumFactory, err := iquorumfactory.NewIQuorumFactory(anvilFixtureAddress( + "CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS", "0x0754D5Eb680c71bf469B39e48C5b64AB0813fdb9"), client) + require.NoError(t, err) + version, err := quorumFactory.Version(&bind.CallOpts{Context: ctx}) + require.NoError(t, err) + require.EqualValues(t, 3, version.Major) + require.Equal(t, "alpha.10", version.PreRelease, "this profile is calibrated for the repository's supported version") + appFactory, err := iapplicationfactory.NewIApplicationFactory(anvilFixtureAddress( + "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS", "0x35Cd91f13141Bb6A6FC69E1eeDD241bbA1Ddd45F"), client) + require.NoError(t, err) + inputBox := anvilFixtureAddress("CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", "0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C") + for _, fixture := range []struct { + name string + expensive bool + calibrated bool + }{ + {"ordinary_estimate_only", false, false}, + {"ordinary_calibrated", false, true}, + {"expensive_estimate_only", true, false}, + {"expensive_calibrated", true, true}, + } { + t.Run(fixture.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + node, rival, other := anvilClaimSigner(ctx, t, client, chainID), + anvilClaimSigner(ctx, t, client, chainID), anvilClaimSigner(ctx, t, client, chainID) + validators := []common.Address{node.From, rival.From, other.From} + salt := crypto.Keccak256Hash(node.From.Bytes()) + quorumAddress, err := quorumFactory.CalculateQuorumAddress( + &bind.CallOpts{Context: ctx}, validators, big.NewInt(1), new(big.Int), salt) + require.NoError(t, err) + tx, err := quorumFactory.NewQuorum(node, validators, big.NewInt(1), new(big.Int), salt) + require.NoError(t, err) + anvilClaimMined(ctx, t, client, tx) + withdrawal := iapplicationfactory.WithdrawalConfig{} + appAddress, err := appFactory.CalculateApplicationAddress( + &bind.CallOpts{Context: ctx}, quorumAddress, node.From, salt, inputBox, withdrawal, salt) + require.NoError(t, err) + tx, err = appFactory.NewApplication0(node, quorumAddress, node.From, salt, inputBox, withdrawal, salt) + require.NoError(t, err) + deployment := anvilClaimMined(ctx, t, client, tx) + if fixture.expensive { + appAddress, deployment = anvilExpensiveAppProxy(ctx, t, client, node, appAddress) + } + facts := anvilClaimFinalized(ctx, t, client, deployment.BlockNumber.Uint64()) + app, epoch := deliveryApplication(1, model.Consensus_Quorum) + app.IApplicationAddress, app.IConsensusAddress = appAddress, quorumAddress + app.ClaimStagingPeriod = 0 + epoch.LastBlock = facts.Number // epoch length one: every block is an epoch final block + s, _, lookup := newDeliveryFixture(t) + backend := &decidingVoteBackend{ProtectedClient: txsender.ProtectedClient{Client: client}} + s.publisher.backend, s.publisher.signer = backend, node.From + s.blockchain = &claimerBlockchain{txOptsFactory: ethutil.NewStaticTransactOptsFactory(node)} + lookup.add(app, epoch) + s.publisher.sender, err = txsender.New[claimAction](backend, ethutil.NewStaticTransactOptsFactory(node), chainID.Uint64(), + txsender.Policy{MaxFee: big.NewInt(1_000_000_000_000)}, func(context.Context) error { return nil }, s.Logger) + require.NoError(t, err) + candidate, err := claimCall(app, epoch, mustEpochProof(t, epoch), true) + require.NoError(t, err) + + before, err := client.EstimateGas(ctx, ethereum.CallMsg{From: node.From, To: &quorumAddress, Data: candidate.request.Data}) + require.NoError(t, err) + quorum, err := iconsensus.NewIConsensus(quorumAddress, client) + require.NoError(t, err) + var after uint64 + // The rival executes after our final preflight, immediately before raw + // publication. This exercises the unavoidable post-check state race. + backend.beforePublication = func() { + rivalTx, rivalErr := quorum.SubmitClaim(rival, appAddress, new(big.Int).SetUint64(epoch.LastBlock), + [32]byte(*epoch.MachineHash), consensusMachineValidityProof(mustEpochProof(t, epoch))) + require.NoError(t, rivalErr) + anvilClaimMined(ctx, t, client, rivalTx) + after, rivalErr = client.EstimateGas(ctx, ethereum.CallMsg{ + From: node.From, To: &quorumAddress, Data: candidate.request.Data}) + require.NoError(t, rivalErr) + require.Greater(t, after, before) + } + var hash common.Hash + if fixture.calibrated { + planner := &claimFixturePlanner{publisher: s.publisher, candidate: candidate} + report, publishErr := s.publisher.sender.PublishNext(ctx, planner) + require.NoError(t, publishErr) + require.True(t, report.Attempted) + hash = report.Hash + } else { + hash = anvilPublishEstimateControl(ctx, t, backend, node, chainID, candidate.request, before) + } + + mined, _, err := client.TransactionByHash(ctx, hash) + require.NoError(t, err) + t.Logf("selected transaction gas=%d", mined.Gas()) + receipt, err := bind.WaitMined(ctx, client, mined) + require.NoError(t, err) + if fixture.calibrated { + require.Equal(t, uint64(types.ReceiptStatusSuccessful), receipt.Status) + require.GreaterOrEqual(t, mined.Gas(), quorumSubmitGasLimit) + } else { + require.Equal(t, uint64(types.ReceiptStatusFailed), receipt.Status, + "the estimate-only control must expose the deciding-vote gas race") + } + binding, err := iconsensus.NewIConsensus(quorumAddress, nil) + require.NoError(t, err) + staged := false + for _, log := range receipt.Logs { + if _, err := binding.ParseClaimStaged(*log); err == nil { + staged = true + } + } + require.Equal(t, fixture.calibrated, staged, "only the calibrated deciding vote must stage the claim") + + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status, + "even an actual status-1 execution needs Observe before durable domain progress") + code, err := client.CodeAt(ctx, quorumAddress, nil) + require.NoError(t, err) + t.Logf("Quorum %s: before-estimate=%d deciding-estimate=%d selected-gas=%d gas-used=%d code=%s", + version.PreRelease, before, after, mined.Gas(), receipt.GasUsed, crypto.Keccak256Hash(code)) + }) + } +} + +// The negative control deliberately signs the raw estimate as an exact limit. +// Request.GasLimit is a floor, so passing it through Sender would add padding +// and stop testing the insufficient-gas envelope. Both paths run the rival only +// after their last exact preflight and before publishing the signed bytes. +func anvilPublishEstimateControl( + ctx context.Context, t *testing.T, backend *decidingVoteBackend, signer *bind.TransactOpts, + chainID *big.Int, request txsender.Request, gas uint64, +) common.Hash { + t.Helper() + header, err := backend.Header(ctx, "latest") + require.NoError(t, err) + block := txsender.BlockRef{Number: header.Number.Uint64(), Hash: header.Hash()} + nonce, err := backend.Nonce(ctx, signer.From, block) + require.NoError(t, err) + tip, err := backend.Tip(ctx) + require.NoError(t, err) + feeCap := new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), tip) + unsigned := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, Nonce: nonce, To: &request.To, Value: request.Value, Data: request.Data, + Gas: gas, GasFeeCap: feeCap, GasTipCap: tip, + }) + signed, err := signer.Signer(signer.From, unsigned) + require.NoError(t, err) + raw, err := signed.MarshalBinary() + require.NoError(t, err) + _, err = backend.Call(ctx, ethereum.CallMsg{ + From: signer.From, To: &request.To, Value: request.Value, Data: request.Data, + Gas: gas, GasFeeCap: feeCap, GasTipCap: tip, + }, block) + require.NoError(t, err, "the raw estimate must work before the competing vote") + hash, err := backend.Publish(ctx, raw) + require.NoError(t, err) + require.Equal(t, signed.Hash(), hash) + return hash +} + +type decidingVoteBackend struct { + txsender.ProtectedClient + beforePublication func() +} + +func (b *decidingVoteBackend) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + if callback := b.beforePublication; callback != nil { + b.beforePublication = nil + callback() + } + return b.ProtectedClient.Publish(ctx, raw) +} + +func anvilFixtureAddress(key, fallback string) common.Address { + address := os.Getenv(key) + if address == "" { + address = fallback + } + return common.HexToAddress(address) +} + +func anvilClaimSigner(ctx context.Context, t *testing.T, client *ethclient.Client, chainID *big.Int) *bind.TransactOpts { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, chainID) + require.NoError(t, err) + opts.Context = ctx + var funded any + require.NoError(t, client.Client().CallContext(ctx, &funded, "anvil_setBalance", opts.From, "0x56bc75e2d63100000")) + return opts +} + +func anvilClaimMined(ctx context.Context, t *testing.T, client *ethclient.Client, tx *types.Transaction) *types.Receipt { + t.Helper() + receipt, err := bind.WaitMined(ctx, client, tx) + require.NoError(t, err) + require.Equal(t, uint64(types.ReceiptStatusSuccessful), receipt.Status) + return receipt +} + +func anvilClaimFinalized(ctx context.Context, t *testing.T, client *ethclient.Client, atLeast uint64) txsender.BlockRef { + t.Helper() + wait, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + poll := time.NewTicker(250 * time.Millisecond) + defer poll.Stop() + backend := txsender.ProtectedClient{Client: client} + for { + header, err := backend.Header(wait, "finalized") + require.NoError(t, err) + if header.Number.Uint64() >= atLeast && header.Number.Sign() > 0 { + latest, err := backend.Header(wait, "latest") + require.NoError(t, err) + t.Logf("natural producer: latest=%d finalized=%d finality lag=%d blocks", + latest.Number.Uint64(), header.Number.Uint64(), latest.Number.Uint64()-header.Number.Uint64()) + return txsender.BlockRef{Number: header.Number.Uint64(), Hash: header.Hash()} + } + select { + case <-wait.Done(): + t.Fatalf("fresh application or receipt did not become naturally finalized: %v", wait.Err()) + case <-poll.C: + } + } +} + +// The proxy expands memory before forwarding every STATICCALL to a genuine +// supported Application. Both consensus prerequisite callbacks can therefore +// succeed at high cost. It propagates the exact return or revert bytes. +func anvilExpensiveAppProxy( + ctx context.Context, t *testing.T, client *ethclient.Client, opts *bind.TransactOpts, target common.Address, +) (common.Address, *types.Receipt) { + t.Helper() + runtime := "600062040000523660006000376000600036600073" + common.Bytes2Hex(target[:]) + + "5afa6038573d600060003e3d6000fd5b3d600060003e3d6000f3" + init := common.FromHex("0x6043600c60003960436000f3" + runtime) + address, tx, _, err := bind.DeployContract(opts, abi.ABI{}, init, client) + require.NoError(t, err) + return address, anvilClaimMined(ctx, t, client, tx) +} + +// Calibrated cases use the actual Claimer post-sign protocol check and sender. +type claimFixturePlanner struct { + publisher *claimPublisher + candidate claimCandidate +} + +func (p *claimFixturePlanner) Next(context.Context, txsender.BlockRef, *txsender.Current[claimAction]) ( + txsender.Decision[claimAction], error, +) { + return txsender.Publish(p.candidate.action, p.candidate.request), nil +} +func (p *claimFixturePlanner) Recheck(ctx context.Context, block txsender.BlockRef, action claimAction) ( + txsender.Eligibility, error, +) { + return p.publisher.Recheck(ctx, block, action) +} diff --git a/internal/claimer/blockchain.go b/internal/claimer/blockchain.go new file mode 100644 index 000000000..09b603ada --- /dev/null +++ b/internal/claimer/blockchain.go @@ -0,0 +1,531 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" +) + +type iclaimerBlockchain interface { + claimTransactionCanonical(ctx context.Context, hash common.Hash, block uint64) (bool, error) + findClaimSubmittedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, + ) ( + *iconsensus.IConsensus, + []*iconsensus.IConsensusClaimSubmitted, + error, + ) + + findClaimStagedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, + ) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimStaged, + *iconsensus.IConsensusClaimStaged, + error, + ) + + getClaimStatus( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + blockNumber *big.Int, + ) (iconsensus.IConsensusClaim, error) + + findClaimAcceptedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, + ) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimAccepted, + *iconsensus.IConsensusClaimAccepted, + error, + ) + + getDefaultBlockNumber(ctx context.Context) (*big.Int, error) + + getConsensusAddress( + ctx context.Context, + app *model.Application, + blockNumber *big.Int, + ) (common.Address, error) + + claimSubmitterAddress() (common.Address, bool) +} + +func (cb *claimerBlockchain) claimTransactionCanonical(ctx context.Context, hash common.Hash, block uint64) (bool, error) { + receipt, err := cb.client.TransactionReceipt(ctx, hash) + if errors.Is(err, ethereum.NotFound) { + return false, nil + } + if err != nil { + return false, err + } + if receipt == nil || receipt.BlockNumber == nil || !receipt.BlockNumber.IsUint64() || receipt.BlockNumber.Uint64() > block { + return false, txsender.ErrInconclusive + } + if receipt.TxHash != hash || receipt.BlockHash == (common.Hash{}) || receipt.Status > types.ReceiptStatusSuccessful { + return false, txsender.ErrInconclusive + } + if receipt.Status == types.ReceiptStatusFailed { + return false, nil + } + return cb.backend.Canonical(ctx, txsender.BlockRef{Number: receipt.BlockNumber.Uint64(), Hash: receipt.BlockHash}) +} + +type claimerBlockchain struct { + client *ethclient.Client + txOptsFactory ethutil.TransactOptsFactory + logger *slog.Logger + defaultBlock config.DefaultBlock + observed txsender.BlockRef + backend txsender.Backend + fault error +} + +func (cb *claimerBlockchain) claimSubmitterAddress() (common.Address, bool) { + if cb.txOptsFactory == nil { + return common.Address{}, false + } + return cb.txOptsFactory.From(), true +} + +type eventIterator interface { + Next() bool + Close() error + Error() error +} + +// newOracle adapts a contract counter getter to the function shape expected by +// ethutil.FindTransitions. The getter is called for one app at one block. +func (cb *claimerBlockchain) newOracle( + addr common.Address, + nr func(*bind.CallOpts, common.Address) (*big.Int, error), +) func(ctx context.Context, block uint64) (*big.Int, error) { + return func(ctx context.Context, block uint64) (*big.Int, error) { + opts := cb.callOpts(ctx, new(big.Int).SetUint64(block)) + if opts.BlockHash == (common.Hash{}) { + header, err := cb.client.HeaderByNumber(ctx, new(big.Int).SetUint64(block)) + if err != nil { + return nil, err + } + if header == nil || header.Number == nil || header.Number.Uint64() != block { + return nil, txsender.ErrInconclusive + } + opts = &bind.CallOpts{Context: ctx, BlockHash: header.Hash()} + } + return nr(opts, addr) + } +} + +// priorCounter reads the counter at the block before fromBlock. That value is +// used as the previous value for ethutil.FindTransitions. +// +// When fromBlock is zero, there is no earlier block to read. In that case this +// returns nil, and FindTransitions starts without a previous value. +// +// FindTransitions needs the counter value from just before the scan starts. +// That is why this uses oracle(fromBlock-1). +// +// Do not use epoch.LastBlock here. For scans that start after a previous +// epoch, epoch.LastBlock can be after events that are inside the scan window. +// Reading the counter there would make the previous value too high. +func priorCounter( + ctx context.Context, + oracle func(ctx context.Context, block uint64) (*big.Int, error), + fromBlock uint64, +) (*big.Int, error) { + if fromBlock == 0 { + return nil, nil + } + return oracle(ctx, fromBlock-1) +} + +func newOnHit[IT eventIterator]( + ctx context.Context, + address common.Address, + filter func(*bind.FilterOpts, []common.Address, []common.Address) (IT, error), + oracle func(context.Context, uint64) (*big.Int, error), + onEvent func(IT) error, +) func(block uint64) error { + return func(block uint64) (err error) { + before := new(big.Int) + if block > 0 { + before, err = oracle(ctx, block-1) + if err != nil { + return err + } + } + after, err := oracle(ctx, block) + if err != nil { + return err + } + if before == nil || after == nil || after.Cmp(before) < 0 { + return txsender.ErrInconclusive + } + expected := new(big.Int).Sub(after, before) + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: block, + End: &block, + } + it, err := filter(filterOpts, nil, []common.Address{address}) + if err != nil { + return err + } + defer func() { err = errors.Join(err, it.Close()) }() + var count uint64 + for it.Next() { + count++ + if err := onEvent(it); err != nil { + return err + } + } + if err := it.Error(); err != nil { + return err + } + if expected.Cmp(new(big.Int).SetUint64(count)) != 0 { + return fmt.Errorf("%w: event window at block %d returned %d logs, counter advanced %s", + txsender.ErrInconclusive, block, count, expected) + } + return nil + } +} + +// findClaimSubmittedEventAndSucc scans for ClaimSubmitted events for this +// epoch. It returns the matching event and later events in the same stream. +// The caller then decides whether each event is our claim or a different one. +func (cb *claimerBlockchain) findClaimSubmittedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + []*iconsensus.IConsensusClaimSubmitted, + error, +) { + ic, err := iconsensus.NewIConsensus(application.IConsensusAddress, cb.client) + if err != nil { + return nil, nil, fmt.Errorf("creating IConsensus binding for submitted events of application: %v, epoch: %v (%v): %w", + application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) + } + caller, err := iconsensus.NewIConsensusCaller(application.IConsensusAddress, txsender.ProtectedClient{Client: cb.client}) + if err != nil { + return nil, nil, err + } + oracle := cb.newOracle(application.IApplicationAddress, caller.GetNumberOfSubmittedClaims) + events := []*iconsensus.IConsensusClaimSubmitted{} + onHit := newOnHit(ctx, application.IApplicationAddress, ic.FilterClaimSubmitted, oracle, + func(it *iconsensus.IConsensusClaimSubmittedIterator) error { + event := it.Event + if err := cb.validateObservedLog(ctx, event.Raw, toBlock); err != nil { + return err + } + if (len(events) > 0) || claimSubmittedEventMatchesEpoch(application, epoch, event) { + events = append(events, event) + } + return nil + }, + ) + + prevValue, err := priorCounter(ctx, oracle, fromBlock) + if err != nil { + return nil, nil, fmt.Errorf("querying number of submitted claims for epoch %v (%v) before block %d: %w", + epoch.Index, epoch.VirtualIndex, fromBlock, err) + } + _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, prevValue, oracle, onHit) + if err != nil { + return nil, nil, fmt.Errorf("walking ClaimSubmitted transitions for application: %v, epoch %v (%v): %w", + application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) + } + + return ic, events, nil +} + +// findClaimStagedEventAndSucc scans for a ClaimStaged event for this epoch. +// It returns the first matching event and the next event after it, if any. +// +// The scan matches only app and lastProcessedBlockNumber. It does not require +// the Merkle roots to match. This is intentional: the caller must still see a +// staged event for this epoch even when the event contains different roots. +func (cb *claimerBlockchain) findClaimStagedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimStaged, + *iconsensus.IConsensusClaimStaged, + error, +) { + ic, err := iconsensus.NewIConsensus(application.IConsensusAddress, cb.client) + if err != nil { + return nil, nil, nil, fmt.Errorf("creating IConsensus binding for staged events: %w", err) + } + + caller, err := iconsensus.NewIConsensusCaller(application.IConsensusAddress, txsender.ProtectedClient{Client: cb.client}) + if err != nil { + return nil, nil, nil, err + } + oracle := cb.newOracle(application.IApplicationAddress, caller.GetNumberOfStagedClaims) + events := []*iconsensus.IConsensusClaimStaged{} + filter := func( + opts *bind.FilterOpts, + _ []common.Address, + appContract []common.Address, + ) (*iconsensus.IConsensusClaimStagedIterator, error) { + return ic.FilterClaimStaged(opts, appContract) + } + onHit := newOnHit(ctx, application.IApplicationAddress, filter, oracle, + func(it *iconsensus.IConsensusClaimStagedIterator) error { + event := it.Event + if err := cb.validateObservedLog(ctx, event.Raw, toBlock); err != nil { + return err + } + if (len(events) > 0) || claimStagedEventMatchesEpoch(application, epoch, event) { + events = append(events, event) + } + return nil + }, + ) + + prevValue, err := priorCounter(ctx, oracle, fromBlock) + if err != nil { + return nil, nil, nil, fmt.Errorf("querying number of staged claims before block %d: %w", fromBlock, err) + } + _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, prevValue, oracle, onHit) + if err != nil { + return nil, nil, nil, fmt.Errorf("walking ClaimStaged transitions for application: %v, epoch %v (%v): %w", + application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) + } + + switch len(events) { + case 0: + return ic, nil, nil, nil + case 1: + return ic, events[0], nil, nil + default: + return ic, events[0], events[1], nil + } +} + +// findClaimAcceptedEventAndSucc scans for a ClaimAccepted event for this +// epoch. It returns the first event with matching app and lastBlock, plus its +// successor, if any. The caller compares roots to distinguish our claim from a +// different claim for the same epoch. +func (cb *claimerBlockchain) findClaimAcceptedEventAndSucc( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimAccepted, + *iconsensus.IConsensusClaimAccepted, + error, +) { + ic, err := iconsensus.NewIConsensus(application.IConsensusAddress, cb.client) + if err != nil { + return nil, nil, nil, fmt.Errorf("creating IConsensus binding for accepted events: %w", err) + } + + caller, err := iconsensus.NewIConsensusCaller(application.IConsensusAddress, txsender.ProtectedClient{Client: cb.client}) + if err != nil { + return nil, nil, nil, err + } + oracle := cb.newOracle(application.IApplicationAddress, caller.GetNumberOfAcceptedClaims) + events := []*iconsensus.IConsensusClaimAccepted{} + filter := func( + opts *bind.FilterOpts, + _ []common.Address, + appContract []common.Address, + ) (*iconsensus.IConsensusClaimAcceptedIterator, error) { + return ic.FilterClaimAccepted(opts, appContract) + } + onHit := newOnHit(ctx, application.IApplicationAddress, filter, oracle, + func(it *iconsensus.IConsensusClaimAcceptedIterator) error { + event := it.Event + if err := cb.validateObservedLog(ctx, event.Raw, toBlock); err != nil { + return err + } + // Match only app and lastBlock. Do not require the Merkle root to + // match here. The caller still needs to see a ClaimAccepted event + // from a different claim, especially in Quorum. + if (len(events) > 0) || claimAcceptedEventMatchesEpoch(application, epoch, event) { + events = append(events, event) + } + return nil + }, + ) + + prevValue, err := priorCounter(ctx, oracle, fromBlock) + if err != nil { + return nil, nil, nil, fmt.Errorf("querying number of accepted claims before block %d: %w", fromBlock, err) + } + _, err = ethutil.FindTransitions(ctx, fromBlock, toBlock, prevValue, oracle, onHit) + if err != nil { + return nil, nil, nil, fmt.Errorf("walking ClaimAccepted transitions for application: %v, epoch %v (%v): %w", + application.IApplicationAddress, epoch.Index, epoch.VirtualIndex, err) + } + + switch len(events) { + case 0: + return ic, nil, nil, nil + case 1: + return ic, events[0], nil, nil + default: + return ic, events[0], events[1], nil + } +} + +func (cb *claimerBlockchain) getConsensusAddress( + ctx context.Context, + app *model.Application, + blockNumber *big.Int, +) (common.Address, error) { + binding, err := iapplication.NewIApplicationCaller(app.IApplicationAddress, txsender.ProtectedClient{Client: cb.client}) + if err != nil { + return common.Address{}, err + } + return binding.GetOutputsMerkleRootValidator(cb.callOpts(ctx, blockNumber)) +} + +// getClaimStatus reads the on-chain Claim record for this app, last processed +// block, and machine root. +// +// The caller passes the tick's configured observation block. That makes all chain +// reads in the same tick use the same block. +func (cb *claimerBlockchain) getClaimStatus( + ctx context.Context, + application *model.Application, + epoch *model.Epoch, + blockNumber *big.Int, +) (iconsensus.IConsensusClaim, error) { + var zero iconsensus.IConsensusClaim + if epoch.MachineHash == nil { + return zero, fmt.Errorf( + "epoch %d (%d) has no machine_hash; cannot query getClaim", + epoch.Index, epoch.VirtualIndex) + } + ic, err := iconsensus.NewIConsensusCaller(application.IConsensusAddress, txsender.ProtectedClient{Client: cb.client}) + if err != nil { + return zero, fmt.Errorf("creating IConsensus caller for getClaim: %w", err) + } + opts := cb.callOpts(ctx, blockNumber) + return ic.GetClaim(opts, application.IApplicationAddress, + new(big.Int).SetUint64(epoch.LastBlock), *epoch.MachineHash) +} + +/* Retrieve the block number for the configured commitment level in ethereum terms, + * that is: `latest`, `safe`, `finalized`, etc. Which may be many blocks behind. */ +func (cb *claimerBlockchain) getDefaultBlockNumber(ctx context.Context) (*big.Int, error) { + if cb.fault != nil { + return nil, cb.fault + } + var nr int64 + switch cb.defaultBlock { + case model.DefaultBlock_Pending: + nr = rpc.PendingBlockNumber.Int64() + case model.DefaultBlock_Latest: + nr = rpc.LatestBlockNumber.Int64() + case model.DefaultBlock_Finalized: + nr = rpc.FinalizedBlockNumber.Int64() + case model.DefaultBlock_Safe: + nr = rpc.SafeBlockNumber.Int64() + default: + return nil, fmt.Errorf("default block '%v' not supported", cb.defaultBlock) + } + + hdr, err := cb.client.HeaderByNumber(ctx, big.NewInt(nr)) + if err != nil { + return nil, fmt.Errorf("fetching header for block %v: %w", nr, err) + } + if hdr == nil || hdr.Number == nil || !hdr.Number.IsUint64() { + return nil, fmt.Errorf("returned header for block %v is invalid", nr) + } + candidate := txsender.BlockRef{Number: hdr.Number.Uint64(), Hash: hdr.Hash()} + canonical, err := cb.backend.Canonical(ctx, candidate) + if err != nil { + return nil, err + } + if !canonical { + return nil, txsender.ErrInconclusive + } + if cb.observed.Hash != (common.Hash{}) && cb.defaultBlock == model.DefaultBlock_Finalized { + if hdr.Number.Uint64() < cb.observed.Number { + return nil, txsender.ErrInconclusive + } + savedHeader, err := cb.client.HeaderByNumber(ctx, new(big.Int).SetUint64(cb.observed.Number)) + if err != nil { + return nil, err + } + if savedHeader == nil || savedHeader.Number == nil || savedHeader.Number.Uint64() != cb.observed.Number { + return nil, txsender.ErrInconclusive + } + if savedHeader.Hash() != cb.observed.Hash { + cb.fault = fmt.Errorf("%w: finalized block %d saved %s now %s", + txsender.ErrFinality, cb.observed.Number, cb.observed.Hash, savedHeader.Hash()) + return nil, cb.fault + } + } + cb.observed = candidate + return hdr.Number, nil +} + +// Current observations use the exact header selected at the start of Observe. +// The protected adapter requires that hash to remain canonical on this backend. +func (cb *claimerBlockchain) callOpts(ctx context.Context, block *big.Int) *bind.CallOpts { + if block != nil && block.IsUint64() && block.Uint64() == cb.observed.Number && cb.observed.Hash != (common.Hash{}) { + return &bind.CallOpts{Context: ctx, BlockHash: cb.observed.Hash} + } + return &bind.CallOpts{Context: ctx, BlockNumber: block} +} + +func (cb *claimerBlockchain) validateObservedLog(ctx context.Context, log types.Log, end uint64) error { + if log.Removed || log.BlockNumber > end || log.BlockHash == (common.Hash{}) { + return txsender.ErrInconclusive + } + canonical, err := cb.backend.Canonical(ctx, txsender.BlockRef{Number: log.BlockNumber, Hash: log.BlockHash}) + if err != nil { + return err + } + if !canonical { + return txsender.ErrNonCanonical + } + return nil +} diff --git a/internal/claimer/claim_audit_rpc_test.go b/internal/claimer/claim_audit_rpc_test.go new file mode 100644 index 000000000..53cd631d5 --- /dev/null +++ b/internal/claimer/claim_audit_rpc_test.go @@ -0,0 +1,75 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type claimAuditRPC struct { + receipt *types.Receipt + header *types.Header +} + +func (r *claimAuditRPC) GetTransactionReceipt(context.Context, common.Hash) (any, error) { + if r.receipt == nil { + return nil, nil + } + return r.receipt, nil +} + +func (r *claimAuditRPC) GetBlockByNumber(context.Context, rpc.BlockNumber, bool) (*types.Header, error) { + return r.header, nil +} + +func TestClaimAuditReceiptRequiresCanonicalSuccessfulInclusionAtSelectedView(t *testing.T) { + for _, scenario := range []string{"canonical", "missing", "orphaned", "newer than selected", "failed", "wrong hash"} { + t.Run(scenario, func(t *testing.T) { + header := deliveryHeader(44) + hash := common.HexToHash("0xcafe") + api := &claimAuditRPC{header: header, receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, TxHash: hash, BlockNumber: big.NewInt(44), BlockHash: header.Hash(), + Logs: []*types.Log{}, + }} + selected := uint64(60) + var expectedError error + switch scenario { + case "missing": + api.receipt = nil + case "orphaned": + api.receipt.BlockHash = common.HexToHash("0xbad") + case "newer than selected": + selected = 43 + expectedError = txsender.ErrInconclusive + case "failed": + api.receipt.Status = types.ReceiptStatusFailed + case "wrong hash": + api.receipt.TxHash = common.HexToHash("0xbad") + expectedError = txsender.ErrInconclusive + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", api)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chain := &claimerBlockchain{client: client, backend: txsender.ProtectedClient{Client: client}} + canonical, err := chain.claimTransactionCanonical(t.Context(), hash, selected) + if expectedError != nil { + require.ErrorIs(t, err, expectedError) + } else { + require.NoError(t, err) + } + require.Equal(t, scenario == "canonical", canonical) + }) + } +} diff --git a/internal/claimer/claim_foreclosure.go b/internal/claimer/claim_foreclosure.go new file mode 100644 index 000000000..23863a3ba --- /dev/null +++ b/internal/claimer/claim_foreclosure.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" +) + +// The complete stream through ref accounts for every prior acceptance. The +// protocol's terminal-foreclosure premise excludes acceptance after this flag. +func (cb *claimerBlockchain) claimForeclosureFinalized(ctx context.Context, + app *model.Application, ref txsender.BlockRef) (bool, error) { + if cb.defaultBlock != model.DefaultBlock_Finalized { + return false, txsender.ErrInconclusive + } + if err := cb.checkClaimRead(ctx, ref); err != nil { + return false, err + } + caller, err := iapplication.NewIApplicationCaller(app.IApplicationAddress, txsender.ProtectedClient{Client: cb.client}) + if err != nil { + return false, err + } + foreclosed, err := caller.IsForeclosed(claimScanCallOpts(ctx, ref)) + if err != nil { + return false, err + } + if err = cb.checkClaimReference(ctx, ref); err != nil { + return false, err + } + return foreclosed, nil +} diff --git a/internal/claimer/claim_observer.go b/internal/claimer/claim_observer.go new file mode 100644 index 000000000..1faea1919 --- /dev/null +++ b/internal/claimer/claim_observer.go @@ -0,0 +1,471 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "container/list" + "context" + "errors" + "fmt" + "log/slog" + "math" + "math/big" + "time" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" +) + +type claimObservationRepository interface { + ListApplicationsByID(context.Context, repository.ApplicationFilter, int64, int64, uint64) ([]*model.Application, error) + GetApplication(context.Context, string) (*model.Application, error) + GetEventLastCheckBlock(context.Context, int64, model.MonitoredEvent) (uint64, error) + UpdateEventLastCheckBlock(context.Context, []int64, model.MonitoredEvent, uint64) error + ReadClaimRoots(context.Context, int64, uint64) (repository.ClaimRoots, error) + appstatus.Repository +} + +type claimObservationBlockchain interface { + scanClaims(context.Context, *model.Application, uint64, *claimRead, uint64) (*claimScan, error) + checkClaimRead(context.Context, txsender.BlockRef) error + claimForeclosureFinalized(context.Context, *model.Application, txsender.BlockRef) (bool, error) +} + +type pendingObservedClaim struct { + observedClaim + element *list.Element + reason string + cause string +} + +type claimObservationPhase uint8 + +const ( + claimObservationOld claimObservationPhase = iota + claimObservationFetch + claimObservationFresh + claimObservationCheckpoint +) + +// All volatile facts belong to this unit. Eviction discards the unit; its next +// visit reloads the durable safe prefix and reconstructs every later claim. +type claimObservation struct { + safe uint64 + loaded bool + read *claimRead + pending map[observedClaimKey]*pendingObservedClaim + order list.List + fresh []observedClaimKey + verdict *pendingObservedClaim + phase claimObservationPhase + foreclosureAt txsender.BlockRef + foreclosed bool + warnings [claimWarningKinds]claimWarning +} + +type claimWarningKind uint8 + +const ( + claimWarningPending claimWarningKind = iota + claimWarningObservation + claimWarningCheckpoint + claimWarningKinds +) + +type claimWarning struct { + cause string + at time.Time +} + +type claimVisitProgress struct { + advanced bool + completed bool +} + +type independentClaimObserver struct { + repository claimObservationRepository + blockchain claimObservationBlockchain + logger *slog.Logger + policy model.DefaultBlock + maxRange uint64 + budget time.Duration + after int64 + apps map[int64]*claimObservation + selectionWarnedAt time.Time +} + +var errClaimObservationInactive = errors.New("claim observation application is inactive") + +func (o *independentClaimObserver) observe(ctx context.Context, head uint64) (bool, error) { + started := time.Now() + visited := map[int64]bool{} + reschedule := false + for len(visited) < int(claimPageSize) && (len(visited) == 0 || time.Since(started) < o.budget) { + filter := repository.ApplicationFilter{Enabled: new(true), + ConsensusTypes: []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum}} + apps, err := o.repository.ListApplicationsByID(ctx, filter, o.after, math.MaxInt64, 1) + if err == nil && len(apps) == 0 && o.after != 0 { + apps, err = o.repository.ListApplicationsByID(ctx, filter, 0, math.MaxInt64, 1) + } + if err != nil { + if ctx.Err() != nil { + return false, ctx.Err() + } + if time.Since(o.selectionWarnedAt) >= claimWarningInterval { + o.logger.Warn("Cannot select applications for claim observation", "error", err) + o.selectionWarnedAt = time.Now() + } + return reschedule, nil + } + if len(apps) == 0 { + o.apps = nil + o.after = 0 + break + } + app := apps[0] + // An eligible-ID gap proves these cached owners were removed or disabled. + // Dropping the whole unit is safe because the durable R has not moved. + for id := range o.apps { + passed := o.after < id && id <= app.ID + if app.ID <= o.after { + passed = o.after < id || id <= app.ID + } + if passed && id != app.ID { + delete(o.apps, id) + } + } + if visited[app.ID] { + break + } + visited[app.ID] = true + o.after = app.ID + state := o.apps[app.ID] + if state == nil { + state = &claimObservation{pending: map[observedClaimKey]*pendingObservedClaim{}} + if o.apps == nil { + o.apps = map[int64]*claimObservation{} + } + o.apps[app.ID] = state + } + if !state.loaded { + safe, readErr := o.repository.GetEventLastCheckBlock(ctx, app.ID, model.MonitoredEvent_ClaimAccepted) + if readErr != nil { + if ctx.Err() != nil { + return false, ctx.Err() + } + if errors.Is(readErr, repository.ErrNotFound) { + delete(o.apps, app.ID) + continue + } + o.warn(app, state, claimWarningCheckpoint, "checkpoint_read", readErr.Error()) + continue + } + state.safe, state.loaded = safe, true + } + progressed, visitErr := o.visit(ctx, app, state) + if ctx.Err() != nil { + return false, ctx.Err() + } + if txsender.HardFailure(visitErr) { + return false, visitErr + } + if errors.Is(visitErr, errClaimObservationInactive) || errors.Is(visitErr, repository.ErrNotFound) { + delete(o.apps, app.ID) + continue + } + if errors.Is(visitErr, txsender.ErrNonCanonical) { + delete(o.apps, app.ID) + } + if visitErr != nil { + if errors.Is(visitErr, errClaimDeploymentPending) { + o.logger.Debug("Claim observation waits for deployment", "application", app.Name) + } else { + o.warn(app, state, claimWarningObservation, claimErrorCause(visitErr), visitErr.Error()) + } + } else if len(state.pending) > 0 { + claim := state.example() + reason, cause := claim.reason, claim.cause + if reason == "" { + reason = "input coverage or local validation is incomplete" + } + o.warn(app, state, claimWarningPending, cause, reason) + } + reschedule = reschedule || progressed.advanced && state.read != nil && state.read.At.Number < head || + progressed.completed && len(state.pending) > 0 + } + return reschedule, nil +} + +// Admission is soft and applies between app visits. Complete this finite unit +// without a poll-derived child deadline. Ordinary I/O timeouts and shutdown apply. +func (o *independentClaimObserver) visit(ctx context.Context, app *model.Application, s *claimObservation) (claimVisitProgress, error) { + progressed := claimVisitProgress{} + if s.read != nil { + if err := o.blockchain.checkClaimRead(ctx, s.read.At); err != nil { + return progressed, err + } + } + for { + if err := ctx.Err(); err != nil { + return progressed, err + } + if s.verdict != nil { + if err := o.persistMismatch(ctx, app, s.verdict.reason); err != nil { + return progressed, err + } + s.remove(s.verdict) + s.verdict = nil + progressed.completed = true + } + switch s.phase { + case claimObservationOld: + if first := s.order.Front(); first != nil { + claim := first.Value.(*pendingObservedClaim) + before := len(s.pending) + if err := o.evaluate(ctx, app, s, claim); err != nil { + s.order.MoveToBack(claim.element) + s.phase = claimObservationFetch + return progressed, err + } + progressed.completed = progressed.completed || len(s.pending) < before + } + s.phase = claimObservationFetch + case claimObservationFetch: + cursor := s.safe + if s.read != nil { + cursor = s.read.At.Number + } + batch, err := o.blockchain.scanClaims(ctx, app, cursor, s.read, o.maxRange) + if err != nil { + s.phase = claimObservationOld + return progressed, fmt.Errorf("scan claim events: %w", err) + } + if batch != nil { + if err = s.adopt(batch); err != nil { + s.phase = claimObservationOld + return progressed, err + } + progressed.advanced = progressed.advanced || batch.Read.At.Number > cursor + } + s.phase = claimObservationFresh + case claimObservationFresh: + if len(s.fresh) == 0 { + s.phase = claimObservationCheckpoint + continue + } + key := s.fresh[0] + if claim := s.pending[key]; claim != nil { + before := len(s.pending) + if err := o.evaluate(ctx, app, s, claim); err != nil { + s.order.MoveToBack(claim.element) + s.fresh = s.fresh[1:] + if len(s.fresh) == 0 { + s.phase = claimObservationCheckpoint + } + return progressed, err + } + progressed.completed = progressed.completed || len(s.pending) < before + } + s.fresh = s.fresh[1:] + if len(s.fresh) == 0 { + s.phase = claimObservationCheckpoint + } + case claimObservationCheckpoint: + safe := s.safe + if s.read != nil { + safe = s.read.At.Number + } + for _, claim := range s.pending { + if claim.Stage == nil || claim.Stage.BlockNumber == 0 { + return progressed, txsender.ErrInconclusive + } + if claim.Stage.BlockNumber <= safe { + safe = claim.Stage.BlockNumber - 1 + } + } + if safe > s.safe { + if err := o.repository.UpdateEventLastCheckBlock( + ctx, []int64{app.ID}, model.MonitoredEvent_ClaimAccepted, safe); err != nil { + s.phase = claimObservationOld + return progressed, fmt.Errorf("save claim checkpoint: %w", err) + } + s.safe = safe + } + s.phase = claimObservationOld + return progressed, nil + } + } +} + +func (s *claimObservation) adopt(batch *claimScan) error { + // Validate all cross-range merges before changing any cached fact or read. + for _, fact := range batch.Claims { + if old := s.pending[fact.Key]; old != nil && (old.Outputs != fact.Outputs || fact.Stage != nil || + fact.Acceptance != nil && old.Acceptance != nil) { + return txsender.ErrInconclusive + } + } + s.fresh = nil + for _, fact := range batch.Claims { + claim := s.pending[fact.Key] + if claim == nil && fact.Stage != nil { + claim = &pendingObservedClaim{observedClaim: fact} + claim.element = s.order.PushBack(claim) + s.pending[fact.Key] = claim + } + if claim == nil { + continue + } // completed before this acceptance; still counted by scanner + if fact.Acceptance != nil { + claim.Acceptance = fact.Acceptance + } + s.fresh = append(s.fresh, fact.Key) + } + read := batch.Read + s.read = &read + return nil +} + +func (s *claimObservation) remove(claim *pendingObservedClaim) { + delete(s.pending, claim.Key) + s.order.Remove(claim.element) +} + +func (o *independentClaimObserver) evaluate(ctx context.Context, app *model.Application, s *claimObservation, + claim *pendingObservedClaim) error { + covered, ok := new(big.Int).SetString(claim.Key.Covered, claimDecimalRadix) + if !ok { + return txsender.ErrInconclusive + } + roots := repository.ClaimRoots{ReasonClass: "covered_domain", Reason: "covered block exceeds the local uint64 input domain"} + var err error + if covered.IsUint64() { + roots, err = o.repository.ReadClaimRoots(ctx, app.ID, covered.Uint64()) + } + if err != nil { + return fmt.Errorf("read local roots for covered block %s: %w", claim.Key.Covered, err) + } + claim.reason, claim.cause = roots.Reason, roots.ReasonClass + switch { + case roots.Ready: + if roots.MachineHash == claim.Key.Machine && roots.OutputsRoot == claim.Outputs { + s.remove(claim) + return nil + } + claim.reason = fmt.Sprintf("independent_claim_mismatch: covered block %s, on-chain machine=%s outputs=%s, "+ + "validated local machine=%s outputs=%s", + claim.Key.Covered, claim.Key.Machine, claim.Outputs, roots.MachineHash, roots.OutputsRoot) + s.verdict = claim + phase := "staged" + if claim.Acceptance != nil { + phase = "accepted" + } + o.logger.Error("Divergent on-chain claim observed", "source", "independent_claim_observer", "application", app.Name, + "address", app.IApplicationAddress, "consensus", app.IConsensusAddress, + "covered_block", claim.Key.Covered, "machine_merkle_root", claim.Key.Machine, "outputs_merkle_root", claim.Outputs, + "comparison", "mismatch", "reason", claim.reason, "phase", phase, "observation_policy", o.policy, + "source_block", claim.Stage.BlockNumber, "source_block_hash", claim.Stage.BlockHash, + "source_transaction", claim.Stage.TxHash, "staging_contract_block", claim.StagingBlock, + "staging_period", claim.Period, "acceptance_from_contract_block", claim.acceptanceFrom()) + case claim.Acceptance == nil && o.policy == model.DefaultBlock_Finalized && app.ForecloseBlock != 0 && s.read != nil: + // Only the flag at the certified read excludes a later acceptance. The + // evmreader's stored foreclosure height is a hint, not a semantic bound. + if s.foreclosureAt != s.read.At { + foreclosed, readErr := o.blockchain.claimForeclosureFinalized(ctx, app, s.read.At) + if readErr != nil { + return fmt.Errorf("read foreclosure at block %d: %w", s.read.At.Number, readErr) + } + s.foreclosureAt, s.foreclosed = s.read.At, foreclosed + } + if s.foreclosed { + s.remove(claim) + return nil + } + } // A pending cause alone never authorizes a status write. + s.order.MoveToBack(claim.element) + return nil +} + +func (o *independentClaimObserver) persistMismatch(ctx context.Context, app *model.Application, reason string) error { + current, err := o.repository.GetApplication(ctx, app.IApplicationAddress.Hex()) + if err != nil { + return err + } + if current == nil || current.ID != app.ID || !current.Enabled { + return errClaimObservationInactive + } + if current.Status == model.ApplicationStatus_Diverged || current.Status == model.ApplicationStatus_Corrupted { + return nil + } + writeErr := appstatus.SetDiverged(ctx, o.logger, o.repository, current, reason) + // A concurrent integrity writer or a committed write with a lost response + // is success only after current durable state confirms its integrity effect. + confirmed, readErr := o.repository.GetApplication(ctx, app.IApplicationAddress.Hex()) + if readErr != nil { + return errors.Join(writeErr, readErr) + } + if confirmed == nil || confirmed.ID != app.ID || !confirmed.Enabled { + return errClaimObservationInactive + } + if confirmed.Status == model.ApplicationStatus_Diverged || confirmed.Status == model.ApplicationStatus_Corrupted { + return nil + } + return writeErr +} + +// Choose the oldest source independently of FIFO rotation and map iteration. +func (s *claimObservation) example() *pendingObservedClaim { + var first *pendingObservedClaim + for _, claim := range s.pending { + if first == nil || claim.Stage.BlockNumber < first.Stage.BlockNumber || + claim.Stage.BlockNumber == first.Stage.BlockNumber && (claim.Key.Covered < first.Key.Covered || + claim.Key.Covered == first.Key.Covered && claim.Key.Machine.Hex() < first.Key.Machine.Hex()) { + first = claim + } + } + return first +} + +func claimErrorCause(err error) string { + switch { + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, txsender.ErrNonCanonical): + return "noncanonical" + case errors.Is(err, txsender.ErrInconclusive): + return "inconclusive" + default: + return "dependency" + } +} + +func (o *independentClaimObserver) warn(app *model.Application, s *claimObservation, + kind claimWarningKind, cause, reason string) { + warning := &s.warnings[kind] + if !warning.at.IsZero() && warning.cause == cause && time.Since(warning.at) < claimWarningInterval { + return + } + warning.cause, warning.at = cause, time.Now() + var read, safe any = "unscanned", "unloaded" + if s.loaded { + safe = s.safe + } + if s.read != nil { + read = s.read.At.Number + } + fields := []any{"source", "independent_claim_observer", "application", app.Name, + "address", app.IApplicationAddress, "safe_claim_block", safe, "read_claim_block", read, + "pending_claims", len(s.pending), "observation_policy", o.policy, "cause_class", cause, "cause", reason} + if claim := s.example(); claim != nil { + phase := "staged" + if claim.Acceptance != nil { + phase = "accepted" + } + fields = append(fields, "example_covered_block", claim.Key.Covered, "example_machine_root", claim.Key.Machine, + "example_outputs_root", claim.Outputs, "example_phase", phase, "acceptance_from_contract_block", claim.acceptanceFrom()) + } + o.logger.Warn("Independent claim verification is incomplete", fields...) +} diff --git a/internal/claimer/claim_observer_regression_test.go b/internal/claimer/claim_observer_regression_test.go new file mode 100644 index 000000000..3ffc3c74e --- /dev/null +++ b/internal/claimer/claim_observer_regression_test.go @@ -0,0 +1,378 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const claimCheckpointCase = "checkpoint" + +func seedObservedClaims(o *independentClaimObserver, app *model.Application, facts ...observedClaim) *claimObservation { + s := &claimObservation{safe: 4, loaded: true, read: &observerBatch(29).Read, + pending: map[observedClaimKey]*pendingObservedClaim{}} + for _, fact := range facts { + claim := &pendingObservedClaim{observedClaim: fact} + claim.element = s.order.PushBack(claim) + s.pending[fact.Key] = claim + } + o.apps = map[int64]*claimObservation{app.ID: s} + return s +} + +func TestIndependentClaimObserverPersistentFailureDoesNotHideReadyMismatch(t *testing.T) { + for _, failure := range []string{"old roots", "old closure", "fresh roots", "fresh closure", "fetch", "adopt", claimCheckpointCase} { + t.Run(failure, func(t *testing.T) { + o, r, c, app := observerFixture(t) + blocked := observerFact(9, 15, common.HexToHash("0x11"), common.HexToHash("0x22")) + bad := observerFact(19, 25, common.HexToHash("0xbad"), common.HexToHash("0x44")) + state := seedObservedClaims(o, app, blocked, bad) + sticky := errors.New("persistent dependency failure") + ready := failure != claimCheckpointCase + r.rootHook = func(covered uint64) (repository.ClaimRoots, error) { + if covered == 9 && strings.HasSuffix(failure, "roots") { + return repository.ClaimRoots{}, sticky + } + if covered == 19 && ready { + return readyRoots(common.HexToHash("0x33"), bad.Outputs), nil + } + return repository.ClaimRoots{ReasonClass: "validation", Reason: "validation is pending"}, nil + } + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + switch failure { + case "fetch": + return nil, sticky + case "adopt": + return observerBatch(39, blocked), nil // a repeated stage cannot replace the certified cache + default: + return nil, nil + } + } + if strings.HasPrefix(failure, "fresh") { + state.phase = claimObservationFresh + state.fresh = []observedClaimKey{blocked.Key, bad.Key} + } + if strings.HasSuffix(failure, "closure") { + app.ForecloseBlock, c.closureErr = 1, sticky + } + if failure == claimCheckpointCase { + r.cursorErr = sticky + } + for visit := 0; visit < 6 && app.Status != model.ApplicationStatus_Diverged; visit++ { + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + ready = true + } + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.NotNil(t, app.Reason) + require.Contains(t, *app.Reason, "covered block 19") + require.Contains(t, state.pending, blocked.Key, "failed obligations remain replayable") + require.NotContains(t, state.pending, bad.Key) + require.LessOrEqual(t, r.safe[app.ID], uint64(14), "R must stay before the failed stage at 15") + require.Equal(t, 1, r.statusCalls) + }) + } +} + +func TestIndependentClaimObserverOldFailureStillDiscoversNewMismatch(t *testing.T) { + o, r, c, app := observerFixture(t) + blocked := observerFact(9, 15, common.HexToHash("0x11"), common.Hash{}) + bad := observerFact(19, 35, common.HexToHash("0xbad"), common.Hash{}) + state := seedObservedClaims(o, app, blocked) + r.rootHook = func(block uint64) (repository.ClaimRoots, error) { + if block == 9 { + return repository.ClaimRoots{}, errors.New("old query remains unavailable") + } + return readyRoots(common.HexToHash("0x22"), bad.Outputs), nil + } + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + require.EqualValues(t, 29, cursor) + return observerBatch(39, bad), nil + } + _, err := o.observe(t.Context(), 39) + require.NoError(t, err) + require.Empty(t, c.cursors, "the failed evaluation ends its visit") + _, err = o.observe(t.Context(), 39) + require.NoError(t, err) + require.Equal(t, []uint64{29}, c.cursors, "a failing old claim must not prevent new discovery") + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.Contains(t, state.pending, blocked.Key) + require.NotContains(t, state.pending, bad.Key) + require.EqualValues(t, 14, r.safe[app.ID]) +} + +func TestIndependentClaimObserverStatusFailureKeepsStrictVerdictBarrier(t *testing.T) { + o, r, c, app := observerFixture(t) + first := observerFact(9, 15, common.HexToHash("0xbad"), common.Hash{}) + second := observerFact(19, 25, common.HexToHash("0xdead"), common.Hash{}) + state := seedObservedClaims(o, app, first, second) + r.roots[9], r.roots[19] = readyRoots(common.Hash{}, common.Hash{}), readyRoots(common.Hash{}, common.Hash{}) + r.statusErr = errors.New("integrity write unavailable") + app.ForecloseBlock = 1 + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { return nil, nil } + for range 3 { + more, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.False(t, more, "a failed status write alone must not request an immediate retry") + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.NotNil(t, state.verdict) + require.Equal(t, first.Key, state.verdict.Key) + require.Equal(t, []uint64{9}, r.rootCalls, "do not evaluate another claim before the verdict is durable") + require.Empty(t, c.cursors) + require.Zero(t, c.closures) + require.Zero(t, r.cursorCalls) + } + r.statusErr = nil + more, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.True(t, more, "completion requests the next cached recheck at the same head") + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + firstReason := *app.Reason + require.NotContains(t, state.pending, first.Key) + require.Contains(t, state.pending, second.Key) + require.Nil(t, state.verdict) + require.EqualValues(t, 24, r.safe[app.ID], "the remaining source at25 still holds R") + _, err = o.observe(t.Context(), 29) + require.NoError(t, err) + require.Empty(t, state.pending) + require.Equal(t, []uint64{9, 19}, r.rootCalls) + require.Equal(t, firstReason, *app.Reason) + require.Equal(t, 4, r.statusCalls, "three failed writes and one success; the second finding keeps that effect") + require.EqualValues(t, 29, r.safe[app.ID]) +} + +func TestIndependentClaimObserverOutputsOnlyMismatchIsDurable(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(19, 25, common.HexToHash("0x11"), common.HexToHash("0xbad")) + r.roots[19] = readyRoots(fact.Key.Machine, common.HexToHash("0x22")) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(29, fact), nil + } + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.Contains(t, *app.Reason, "independent_claim_mismatch:") + require.Contains(t, *app.Reason, fact.Outputs.Hex()) + require.Contains(t, *app.Reason, r.roots[19].OutputsRoot.Hex()) + require.EqualValues(t, 29, r.safe[app.ID]) +} + +func TestIndependentClaimObserverCheckpointUsesEarliestStageAcrossFIFOOrders(t *testing.T) { + for repeat := range 16 { + for _, reverse := range []bool{false, true} { + t.Run(fmt.Sprintf("repeat=%d/reverse=%t", repeat, reverse), func(t *testing.T) { + o, r, c, app := observerFixture(t) + early := observerFact(9, 15, common.HexToHash("0x11"), common.Hash{}) + late := observerFact(19, 25, common.HexToHash("0x22"), common.Hash{}) + facts := []observedClaim{early, late} + if reverse { + facts[0], facts[1] = facts[1], facts[0] + } + state := seedObservedClaims(o, app, facts...) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { return nil, nil } + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.EqualValues(t, 14, r.safe[app.ID], "the minimum stage is 15, regardless of FIFO or map order") + require.EqualValues(t, 14, state.safe) + require.Len(t, state.pending, 2) + }) + } + } +} + +func TestIndependentClaimObserverCompletedBacklogReschedulesAtFixedHead(t *testing.T) { + o, r, c, app := observerFixture(t) + first := observerFact(9, 15, common.HexToHash("0x11"), common.Hash{}) + second := observerFact(19, 25, common.HexToHash("0x22"), common.Hash{}) + third := observerFact(29, 35, common.HexToHash("0x33"), common.Hash{}) + state := seedObservedClaims(o, app, first, second, third) + state.read = &observerBatch(39).Read + r.roots[9], r.roots[19], r.roots[29] = readyRoots(first.Key.Machine, first.Outputs), + readyRoots(second.Key.Machine, second.Outputs), readyRoots(third.Key.Machine, third.Outputs) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { return nil, nil } + for visit := range 3 { + more, err := o.observe(t.Context(), 39) + require.NoError(t, err) + require.Equal(t, visit < 2, more, "completed old work requests another visit while the backlog remains") + require.Len(t, state.pending, 2-visit) + } + require.Equal(t, []uint64{9, 19, 29}, r.rootCalls) + require.EqualValues(t, 39, r.safe[app.ID]) +} + +func TestIndependentClaimObserverUnknownAndErrorDoNotBusyLoop(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(fmt.Sprintf("failed=%t", failed), func(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(9, 15, common.HexToHash("0x11"), common.Hash{}) + state := seedObservedClaims(o, app, fact) + if failed { + r.rootErr = errors.New("root read unavailable") + } + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { return nil, nil } + for range 4 { + more, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.False(t, more) + require.Contains(t, state.pending, fact.Key) + require.LessOrEqual(t, r.safe[app.ID], uint64(14)) + } + }) + } +} + +func TestIndependentClaimObserverWarningClassesIgnoreMovingDetailsAndFIFO(t *testing.T) { + o, r, c, app := observerFixture(t) + var output bytes.Buffer + o.logger = slog.New(slog.NewTextHandler(&output, nil)) + early := observerFact(9, 15, common.HexToHash("0x11"), common.Hash{}) + late := observerFact(19, 25, common.HexToHash("0x22"), common.Hash{}) + state := seedObservedClaims(o, app, late, early) + for _, claim := range state.pending { + claim.cause, claim.reason = "input_coverage", "input coverage ends at block 0" + } + coverage := 0 + r.rootHook = func(covered uint64) (repository.ClaimRoots, error) { + coverage++ + return repository.ClaimRoots{ReasonClass: "input_coverage", + Reason: fmt.Sprintf("input coverage ends at block %d before %d", coverage, covered)}, nil + } + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { return nil, nil } + for range 6 { + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + } + require.Equal(t, 1, strings.Count(output.String(), "Independent claim verification is incomplete")) + require.Contains(t, output.String(), "example_covered_block=9") + state.warnings[claimWarningPending].at = state.warnings[claimWarningPending].at.Add(-claimWarningInterval) + state.order.MoveToFront(state.pending[early.Key].element) + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.Equal(t, 2, strings.Count(output.String(), "example_covered_block=9")) + require.NotContains(t, output.String(), "example_covered_block=19", "the FIFO front after rotation is not the stable example") + o.warn(app, state, claimWarningObservation, "dependency", "provider failed at block 100") + o.warn(app, state, claimWarningObservation, "dependency", "provider failed at block 101") + o.warn(app, state, claimWarningCheckpoint, "checkpoint_read", "checkpoint unavailable at attempt 1") + o.warn(app, state, claimWarningCheckpoint, "checkpoint_read", "checkpoint unavailable at attempt 2") + o.warn(app, state, claimWarningObservation, "dependency", "provider failed at block 102") + o.warn(app, state, claimWarningPending, "input_coverage", "input coverage ends at block 100") + require.Equal(t, 4, strings.Count(output.String(), "Independent claim verification is incomplete"), + "pending, observation and checkpoint warnings have independent throttles") +} + +func TestIndependentClaimObserverCheckpointReadWarningsArePerApplication(t *testing.T) { + o, r, c, app := observerFixture(t) + sibling := *app + sibling.ID, sibling.Name, sibling.IApplicationAddress = 2, "sibling", common.HexToAddress("0x2") + r.apps = append(r.apps, &sibling) + r.checkpointHook = func(int64) (uint64, error) { return 0, errors.New("checkpoint database unavailable") } + var output bytes.Buffer + o.logger = slog.New(slog.NewTextHandler(&output, nil)) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + t.Fatal("discovery cannot start before its checkpoint is loaded") + return nil, nil + } + for range 3 { + more, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.False(t, more) + } + require.Equal(t, 2, strings.Count(output.String(), "cause_class=checkpoint_read")) + require.Contains(t, output.String(), "application="+app.Name) + require.Contains(t, output.String(), "application=sibling") + require.False(t, o.apps[app.ID].loaded) + require.False(t, o.apps[sibling.ID].loaded) +} + +func TestIndependentClaimObserverNoncanonicalReadEvictsAndReplaysSafePrefix(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(19, 25, common.HexToHash("0xbad"), common.Hash{}) + old := seedObservedClaims(o, app, fact) + r.safe[app.ID] = 14 + old.safe = 14 + c.checkErr = txsender.ErrNonCanonical + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + t.Fatal("an invalid cached reference must be evicted before discovery") + return nil, nil + } + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.NotContains(t, o.apps, app.ID) + require.Empty(t, r.rootCalls) + require.EqualValues(t, 14, r.safe[app.ID]) + c.checkErr = nil + r.roots[19] = readyRoots(common.Hash{}, fact.Outputs) + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, read *claimRead) (*claimScan, error) { + require.EqualValues(t, 14, cursor) + require.Nil(t, read) + return observerBatch(29, fact), nil + } + _, err = o.observe(t.Context(), 29) + require.NoError(t, err) + require.NotSame(t, old, o.apps[app.ID]) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) +} + +func TestIndependentClaimObserverForeclosureUsesCertifiedReadAndIgnoresStoredHeight(t *testing.T) { + for _, stored := range []uint64{1, 1000} { + t.Run(fmt.Sprintf("stored=%d", stored), func(t *testing.T) { + o, r, c, app := observerFixture(t) + app.ForecloseBlock = stored + fact := observerFact(19, 25, common.HexToHash("0x11"), common.Hash{}) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(29, fact), nil + } + c.closureAt = func(ref txsender.BlockRef) (bool, error) { + require.Equal(t, observerBatch(29).Read.At, ref, "the newer observed head is not a certified claim read") + return true, nil + } + _, err := o.observe(t.Context(), 100) + require.NoError(t, err) + require.Empty(t, o.apps[app.ID].pending) + require.EqualValues(t, 29, r.safe[app.ID]) + require.Equal(t, 1, c.closures) + }) + } +} + +func TestIndependentClaimObserverFalseForeclosureIsRetriedAtNewCertifiedRead(t *testing.T) { + o, r, c, app := observerFixture(t) + app.ForecloseBlock = 1 + fact := observerFact(19, 25, common.HexToHash("0x11"), common.Hash{}) + c.closureAt = func(ref txsender.BlockRef) (bool, error) { return ref.Number >= 39, nil } + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + switch cursor { + case 0: + return observerBatch(29, fact), nil + case 29: + return observerBatch(39), nil + default: + return nil, nil + } + } + for visit := range 3 { + _, err := o.observe(t.Context(), 39) + require.NoError(t, err) + if visit < 2 { + require.Contains(t, o.apps[app.ID].pending, fact.Key) + require.EqualValues(t, 24, r.safe[app.ID]) + } + } + require.Empty(t, o.apps[app.ID].pending) + require.EqualValues(t, 39, r.safe[app.ID]) + require.Equal(t, []txsender.BlockRef{observerBatch(29).Read.At, observerBatch(39).Read.At}, c.closureRefs, + "a false flag is cached only for its exact certified read") +} diff --git a/internal/claimer/claim_observer_test.go b/internal/claimer/claim_observer_test.go new file mode 100644 index 000000000..bf30cc43b --- /dev/null +++ b/internal/claimer/claim_observer_test.go @@ -0,0 +1,626 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +type observationTestRepo struct { + apps []*model.Application + safe map[int64]uint64 + roots map[uint64]repository.ClaimRoots + rootCalls []uint64 + rootHook func(uint64) (repository.ClaimRoots, error) + appHook func(string) (*model.Application, error) + checkpointHook func(int64) (uint64, error) + statusCalls, cursorCalls int + statusErr, cursorErr, rootErr error + commitOnError bool + concurrent model.ApplicationStatus +} + +func (r *observationTestRepo) ListApplicationsByID( + _ context.Context, _ repository.ApplicationFilter, after, through int64, _ uint64, +) ([]*model.Application, error) { + for _, app := range r.apps { + if app.ID > after && app.ID <= through && app.Enabled { + stored := *app + return []*model.Application{&stored}, nil + } + } + return nil, nil +} +func (r *observationTestRepo) GetApplication(_ context.Context, address string) (*model.Application, error) { + if r.appHook != nil { + return r.appHook(address) + } + for _, app := range r.apps { + if app.IApplicationAddress.Hex() == address { + stored := *app + return &stored, nil + } + } + return nil, nil +} +func (r *observationTestRepo) GetEventLastCheckBlock(_ context.Context, id int64, _ model.MonitoredEvent) (uint64, error) { + if r.checkpointHook != nil { + return r.checkpointHook(id) + } + return r.safe[id], nil +} +func (r *observationTestRepo) UpdateEventLastCheckBlock(_ context.Context, ids []int64, _ model.MonitoredEvent, block uint64) error { + r.cursorCalls++ + if r.cursorErr != nil { + return r.cursorErr + } + for _, id := range ids { + if block > r.safe[id] { + r.safe[id] = block + } + } + return nil +} +func (r *observationTestRepo) ReadClaimRoots(_ context.Context, _ int64, block uint64) (repository.ClaimRoots, error) { + r.rootCalls = append(r.rootCalls, block) + if r.rootHook != nil { + return r.rootHook(block) + } + return r.roots[block], r.rootErr +} +func (r *observationTestRepo) UpdateApplicationStatus(_ context.Context, id int64, status model.ApplicationStatus, reason *string) error { + r.statusCalls++ + for _, app := range r.apps { + if app.ID == id { + if r.concurrent != "" { + app.Status = r.concurrent + app.Reason = new("first concurrent integrity finding") + return errors.New("stale terminal write") + } + if r.statusErr == nil || r.commitOnError { + app.Status = status + stored := *reason + app.Reason = &stored + } + return r.statusErr + } + } + return repository.ErrNotFound +} + +type observationTestChain struct { + scan func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) + cursors []uint64 + checkErr error + closures int + closureErr error + closed bool + closureAt func(txsender.BlockRef) (bool, error) + closureRefs []txsender.BlockRef +} + +func (c *observationTestChain) scanClaims( + ctx context.Context, app *model.Application, cursor uint64, read *claimRead, _ uint64, +) (*claimScan, error) { + c.cursors = append(c.cursors, cursor) + return c.scan(ctx, app, cursor, read) +} +func (c *observationTestChain) checkClaimRead(context.Context, txsender.BlockRef) error { + return c.checkErr +} +func (c *observationTestChain) claimForeclosureFinalized( + _ context.Context, _ *model.Application, ref txsender.BlockRef, +) (bool, error) { + c.closures++ + c.closureRefs = append(c.closureRefs, ref) + if c.closureAt != nil { + return c.closureAt(ref) + } + return c.closed, c.closureErr +} + +func observerFixture(t *testing.T) (*independentClaimObserver, *observationTestRepo, *observationTestChain, *model.Application) { + t.Helper() + app := makeApplication() + app.ID = 1 + app.Enabled = true + r := &observationTestRepo{apps: []*model.Application{app}, safe: map[int64]uint64{}, roots: map[uint64]repository.ClaimRoots{}} + c := &observationTestChain{closed: true} + o := &independentClaimObserver{repository: r, blockchain: c, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + policy: model.DefaultBlock_Finalized, budget: time.Second} + return o, r, c, app +} +func observerFact(block, source uint64, machine, outputs common.Hash) observedClaim { + return observedClaim{Key: observedClaimKey{Covered: new(big.Int).SetUint64(block).String(), Machine: machine}, Outputs: outputs, + Stage: &types.Log{BlockNumber: source, BlockHash: common.BigToHash(new(big.Int).SetUint64(source)), + TxHash: common.HexToHash("0x1")}, + StagingBlock: new(big.Int).SetUint64(source), Period: big.NewInt(20)} +} +func observerBatch(head uint64, facts ...observedClaim) *claimScan { + return &claimScan{Read: claimRead{At: txsender.BlockRef{Number: head, Hash: common.BigToHash(new(big.Int).SetUint64(head))}, + Staged: big.NewInt(1), Accepted: new(big.Int)}, Claims: facts} +} +func readyRoots(machine, outputs common.Hash) repository.ClaimRoots { + return repository.ClaimRoots{Ready: true, MachineHash: machine, OutputsRoot: outputs} +} + +func TestIndependentClaimObserverCheckpointReconstructsOldPendingAfterRestart(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(9, 15, common.HexToHash("0xbad"), common.HexToHash("0x22")) + r.safe[app.ID] = 4 + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + require.Contains(t, []uint64{4, 14}, cursor) + return observerBatch(19, fact), nil + } + _, err := o.observe(t.Context(), 19) + require.NoError(t, err) + require.EqualValues(t, 14, r.safe[app.ID], "a pending S=15 holds R below 15") + require.Len(t, o.apps[app.ID].pending, 1) + // A long outage changes H, not the safe replay start. There is no age window. + restarted := &independentClaimObserver{repository: r, blockchain: c, logger: o.logger, policy: o.policy, budget: time.Second} + r.roots[9] = readyRoots(common.HexToHash("0x11"), fact.Outputs) + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + require.EqualValues(t, 14, cursor, "resume immediately before the unresolved source") + return observerBatch(100000, fact), nil + } + _, err = restarted.observe(t.Context(), 100000) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.EqualValues(t, 100000, r.safe[app.ID]) + require.Empty(t, restarted.apps[app.ID].pending) +} + +func TestIndependentClaimObserverRetainsProofUntilStatusWriteWorks(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(9, 15, common.HexToHash("0xbad"), common.HexToHash("0x22")) + r.roots[9] = readyRoots(common.HexToHash("0x11"), fact.Outputs) + r.statusErr = errors.New("database write failed") + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(19, fact), nil + } + _, err := o.observe(t.Context(), 19) + require.NoError(t, err, "app-owned errors do not escape Tick") + require.Zero(t, r.safe[app.ID]) + require.NotNil(t, o.apps[app.ID].verdict) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + app.ForecloseBlock = 18 + app.ForecloseTransaction = new(common.HexToHash("0xf")) + r.statusErr = nil + r.rootErr = errors.New("comparison is temporarily unavailable") + _, err = o.observe(t.Context(), 19) + require.NoError(t, err) + require.Equal(t, []uint64{9}, r.rootCalls, "retry the proved verdict without another root read") + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.EqualValues(t, 19, r.safe[app.ID]) + require.Zero(t, c.closures, "foreclosure cannot erase a proved mismatch") +} + +func TestIndependentClaimObserverCrashBeforeStatusRebuildsQualifiedResult(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(9, 15, common.HexToHash("0xbad"), common.HexToHash("0x22")) + fact.Acceptance = &types.Log{BlockNumber: 17} + r.roots[9] = readyRoots(common.HexToHash("0x11"), fact.Outputs) + r.statusErr = errors.New("database write failed") + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + require.Zero(t, cursor) + return observerBatch(19, fact), nil + } + _, err := o.observe(t.Context(), 19) + require.NoError(t, err) + require.Zero(t, r.safe[app.ID]) + require.NotNil(t, o.apps[app.ID].verdict) + // S1 tests the real CF marker. Here the root provider continues to return + // that qualified result after the observer loses every volatile field. + r.statusErr = nil + restarted := &independentClaimObserver{repository: r, blockchain: c, logger: o.logger, policy: o.policy, budget: time.Second} + _, err = restarted.observe(t.Context(), 19) + require.NoError(t, err) + require.Equal(t, []uint64{9, 9}, r.rootCalls) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.EqualValues(t, 19, r.safe[app.ID]) +} + +func TestIndependentClaimObserverCurrentIntegrityAndAmbiguousStatusCommit(t *testing.T) { + for _, status := range []model.ApplicationStatus{"", model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted} { + t.Run(string(status), func(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(9, 15, common.HexToHash("0xbad"), common.HexToHash("0x22")) + r.roots[9] = readyRoots(common.HexToHash("0x11"), fact.Outputs) + r.statusErr = errors.New("write response lost") + r.commitOnError = true + r.concurrent = status + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(19, fact), nil + } + _, err := o.observe(t.Context(), 19) + require.NoError(t, err) + require.EqualValues(t, 19, r.safe[app.ID]) + require.Nil(t, o.apps[app.ID].verdict) + if status != "" { + require.Equal(t, status, app.Status) + require.Equal(t, "first concurrent integrity finding", *app.Reason) + } else { + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + } + }) + } +} + +func TestIndependentClaimObserverIntegrityDoesNotCompleteAnotherUnknown(t *testing.T) { + o, r, c, app := observerFixture(t) + unknown := observerFact(19, 25, common.HexToHash("0x33"), common.HexToHash("0x44")) + bad := observerFact(9, 25, common.HexToHash("0xbad"), common.HexToHash("0x22")) + r.roots[9] = readyRoots(common.HexToHash("0x11"), bad.Outputs) + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + if cursor == 29 { + return nil, nil + } + return observerBatch(29, unknown, bad), nil + } + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + first := *app.Reason + require.EqualValues(t, 24, r.safe[app.ID]) + require.Len(t, o.apps[app.ID].pending, 1) + // A validation already in flight can finish after DF. Health alone must not + // suppress the recheck. The real query intentionally has no app-OK gate. + r.roots[19] = readyRoots(unknown.Key.Machine, unknown.Outputs) + _, err = o.observe(t.Context(), 29) + require.NoError(t, err) + require.Equal(t, []uint64{19, 9, 19}, r.rootCalls) + require.EqualValues(t, 29, r.safe[app.ID]) + require.Equal(t, first, *app.Reason) + require.Equal(t, 1, r.statusCalls) +} + +func TestIndependentClaimObserverFreshAttemptDoesNotWaitForOldBacklog(t *testing.T) { + o, r, c, app := observerFixture(t) + state := &claimObservation{safe: 14, loaded: true, + pending: map[observedClaimKey]*pendingObservedClaim{}, read: &observerBatch(19).Read} + for i := uint64(1); i <= 100; i++ { + fact := observerFact(9, 15, common.BigToHash(new(big.Int).SetUint64(i)), common.Hash{}) + entry := &pendingObservedClaim{observedClaim: fact} + entry.element = state.order.PushBack(entry) + state.pending[fact.Key] = entry + } + fresh := observerFact(19, 25, common.HexToHash("0xff"), common.Hash{}) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(29, fresh), nil + } + o.apps = map[int64]*claimObservation{app.ID: state} + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.Equal(t, []uint64{9, 19}, r.rootCalls, "one old recheck then every fresh fact") + require.Len(t, state.pending, 101) + require.EqualValues(t, 14, state.safe) +} + +func TestIndependentClaimObserverSoftAdmissionFinishesExpensiveUnit(t *testing.T) { + o, r, c, app := observerFixture(t) + o.budget = time.Millisecond + first := observerFact(9, 25, common.HexToHash("0x11"), common.Hash{}) + second := observerFact(19, 25, common.HexToHash("0x22"), common.Hash{}) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + deadline, _ := ctx.Deadline() + c.scan = func(observed context.Context, _ *model.Application, _ uint64, _ *claimRead) (*claimScan, error) { + actual, ok := observed.Deadline() + require.True(t, ok) + require.Equal(t, deadline, actual, "poll pacing must not shorten I/O context") + time.Sleep(10 * time.Millisecond) + return observerBatch(29, first, second), nil + } + _, err := o.observe(ctx, 29) + require.NoError(t, err) + require.Equal(t, []uint64{9, 19}, r.rootCalls, "the admitted range receives all first evaluations after budget expiry") + require.EqualValues(t, 24, r.safe[app.ID]) + require.Len(t, o.apps[app.ID].pending, 2) +} + +func TestIndependentClaimObserverCheckpointRetryAndCrashAfterStatus(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(9, 15, common.HexToHash("0xbad"), common.HexToHash("0x22")) + r.roots[9] = readyRoots(common.HexToHash("0x11"), fact.Outputs) + r.cursorErr = errors.New("checkpoint response failed") + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(19, fact), nil + } + _, err := o.observe(t.Context(), 19) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + reason := *app.Reason + require.Zero(t, r.safe[app.ID]) + require.Equal(t, 1, r.statusCalls) + r.cursorErr = nil + restarted := &independentClaimObserver{repository: r, blockchain: c, logger: o.logger, policy: o.policy, budget: time.Second} + _, err = restarted.observe(t.Context(), 19) + require.NoError(t, err) + require.EqualValues(t, 19, r.safe[app.ID]) + require.Equal(t, 1, r.statusCalls) + require.Equal(t, reason, *app.Reason) +} + +func TestIndependentClaimObserverClosureRequiresFullFinalizedAcceptanceStream(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Finalized, model.DefaultBlock_Latest, model.DefaultBlock_Safe} { + t.Run(string(policy), func(t *testing.T) { + o, r, c, app := observerFixture(t) + o.policy = policy + app.ForecloseBlock = 30 + app.ForecloseTransaction = new(common.HexToHash("0xf")) + fact := observerFact(19, 25, common.Hash{}, common.Hash{}) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(39, fact), nil + } + _, err := o.observe(t.Context(), 39) + require.NoError(t, err) + if policy == model.DefaultBlock_Finalized { + require.EqualValues(t, 39, r.safe[app.ID]) + require.Equal(t, 1, c.closures) + } else { + require.EqualValues(t, 24, r.safe[app.ID]) + require.Zero(t, c.closures) + require.Len(t, o.apps[app.ID].pending, 1) + } + require.Zero(t, r.statusCalls) + }) + } + t.Run("acceptance in F is retained after a partial range", func(t *testing.T) { + o, r, c, app := observerFixture(t) + app.ForecloseBlock = 30 + app.ForecloseTransaction = new(common.HexToHash("0xf")) + fact := observerFact(19, 25, common.Hash{}, common.Hash{}) + c.closureAt = func(ref txsender.BlockRef) (bool, error) { return ref.Number >= 30, nil } + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + if cursor == 0 { + return observerBatch(29, fact), nil + } + acceptance := observedClaim{Key: fact.Key, Outputs: fact.Outputs, Acceptance: &types.Log{BlockNumber: 30}} + return observerBatch(39, acceptance), nil + } + more, err := o.observe(t.Context(), 39) + require.NoError(t, err) + require.True(t, more) + require.Equal(t, 1, c.closures, "the false flag at the certified read keeps the claim pending") + require.EqualValues(t, 24, r.safe[app.ID]) + _, err = o.observe(t.Context(), 39) + require.NoError(t, err) + require.Equal(t, 1, c.closures, "the accepted projection prevents another closure attempt") + require.EqualValues(t, 24, r.safe[app.ID]) + require.Len(t, o.apps[app.ID].pending, 1) + require.NotNil(t, o.apps[app.ID].pending[fact.Key].Acceptance) + }) +} + +func TestIndependentClaimObserverAppFaultDoesNotBlockSibling(t *testing.T) { + o, r, c, app := observerFixture(t) + sibling := *app + sibling.ID = 2 + sibling.IApplicationAddress = common.HexToAddress("0x2") + r.apps = append(r.apps, &sibling) + fact := observerFact(9, 15, common.HexToHash("0x11"), common.Hash{}) + r.roots[9] = readyRoots(fact.Key.Machine, fact.Outputs) + c.scan = func(_ context.Context, a *model.Application, _ uint64, _ *claimRead) (*claimScan, error) { + if a.ID == app.ID { + return nil, errors.New("one app RPC history failed") + } + return observerBatch(19, fact), nil + } + _, err := o.observe(t.Context(), 19) + require.NoError(t, err) + require.Zero(t, r.safe[app.ID]) + require.EqualValues(t, 19, r.safe[sibling.ID]) +} + +func TestIndependentClaimObserverPendingWarningNamesDeadlineAndProof(t *testing.T) { + o, _, c, _ := observerFixture(t) + var log bytes.Buffer + o.logger = slog.New(slog.NewTextHandler(&log, nil)) + fact := observerFact(9, 15, common.HexToHash("0x11"), common.HexToHash("0x22")) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(19, fact), nil + } + _, err := o.observe(t.Context(), 19) + require.NoError(t, err) + require.Contains(t, log.String(), "pending_claims=1") + require.Contains(t, log.String(), "example_covered_block=9") + require.Contains(t, log.String(), "example_phase=staged") + require.Contains(t, log.String(), "acceptance_from_contract_block=35") + require.Contains(t, log.String(), fact.Key.Machine.Hex()) + require.Contains(t, log.String(), fact.Outputs.Hex()) +} + +type emptyObservedLifecycle struct{ iclaimerRepository } + +func (*emptyObservedLifecycle) SelectClaimsToSubmitPage(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error) { + return repository.ClaimPage{}, nil +} +func (*emptyObservedLifecycle) SelectClaimsToStagePage(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error) { + return repository.ClaimPage{}, nil +} +func (*emptyObservedLifecycle) SelectClaimsToAcceptPage(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error) { + return repository.ClaimPage{}, nil +} +func (*emptyObservedLifecycle) ListApplicationsByID( + context.Context, repository.ApplicationFilter, int64, int64, uint64, +) ([]*model.Application, error) { + return nil, nil +} + +type observedTickHead struct{ iclaimerBlockchain } + +func (*observedTickHead) getDefaultBlockNumber(context.Context) (*big.Int, error) { + return big.NewInt(29), nil +} + +func TestIndependentClaimObserverTickDoesNotNeedAnEligibleLocalEpoch(t *testing.T) { + for _, submit := range []bool{false, true} { + t.Run(fmt.Sprintf("submission=%t", submit), func(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(19, 25, common.HexToHash("0xbad"), common.HexToHash("0x22")) + r.roots[19] = readyRoots(common.HexToHash("0x11"), fact.Outputs) + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(29, fact), nil + } + service := &Service{repository: &emptyObservedLifecycle{}, blockchain: &observedTickHead{}, + claimObserver: o, submissionEnabled: submit} + _, err := service.Tick(t.Context()) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.EqualValues(t, 29, r.safe[app.ID]) + }) + } +} + +func TestIndependentClaimObserverFreshQueryFailureReturnsToOldQueue(t *testing.T) { + o, r, c, app := observerFixture(t) + first := observerFact(9, 25, common.HexToHash("0x11"), common.Hash{}) + second := observerFact(19, 25, common.HexToHash("0x22"), common.Hash{}) + queryFailed := true + r.rootHook = func(block uint64) (repository.ClaimRoots, error) { + if block == 9 { + return readyRoots(first.Key.Machine, first.Outputs), nil + } + if queryFailed { + return repository.ClaimRoots{}, errors.New("local query failed") + } + return repository.ClaimRoots{Reason: "validation is pending"}, nil + } + c.scan = func(_ context.Context, _ *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + if cursor == 29 { + return nil, nil + } + return observerBatch(29, first, second), nil + } + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + require.Zero(t, r.safe[app.ID]) + require.Empty(t, o.apps[app.ID].fresh, "the failed key stays pending but does not block the fresh suffix") + require.Len(t, o.apps[app.ID].pending, 1) + queryFailed = false + _, err = o.observe(t.Context(), 29) + require.NoError(t, err) + _, err = o.observe(t.Context(), 29) + require.NoError(t, err) + require.Equal(t, []uint64{9, 19, 19}, r.rootCalls) + require.Equal(t, []uint64{0, 29}, c.cursors, "the failed key receives an ordinary old recheck") + require.EqualValues(t, 24, r.safe[app.ID]) + require.Len(t, o.apps[app.ID].pending, 1) +} + +func TestIndependentClaimObserverEvictsWholeInactiveUnitAndReplaysFromSafePoint(t *testing.T) { + for _, removed := range []bool{false, true} { + t.Run(fmt.Sprintf("removed=%t", removed), func(t *testing.T) { + o, r, c, app := observerFixture(t) + sibling := *app + sibling.ID = 2 + sibling.IApplicationAddress = common.HexToAddress("0x2") + r.apps = append(r.apps, &sibling) + fact := observerFact(19, 25, common.HexToHash("0x11"), common.Hash{}) + var replay []uint64 + c.scan = func(_ context.Context, a *model.Application, cursor uint64, _ *claimRead) (*claimScan, error) { + if a.ID == 2 { + return nil, nil + } + replay = append(replay, cursor) + return observerBatch(29, fact), nil + } + _, err := o.observe(t.Context(), 29) + require.NoError(t, err) + old := o.apps[app.ID] + require.NotNil(t, old) + require.EqualValues(t, 24, r.safe[app.ID]) + if removed { + r.apps = []*model.Application{&sibling} + delete(r.safe, app.ID) + } else { + app.Enabled = false + } + _, err = o.observe(t.Context(), 29) + require.NoError(t, err) + require.NotContains(t, o.apps, app.ID, "wrap removes the whole inactive unit, not just its cache") + id := app.ID + if removed { + registered := *app + registered.ID = 3 + registered.Name += "-registered" + id = registered.ID + r.apps = append(r.apps, ®istered) + } else { + app.Enabled = true + } + _, err = o.observe(t.Context(), 29) + require.NoError(t, err) + require.NotSame(t, old, o.apps[id]) + expected := uint64(24) + if removed { + expected = 0 + } + require.Equal(t, []uint64{0, expected}, replay) + require.Len(t, o.apps[id].pending, 1) + }) + } +} + +func TestIndependentClaimObserverWarningReportsChangedCause(t *testing.T) { + o, _, _, app := observerFixture(t) + var log bytes.Buffer + o.logger = slog.New(slog.NewTextHandler(&log, nil)) + state := &claimObservation{} + o.warn(app, state, claimWarningPending, "validation", "validation is pending") + o.warn(app, state, claimWarningPending, "validation", "validation is pending") + require.Equal(t, 1, strings.Count(log.String(), "Independent claim verification is incomplete")) + o.warn(app, state, claimWarningPending, "problem", "local CLAIM_FORECLOSED prevents validation") + require.Equal(t, 2, strings.Count(log.String(), "Independent claim verification is incomplete")) +} + +func TestIndependentClaimObserverRemovalDuringStatusConfirmation(t *testing.T) { + for _, afterWrite := range []bool{false, true} { + t.Run(fmt.Sprintf("after_write=%t", afterWrite), func(t *testing.T) { + o, r, c, app := observerFixture(t) + fact := observerFact(19, 25, common.HexToHash("0xbad"), common.HexToHash("0x22")) + r.roots[19] = readyRoots(common.HexToHash("0x11"), fact.Outputs) + reads := 0 + r.appHook = func(string) (*model.Application, error) { + reads++ + if !afterWrite || reads == 2 { + return nil, nil + } + stored := *app + return &stored, nil + } + c.scan = func(context.Context, *model.Application, uint64, *claimRead) (*claimScan, error) { + return observerBatch(29, fact), nil + } + var captured bytes.Buffer + o.logger = slog.New(slog.NewTextHandler(&captured, nil)) + require.NotPanics(t, func() { _, err := o.observe(t.Context(), 29); require.NoError(t, err) }) + require.NotContains(t, o.apps, app.ID) + require.Zero(t, r.safe[app.ID], "removal is abandonment, not confirmed integrity processing") + expectedWrites := 0 + if afterWrite { + expectedWrites = 1 + } + require.Equal(t, expectedWrites, r.statusCalls) + require.NotContains(t, captured.String(), "Independent claim verification is incomplete", + "the removed application must not receive a pending warning") + }) + } +} diff --git a/internal/claimer/claim_refresh.go b/internal/claimer/claim_refresh.go new file mode 100644 index 000000000..a95ed2600 --- /dev/null +++ b/internal/claimer/claim_refresh.go @@ -0,0 +1,134 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/common" +) + +// These reads use the observer's selected view. Publication at latest cannot +// replace audit evidence saved by a safe/finalized observer. +func (s *Service) refreshSubmittedClaim(ctx context.Context, app *model.Application, epoch *model.Epoch, block uint64) error { + if epoch.ClaimTransactionHash != nil { + canonical, err := s.blockchain.claimTransactionCanonical(ctx, *epoch.ClaimTransactionHash, block) + if err != nil || canonical { + return err + } + } + _, events, err := s.blockchain.findClaimSubmittedEventAndSucc(ctx, app, epoch, epoch.LastBlock+1, block) + if err != nil { + return err + } + signer, known := s.blockchain.claimSubmitterAddress() + for _, event := range events { + matches, complete := claimSubmittedEventMatches(app, epoch, event) + if !complete || !matches { + continue + } + if s.submissionEnabled && app.ConsensusType == model.Consensus_Quorum && known && event.Submitter != signer { + continue + } + if event.Raw.BlockNumber > block { + return fmt.Errorf("%w: epoch %d submission event block %d exceeds observation block %d", + txsender.ErrInconclusive, epoch.Index, event.Raw.BlockNumber, block) + } + return s.refreshClaimMetadata(ctx, epoch, new(event.Raw.TxHash), nil) + } + return nil +} + +func (s *Service) refreshStagedClaim( + ctx context.Context, app *model.Application, epoch *model.Epoch, claim iconsensus.IConsensusClaim, block uint64, +) error { + if claim.StagingBlockNumber == nil || !claim.StagingBlockNumber.IsUint64() || claim.StagingBlockNumber.Uint64() > block { + return txsender.ErrInconclusive + } + if epoch.StagedAtBlock != nil && *epoch.StagedAtBlock == claim.StagingBlockNumber.Uint64() && epoch.ClaimTransactionHash != nil { + canonical, err := s.blockchain.claimTransactionCanonical(ctx, *epoch.ClaimTransactionHash, block) + if err != nil || canonical { + return err + } + } + _, event, _, err := s.blockchain.findClaimStagedEventAndSucc(ctx, app, epoch, epoch.LastBlock+1, block) + if err != nil { + return err + } + matches, complete := claimStagedEventMatches(app, epoch, event) + if !complete || !matches || claim.StagingBlockNumber == nil || !claim.StagingBlockNumber.IsUint64() || + event.Raw.BlockNumber != claim.StagingBlockNumber.Uint64() || event.Raw.BlockNumber > block { + return fmt.Errorf("%w: application %s (%s), epoch %d staging state and event disagree at block %d", + txsender.ErrInconclusive, app.Name, app.IApplicationAddress, epoch.Index, block) + } + hash, err := s.stagedClaimAuditHash(ctx, app, epoch, event.Raw.TxHash, block) + if err != nil { + return err + } + return s.refreshClaimMetadata(ctx, epoch, hash, new(event.Raw.BlockNumber)) +} + +// A submitting Quorum node records its own matching vote before acceptance. +// The deciding vote belongs to another validator in the usual case. Reader +// mode has no local vote identity and records the matching staging event. +func (s *Service) stagedClaimAuditHash( + ctx context.Context, app *model.Application, epoch *model.Epoch, stagedHash common.Hash, block uint64, +) (*common.Hash, error) { + if app.ConsensusType != model.Consensus_Quorum || !s.submissionEnabled { + return new(stagedHash), nil + } + signer, known := s.blockchain.claimSubmitterAddress() + if !known { + return nil, txsender.ErrInconclusive + } + if epoch.ClaimTransactionHash != nil { + canonical, err := s.blockchain.claimTransactionCanonical(ctx, *epoch.ClaimTransactionHash, block) + if err != nil || canonical { + return epoch.ClaimTransactionHash, err + } + } + _, events, err := s.blockchain.findClaimSubmittedEventAndSucc(ctx, app, epoch, epoch.LastBlock+1, block) + if err != nil { + return nil, err + } + for _, event := range events { + matches, complete := claimSubmittedEventMatches(app, epoch, event) + if !complete || !matches || event.Submitter != signer { + continue + } + if event.Raw.BlockNumber > block || event.Raw.TxHash == (common.Hash{}) { + return nil, txsender.ErrInconclusive + } + return new(event.Raw.TxHash), nil + } + // Peers can stage a claim after our vote was orphaned. The staging clock + // remains observable; a missing local vote must not become a peer's hash. + return nil, nil +} + +func (s *Service) refreshClaimMetadata( + ctx context.Context, epoch *model.Epoch, hash *common.Hash, stagingBlock *uint64, +) error { + sameHash := epoch.ClaimTransactionHash == nil && hash == nil || + epoch.ClaimTransactionHash != nil && hash != nil && *epoch.ClaimTransactionHash == *hash + if sameHash && + (stagingBlock == nil || epoch.StagedAtBlock != nil && *epoch.StagedAtBlock == *stagingBlock) { + return nil + } + if hash != nil && *hash == (common.Hash{}) { + return fmt.Errorf("%w: epoch %d matching claim event has no transaction hash", txsender.ErrInconclusive, epoch.Index) + } + if err := s.repository.RefreshClaimObservation(ctx, epoch, hash, stagingBlock); err != nil { + return fmt.Errorf("refreshing epoch %d claim audit metadata: %w", epoch.Index, err) + } + epoch.ClaimTransactionHash = hash + if stagingBlock != nil { + epoch.StagedAtBlock = new(*stagingBlock) + } + return nil +} diff --git a/internal/claimer/claim_refresh_test.go b/internal/claimer/claim_refresh_test.go new file mode 100644 index 000000000..2c0c0ff28 --- /dev/null +++ b/internal/claimer/claim_refresh_test.go @@ -0,0 +1,228 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestProvisionalObserverRefreshesMatchingStageMetadataAtSelectedView(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe} { + for _, sameHeight := range []bool{false, true} { + t.Run(policy.String()+map[bool]string{false: "/new block", true: "/same block"}[sameHeight], func(t *testing.T) { + s, repo, chain := newServiceMock(t) + s.defaultBlock = policy + app := makeApplication() + epoch := makeStagedEpoch(app, 3, 44) + oldHash := *epoch.ClaimTransactionHash + staged := uint64(46) + if sameHeight { + staged = 44 + } + event := makeStagedEvent(app, epoch) + event.Raw.BlockNumber, event.Raw.TxHash = staged, common.HexToHash("0xcafe") + if sameHeight { + chain.On("claimTransactionCanonical", mock.Anything, oldHash, uint64(60)).Return(false, nil).Once() + } + chain.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, event, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + repo.On("RefreshClaimObservation", mock.Anything, epoch, new(event.Raw.TxHash), new(staged)). + Run(func(args mock.Arguments) { + expected := args.Get(1).(*model.Epoch) + require.Equal(t, oldHash, *expected.ClaimTransactionHash) + require.Equal(t, uint64(44), *expected.StagedAtBlock) + }).Return(nil).Once() + result, done := s.handlePreAcceptClaimStatus(t.Context(), app, epoch, + makeClaimStatus(claimStatusStaged, epoch, staged), 60) + require.NoError(t, result.err) + require.False(t, done) + require.Equal(t, event.Raw.TxHash, *epoch.ClaimTransactionHash) + require.Equal(t, staged, *epoch.StagedAtBlock) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + chain.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } + } +} + +func TestProvisionalObserverRejectsInconsistentOrConcurrentStageRefresh(t *testing.T) { + for _, conflict := range []string{"other epoch", "different root", "different block", "concurrent acceptance"} { + t.Run(conflict, func(t *testing.T) { + concurrent := conflict == "concurrent acceptance" + s, repo, chain := newServiceMock(t) + s.defaultBlock = model.DefaultBlock_Latest + app := makeApplication() + epoch := makeStagedEpoch(app, 3, 44) + beforeHash, beforeBlock := *epoch.ClaimTransactionHash, *epoch.StagedAtBlock + event := makeStagedEvent(app, epoch) + event.Raw.BlockNumber, event.Raw.TxHash = 46, common.HexToHash("0xcafe") + switch conflict { + case "other epoch": + event.LastProcessedBlockNumber = new(big.Int).SetUint64(epoch.LastBlock + 10) + case "different root": + event.OutputsMerkleRoot = common.HexToHash("0xbad") + case "different block": + event.Raw.BlockNumber = 47 + } + chain.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, event, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + if concurrent { + repo.On("RefreshClaimObservation", mock.Anything, epoch, new(event.Raw.TxHash), new(uint64(46))). + Return(repository.ErrNoUpdate).Once() + } + err := s.refreshStagedClaim(t.Context(), app, epoch, makeClaimStatus(claimStatusStaged, epoch, 46), 60) + if concurrent { + require.ErrorIs(t, err, repository.ErrNoUpdate) + } else { + require.ErrorIs(t, err, txsender.ErrInconclusive) + } + require.Equal(t, beforeHash, *epoch.ClaimTransactionHash) + require.Equal(t, beforeBlock, *epoch.StagedAtBlock) + repo.AssertExpectations(t) + }) + } +} + +func TestProvisionalSubmittedObserverRefreshesOnlyMatchingLocalVote(t *testing.T) { + s, repo, chain := newServiceMock(t) + s.defaultBlock = model.DefaultBlock_Safe + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + epoch := makeComputedEpoch(app, 3) + epoch.Status = model.EpochStatus_ClaimSubmitted + other := makeSubmittedEventWithTxHash(app, epoch, common.HexToHash("0x1234")) + other.Submitter = common.HexToAddress("0xbeef") + local := makeSubmittedEventWithTxHash(app, epoch, common.HexToHash("0xcafe")) + local.Submitter = chain.submitterAddress + chain.On("claimTransactionCanonical", mock.Anything, *epoch.ClaimTransactionHash, uint64(60)).Return(false, nil).Once() + chain.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{other, local}, nil).Once() + repo.On("RefreshClaimObservation", mock.Anything, epoch, new(local.Raw.TxHash), (*uint64)(nil)).Return(nil).Once() + require.NoError(t, s.refreshSubmittedClaim(t.Context(), app, epoch, 60)) + require.Equal(t, local.Raw.TxHash, *epoch.ClaimTransactionHash) + require.Equal(t, model.EpochStatus_ClaimSubmitted, epoch.Status) + require.Nil(t, epoch.StagedAtBlock) + repo.AssertExpectations(t) +} + +func TestClaimMetadataRefreshRejectsSubmissionAboveSelectedView(t *testing.T) { + s, repo, chain := newServiceMock(t) + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + epoch.Status = model.EpochStatus_ClaimSubmitted + original := *epoch.ClaimTransactionHash + event := makeSubmittedEventWithTxHash(app, epoch, common.HexToHash("0xcafe")) + event.Raw.BlockNumber = 61 + chain.On("claimTransactionCanonical", mock.Anything, original, uint64(60)).Return(false, nil).Once() + chain.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{event}, nil).Once() + require.ErrorIs(t, s.refreshSubmittedClaim(t.Context(), app, epoch, 60), txsender.ErrInconclusive) + require.Equal(t, original, *epoch.ClaimTransactionHash) + repo.AssertNotCalled(t, "RefreshClaimObservation", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestUnchangedCanonicalClaimMetadataDoesNotRescanHistory(t *testing.T) { + s, repo, chain := newServiceMock(t) + epoch := makeStagedEpoch(makeApplication(), 3, 44) + chain.On("claimTransactionCanonical", mock.Anything, *epoch.ClaimTransactionHash, uint64(60)).Return(true, nil).Twice() + require.NoError(t, s.refreshStagedClaim(t.Context(), makeApplication(), epoch, makeClaimStatus(claimStatusStaged, epoch, 44), 60)) + require.NoError(t, s.refreshSubmittedClaim(t.Context(), makeApplication(), epoch, 60)) + chain.AssertNotCalled(t, "findClaimStagedEventAndSucc", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + chain.AssertNotCalled(t, "findClaimSubmittedEventAndSucc", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "RefreshClaimObservation", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestRepairedStageAcceptedBetweenPollsRefreshesAuditBeforeTerminalWrite(t *testing.T) { + for _, eventPath := range []bool{false, true} { + t.Run(map[bool]string{false: "state read", true: "event"}[eventPath], func(t *testing.T) { + s, repo, chain := newServiceMock(t) + s.defaultBlock = model.DefaultBlock_Safe + app := makeApplication() + epoch := makeStagedEpoch(app, 3, 44) + staged := makeStagedEvent(app, epoch) + staged.Raw.BlockNumber, staged.Raw.TxHash = 46, common.HexToHash("0xcafe") + claim := makeClaimStatus(claimStatusAccepted, epoch, 46) + chain.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, staged, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + refresh := repo.On("RefreshClaimObservation", mock.Anything, epoch, new(staged.Raw.TxHash), new(uint64(46))).Return(nil).Once() + if eventPath { + accepted := makeAcceptedEvent(app, epoch) + accepted.Raw.TxHash = common.HexToHash("0xacce") + chain.On("getConsensusAddress", mock.Anything, app, big.NewInt(60)).Return(app.IConsensusAddress, nil).Once() + chain.On("findClaimAcceptedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, accepted, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + chain.On("getClaimStatus", mock.Anything, app, epoch, big.NewInt(60)).Return(claim, nil).Once() + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, new(accepted.Raw.TxHash)). + Return(nil).NotBefore(refresh).Once() + result := s.processAcceptedClaimEvent(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(60)) + require.NoError(t, result.err) + require.Equal(t, 1, result.progress) + } else { + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, (*common.Hash)(nil)). + Return(nil).NotBefore(refresh).Once() + result, done := s.handlePreAcceptClaimStatus(t.Context(), app, epoch, claim, 60) + require.True(t, done) + require.NoError(t, result.err) + } + require.Equal(t, uint64(46), *epoch.StagedAtBlock) + require.Equal(t, staged.Raw.TxHash, *epoch.ClaimTransactionHash) + chain.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func TestSubmittedObservationFallbackRepairsTheLocalVoteHash(t *testing.T) { + s, repo, chain := newServiceMock(t) + s.defaultBlock = model.DefaultBlock_Safe + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + epoch := makeSubmittedEpoch(app, 3) + old := *epoch.ClaimTransactionHash + event := makeSubmittedEventWithTxHash(app, epoch, common.HexToHash("0xcafe")) + event.Submitter, event.Raw.BlockNumber = chain.submitterAddress, 45 + chain.On("getConsensusAddress", mock.Anything, app, big.NewInt(60)).Return(app.IConsensusAddress, nil).Once() + chain.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, (*iconsensus.IConsensusClaimStaged)(nil), (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + chain.On("claimTransactionCanonical", mock.Anything, old, uint64(60)).Return(false, nil).Once() + chain.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{event}, nil).Once() + repo.On("RefreshClaimObservation", mock.Anything, epoch, new(event.Raw.TxHash), (*uint64)(nil)).Return(nil).Once() + result := s.processSubmittedClaim(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(60)) + require.NoError(t, result.err) + require.Zero(t, result.progress) + require.Equal(t, event.Raw.TxHash, *epoch.ClaimTransactionHash) + require.Equal(t, model.EpochStatus_ClaimSubmitted, epoch.Status) + chain.AssertExpectations(t) + repo.AssertExpectations(t) +} + +func TestProvisionalAcceptedEventCannotOverrideDisagreeingClaimState(t *testing.T) { + s, repo, chain := newServiceMock(t) + s.defaultBlock = model.DefaultBlock_Latest + app := makeApplication() + epoch := makeStagedEpoch(app, 3, 44) + event := makeAcceptedEvent(app, epoch) + chain.On("getConsensusAddress", mock.Anything, app, big.NewInt(60)).Return(app.IConsensusAddress, nil).Once() + chain.On("findClaimAcceptedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, event, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + chain.On("getClaimStatus", mock.Anything, app, epoch, big.NewInt(60)). + Return(makeClaimStatus(claimStatusStaged, epoch, 44), nil).Once() + result := s.processAcceptedClaimEvent(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(60)) + require.ErrorIs(t, result.err, txsender.ErrInconclusive) + require.Zero(t, result.progress) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + chain.AssertExpectations(t) +} diff --git a/internal/claimer/claim_repair_test.go b/internal/claimer/claim_repair_test.go new file mode 100644 index 000000000..a14633887 --- /dev/null +++ b/internal/claimer/claim_repair_test.go @@ -0,0 +1,93 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "testing" +) + +func (m *claimerRepositoryMock) RefreshClaimObservation( + ctx context.Context, expected *model.Epoch, hash *common.Hash, block *uint64, +) error { + return m.Called(ctx, expected, hash, block).Error(0) +} + +func (m *claimerBlockchainMock) claimTransactionCanonical(ctx context.Context, hash common.Hash, block uint64) (bool, error) { + args := m.Called(ctx, hash, block) + return args.Bool(0), args.Error(1) +} + +func (*eventChain) claimTransactionCanonical(context.Context, common.Hash, uint64) (bool, error) { + return true, nil +} + +func (r *eventRepository) RefreshClaimObservation( + _ context.Context, expected *model.Epoch, hash *common.Hash, block *uint64, +) error { + stored := r.epoch(expected.ApplicationID, expected.Index) + if stored == nil || stored.Status != expected.Status || + (stored.Status != model.EpochStatus_ClaimSubmitted && stored.Status != model.EpochStatus_ClaimStaged) { + return repository.ErrNoUpdate + } + stored.ClaimTransactionHash = hash + if block != nil { + stored.StagedAtBlock = new(*block) + } + return nil +} + +func TestRecordedQuorumVoteMaintenanceUsesLiveVoteEligibility(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe} { + t.Run(policy.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, policy) + app, epoch := deliveryApplication(1, model.Consensus_Quorum) + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + require.NoError(t, s.publisher.tick(t.Context())) + first := backend.published[0] + epoch.Status = model.EpochStatus_ClaimSubmitted + backend.last, backend.safe = 52, 51 + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Equal(t, first.Data(), backend.published[1].Data()) + require.Equal(t, first.Nonce(), backend.published[1].Nonce()) + }) + } +} + +func TestVanishedClaimStageRepairsHeldAcceptanceButImmaturityWaits(t *testing.T) { + for _, absent := range []bool{false, true} { + t.Run(map[bool]string{false: "immature", true: "absent"}[absent], func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + app, epoch := deliveryApplication(1, model.Consensus_Authority) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + app.ClaimStagingPeriod = 10 + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 30) + require.NoError(t, s.publisher.tick(t.Context())) + if absent { + delete(backend.claims, app.ID) + } else { + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 49) + } + backend.last = 51 + err := s.publisher.tick(t.Context()) + if !absent { + require.NoError(t, err) + require.Len(t, backend.published, 1) + return + } + require.NoError(t, err) + require.Len(t, backend.published, 2) + require.Equal(t, claimSubmitMethod, publishedClaimMethod(t, backend.published[1].Data())) + require.Equal(t, backend.published[0].Nonce(), backend.published[1].Nonce()) + }) + } +} diff --git a/internal/claimer/claim_scan.go b/internal/claimer/claim_scan.go new file mode 100644 index 000000000..0fcb697b2 --- /dev/null +++ b/internal/claimer/claim_scan.go @@ -0,0 +1,390 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" +) + +const ( + claimScanBlockLimit uint64 = 4096 + claimScanEventTarget uint64 = 256 + claimScanSplitFactor uint64 = 2 + claimDecimalRadix int = 10 +) + +var errClaimDeploymentPending = fmt.Errorf("%w: application deployment is not visible at the selected head", txsender.ErrInconclusive) + +type observedClaimKey struct { + Covered string + Machine common.Hash +} + +type observedClaim struct { + Key observedClaimKey + Outputs common.Hash + Stage, Acceptance *types.Log + StagingBlock, Period *big.Int +} + +func (c observedClaim) acceptanceFrom() string { + if c.StagingBlock == nil || c.Period == nil { + return "unknown" + } + return new(big.Int).Add(c.StagingBlock, c.Period).String() +} + +type claimRead struct { + At txsender.BlockRef + Staged, Accepted *big.Int +} + +type claimScan struct { + Read claimRead + Claims []observedClaim +} + +func claimUint256(n *big.Int) bool { return n != nil && n.Sign() >= 0 && n.BitLen() <= 256 } + +func (cb *claimerBlockchain) claimReference(ctx context.Context, number uint64) (txsender.BlockRef, error) { + if number == cb.observed.Number { + return cb.observed, nil + } + h, err := cb.backend.Header(ctx, hexutil.EncodeUint64(number)) + if err != nil { + return txsender.BlockRef{}, err + } + if h == nil || h.Number == nil || !h.Number.IsUint64() || h.Number.Uint64() != number { + return txsender.BlockRef{}, txsender.ErrInconclusive + } + return txsender.BlockRef{Number: number, Hash: h.Hash()}, nil +} + +func (cb *claimerBlockchain) checkClaimReference(ctx context.Context, ref txsender.BlockRef) error { + if ref.Hash == (common.Hash{}) || ref.Number > cb.observed.Number { + return txsender.ErrInconclusive + } + canonical, err := cb.backend.Canonical(ctx, ref) + if err != nil { + return err + } + if !canonical { + return txsender.ErrNonCanonical + } + return nil +} + +// Tick already validated this exact observed head. Other references still need +// a pre-use check; post-read checks always use checkClaimReference directly. +func (cb *claimerBlockchain) checkClaimRead(ctx context.Context, ref txsender.BlockRef) error { + if ref == cb.observed && ref.Hash != (common.Hash{}) { + return ctx.Err() + } + return cb.checkClaimReference(ctx, ref) +} + +func claimScanCallOpts(ctx context.Context, ref txsender.BlockRef) *bind.CallOpts { + return &bind.CallOpts{Context: ctx, BlockHash: ref.Hash} +} + +func (cb *claimerBlockchain) scanClaims(ctx context.Context, app *model.Application, cursor uint64, + previous *claimRead, maxRange uint64) (*claimScan, error) { + head := cb.observed + if app == nil || head.Hash == (common.Hash{}) || cursor > head.Number { + return nil, txsender.ErrInconclusive + } + if previous != nil { + if previous.At.Number != cursor || !claimUint256(previous.Staged) || !claimUint256(previous.Accepted) || + previous.Accepted.Cmp(previous.Staged) > 0 { + return nil, txsender.ErrInconclusive + } + if previous.At == head { + return nil, nil + } + if err := cb.checkClaimRead(ctx, previous.At); err != nil { + return nil, err + } + } + protected := txsender.ProtectedClient{Client: cb.client} + application, err := iapplication.NewIApplicationCaller(app.IApplicationAddress, protected) + if err != nil { + return nil, err + } + checkBinding := func(ref txsender.BlockRef) error { + address, readErr := application.GetOutputsMerkleRootValidator(claimScanCallOpts(ctx, ref)) + if readErr != nil { + return readErr + } + if address != app.IConsensusAddress { + return fmt.Errorf("%w: registered consensus %s differs from observed %s", + txsender.ErrInconclusive, app.IConsensusAddress, address) + } + return nil + } + if err = checkBinding(head); err != nil { + if errors.Is(err, bind.ErrNoCode) && previous == nil { + return nil, errClaimDeploymentPending + } + return nil, err + } + caller, err := iconsensus.NewIConsensusCaller(app.IConsensusAddress, protected) + if err != nil { + return nil, err + } + counters := func(ref txsender.BlockRef) (claimRead, error) { + staged, readErr := caller.GetNumberOfStagedClaims(claimScanCallOpts(ctx, ref), app.IApplicationAddress) + if errors.Is(readErr, bind.ErrNoCode) && ref.Number < head.Number { + // The protected binding confirmed no code at this canonical hash. + // Missing historical state remains an error, never zero counters. + return claimRead{At: ref, Staged: new(big.Int), Accepted: new(big.Int)}, nil + } + if readErr != nil { + return claimRead{}, readErr + } + accepted, readErr := caller.GetNumberOfAcceptedClaims(claimScanCallOpts(ctx, ref), app.IApplicationAddress) + if readErr != nil { + return claimRead{}, readErr + } + if !claimUint256(staged) || !claimUint256(accepted) || accepted.Cmp(staged) > 0 { + return claimRead{}, txsender.ErrInconclusive + } + return claimRead{At: ref, Staged: staged, Accepted: accepted}, nil + } + var before claimRead + if previous != nil { + before = *previous + } else { + deployment, readErr := application.GetDeploymentBlockNumber(claimScanCallOpts(ctx, head)) + if readErr != nil { + return nil, readErr + } + if deployment == nil || !deployment.IsUint64() || deployment.Uint64() > head.Number { + return nil, txsender.ErrInconclusive + } + // No claim can precede the application's deployment. Include its entire + // deployment block, even if the InputBox was registered much later. + if deployment.Uint64() > cursor { + ref, readErr := cb.claimReference(ctx, deployment.Uint64()-1) + if readErr != nil { + return nil, readErr + } + before = claimRead{At: ref, Staged: new(big.Int), Accepted: new(big.Int)} + } else { + ref, readErr := cb.claimReference(ctx, cursor) + if readErr != nil { + return nil, readErr + } + if err = checkBinding(ref); err != nil { + return nil, err + } + before, err = counters(ref) + if err != nil { + return nil, err + } + } + } + if !claimUint256(before.Staged) || !claimUint256(before.Accepted) || before.Accepted.Cmp(before.Staged) > 0 { + return nil, txsender.ErrInconclusive + } + if before.At.Number == head.Number { + return nil, nil + } + tip, err := counters(head) + if err != nil { + return nil, err + } + if tip.Staged.Cmp(before.Staged) < 0 || tip.Accepted.Cmp(before.Accepted) < 0 { + return nil, txsender.ErrInconclusive + } + result := &claimScan{Read: tip} + if tip.Staged.Cmp(before.Staged) != 0 || tip.Accepted.Cmp(before.Accepted) != 0 { + start := before.At.Number + 1 + limit := claimScanBlockLimit + if maxRange > 0 && maxRange < limit { + limit = maxRange + } + end := head.Number + if end-start >= limit { + end = start + limit - 1 + } + for { + if end != head.Number { + ref, readErr := cb.claimReference(ctx, end) + if readErr != nil { + return nil, readErr + } + if err = checkBinding(ref); err != nil { + return nil, err + } + result.Read, err = counters(ref) + if err != nil { + return nil, err + } + } + if result.Read.Staged.Cmp(before.Staged) < 0 || result.Read.Accepted.Cmp(before.Accepted) < 0 || + result.Read.Staged.Cmp(tip.Staged) > 0 || result.Read.Accepted.Cmp(tip.Accepted) > 0 { + return nil, txsender.ErrInconclusive + } + delta := new(big.Int).Sub(result.Read.Staged, before.Staged) + delta.Add(delta, new(big.Int).Sub(result.Read.Accepted, before.Accepted)) + // This is an admission target. An indivisible block is always read whole. + if end == start || delta.Cmp(new(big.Int).SetUint64(claimScanEventTarget)) <= 0 { + break + } + end = start + (end-start)/claimScanSplitFactor + } + result.Claims, err = cb.readClaimLogs(ctx, app, start, before, result.Read, caller) + if err != nil { + return nil, err + } + } + references := []txsender.BlockRef{before.At, result.Read.At} + if result.Read.At != head { + references = append(references, head) + } + for _, ref := range references { + if err = cb.checkClaimReference(ctx, ref); err != nil { + return nil, err + } + } + return result, nil +} + +func orderedClaimLog(previous, next types.Log) bool { + if previous.BlockNumber != next.BlockNumber { + return previous.BlockNumber < next.BlockNumber + } + return previous.BlockHash == next.BlockHash && previous.Index < next.Index && previous.TxIndex <= next.TxIndex && + (previous.TxIndex == next.TxIndex) == (previous.TxHash == next.TxHash) +} + +func (cb *claimerBlockchain) readClaimLogs(ctx context.Context, app *model.Application, start uint64, + before, after claimRead, caller *iconsensus.IConsensusCaller) ([]observedClaim, error) { + abi, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + return nil, err + } + stagedTopic, acceptedTopic := abi.Events["ClaimStaged"].ID, abi.Events["ClaimAccepted"].ID + binding, err := iconsensus.NewIConsensus(app.IConsensusAddress, cb.client) + if err != nil { + return nil, err + } + filter := ethutil.Filter{MaxChunkSize: new(big.Int).SetUint64(claimScanBlockLimit), Logger: cb.logger} + logs, err := filter.ChunkedFilterLogs(ctx, cb.client, ethereum.FilterQuery{ + Addresses: []common.Address{app.IConsensusAddress}, + Topics: [][]common.Hash{{stagedTopic, acceptedTopic}, {common.BytesToHash(app.IApplicationAddress.Bytes())}}, + FromBlock: new(big.Int).SetUint64(start), ToBlock: new(big.Int).SetUint64(after.At.Number), + }) + if err != nil { + return nil, err + } + var claims []observedClaim + positions := map[observedClaimKey]int{} + checked := map[txsender.BlockRef]bool{} + var prior *types.Log + var staged, accepted uint64 + for raw, readErr := range logs { + if readErr != nil { + return nil, readErr + } + if raw == nil || raw.Address != app.IConsensusAddress || raw.BlockNumber < start || raw.BlockNumber > after.At.Number || + raw.Removed || raw.BlockHash == (common.Hash{}) || raw.TxHash == (common.Hash{}) || len(raw.Topics) != 2 || + raw.Topics[1] != common.BytesToHash(app.IApplicationAddress.Bytes()) || prior != nil && !orderedClaimLog(*prior, *raw) { + return nil, txsender.ErrInconclusive + } + ref := txsender.BlockRef{Number: raw.BlockNumber, Hash: raw.BlockHash} + if !checked[ref] { + if err = cb.checkClaimReference(ctx, ref); err != nil { + return nil, err + } + checked[ref] = true + } + claim := observedClaim{} + switch raw.Topics[0] { + case stagedTopic: + event, parseErr := binding.ParseClaimStaged(*raw) + if parseErr != nil { + return nil, parseErr + } + claim.Key = observedClaimKey{Covered: event.LastProcessedBlockNumber.String(), Machine: common.Hash(event.MachineMerkleRoot)} + claim.Outputs, claim.Stage = common.Hash(event.OutputsMerkleRoot), raw + staged++ + case acceptedTopic: + event, parseErr := binding.ParseClaimAccepted(*raw) + if parseErr != nil { + return nil, parseErr + } + claim.Key = observedClaimKey{Covered: event.LastProcessedBlockNumber.String(), Machine: common.Hash(event.MachineMerkleRoot)} + claim.Outputs, claim.Acceptance = common.Hash(event.OutputsMerkleRoot), raw + accepted++ + default: + return nil, txsender.ErrInconclusive + } + if position, exists := positions[claim.Key]; exists { + saved := &claims[position] + if saved.Outputs != claim.Outputs || claim.Stage != nil || saved.Acceptance != nil { + return nil, txsender.ErrInconclusive + } + saved.Acceptance = claim.Acceptance + } else { + positions[claim.Key] = len(claims) + claims = append(claims, claim) + } + prior = raw + } + // Count every log before projecting to pending claims. An acceptance can + // refer to a claim that a prior local comparison already completed. + if new(big.Int).Add(before.Staged, new(big.Int).SetUint64(staged)).Cmp(after.Staged) != 0 || + new(big.Int).Add(before.Accepted, new(big.Int).SetUint64(accepted)).Cmp(after.Accepted) != 0 { + return nil, fmt.Errorf("%w: staged/accepted logs do not match pinned counters", txsender.ErrInconclusive) + } + var period *big.Int + for i := range claims { + claim := &claims[i] + if claim.Stage == nil { + continue + } + if period == nil { + period, err = caller.GetClaimStagingPeriod(claimScanCallOpts(ctx, after.At)) + if err != nil { + return nil, err + } + if !claimUint256(period) { + return nil, txsender.ErrInconclusive + } + } + covered, ok := new(big.Int).SetString(claim.Key.Covered, claimDecimalRadix) + if !ok { + return nil, txsender.ErrInconclusive + } + state, readErr := caller.GetClaim(claimScanCallOpts(ctx, after.At), app.IApplicationAddress, covered, claim.Key.Machine) + if readErr != nil { + return nil, readErr + } + expected := claimStatusStaged + if claim.Acceptance != nil { + expected = claimStatusAccepted + } + if state.Status != expected || common.Hash(state.StagedOutputsMerkleRoot) != claim.Outputs || + !claimUint256(state.StagingBlockNumber) { + return nil, txsender.ErrInconclusive + } + claim.StagingBlock, claim.Period = state.StagingBlockNumber, new(big.Int).Set(period) + } + return claims, nil +} diff --git a/internal/claimer/claim_scan_regression_test.go b/internal/claimer/claim_scan_regression_test.go new file mode 100644 index 000000000..950d90424 --- /dev/null +++ b/internal/claimer/claim_scan_regression_test.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestIndependentClaimScanOmittedAcceptanceAfterPriorStageIsInconclusive(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + before := claimRead{At: txsender.BlockRef{Number: 40, Hash: api.header(40).Hash()}, + Staged: big.NewInt(1), Accepted: new(big.Int)} + api.logs = nil // the stage is before this range; only the later acceptance is omitted + api.counterAt = func(block uint64) (*big.Int, *big.Int, error) { + accepted := new(big.Int) + if block >= 41 { + accepted.SetUint64(1) + } + return big.NewInt(1), accepted, nil + } + batch, err := chain.scanClaims(t.Context(), app, 40, &before, 0) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.ErrorContains(t, err, "staged/accepted logs do not match pinned counters") + require.Nil(t, batch) + require.Equal(t, 0, api.claimReads, "the accepted-counter certificate is the only claim-level check in this range") + require.Len(t, api.filters, 1) + require.Zero(t, before.Accepted.Sign()) +} + +func TestIndependentClaimScanFixedHeadStillChecksLocalReadiness(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + r := &observationTestRepo{apps: []*model.Application{app}, safe: map[int64]uint64{}, + roots: map[uint64]repository.ClaimRoots{}} + o := &independentClaimObserver{repository: r, blockchain: chain, logger: chain.logger, + policy: model.DefaultBlock_Finalized, budget: time.Second} + _, err := o.observe(t.Context(), 100) + require.NoError(t, err) + require.Len(t, o.apps[app.ID].pending, 1) + require.EqualValues(t, 39, r.safe[app.ID]) + headers, selectors, filters := api.headerReads, len(api.selectors), len(api.filters) + r.roots[29] = readyRoots(api.machine, common.HexToHash("0xbad")) + _, err = o.observe(t.Context(), 100) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.EqualValues(t, 100, r.safe[app.ID]) + require.Equal(t, []uint64{29, 29}, r.rootCalls) + require.Equal(t, headers, api.headerReads, "fixed-head discovery performs no header RPC") + require.Len(t, api.selectors, selectors, "fixed-head discovery performs no state RPC") + require.Len(t, api.filters, filters) +} + +func TestIndependentClaimScanSameHeightDifferentHashRejectsOldCertificate(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + previous := claimRead{At: chain.observed, Staged: big.NewInt(1), Accepted: new(big.Int)} + replacement := *api.header(100) + replacement.Extra = []byte("new branch at the same height") + api.headers[100] = &replacement + api.byHash[replacement.Hash()] = 100 + chain.observed.Hash = replacement.Hash() + batch, err := chain.scanClaims(t.Context(), app, 100, &previous, 0) + require.ErrorIs(t, err, txsender.ErrNonCanonical) + require.Nil(t, batch) + require.Empty(t, api.selectors, "reject the cached branch before using its counters") + require.Empty(t, api.filters) + require.Positive(t, api.headerReads) +} + +func TestIndependentClaimScanDensityUsesBothCounterDeltas(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + app.EpochLength = 1 + api.head, api.clock = 1000, big.NewInt(600) + chain.observed = txsender.BlockRef{Number: 1000, Hash: api.header(1000).Hash()} + before := claimRead{At: txsender.BlockRef{Number: 400, Hash: api.header(400).Hash()}, + Staged: big.NewInt(150), Accepted: new(big.Int)} + // The block has 150 new stages and 150 acceptances of older stages. Each + // delta alone is below the admission target; their total is above it. + api.counterAt = func(block uint64) (*big.Int, *big.Int, error) { + if block < 600 { + return big.NewInt(150), new(big.Int), nil + } + return big.NewInt(300), big.NewInt(150), nil + } + api.logs = nil + for index := range 150 { + api.logs = append(api.logs, watchLog(api, watchStageTopic, big.NewInt(int64(150+index)), 600, uint(index))) + } + for index := range 150 { + api.logs = append(api.logs, watchLog(api, watchAcceptTopic, big.NewInt(int64(index)), 600, uint(150+index))) + } + batch, err := chain.scanClaims(t.Context(), app, 400, &before, 0) + require.NoError(t, err) + require.NotNil(t, batch) + require.GreaterOrEqual(t, batch.Read.At.Number, uint64(401)) + require.Less(t, batch.Read.At.Number, uint64(600), "split before the dense block instead of admitting all 300 events") + require.Empty(t, batch.Claims) + require.Equal(t, big.NewInt(150), batch.Read.Staged) + require.Zero(t, batch.Read.Accepted.Sign()) + require.Len(t, api.filters, 1, "count-based admission happens before fetching logs") + require.Equal(t, batch.Read.At.Number, api.filters[0].ToBlock.ToInt().Uint64()) +} + +func TestIndependentClaimScanDenseSingleBlockIsReadWhole(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + app.EpochLength = 1 + api.head, api.clock = 600, big.NewInt(600) + chain.observed = txsender.BlockRef{Number: 600, Hash: api.header(600).Hash()} + before := claimRead{At: txsender.BlockRef{Number: 599, Hash: api.header(599).Hash()}, + Staged: new(big.Int), Accepted: new(big.Int)} + api.counterAt = func(block uint64) (*big.Int, *big.Int, error) { + if block < 600 { + return new(big.Int), new(big.Int), nil + } + return big.NewInt(300), new(big.Int), nil + } + api.logs = make([]types.Log, 0, 300) + for index := range 300 { + api.logs = append(api.logs, watchLog(api, watchStageTopic, big.NewInt(int64(index)), 600, uint(index))) + } + batch, err := chain.scanClaims(t.Context(), app, 599, &before, 1) + require.NoError(t, err) + require.EqualValues(t, 600, batch.Read.At.Number) + require.Len(t, batch.Claims, 300, "a block is indivisible; the event target must never truncate it") + require.Equal(t, big.NewInt(300), batch.Read.Staged) + require.Equal(t, 300, api.claimReads) + require.Len(t, api.filters, 1) + require.EqualValues(t, 600, api.filters[0].FromBlock.ToInt().Uint64()) + require.EqualValues(t, 600, api.filters[0].ToBlock.ToInt().Uint64()) +} diff --git a/internal/claimer/claim_scan_test.go b/internal/claimer/claim_scan_test.go new file mode 100644 index 000000000..f230eb65e --- /dev/null +++ b/internal/claimer/claim_scan_test.go @@ -0,0 +1,532 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "slices" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +// Event data is encoded directly from alpha.10's published signature and ABI +// word layout. These are observation fixtures, not application execution proofs. +const claimCounterHistoryCase = "counter history" + +const claimWrongHashCase = "wrong hash" + +var ( + watchStageTopic = crypto.Keccak256Hash([]byte("ClaimStaged(address,uint256,bytes32,bytes32)")) + watchAcceptTopic = crypto.Keccak256Hash([]byte("ClaimAccepted(address,uint256,bytes32,bytes32)")) +) + +type watchCallArgs struct { + To common.Address + Input hexutil.Bytes +} + +type watchRPCHistory struct { + app, consensus common.Address + applicationFrom uint64 + bindingError error + head uint64 + headers map[uint64]*types.Header + byHash map[common.Hash]uint64 + logs []types.Log + staged *big.Int + accepted *big.Int + clock, period *big.Int + claimAt func(*big.Int) (uint8, *big.Int) + claimStatus uint8 + machine, roots common.Hash + noCode map[uint64]bool + codeError error + noCodeAt func(uint64) bool + counterAt func(uint64) (*big.Int, *big.Int, error) + stagedReads int + headerReads int + claimReads int + codeReads int + getLogs func(uint64, uint64) ([]types.Log, error) + selectors []rpc.BlockNumberOrHash + filters []acceptedOrderFilter + foreclosedAt func(uint64) (bool, error) + foreclosureReads []uint64 +} + +func (s *watchRPCHistory) header(height uint64) *types.Header { + if header := s.headers[height]; header != nil { + return header + } + header := deliveryHeader(height) + s.headers[height], s.byHash[header.Hash()] = header, height + return header +} + +func (s *watchRPCHistory) codeAbsent(block uint64) bool { + return s.noCode[block] || (s.noCodeAt != nil && s.noCodeAt(block)) +} + +func (s *watchRPCHistory) counters(block uint64) (*big.Int, *big.Int, error) { + if s.counterAt != nil { + return s.counterAt(block) + } + // Independent oracle: this fixture's first stage is at40 even when a + // faulty provider omits, duplicates or fabricates the returned logs. + if block < 40 { + return new(big.Int), new(big.Int), nil + } + return s.staged, s.accepted, nil +} + +func (s *watchRPCHistory) GetBlockByNumber(_ context.Context, n rpc.BlockNumber, _ bool) *types.Header { + s.headerReads++ + if n < 0 { + return s.header(s.head) + } + return s.header(uint64(n)) +} + +func (s *watchRPCHistory) at(block rpc.BlockNumberOrHash) (uint64, error) { + s.selectors = append(s.selectors, block) + if block.BlockHash == nil || block.BlockNumber != nil || !block.RequireCanonical { + return 0, errors.New("watch reads must require a canonical hash") + } + n, ok := s.byHash[*block.BlockHash] + if !ok { + return 0, errors.New("unknown fixture hash") + } + return n, nil +} + +func (s *watchRPCHistory) GetCode(_ context.Context, address common.Address, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + n, err := s.at(block) + if err != nil { + return nil, err + } + s.codeReads++ + if s.codeError != nil { + return nil, s.codeError + } + if address == s.app { + if n < s.applicationFrom { + return hexutil.Bytes{}, nil + } + return hexutil.Bytes{0x60, 0x00}, nil + } + if s.codeAbsent(n) { + return hexutil.Bytes{}, nil + } + return hexutil.Bytes{0x60, 0x00}, nil +} + +func watchWord(n *big.Int) []byte { return n.FillBytes(make([]byte, 32)) } + +func (s *watchRPCHistory) Call(_ context.Context, call watchCallArgs, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + height, err := s.at(block) + if err != nil { + return nil, err + } + if len(call.Input) < 4 { + return nil, errors.New("missing selector") + } + selector := call.Input[:4] + is := func(signature string) bool { return bytes.Equal(selector, crypto.Keccak256([]byte(signature))[:4]) } + if is("getDeploymentBlockNumber()") { + if call.To != s.app { + return nil, errors.New("deployment read used a different application") + } + return watchWord(new(big.Int).SetUint64(s.applicationFrom)), nil + } + if is("getOutputsMerkleRootValidator()") { + if call.To != s.app { + return nil, errors.New("binding read used a different application") + } + if s.bindingError != nil { + return nil, s.bindingError + } + if height < s.applicationFrom { + return hexutil.Bytes{}, nil + } + return common.LeftPadBytes(s.consensus.Bytes(), 32), nil + } + if is("isForeclosed()") { + if call.To != s.app { + return nil, errors.New("foreclosure read used a different application") + } + s.foreclosureReads = append(s.foreclosureReads, height) + closed, readErr := s.foreclosedAt(height) + if readErr != nil { + return nil, readErr + } + value := new(big.Int) + if closed { + value.SetUint64(1) + } + return watchWord(value), nil + } + if call.To != s.consensus { + return nil, errors.New("claim read used a different consensus") + } + if s.codeAbsent(height) { + return hexutil.Bytes{}, nil + } + switch { + case is("getNumberOfStagedClaims(address)"): + s.stagedReads++ + staged, _, err := s.counters(height) + if err != nil { + return nil, err + } + return watchWord(staged), nil + case is("getNumberOfAcceptedClaims(address)"): + _, accepted, err := s.counters(height) + if err != nil { + return nil, err + } + return watchWord(accepted), nil + case is("getClaimStagingPeriod()"): + return watchWord(s.period), nil + case is("getClaim(address,uint256,bytes32)"): + s.claimReads++ + if len(call.Input) != 100 || common.BytesToAddress(call.Input[4:36]) != s.app { + return nil, errors.New("invalid claim identity") + } + status, clock := s.claimStatus, s.clock + if s.claimAt != nil { + status, clock = s.claimAt(new(big.Int).SetBytes(call.Input[36:68])) + } + result := append(watchWord(new(big.Int).SetUint64(uint64(status))), watchWord(clock)...) + return append(result, s.roots[:]...), nil + default: + return nil, fmt.Errorf("unexpected selector %x", selector) + } +} + +func (s *watchRPCHistory) GetLogs(_ context.Context, filter acceptedOrderFilter) ([]types.Log, error) { + s.filters = append(s.filters, filter) + from, to := filter.FromBlock.ToInt().Uint64(), filter.ToBlock.ToInt().Uint64() + if s.getLogs != nil { + return s.getLogs(from, to) + } + result := make([]types.Log, 0) + for _, log := range s.logs { + if log.BlockNumber < from || log.BlockNumber > to || len(filter.Address) > 0 && !slices.Contains(filter.Address, log.Address) { + continue + } + matches := true + for index, topics := range filter.Topics { + if len(topics) > 0 && (index >= len(log.Topics) || !slices.Contains(topics, log.Topics[index])) { + matches = false + break + } + } + if matches { + result = append(result, log) + } + } + return result, nil +} + +func watchLog(s *watchRPCHistory, topic common.Hash, covered *big.Int, block uint64, index uint) types.Log { + data := append(watchWord(covered), s.roots[:]...) + data = append(data, s.machine[:]...) + return types.Log{ + Address: s.consensus, Topics: []common.Hash{topic, common.BytesToHash(s.app.Bytes())}, Data: data, + BlockNumber: block, BlockHash: s.header(block).Hash(), TxHash: common.HexToHash("0x1234"), + TxIndex: 1, Index: index, + } +} + +func newClaimScanHistory(t *testing.T) (*claimerBlockchain, *watchRPCHistory, *model.Application) { + t.Helper() + app := makeApplication() + app.ID, app.Enabled, app.IInputBoxBlock = 1, true, 90 + api := &watchRPCHistory{app: app.IApplicationAddress, consensus: app.IConsensusAddress, head: 100, + headers: map[uint64]*types.Header{}, byHash: map[common.Hash]uint64{}, noCode: map[uint64]bool{}, + staged: big.NewInt(1), accepted: new(big.Int), clock: big.NewInt(40), period: big.NewInt(7), + claimStatus: claimStatusStaged, machine: common.HexToHash("0x33"), roots: common.HexToHash("0x22")} + for height := uint64(0); height <= api.head; height++ { + api.header(height) + } + api.logs = []types.Log{watchLog(api, watchStageTopic, big.NewInt(29), 40, 0)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", api)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chain := &claimerBlockchain{client: client, backend: txsender.ProtectedClient{Client: client}, + observed: txsender.BlockRef{Number: api.head, Hash: api.header(api.head).Hash()}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + return chain, api, app +} + +func TestIndependentClaimScanReadsBeforeInputBoxAndCertifiesBothStreams(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.accepted.SetUint64(1) + api.claimStatus = claimStatusAccepted + api.logs = append(api.logs, watchLog(api, watchAcceptTopic, big.NewInt(29), 40, 1)) + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 0) + require.NoError(t, err) + require.NotNil(t, batch) + require.EqualValues(t, 100, batch.Read.At.Number) + require.Equal(t, big.NewInt(1), batch.Read.Staged) + require.Equal(t, big.NewInt(1), batch.Read.Accepted) + require.Len(t, batch.Claims, 1) + claim := batch.Claims[0] + require.Equal(t, "29", claim.Key.Covered) + require.Equal(t, api.machine, claim.Key.Machine) + require.Equal(t, api.roots, claim.Outputs) + require.Equal(t, big.NewInt(40), claim.StagingBlock) + require.Equal(t, big.NewInt(7), claim.Period) + require.NotNil(t, claim.Stage) + require.NotNil(t, claim.Acceptance) + require.EqualValues(t, 40, claim.Stage.BlockNumber, "stage predates the registered InputBox floor of 90") + require.EqualValues(t, 0, claim.Stage.Index) + require.EqualValues(t, 1, claim.Acceptance.Index) + filters := len(api.filters) + headers, selectors := api.headerReads, len(api.selectors) + repeat, err := chain.scanClaims(t.Context(), app, batch.Read.At.Number, &batch.Read, 0) + require.NoError(t, err) + require.Nil(t, repeat) + require.Len(t, api.filters, filters, "same head does not reread a log range") + require.Equal(t, headers, api.headerReads, "the exact certified head needs no discovery RPC") + require.Len(t, api.selectors, selectors, "the exact certified head needs no contract read") +} + +func TestIndependentClaimScanAcceptancesOutsidePendingProjection(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.accepted.SetUint64(1) + api.counterAt = func(n uint64) (*big.Int, *big.Int, error) { + accepted := new(big.Int) + if n >= 41 { + accepted.SetUint64(1) + } + staged := new(big.Int) + if n >= 40 { + staged.SetUint64(1) + } + return staged, accepted, nil + } + api.logs = []types.Log{watchLog(api, watchAcceptTopic, big.NewInt(29), 41, 0)} + before := claimRead{At: txsender.BlockRef{Number: 40, Hash: api.header(40).Hash()}, Staged: big.NewInt(1), Accepted: new(big.Int)} + batch, err := chain.scanClaims(t.Context(), app, 40, &before, 0) + require.NoError(t, err) + require.NotNil(t, batch) + require.Len(t, batch.Claims, 1) + require.Nil(t, batch.Claims[0].Stage) + require.NotNil(t, batch.Claims[0].Acceptance) + require.Equal(t, big.NewInt(1), batch.Read.Accepted) + require.Zero(t, before.Accepted.Sign(), "scanner must not mutate the previous certified point") +} + +func TestIndependentClaimScanRejectsIncompleteAndIncoherentObservations(t *testing.T) { + for _, test := range []string{ + "omitted stage", "duplicate", "removed", claimWrongHashCase, "wrong app", "wrong root", "binding", claimCounterHistoryCase, + } { + t.Run(test, func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + want := txsender.ErrInconclusive + switch test { + case "omitted stage": + api.logs = nil + case "duplicate": + api.logs = append(api.logs, api.logs[0]) + case "removed": + api.logs[0].Removed = true + case claimWrongHashCase: + api.logs[0].BlockHash = common.HexToHash("0xdead") + want = txsender.ErrNonCanonical + case "wrong app": + api.logs[0].Topics[1] = common.HexToHash("0xbeef") + case "wrong root": + api.roots = common.HexToHash("0xabc") // getClaim disagrees with the log + case "binding": + app.IConsensusAddress = common.HexToAddress("0xdead") + case claimCounterHistoryCase: + want = errors.New("historical state unavailable") + api.counterAt = func(_ uint64) (*big.Int, *big.Int, error) { + return nil, nil, want + } + } + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 0) + if test == claimCounterHistoryCase { + require.ErrorContains(t, err, want.Error()) + } else { + require.ErrorIs(t, err, want) + } + require.Nil(t, batch, "failure cannot return an advanceable range") + }) + } +} + +func TestIndependentClaimScanBoundAndEmptyTail(t *testing.T) { + t.Run("zero range has an internal cap", func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.head = 10000 + chain.observed = txsender.BlockRef{Number: api.head, Hash: api.header(api.head).Hash()} + api.counterAt = func(n uint64) (*big.Int, *big.Int, error) { + if n < 9000 { + return new(big.Int), new(big.Int), nil + } + return big.NewInt(1), new(big.Int), nil + } + api.logs = nil + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 0) + require.NoError(t, err) + require.EqualValues(t, claimScanBlockLimit, batch.Read.At.Number) + require.Len(t, api.filters, 1) + }) + t.Run("configured smaller range", func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 5) + require.NoError(t, err) + require.EqualValues(t, 5, batch.Read.At.Number) + require.Empty(t, batch.Claims) + require.Len(t, api.filters, 1) + }) + t.Run("both counters prove empty tail", func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.staged.SetUint64(0) + api.logs = nil + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 1) + require.NoError(t, err) + require.EqualValues(t, 100, batch.Read.At.Number) + require.Empty(t, api.filters, "empty tail can jump to head without scanning every capped range") + }) +} + +func TestIndependentClaimScanYoungDeploymentAndUnavailableHistory(t *testing.T) { + t.Run("young app does not need predeployment state", func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.applicationFrom = 95 + api.clock.SetUint64(99) + api.logs = []types.Log{watchLog(api, watchStageTopic, big.NewInt(89), 99, 0)} + api.counterAt = func(n uint64) (*big.Int, *big.Int, error) { + if n < 95 { + return nil, nil, errors.New("predeployment state is pruned") + } + if n < 99 { + return new(big.Int), new(big.Int), nil + } + return big.NewInt(1), new(big.Int), nil + } + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 0) + require.NoError(t, err) + require.Len(t, batch.Claims, 1) + require.EqualValues(t, 95, api.filters[0].FromBlock.ToInt().Uint64()) + }) + t.Run("deploy not visible is a wait", func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.applicationFrom = 101 + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 0) + require.ErrorIs(t, err, errClaimDeploymentPending) + require.Nil(t, batch) + }) + t.Run("confirmed historical no-code is not missing state", func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.noCodeAt = func(n uint64) bool { return n < 45 } + api.clock.SetUint64(60) + api.logs = []types.Log{watchLog(api, watchStageTopic, big.NewInt(49), 60, 0)} + api.counterAt = func(n uint64) (*big.Int, *big.Int, error) { + if n < 60 { + return new(big.Int), new(big.Int), nil + } + return big.NewInt(1), new(big.Int), nil + } + batch, err := chain.scanClaims(t.Context(), app, 0, nil, 10) + require.NoError(t, err) + require.EqualValues(t, 10, batch.Read.At.Number) + require.Empty(t, batch.Claims) + require.Zero(t, batch.Read.Staged.Sign()) + }) + t.Run("old checkpoint needs its actual historical state", func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + api.applicationFrom = 5 + missing := errors.New("state at checkpoint is pruned") + api.counterAt = func(n uint64) (*big.Int, *big.Int, error) { + if n == 20 { + return nil, nil, missing + } + return big.NewInt(1), new(big.Int), nil + } + batch, err := chain.scanClaims(t.Context(), app, 20, nil, 0) + require.ErrorContains(t, err, missing.Error()) + require.Nil(t, batch) + }) +} + +func TestIndependentClaimClosureAuthenticatesFlagAtCertifiedRead(t *testing.T) { + const ( + latestCase = "latest" + safeCase = "safe" + ) + for _, scenario := range []string{ + "true", "false before actual foreclosure", latestCase, safeCase, + "future reference", claimWrongHashCase, "RPC error", "post-read reorg", + } { + t.Run(scenario, func(t *testing.T) { + chain, api, app := newClaimScanHistory(t) + chain.defaultBlock = model.DefaultBlock_Finalized + app.ForecloseBlock = 1 // a stale event hint cannot prove the storage gate + ref := txsender.BlockRef{Number: 40, Hash: api.header(40).Hash()} + wantClosed := true + var wantErr error + api.foreclosedAt = func(block uint64) (bool, error) { return block >= 40, nil } + switch scenario { + case "false before actual foreclosure": + ref = txsender.BlockRef{Number: 29, Hash: api.header(29).Hash()} + wantClosed = false + case latestCase: + chain.defaultBlock = model.DefaultBlock_Latest + wantErr = txsender.ErrInconclusive + case safeCase: + chain.defaultBlock = model.DefaultBlock_Safe + wantErr = txsender.ErrInconclusive + case "future reference": + ref = txsender.BlockRef{Number: 101, Hash: api.header(101).Hash()} + wantErr = txsender.ErrInconclusive + case claimWrongHashCase: + ref.Hash = common.HexToHash("0xdead") + wantErr = txsender.ErrNonCanonical + case "RPC error": + wantErr = errors.New("foreclosure state unavailable") + api.foreclosedAt = func(uint64) (bool, error) { return false, wantErr } + case "post-read reorg": + ref = chain.observed // The post-call check must not reuse the Tick-start head. + wantErr = txsender.ErrNonCanonical + api.foreclosedAt = func(block uint64) (bool, error) { + header := *api.header(block) + header.Extra = []byte("replacement branch") + api.headers[block] = &header + return true, nil + } + } + closed, err := chain.claimForeclosureFinalized(t.Context(), app, ref) + if wantErr == nil { + require.NoError(t, err) + require.Equal(t, wantClosed, closed) + require.Equal(t, []uint64{ref.Number}, api.foreclosureReads, "the gate must use the certified read, not head 100") + } else { + require.ErrorContains(t, err, wantErr.Error()) + require.False(t, closed) + } + }) + } +} diff --git a/internal/claimer/claim_status.go b/internal/claimer/claim_status.go new file mode 100644 index 000000000..747f0a2ee --- /dev/null +++ b/internal/claimer/claim_status.go @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" +) + +const ( + claimStatusUnstaged uint8 = 0 + claimStatusStaged uint8 = 1 + claimStatusAccepted uint8 = 2 +) + +func (s *Service) updateEpochAcceptedFromClaimStatus( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + claim iconsensus.IConsensusClaim, + site string, +) error { + if err := s.verifyClaimOutputsMatch(ctx, app, epoch, claim, site); err != nil { + return err + } + // getClaim is read-only. It tells us the claim state, but not the + // transaction hash that accepted the claim. Preserve the existing audit hash; + // only an observed event can identify a replacement transaction. + if err := s.repository.UpdateEpochWithAcceptedClaim( + ctx, epoch.ApplicationID, epoch.Index, nil); err != nil { + return err + } + return nil +} + +func (s *Service) updateEpochStagedFromClaimStatus( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + claim iconsensus.IConsensusClaim, + site string, +) (uint64, error) { + if err := s.verifyClaimOutputsMatch(ctx, app, epoch, claim, site); err != nil { + return 0, err + } + if claim.StagingBlockNumber == nil { + return 0, fmt.Errorf("claim status STAGED for epoch %d (%d) has nil staging block", + epoch.Index, epoch.VirtualIndex) + } + stagingBlock := claim.StagingBlockNumber.Uint64() + if err := s.repository.UpdateEpochReconciledStaged( + ctx, epoch.ApplicationID, epoch.Index, stagingBlock); err != nil { + return 0, err + } + return stagingBlock, nil +} diff --git a/internal/claimer/claim_status_test.go b/internal/claimer/claim_status_test.go new file mode 100644 index 000000000..ec9b6fe56 --- /dev/null +++ b/internal/claimer/claim_status_test.go @@ -0,0 +1,25 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpdateEpochStagedFromClaimStatus_NilStagingBlock_ReturnsError(t *testing.T) { + m, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + app := makeApplication() + epoch := makeComputedEpoch(app, 1) + claim := makeClaimStatus(claimStatusStaged, epoch, 0) + + _, err := m.updateEpochStagedFromClaimStatus(context.Background(), app, epoch, claim, "test") + + require.Error(t, err) + require.Contains(t, err.Error(), "nil staging block") +} diff --git a/internal/claimer/claimer.go b/internal/claimer/claimer.go new file mode 100644 index 000000000..86d03e95f --- /dev/null +++ b/internal/claimer/claimer.go @@ -0,0 +1,132 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package claimer drives the on-chain claim lifecycle for Authority and +// Quorum consensus apps. +// +// Tick observes claims and events at the configured block policy. A separate publisher +// reads committed rows and maintains its signer without waiting for Tick. +// The Claimer owns its signer independently of the PRT service. +// +// The in-DB lifecycle mirrors the v3 IConsensus contract: +// +// CLAIM_COMPUTED -> CLAIM_SUBMITTED -> CLAIM_STAGED -> CLAIM_ACCEPTED +// +// There are also shortcut transitions for recovery after restart or for reader +// mode catching up from chain state: +// +// CLAIM_COMPUTED -> CLAIM_STAGED +// CLAIM_COMPUTED -> CLAIM_ACCEPTED +// +// In Quorum, an epoch can also move to CLAIM_REJECTED when another validator's +// claim wins before our claim reaches CLAIM_STAGED. This transition is coupled +// with the application becoming DIVERGED: the local claim did not win, and +// the app must stop until the operator or guardian handles the divergence. +// +// Other divergence paths do not necessarily rewrite the epoch to +// CLAIM_REJECTED. For example, Authority divergence and any divergence after +// our local epoch is CLAIM_STAGED are app-level failures. In those cases the +// epoch may keep its current status, but the application becomes DIVERGED +// with a reason that says where the mismatch was found: submit, stage, or +// accept. +// +// Foreclosed apps can also move remaining pre-foreclosure claim work to +// CLAIM_FORECLOSED. This is used after read-only reconciliation has checked +// that the claim was not already STAGED or ACCEPTED on chain. The app itself +// stays enabled for L1 observation with foreclose_block set and normally keeps +// status OK. If it was already DIVERGED because of a divergence, EVM reader +// preserves that status while still recording foreclose_block. +// +// PRT (DaveConsensus) stages and accepts tournament results through the PRT +// service. The claimer queries exclude PRT apps. +package claimer + +import ( + "context" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "math/big" + "time" +) + +func (s *Service) Tick(ctx context.Context) (reschedule bool, err error) { + defer func() { + if txsender.HardFailure(err) { + cause := err + s.failure.CompareAndSwap(nil, &cause) + if time.Since(s.observationHardWarnedAt) < claimWarningInterval { + err = nil + } else { + s.observationHardWarnedAt = time.Now() + } + } + if txsender.ShutdownOnly(ctx, err, txsender.ErrLimit) { + reschedule, err = false, nil + } else if txsender.WaitOnly(err) { + s.Logger.Debug("Claim observation deferred", "error", err) + err = nil + } + }() + block, err := s.blockchain.getDefaultBlockNumber(ctx) + if err != nil { + return false, err + } + if s.claimObserver != nil { + reschedule, err = s.claimObserver.observe(ctx, block.Uint64()) + if err != nil { + return false, err + } + } + + s.consensusAddressChecks = map[consensusAddressCheckKey]error{} + defer func() { s.consensusAddressChecks = nil }() + for kind := claimSubmitPage; kind <= claimAcceptPage; kind++ { + count, stageErr := s.observeClaimStage(ctx, kind, block) + reschedule = reschedule || count > 0 + err = errors.Join(err, stageErr) + if txsender.HardFailure(stageErr) || ctx.Err() != nil { + break + } + } + return reschedule, err +} + +func (s *Service) observeClaimStage(ctx context.Context, kind int, block *big.Int) (int, error) { + page, err := s.selectClaimPage(ctx, kind) + if err != nil { + return 0, err + } + count := 0 + for _, item := range page.Work { + epochs := map[int64]*model.Epoch{item.Application.ID: item.Epoch} + apps := map[int64]*model.Application{item.Application.ID: item.Application} + var progressed int + var observeErr error + switch kind { + case claimSubmitPage: + progressed, observeErr = s.submitClaimsAndUpdateDatabase(ctx, epochs, apps, block) + case claimStagePage: + progressed, observeErr = s.stageClaimsAndUpdateDatabase(ctx, epochs, apps, block) + default: + accepted, acceptErr := s.acceptClaimsAndUpdateDatabase(ctx, epochs, apps, block) + issued, issueErr := s.observeStagedClaims(ctx, epochs, apps, block) + progressed, observeErr = accepted+issued, errors.Join(acceptErr, issueErr) + } + s.pageCursors[kind].Advance(item, page) + count += progressed + err = errors.Join(err, observeErr) + if txsender.HardFailure(observeErr) || ctx.Err() != nil { + break + } + } + if kind == claimAcceptPage { + foreclosed, listErr := s.listEnabledForeclosedNonPRTApps(ctx) + err = errors.Join(err, listErr, s.processForeclosedApps(ctx, foreclosed, block.Uint64())) + } + return count, err +} + +func (s *Service) Ready() bool { + return s.failure.Load() == nil && (!s.submissionEnabled || s.publicationReady.Load()) +} diff --git a/internal/claimer/claimer_test.go b/internal/claimer/claimer_test.go new file mode 100644 index 000000000..cb9a47d9a --- /dev/null +++ b/internal/claimer/claimer_test.go @@ -0,0 +1,333 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDoNothing(t *testing.T) { + m, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + currEpochs := makeEpochMap() + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), currEpochs, makeApplicationMap(), big.NewInt(0)) + assert.NoError(t, err) + assert.Equal(t, 0, transitions, "no transitions when no epochs to process") +} + +func TestTickInterleavesStagesWithPinnedBlockAndReschedulesOnProgress(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + err := service.InitTickServiceTemplate(&m.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "claimer-test"}, + PollInterval: time.Hour, + }, m) + require.NoError(t, err) + + tickBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEvent := makeSubmittedEvent(app, currEpoch) + + b.On("getDefaultBlockNumber", mock.Anything). + Return(tickBlock, nil).Once() + r.On("SelectClaimsToSubmitPerApp", mock.Anything).Return( + makeEpochMap(currEpoch), makeApplicationMap(app), nil). + Once() + b.On("getConsensusAddress", mock.Anything, app, tickBlock). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, tickBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, tickBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{currEvent}, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() + + r.On("SelectClaimsToStagePerApp", mock.Anything).Return( + makeEpochMap(), makeApplicationMap(), nil). + Once() + r.On("SelectClaimsToAcceptPerApp", mock.Anything).Return( + makeEpochMap(), makeApplicationMap(), nil). + Once() + r.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.Enabled != nil && + *f.Enabled && + f.ForeclosureRecorded != nil && + *f.ForeclosureRecorded && + assert.ElementsMatch(t, + []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum}, + f.ConsensusTypes, + ) + }), repository.Pagination{}, false). + Return([]*model.Application{}, 0, nil).Once() + + reschedule, err := m.Tick(context.Background()) + + require.NoError(t, err) + assert.True(t, reschedule, "a successful stage transition should request an immediate follow-up tick") +} + +func TestTickCancellationRequiresCanceledServiceContext(t *testing.T) { + stages := []string{"getDefaultBlockNumber", "SelectClaimsToSubmitPerApp", "SelectClaimsToStagePerApp", "SelectClaimsToAcceptPerApp"} + for stage, name := range stages { + for _, canceled := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/canceled=%v", name, canceled), func(t *testing.T) { + m, r, b := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + failure := fmt.Errorf("dependency: %w", context.Canceled) + var rpcErr error + if stage == 0 { + rpcErr = failure + } + blockCall := b.On(stages[0], mock.Anything).Return(big.NewInt(100), rpcErr).Once() + if canceled && stage == 0 { + blockCall.Run(func(mock.Arguments) { cancel() }) + } + if stage > 0 { + last := len(stages) - 1 + if canceled { + last = stage + } + for i := 1; i <= last; i++ { + var queryErr error + if i == stage { + queryErr = failure + } + call := r.On(stages[i], mock.Anything).Return(makeEpochMap(), makeApplicationMap(), queryErr).Once() + if canceled && i == stage { + call.Run(func(mock.Arguments) { cancel() }) + } + } + if !canceled && stage != 3 { + r.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, 0, nil).Once() + } + } + reschedule, err := m.Tick(ctx) + require.False(t, reschedule) + if canceled { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, context.Canceled) + } + r.AssertExpectations(t) + b.AssertExpectations(t) + }) + } + } +} + +func TestTickShutdownErrorClassification(t *testing.T) { + const canceledState = "canceled" + dbErr := errors.New("database unavailable") + for _, state := range []string{"active", canceledState, "deadline exceeded"} { + for _, test := range []struct { + name string + err error + cancellationOnly bool + }{ + {"cancellation", context.Canceled, true}, + {"wrapped cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined cancellations", errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), true}, + {"wait joined with cancellation", errors.Join(txsender.ErrInconclusive, context.Canceled), true}, + {"database failure", dbErr, false}, + {"deadline", context.DeadlineExceeded, false}, + {"cancellation and deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"cancellation and database failure", errors.Join(context.Canceled, dbErr), false}, + {"wait, cancellation and database failure", errors.Join(txsender.ErrInconclusive, context.Canceled, dbErr), false}, + {"nested database failure", fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), false}, + } { + t.Run(state+"/"+test.name, func(t *testing.T) { + m, _, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch state { + case canceledState: + cancel() + case "deadline exceeded": + var deadlineCancel context.CancelFunc + ctx, deadlineCancel = context.WithDeadline(ctx, time.Now().Add(-time.Second)) + defer deadlineCancel() + } + blockchain.On("getDefaultBlockNumber", mock.Anything).Return(big.NewInt(100), test.err).Once() + + reschedule, err := m.Tick(ctx) + + require.False(t, reschedule) + if state == canceledState && test.cancellationOnly { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.err, "keep the complete operational error") + } + blockchain.AssertExpectations(t) + }) + } + } +} + +func TestClaimObserverKeepsUnhealthyApplicationsInEveryPageSource(t *testing.T) { + s, repo, blockchain := newServiceMock(t) + blockchain.On("getDefaultBlockNumber", mock.Anything).Return(big.NewInt(100), nil).Once() + for _, source := range []string{"SelectClaimsToSubmitPerApp", "SelectClaimsToStagePerApp", "SelectClaimsToAcceptPerApp"} { + repo.On(source, mock.Anything).Return(makeEpochMap(), makeApplicationMap(), nil).Once() + } + repo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, 0, nil).Once() + _, err := s.Tick(t.Context()) + require.NoError(t, err) + require.Len(t, repo.pageReads, 3) + for _, cursor := range repo.pageReads { + require.False(t, cursor.PublishableOnly, "only publisher pages may filter stopped or foreclosed applications") + } + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) +} + +// Exercise the receipt-to-database path that shutdown interrupted in the +// snapshot integration test. Also run it through Serve to check the ERROR log. +func TestTickStagingWriteShutdown(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + writeErr error + queryFails bool + wantErr error + }{ + {"shutdown at next query", context.Canceled, true, nil}, + {"shutdown at tick end", fmt.Errorf("write: %w", context.Canceled), false, nil}, + {"shutdown after completed write", nil, false, nil}, + {"database failure before shutdown", dbErr, true, dbErr}, + {"mixed failure at tick end", errors.Join(context.Canceled, dbErr), false, dbErr}, + {"timeout before shutdown", context.DeadlineExceeded, true, context.DeadlineExceeded}, + } { + for _, throughServe := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/serve=%t", test.name, throughServe), func(t *testing.T) { + m, repo, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var logs bytes.Buffer + m.Logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + app := makeApplication() + epoch := makeSubmittedEpoch(app, 3) + submitted := makeEpochMap(epoch) + tickBlock := big.NewInt(100) + staged := makeStagedEvent(app, epoch) + completedWrite := false + blockchain.On("getDefaultBlockNumber", mock.Anything).Return(tickBlock, nil).Once() + repo.On("SelectClaimsToSubmitPerApp", mock.Anything).Return( + makeEpochMap(), makeApplicationMap(), nil). + Once() + repo.On("SelectClaimsToStagePerApp", mock.Anything).Return( + submitted, makeApplicationMap(app), nil). + Once() + blockchain.On("getConsensusAddress", mock.Anything, app, tickBlock). + Return(app.IConsensusAddress, nil).Once() + blockchain.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, tickBlock.Uint64()). + Return(&iconsensus.IConsensus{}, staged, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + repo.On("UpdateEpochToStaged", mock.Anything, app.ID, epoch.Index, staged.Raw.BlockNumber). + Run(func(mock.Arguments) { completedWrite = test.writeErr == nil; cancel() }).Return(test.writeErr).Once() + // The independent observer stops after cancellation of this commit; + // no unrelated stage query runs during shutdown. + + if throughServe { + require.ErrorIs(t, m.TickServiceTemplate.Serve(ctx), context.Canceled) + if test.wantErr == nil { + require.NotContains(t, logs.String(), "level=ERROR") + } else { + require.Contains(t, logs.String(), "level=ERROR msg=Tick") + require.Contains(t, logs.String(), test.wantErr.Error()) + } + } else { + reschedule, err := m.Tick(ctx) + require.Equal(t, test.writeErr == nil, reschedule) + if test.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.wantErr) + } + } + if test.writeErr == nil { + require.True(t, completedWrite, "the staging write completed before shutdown") + } else { + require.Equal(t, model.EpochStatus_ClaimSubmitted, epoch.Status, "restart retries the uncommitted staging transition") + } + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) + }) + } + } +} + +func TestTickPreservesEarlierErrorsOnShutdown(t *testing.T) { + for _, test := range []struct { + name string + failStage bool + cancelAccept bool + }{ + {name: "submit error before stage cancellation"}, + {name: "submit error before accept cancellation", cancelAccept: true}, + {name: "stage error before accept cancellation", failStage: true, cancelAccept: true}, + } { + t.Run(test.name, func(t *testing.T) { + m, repo, blockchain := newServiceMock(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + workErr := errors.New("claim work RPC failed") + app := makeApplication() + computed, submitted := makeEpochMap(), makeEpochMap() + if test.failStage { + submitted = makeEpochMap(makeSubmittedEpoch(app, 3)) + } else { + computed = makeEpochMap(makeComputedEpoch(app, 3)) + } + blockchain.On("getDefaultBlockNumber", mock.Anything).Return(big.NewInt(100), nil).Once() + blockchain.On("getConsensusAddress", mock.Anything, app, big.NewInt(100)). + Return(app.IConsensusAddress, workErr).Once() + repo.On("SelectClaimsToSubmitPerApp", mock.Anything).Return( + computed, makeApplicationMap(app), nil). + Once() + if test.cancelAccept { + repo.On("SelectClaimsToStagePerApp", mock.Anything).Return( + submitted, makeApplicationMap(app), nil). + Once() + repo.On("SelectClaimsToAcceptPerApp", mock.Anything). + Run(func(mock.Arguments) { cancel() }). + Return(makeEpochMap(), makeApplicationMap(), context.Canceled).Once() + } else { + repo.On("SelectClaimsToStagePerApp", mock.Anything). + Run(func(mock.Arguments) { cancel() }). + Return(makeEpochMap(), makeApplicationMap(), context.Canceled).Once() + } + + reschedule, err := m.Tick(ctx) + + require.False(t, reschedule) + require.ErrorIs(t, err, workErr) + require.ErrorIs(t, err, context.Canceled, "keep both causes when shutdown accompanies a real error") + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) + }) + } +} diff --git a/internal/claimer/deadline_test.go b/internal/claimer/deadline_test.go new file mode 100644 index 000000000..d311c7ad2 --- /dev/null +++ b/internal/claimer/deadline_test.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" + "time" +) + +// Publication continues while observation blocks inside a provider call. +func TestBlockedObservationDoesNotStopPublication(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + chain := s.blockchain.(*claimerBlockchainMock) + started := make(chan struct{}) + chain.On("getDefaultBlockNumber", mock.Anything). + Run(func(args mock.Arguments) { close(started); <-args.Get(0).(context.Context).Done() }). + Return((*big.Int)(nil), context.Canceled).Once() + ctx, cancel := context.WithCancel(t.Context()) + finished := make(chan error, 1) + go func() { _, err := s.Tick(ctx); finished <- err }() + <-started + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + cancel() + select { + case err := <-finished: + require.NoError(t, err, "actual service cancellation is a clean observer shutdown") + case <-time.After(time.Second): + t.Fatal("observer ignored cancellation") + } + chain.AssertExpectations(t) +} diff --git a/internal/claimer/delivery.go b/internal/claimer/delivery.go new file mode 100644 index 000000000..ce9595d09 --- /dev/null +++ b/internal/claimer/delivery.go @@ -0,0 +1,356 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "math/big" + "time" +) + +type claimKind uint8 + +const ( + submitClaimKind claimKind = iota + 1 + acceptClaimKind + quorumSubmitClaimKind +) + +// claimAction identifies immutable protocol content, not one signed attempt. +type claimAction struct { + ApplicationID int64 + Address common.Address + Kind claimKind + Epoch uint64 + Identity common.Hash +} + +type claimCandidate struct { + app *model.Application + epoch *model.Epoch + action claimAction + request txsender.Request +} + +type claimLookup interface { + GetEpochCompletionState(context.Context, string, uint64) (*repository.EpochCompletionState, error) +} + +type claimSignerMismatch struct{ reason string } + +func (e *claimSignerMismatch) Error() string { return e.reason } +func (*claimSignerMismatch) Unwrap() error { return txsender.ErrIneligible } + +func claimCall(app *model.Application, epoch *model.Epoch, proof model.StateProof, submit bool) (claimCandidate, error) { + contractABI, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + return claimCandidate{}, err + } + if epoch.MachineHash == nil { + return claimCandidate{}, errors.New("claim has no machine root") + } + kind := acceptClaimKind + var data []byte + if submit { + kind = submitClaimKind + if app.ConsensusType == model.Consensus_Quorum { + kind = quorumSubmitClaimKind + } + data, err = contractABI.Pack("submitClaim", app.IApplicationAddress, + new(big.Int).SetUint64(epoch.LastBlock), proof.MachineHash, consensusMachineValidityProof(proof)) + } else { + data, err = contractABI.Pack("acceptClaim", app.IApplicationAddress, + new(big.Int).SetUint64(epoch.LastBlock), [32]byte(*epoch.MachineHash)) + } + if err != nil { + return claimCandidate{}, err + } + request := txsender.Request{To: app.IConsensusAddress, Data: data, Value: new(big.Int)} + // A deciding Quorum vote can invoke the staging callbacks after a peer's + // vote changes the branch. This measured bound covers our supported fixture. + if kind == quorumSubmitClaimKind { + request.GasLimit = quorumSubmitGasLimit + } + return claimCandidate{app: app, epoch: epoch, request: request, action: claimAction{ + ApplicationID: app.ID, Address: app.IApplicationAddress, Kind: kind, Epoch: epoch.Index, + Identity: crypto.Keccak256Hash(app.IConsensusAddress.Bytes(), data), + }}, nil +} + +// decide reads current protocol state once for either initial planning or the +// chosen action's post-sign check. Stored status selects rows; chain state +// decides whether submission, acceptance, or no action is useful. +func (s *claimPublisher) decide( + ctx context.Context, block txsender.BlockRef, work repository.ClaimWork, held bool, +) (claimCandidate, txsender.Eligibility, error) { + state, err := s.lookup.GetEpochCompletionState(ctx, work.Application.IApplicationAddress.Hex(), work.Epoch.Index) + if err != nil { + return claimCandidate{}, 0, err + } + if state == nil || state.Application == nil { + return claimCandidate{}, txsender.Obsolete, nil + } + app := state.Application + status := app.Status + if app.ID != work.Application.ID || !app.Enabled || + (status != model.ApplicationStatus_OK && !status.IsExecutionTerminal() && status != model.ApplicationStatus_InvalidOutputsRoot) || + (app.ConsensusType != model.Consensus_Authority && app.ConsensusType != model.Consensus_Quorum) { + return claimCandidate{}, txsender.Obsolete, nil + } + if err := requireObservedBlock(block.Number, app.ForecloseBlock); err != nil { + return claimCandidate{}, 0, err + } + if app.ForecloseBlock != 0 { + return claimCandidate{}, txsender.Obsolete, nil + } + if state.Epoch == nil { + if held { + return claimCandidate{}, 0, txsender.ErrInconclusive + } + return claimCandidate{}, txsender.Obsolete, nil + } + allowed, err := state.AllowsEpochCompletion() + if err != nil { + return claimCandidate{}, 0, fmt.Errorf("%w: application %s (%s), epoch %d: %w", + txsender.ErrInconclusive, app.Name, app.IApplicationAddress, work.Epoch.Index, err) + } + if !allowed { + return claimCandidate{}, txsender.Obsolete, nil + } + epoch := state.Epoch + if epoch.Index != work.Epoch.Index || epoch.ApplicationID != app.ID { + return claimCandidate{}, txsender.Obsolete, nil + } + if !held && epoch.Status != model.EpochStatus_ClaimComputed && epoch.Status != model.EpochStatus_ClaimSubmitted && + epoch.Status != model.EpochStatus_ClaimStaged { + return claimCandidate{}, txsender.Obsolete, nil + } + if err := requireObservedBlock(block.Number, epoch.LastBlock); err != nil { + return claimCandidate{}, 0, err + } + if err := requireObservedStaging(epoch, block.Number); err != nil { + return claimCandidate{}, 0, err + } + if epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + return claimCandidate{}, 0, fmt.Errorf("application %s (%s), epoch %d: "+ + "persisted claim is missing its machine or outputs root; repair stored data", + app.Name, app.IApplicationAddress, epoch.Index) + } + appABI, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return claimCandidate{}, 0, err + } + related, err := s.readProtected(ctx, app.IApplicationAddress, block, appABI, "getOutputsMerkleRootValidator") + if err != nil { + return claimCandidate{}, 0, err + } + consensus, ok := firstValue[common.Address](related) + if !ok { + return claimCandidate{}, 0, txsender.ErrInconclusive + } + if consensus != app.IConsensusAddress { + return claimCandidate{}, txsender.Obsolete, nil + } + values, err := s.readProtected(ctx, app.IApplicationAddress, block, appABI, "isForeclosed") + if err != nil { + return claimCandidate{}, 0, err + } + foreclosed, ok := firstValue[bool](values) + if !ok { + return claimCandidate{}, 0, txsender.ErrInconclusive + } + if foreclosed { + return claimCandidate{}, txsender.Obsolete, nil + } + code, err := s.backend.Code(ctx, app.IConsensusAddress, block) + if err != nil { + return claimCandidate{}, 0, err + } + if len(code) == 0 { + return claimCandidate{}, txsender.Obsolete, nil + } + contractABI, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + return claimCandidate{}, 0, err + } + values, err = s.readProtected(ctx, app.IConsensusAddress, block, contractABI, "getClaim", + app.IApplicationAddress, new(big.Int).SetUint64(epoch.LastBlock), [32]byte(*epoch.MachineHash)) + if err != nil { + return claimCandidate{}, 0, err + } + if len(values) != 1 { + return claimCandidate{}, 0, txsender.ErrInconclusive + } + claim := *abi.ConvertType(values[0], new(iconsensus.IConsensusClaim)).(*iconsensus.IConsensusClaim) + switch claim.Status { + case claimStatusAccepted: + return claimCandidate{}, txsender.Obsolete, nil + case claimStatusStaged: + if claim.StagedOutputsMerkleRoot != [32]byte(*epoch.TxBufferDataBlock) { + return claimCandidate{}, txsender.Obsolete, nil + } + if claim.StagingBlockNumber == nil || !claim.StagingBlockNumber.IsUint64() || + block.Number < claim.StagingBlockNumber.Uint64() { + return claimCandidate{}, 0, txsender.ErrInconclusive + } + candidate, err := claimCall(app, epoch, model.StateProof{}, false) + if err != nil { + return claimCandidate{}, 0, err + } + if block.Number-claim.StagingBlockNumber.Uint64() < app.ClaimStagingPeriod { + return candidate, txsender.ProtocolWait, nil + } + return candidate, txsender.Useful, nil + case claimStatusUnstaged: + // Publication reads latest. A persisted stage under a non-provisional + // observation policy cannot be repaired by resubmission: leave the + // contradiction visible for the independently running observer. + // requireObservedStaging above already excludes a head below the fact. + if epoch.Status == model.EpochStatus_ClaimStaged && !s.observationPolicy.Provisional() { + s.warnStagingContradiction(app, epoch, block.Number) + return claimCandidate{}, 0, fmt.Errorf("%w: epoch %d recorded as CLAIM_STAGED is UNSTAGED at block %d", + txsender.ErrInconclusive, epoch.Index, block.Number) + } + // submitClaim requires a block strictly after the epoch's final block. + // This wait does not apply to accepting an already staged claim. + if block.Number == epoch.LastBlock { + return claimCandidate{}, txsender.ProtocolWait, nil + } + if err := s.authorizeSubmission(ctx, block, app, epoch); err != nil { + if errors.Is(err, errClaimCompleted) { + return claimCandidate{}, txsender.Obsolete, nil + } + return claimCandidate{}, 0, err + } + proof, err := epoch.StateProof() + if err != nil { + return claimCandidate{}, 0, err + } + candidate, err := claimCall(app, epoch, proof, true) + return candidate, txsender.Useful, err + default: + return claimCandidate{}, 0, fmt.Errorf("%w: unsupported claim status %d", txsender.ErrUnsupported, claim.Status) + } +} + +var errClaimCompleted = fmt.Errorf("%w: matching vote already exists", txsender.ErrIneligible) + +func (s *claimPublisher) authorizeSubmission( + ctx context.Context, block txsender.BlockRef, app *model.Application, epoch *model.Epoch, +) error { + if s.signer == (common.Address{}) { + return fmt.Errorf("%w: claim signer is unavailable", txsender.ErrUnsupported) + } + switch app.ConsensusType { + case model.Consensus_Authority: + contractABI, err := iauthority.IAuthorityMetaData.GetAbi() + if err != nil { + return err + } + values, err := s.readProtected(ctx, app.IConsensusAddress, block, contractABI, "owner") + if err != nil { + return err + } + owner, ok := firstValue[common.Address](values) + if !ok { + return txsender.ErrInconclusive + } + if owner != s.signer { + return &claimSignerMismatch{reason: fmt.Sprintf("application %s (%s): signer %s is not Authority owner %s", + app.Name, app.IApplicationAddress, s.signer, owner)} + } + case model.Consensus_Quorum: + contractABI, err := iquorum.IQuorumMetaData.GetAbi() + if err != nil { + return err + } + values, err := s.readProtected(ctx, app.IConsensusAddress, block, contractABI, "validatorId", s.signer) + if err != nil { + return err + } + validator, ok := firstValue[*big.Int](values) + if !ok || validator == nil { + return txsender.ErrInconclusive + } + if validator.Sign() <= 0 { + return &claimSignerMismatch{reason: fmt.Sprintf("application %s (%s): signer %s is not a Quorum validator", + app.Name, app.IApplicationAddress, s.signer)} + } + values, err = s.readProtected(ctx, app.IConsensusAddress, block, contractABI, + "isValidatorInFavorOfAnyClaimInEpoch", app.IApplicationAddress, new(big.Int).SetUint64(epoch.LastBlock), validator) + if err != nil { + return err + } + voted, ok := firstValue[bool](values) + if !ok { + return txsender.ErrInconclusive + } + if voted { + return errClaimCompleted + } + case model.Consensus_PRT: + return txsender.ErrIneligible + default: + return txsender.ErrIneligible + } + return nil +} + +func firstValue[T any](values []any) (T, bool) { + var zero T + if len(values) != 1 { + return zero, false + } + value, ok := values[0].(T) + return value, ok +} + +func (s *claimPublisher) readProtected( + ctx context.Context, address common.Address, block txsender.BlockRef, contractABI *abi.ABI, method string, args ...any, +) ([]any, error) { + data, err := contractABI.Pack(method, args...) + if err != nil { + return nil, err + } + result, err := s.backend.Call(ctx, ethereum.CallMsg{To: &address, Data: data}, block) + if err != nil { + return nil, err + } + return contractABI.Unpack(method, result) +} + +func claimKindName(kind claimKind) string { + switch kind { + case submitClaimKind: + return "authority-submit" + case quorumSubmitClaimKind: + return "quorum-vote" + case acceptClaimKind: + return "accept" + default: + return fmt.Sprintf("unknown-%d", kind) + } +} + +func isClaimSubmission(kind claimKind) bool { + return kind == submitClaimKind || kind == quorumSubmitClaimKind +} + +const ( + claimWarningInterval = time.Minute + quorumSubmitGasLimit uint64 = 650_000 +) diff --git a/internal/claimer/delivery_diagnostics_test.go b/internal/claimer/delivery_diagnostics_test.go new file mode 100644 index 000000000..405150619 --- /dev/null +++ b/internal/claimer/delivery_diagnostics_test.go @@ -0,0 +1,44 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "errors" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestClaimPublicationErrorIdentifiesActionAndOriginalCause(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + cause := errors.New("upstream unavailable") + backend.fail[app.IApplicationAddress] = cause + err := s.publisher.tick(t.Context()) + require.ErrorIs(t, err, cause) + require.Contains(t, err.Error(), app.IApplicationAddress.Hex()) + require.Contains(t, err.Error(), "action authority-submit") + require.Contains(t, err.Error(), "epoch 3") + require.Contains(t, err.Error(), "nonce 0") + require.EqualValues(t, 3, epoch.Index) +} + +func TestClaimHardFailureRetainsItsOriginalCause(t *testing.T) { + s, _, chain := newServiceMock(t) + s.submissionEnabled = false + cause := errors.Join(txsender.ErrFinality, errors.New("finalized block 20 changed hash")) + chain.On("getDefaultBlockNumber", mock.Anything).Return((*big.Int)(nil), cause).Once() + _, err := s.Tick(t.Context()) + require.ErrorIs(t, err, txsender.ErrFinality) + chain.On("getDefaultBlockNumber", mock.Anything).Return((*big.Int)(nil), txsender.ErrContent).Once() + _, err = s.Tick(t.Context()) + require.NoError(t, err, "the latched observer error is rate limited") + require.NotNil(t, s.failure.Load()) + require.ErrorIs(t, *s.failure.Load(), txsender.ErrFinality) + require.Contains(t, (*s.failure.Load()).Error(), "finalized block 20 changed hash") + require.False(t, s.Ready()) + chain.AssertExpectations(t) +} diff --git a/internal/claimer/delivery_regression_test.go b/internal/claimer/delivery_regression_test.go new file mode 100644 index 000000000..bed377035 --- /dev/null +++ b/internal/claimer/delivery_regression_test.go @@ -0,0 +1,76 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + "testing" +) + +// VER2 probe for mutant M4a. A finalized tag served by another replica can name +// an existing height with a different hash. The proposed header must be +// validated before the watermark moves; otherwise the next honest pass latches +// ErrFinality on a mark that was never canonical. +func TestNonCanonicalExistingHeightCannotAdvanceFinalizedWatermark(t *testing.T) { + backend := &finalizedViewRPC{final: 30} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chain := &claimerBlockchain{ + client: client, defaultBlock: model.DefaultBlock_Finalized, backend: txsender.ProtectedClient{Client: client}, + } + backend.final = 10 + _, err := chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err) + // The tag returns height 20 from a different branch; by-number reads of 20 + // return the canonical header (conflict applies only to the tag path here). + tagged := &taggedConflictRPC{finalizedViewRPC: backend, tagNumber: 20} + server2 := rpc.NewServer() + require.NoError(t, server2.RegisterName("eth", tagged)) + t.Cleanup(server2.Stop) + client2 := ethclient.NewClient(rpc.DialInProc(server2)) + t.Cleanup(client2.Close) + chain.client, chain.backend = client2, txsender.ProtectedClient{Client: client2} + backend.final = 25 + _, err = chain.getDefaultBlockNumber(t.Context()) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.EqualValues(t, 10, chain.observed.Number, "an unvalidated tag cannot move the watermark") + tagged.tagNumber = 0 + block, err := chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err, "the next honest view proceeds; no sticky finality fault") + require.EqualValues(t, 25, block.Uint64()) +} + +type taggedConflictRPC struct { + *finalizedViewRPC + tagNumber uint64 +} + +func (v *taggedConflictRPC) GetBlockByNumber(ctx context.Context, n rpc.BlockNumber, full bool) (any, error) { + if n == rpc.FinalizedBlockNumber && v.tagNumber != 0 { + header := deliveryHeader(v.tagNumber) + header.Extra = []byte("other replica branch") + return header, nil + } + return v.finalizedViewRPC.GetBlockByNumber(ctx, n, full) +} + +// Quorum permits one vote per validator and epoch, even for a different claim. +func TestQuorumPriorVoteDoesNotPublishAnotherVote(t *testing.T) { + s, b, r := newDeliveryFixture(t) + app, epoch := deliveryApplication(1, model.Consensus_Quorum) + r.add(app, epoch) + b.apps[app.IApplicationAddress] = app + b.voted = true + err := s.publisher.tick(t.Context()) + require.NoError(t, err) + require.Empty(t, b.published) +} diff --git a/internal/claimer/delivery_test.go b/internal/claimer/delivery_test.go new file mode 100644 index 000000000..d7fa4e0f6 --- /dev/null +++ b/internal/claimer/delivery_test.go @@ -0,0 +1,451 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iauthority" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iquorum" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + "math/big" + "slices" + "strconv" + "testing" + "time" +) + +const ( + claimOwnerMethod = "owner" + claimSubmitMethod = "submitClaim" +) + +type deliveryLookup struct { + apps map[string]*model.Application + epochs map[string]map[uint64]*model.Epoch + err error + epochErrors map[string]error + missingNil bool + pageErr error + hidden map[int64]bool + problems map[int64]*model.ApplicationProblem +} + +func (r *deliveryLookup) add(app *model.Application, epoch *model.Epoch) { + address := app.IApplicationAddress.Hex() + r.apps[address] = app + if r.epochs[address] == nil { + r.epochs[address] = map[uint64]*model.Epoch{} + } + r.epochs[address][epoch.Index] = epoch +} +func (r *deliveryLookup) GetApplication(_ context.Context, address string) (*model.Application, error) { + if r.err != nil { + return nil, r.err + } + app := r.apps[address] + if app == nil { + if r.missingNil { + return nil, nil + } + return nil, repository.ErrNotFound + } + return app, nil +} +func (r *deliveryLookup) GetEpoch(_ context.Context, address string, index uint64) (*model.Epoch, error) { + if err := r.epochErrors[address]; err != nil { + return nil, err + } + if r.err != nil { + return nil, r.err + } + epoch := r.epochs[address][index] + if epoch == nil { + if r.missingNil { + return nil, nil + } + return nil, repository.ErrNotFound + } + return epoch, nil +} + +func (r *deliveryLookup) GetEpochCompletionState( + ctx context.Context, address string, index uint64, +) (*repository.EpochCompletionState, error) { + app, err := r.GetApplication(ctx, address) + if errors.Is(err, repository.ErrNotFound) { + return nil, nil + } + if err != nil || app == nil { + return nil, err + } + epoch, err := r.GetEpoch(ctx, address, index) + if errors.Is(err, repository.ErrNotFound) { + err = nil + } + if err != nil { + return nil, err + } + return &repository.EpochCompletionState{Application: app, Epoch: epoch, Problem: r.problems[app.ID]}, nil +} + +type deliveryBackend struct { + txsender.Backend + apps map[common.Address]*model.Application + claims map[int64]iconsensus.IConsensusClaim + claimsAt map[uint64]iconsensus.IConsensusClaim + claimReads []uint64 + owner common.Address + voted bool + matchingVote bool + votesAt map[uint64]bool + matchingVotesAt map[uint64]bool + claimReadErr error + orphaned map[common.Hash]bool + onHeader func(string) + headerErr error + fail map[common.Address]error + failEpochs map[uint64]error + last, final uint64 + safe uint64 + nonce uint64 + consumedAt uint64 + published []*types.Transaction + raw [][]byte + publishErr error + callRefs []txsender.BlockRef + canonicalRefs []txsender.BlockRef + ownerErr error + balance *big.Int + validatorID *big.Int + blockExecution map[common.Address]bool + executionStarted chan struct{} + onCall func(string) +} + +func deliveryHeader(n uint64) *types.Header { + return &types.Header{Number: new(big.Int).SetUint64(n), BaseFee: big.NewInt(1), GasLimit: 3000000, Difficulty: big.NewInt(0)} +} +func (b *deliveryBackend) Header(_ context.Context, tag string) (*types.Header, error) { + if b.onHeader != nil { + b.onHeader(tag) + } + if b.headerErr != nil { + return nil, b.headerErr + } + n := b.last + switch tag { + case "finalized": + n = b.final + case "safe": + n = b.safe + if n == 0 { + n = b.final + } + case "latest": + default: + var err error + n, err = strconv.ParseUint(tag, 0, 64) + if err != nil { + return nil, err + } + } + return deliveryHeader(n), nil +} +func (b *deliveryBackend) Canonical(_ context.Context, ref txsender.BlockRef) (bool, error) { + b.canonicalRefs = append(b.canonicalRefs, ref) + return !b.orphaned[ref.Hash] && ref.Hash == deliveryHeader(ref.Number).Hash(), nil +} +func (b *deliveryBackend) Nonce(_ context.Context, _ common.Address, ref txsender.BlockRef) (uint64, error) { + if b.consumedAt != 0 && ref.Number >= b.consumedAt { + return b.nonce, nil + } + return 0, nil +} +func (b *deliveryBackend) Balance(context.Context, common.Address, txsender.BlockRef) (*big.Int, error) { + if b.balance != nil { + return new(big.Int).Set(b.balance), nil + } + return new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), nil +} +func (b *deliveryBackend) Code(_ context.Context, address common.Address, _ txsender.BlockRef) ([]byte, error) { + for _, app := range b.apps { + if address == app.IApplicationAddress || address == app.IConsensusAddress { + return []byte{1}, nil + } + } + return nil, nil +} +func (*deliveryBackend) Estimate(context.Context, ethereum.CallMsg, txsender.BlockRef) (uint64, error) { + return 100000, nil +} +func (*deliveryBackend) Tip(context.Context) (*big.Int, error) { return big.NewInt(2), nil } +func (*deliveryBackend) Price(context.Context) (*big.Int, error) { return big.NewInt(3), nil } +func (b *deliveryBackend) Call(ctx context.Context, msg ethereum.CallMsg, ref txsender.BlockRef) ([]byte, error) { + b.callRefs = append(b.callRefs, ref) + if len(msg.Data) == 0 { + return nil, nil + } + metadata := []*bind.MetaData{ + iapplication.IApplicationMetaData, iauthority.IAuthorityMetaData, iconsensus.IConsensusMetaData, iquorum.IQuorumMetaData, + } + for _, meta := range metadata { + contractABI, err := meta.GetAbi() + if err != nil { + return nil, err + } + method, err := contractABI.MethodById(msg.Data[:4]) + if err != nil { + continue + } + args, err := method.Inputs.Unpack(msg.Data[4:]) + if err != nil { + return nil, err + } + if b.onCall != nil { + b.onCall(method.Name) + } + switch method.Name { + case "getOutputsMerkleRootValidator": + return method.Outputs.Pack(b.apps[*msg.To].IConsensusAddress) + case "isForeclosed": + return method.Outputs.Pack(b.apps[*msg.To].ForecloseBlock != 0) + case claimOwnerMethod: + if b.ownerErr != nil { + return nil, b.ownerErr + } + return method.Outputs.Pack(b.owner) + case "validatorId": + if b.validatorID != nil { + return method.Outputs.Pack(b.validatorID) + } + return method.Outputs.Pack(big.NewInt(1)) + case "isValidatorInFavorOfAnyClaimInEpoch": + if voted, ok := b.votesAt[args[1].(*big.Int).Uint64()]; ok { + return method.Outputs.Pack(voted) + } + return method.Outputs.Pack(b.voted) + case "isValidatorInFavorOf": + if voted, ok := b.matchingVotesAt[args[1].(*big.Int).Uint64()]; ok { + return method.Outputs.Pack(voted) + } + return method.Outputs.Pack(b.matchingVote) + case "getClaim": + b.claimReads = append(b.claimReads, args[1].(*big.Int).Uint64()) + if b.claimReadErr != nil { + return nil, b.claimReadErr + } + app := b.apps[args[0].(common.Address)] + c, ok := b.claims[app.ID] + if current, exists := b.claimsAt[args[1].(*big.Int).Uint64()]; exists { + c, ok = current, true + } + if !ok { + c = iconsensus.IConsensusClaim{Status: claimStatusUnstaged, StagingBlockNumber: big.NewInt(0)} + } + if c.StagingBlockNumber == nil { + c.StagingBlockNumber = new(big.Int) + } + return method.Outputs.Pack(c) + case claimSubmitMethod, "acceptClaim": + if b.blockExecution[args[0].(common.Address)] { + select { + case b.executionStarted <- struct{}{}: + default: + } + <-ctx.Done() + return nil, ctx.Err() + } + if err := b.failEpochs[args[1].(*big.Int).Uint64()]; err != nil { + return nil, err + } + if err := b.fail[args[0].(common.Address)]; err != nil { + return nil, err + } + return nil, nil + } + } + return nil, errors.New("unsupported call") +} +func (b *deliveryBackend) Publish(_ context.Context, raw []byte) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + b.published = append(b.published, tx) + b.raw = append(b.raw, append([]byte(nil), raw...)) + return tx.Hash(), b.publishErr +} +func (b *deliveryBackend) finalize(t *testing.T) { + t.Helper() + require.NotEmpty(t, b.published) + tx := b.published[len(b.published)-1] + b.consumedAt = b.last + 1 + b.last = b.consumedAt + b.final = b.consumedAt + b.nonce = tx.Nonce() + 1 + +} +func newDeliveryFixture(t *testing.T, policies ...model.DefaultBlock) (*Service, *deliveryBackend, *deliveryLookup) { + t.Helper() + s, _, chain := newServiceMock(t) + key, err := crypto.HexToECDSA("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(31337)) + require.NoError(t, err) + chain.submitterAddress = opts.From + backend := &deliveryBackend{ + apps: map[common.Address]*model.Application{}, claims: map[int64]iconsensus.IConsensusClaim{}, + owner: opts.From, fail: map[common.Address]error{}, last: 50, final: 45, + } + lookup := &deliveryLookup{apps: map[string]*model.Application{}, epochs: map[string]map[uint64]*model.Epoch{}} + selected := model.DefaultBlock_Finalized + if len(policies) > 0 { + selected = policies[0] + } + policy := txsender.Policy{MaxFee: big.NewInt(1000)} + s.signer = opts.From + s.deliveryRepository = &claimProvenanceRepository{} + s.defaultBlock = selected + s.publisher = &claimPublisher{ + repository: &deliveryCandidateRepository{lookup: lookup}, lookup: lookup, backend: backend, + signer: opts.From, Logger: s.Logger, observationPolicy: selected, + cooldowns: map[claimAction]time.Time{}, failure: &s.failure, + } + s.publisher.sender, err = txsender.New[claimAction](backend, ethutil.NewStaticTransactOptsFactory(opts), 31337, policy, + s.guardClaimPublication, s.Logger) + require.NoError(t, err) + return s, backend, lookup +} + +// Only publication pages use this committed-state fixture. The observer keeps +// its own repository mock, so publication cannot see observer-owned traversal. +type deliveryCandidateRepository struct { + iclaimerRepository + lookup *deliveryLookup +} + +func (r *deliveryCandidateRepository) page(after repository.ClaimCursor, limit uint64, status model.EpochStatus) ( + repository.ClaimPage, error, +) { + if r.lookup.pageErr != nil { + return repository.ClaimPage{}, r.lookup.pageErr + } + ids := make([]int64, 0, len(r.lookup.apps)) + for address, app := range r.lookup.apps { + pending := false + for _, epoch := range r.lookup.epochs[address] { + publishable := !after.PublishableOnly || fixtureClaimPublishable(app, epoch, r.lookup.problems[app.ID]) + pending = pending || epoch.Status == status && publishable + } + if app.Enabled && !r.lookup.hidden[app.ID] && pending { + ids = append(ids, app.ID) + } + } + slices.Sort(ids) + if len(ids) == 0 { + return repository.ClaimPage{}, nil + } + id := ids[0] + for _, candidate := range ids { + if candidate > after.AfterApplicationID { + id = candidate + break + } + } + page := repository.ClaimPage{ApplicationID: id} + for address, app := range r.lookup.apps { + if app.ID != id { + continue + } + var epochs []*model.Epoch + for _, epoch := range r.lookup.epochs[address] { + if epoch.Status == status && (!after.PublishableOnly || fixtureClaimPublishable(app, epoch, r.lookup.problems[app.ID])) { + epochs = append(epochs, epoch) + } + } + slices.SortFunc(epochs, func(a, b *model.Epoch) int { + if a.Index < b.Index { + return -1 + } + if a.Index > b.Index { + return 1 + } + return 0 + }) + if len(epochs) == 0 { + break + } + page.Work = append(page.Work, repository.ClaimWork{Application: app, Epoch: epochs[0]}) + last := epochs[0].Index + if saved, ok := after.Epochs[id]; ok && saved > last { + last = saved + } + var later []*model.Epoch + for _, epoch := range epochs[1:] { + if epoch.Index > last { + later = append(later, epoch) + } + } + if uint64(len(later)) >= limit { + later = later[:limit-1] + page.NextEpoch = new(later[len(later)-1].Index) + } + for _, epoch := range later { + page.Work = append(page.Work, repository.ClaimWork{Application: app, Epoch: epoch}) + } + } + + return page, nil +} +func (r *deliveryCandidateRepository) SelectClaimsToSubmitPage(_ context.Context, after repository.ClaimCursor, limit uint64) ( + repository.ClaimPage, error, +) { + return r.page(after, limit, model.EpochStatus_ClaimComputed) +} +func (r *deliveryCandidateRepository) SelectClaimsToAcceptPage(_ context.Context, after repository.ClaimCursor, limit uint64) ( + repository.ClaimPage, error, +) { + return r.page(after, limit, model.EpochStatus_ClaimStaged) +} + +func (r *deliveryCandidateRepository) SelectClaimsToStagePage(_ context.Context, after repository.ClaimCursor, limit uint64) ( + repository.ClaimPage, error, +) { + return r.page(after, limit, model.EpochStatus_ClaimSubmitted) +} + +func deliveryApplication(id int64, consensus model.Consensus) (*model.Application, *model.Epoch) { + app := makeApplication() + app.ID = id + app.IApplicationAddress = common.BigToAddress(big.NewInt(id)) + app.IConsensusAddress = common.BigToAddress(big.NewInt(id + 100)) + app.ConsensusType = consensus + app.Enabled = true + return app, makeComputedEpoch(app, 3) +} +func mustEpochProof(t *testing.T, epoch *model.Epoch) model.StateProof { + t.Helper() + proof, err := epoch.StateProof() + require.NoError(t, err) + return proof +} +func queueDelivery(t *testing.T, b *deliveryBackend, r *deliveryLookup, id int64) (*model.Application, *model.Epoch) { + t.Helper() + app, epoch := deliveryApplication(id, model.Consensus_Authority) + r.add(app, epoch) + b.apps[app.IApplicationAddress] = app + return app, epoch +} diff --git a/internal/claimer/divergence.go b/internal/claimer/divergence.go new file mode 100644 index 000000000..d56175dcc --- /dev/null +++ b/internal/claimer/divergence.go @@ -0,0 +1,345 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + + "github.com/ethereum/go-ethereum/common" +) + +// divergenceStage names the claim step where the node saw different on-chain +// data than it expected. +type divergenceStage int + +const ( + divergenceStageSubmit divergenceStage = iota + divergenceStageStaging + divergenceStageAcceptance +) + +func (d divergenceStage) String() string { + switch d { + case divergenceStageSubmit: + return "submission" + case divergenceStageStaging: + return "staging" + case divergenceStageAcceptance: + return "acceptance" + default: + return fmt.Sprintf("unknown(%d)", int(d)) + } +} + +// divergenceBucket returns reason keys such as +// "authority_divergence_at_submission" or "quorum_divergence_at_staging". +// Keeping this in one place avoids repeating the string format in each +// handler. +func divergenceBucket(c model.Consensus, stage divergenceStage) string { + consensus := "quorum" + if c == model.Consensus_Authority { + consensus = "authority" + } + return fmt.Sprintf("%s_divergence_at_%s", consensus, stage) +} + +// markDivergence handles a claim that does not match what this node computed. +// +// In Quorum, before our local epoch reaches CLAIM_STAGED, a different claim +// can mean that another validator's vote won. In that case we mark the local +// epoch CLAIM_REJECTED and also mark the app DIVERGED. The two writes happen +// together so the epoch cannot disappear from claim work while the app stays +// runnable. +// +// After CLAIM_STAGED, the local DB says our claim was staged. If chain data +// later disagrees, the whole app is unsafe and becomes DIVERGED. Authority +// has only one submitter, so any divergence is app-level. +func (s *Service) markDivergence( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + stage divergenceStage, + reasonText string, +) error { + rejectable := app.ConsensusType == model.Consensus_Quorum && + stage != divergenceStageSubmit && + epoch.Status != model.EpochStatus_ClaimStaged + if rejectable { + return s.rejectEpochAndSetApplicationDiverged(ctx, app, epoch, reasonText) + } + return s.setApplicationDiverged(ctx, app, "%s", reasonText) +} + +// markStagingDivergence handles a ClaimStaged event for our epoch whose data +// differs from our local claim. markDivergence decides whether this is only an +// epoch reject or an app-level failure. +func (s *Service) markStagingDivergence( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimStaged, + site string, +) error { + ourMachineMerkleRoot := common.Hash{} + if epoch.MachineHash != nil { + ourMachineMerkleRoot = *epoch.MachineHash + } + reason := fmt.Sprintf( + "%s: divergent ClaimStaged observed at %s. "+ + "on-chain machineMerkleRoot=%s, our machineMerkleRoot=%s, "+ + "epoch %d (lastBlock %d). "+ + "Guardian SHOULD call foreclose() on the application contract "+ + "before staged_at_block + claim_staging_period elapses, "+ + "after which outputs from this divergent claim become executable.", + divergenceBucket(app.ConsensusType, divergenceStageStaging), site, + common.BytesToHash(event.MachineMerkleRoot[:]).Hex(), + ourMachineMerkleRoot.Hex(), + epoch.Index, epoch.LastBlock, + ) + return s.markDivergence(ctx, app, epoch, divergenceStageStaging, reason) +} + +// markSubmittedDivergence handles a ClaimSubmitted event whose data differs +// from our local claim. This is always an app-level problem. Even in Quorum, +// if the submitted claim later gets staged, our local claim is wrong. +func (s *Service) markSubmittedDivergence( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimSubmitted, + site string, +) error { + ourOutputsMerkleRoot := common.Hash{} + if epoch.TxBufferDataBlock != nil { + ourOutputsMerkleRoot = *epoch.TxBufferDataBlock + } + ourMachineMerkleRoot := common.Hash{} + if epoch.MachineHash != nil { + ourMachineMerkleRoot = *epoch.MachineHash + } + reason := fmt.Sprintf( + "%s: divergent ClaimSubmitted observed at %s. "+ + "on-chain outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "our outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "epoch %d (lastBlock %d).", + divergenceBucket(app.ConsensusType, divergenceStageSubmit), site, + common.BytesToHash(event.OutputsMerkleRoot[:]).Hex(), + common.BytesToHash(event.MachineMerkleRoot[:]).Hex(), + ourOutputsMerkleRoot.Hex(), ourMachineMerkleRoot.Hex(), + epoch.Index, epoch.LastBlock, + ) + return s.markDivergence(ctx, app, epoch, divergenceStageSubmit, reason) +} + +// markAcceptedDivergence handles a ClaimAccepted event whose data differs from +// our local claim. markDivergence decides whether this rejects only the epoch +// or marks the whole app DIVERGED. +func (s *Service) markAcceptedDivergence( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimAccepted, + site string, +) error { + ourOutputsMerkleRoot := common.Hash{} + if epoch.TxBufferDataBlock != nil { + ourOutputsMerkleRoot = *epoch.TxBufferDataBlock + } + ourMachineMerkleRoot := common.Hash{} + if epoch.MachineHash != nil { + ourMachineMerkleRoot = *epoch.MachineHash + } + reason := fmt.Sprintf( + "%s: divergent ClaimAccepted observed at %s. "+ + "on-chain outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "our outputsMerkleRoot=%s machineMerkleRoot=%s, "+ + "epoch %d (lastBlock %d). "+ + "Outputs from this divergent claim are now executable on-chain; "+ + "manual remediation required.", + divergenceBucket(app.ConsensusType, divergenceStageAcceptance), site, + common.BytesToHash(event.OutputsMerkleRoot[:]).Hex(), + common.BytesToHash(event.MachineMerkleRoot[:]).Hex(), + ourOutputsMerkleRoot.Hex(), ourMachineMerkleRoot.Hex(), + epoch.Index, epoch.LastBlock, + ) + return s.markDivergence(ctx, app, epoch, divergenceStageAcceptance, reason) +} + +func (s *Service) setApplicationDiverged( + ctx context.Context, + app *model.Application, + reasonFmt string, + args ...any, +) error { + return appstatus.SetDivergedf(ctx, s.Logger, s.repository, app, reasonFmt, args...) +} + +func (s *Service) setApplicationCorrupted( + ctx context.Context, + app *model.Application, + reasonFmt string, + args ...any, +) error { + return appstatus.SetCorruptedf(ctx, s.Logger, s.repository, app, reasonFmt, args...) +} + +func (s *Service) rejectEpochAndSetApplicationDiverged( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + reason string, +) error { + s.Logger.Error("claim divergence detected", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "epoch_index", epoch.Index, + "reason", reason) + + result, err := s.repository.RejectEpochAndSetApplicationDiverged( + ctx, app.ID, epoch.Index, reason) + reasonErr := errors.New(reason) + if err != nil { + s.Logger.Error("failed to reject epoch and update application status", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "epoch_index", epoch.Index, + "error", err) + return errors.Join(reasonErr, err) + } + + if result.ApplicationDiverged { + app.Status = model.ApplicationStatus_Diverged + app.Reason = &reason + } + if result.EpochRejected { + epoch.Status = model.EpochStatus_ClaimRejected + } + return reasonErr +} + +// markMatcherPrecondFailure marks an app CORRUPTED when local epoch data is +// missing. The matcher needs outputs_merkle_root and machine_hash to compare a +// chain event with our local claim. Those fields should be present after +// CLAIM_COMPUTED, so missing values mean local state was corrupted or changed +// later. The node cannot safely continue because it cannot compare claims, and +// the missing data cannot be reconstructed by restarting — the status is +// terminal. +func (s *Service) markMatcherPrecondFailure( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + site string, +) error { + return s.setApplicationCorrupted(ctx, app, + "%s: cannot compare epoch %d (%d) against chain event — local row is missing "+ + "outputs_merkle_root or machine_hash. This is terminal; inspect the epoch row.", + site, epoch.Index, epoch.VirtualIndex) +} + +// verifyClaimOutputsMatch checks the outputs returned by getClaim. +// +// getClaim looks up a claim by app, last processed block, and machine root. +// If the chain says that claim is STAGED or ACCEPTED, its outputs root should +// match our local outputs root. A mismatch means this node and the submitter +// disagree about the claim. It can also indicate a spoof attempt, because the +// lookup key includes our machine root but the outputs root comes from whoever +// submitted the claim. +// +// Returns nil when the outputs match or when there is not enough local data to +// check. Returns an error after marking the app DIVERGED when they differ. +func (s *Service) verifyClaimOutputsMatch( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + claim iconsensus.IConsensusClaim, + site string, +) error { + if epoch.TxBufferDataBlock == nil { + // Other paths mark this as a local data problem. Here we only compare + // outputs when the local value exists. + return nil + } + chainStagedOutputs := common.BytesToHash(claim.StagedOutputsMerkleRoot[:]) + if chainStagedOutputs == *epoch.TxBufferDataBlock { + return nil + } + status := fmt.Sprintf("status %d", claim.Status) + switch claim.Status { + case claimStatusStaged: + status = "STAGED" + case claimStatusAccepted: + status = "ACCEPTED" + } + return s.setApplicationDiverged(ctx, app, + "chain_claim_outputs_mismatch: %s — getClaim returned %s for our "+ + "(app, lpbn, machineMerkleRoot) tuple but with stagedOutputsMerkleRoot=%s "+ + "while our local outputs_merkle_root is %s. Epoch %d (lastBlock %d). "+ + "Indicates determinism breakage between this node and the submitter of our "+ + "machineMerkleRoot; manual remediation required.", + site, status, + chainStagedOutputs.Hex(), + epoch.TxBufferDataBlock.Hex(), + epoch.Index, epoch.LastBlock) +} + +type consensusAddressCheckKey struct { + appID int64 + blockNumber string +} + +func (s *Service) checkConsensusForAddressChange( + ctx context.Context, + app *model.Application, + defaultBlockNumber *big.Int, +) error { + if s.consensusAddressChecks != nil { + blockNumber := "" + if defaultBlockNumber != nil { + blockNumber = defaultBlockNumber.String() + } + key := consensusAddressCheckKey{ + appID: app.ID, + blockNumber: blockNumber, + } + if err, ok := s.consensusAddressChecks[key]; ok { + return err + } + err := s.checkConsensusForAddressChangeUncached(ctx, app, defaultBlockNumber) + // A later stage has its own deadline and can retry a failed read. + // Cache only a successful check of this fixed block. + if err == nil { + s.consensusAddressChecks[key] = nil + } + return err + } + return s.checkConsensusForAddressChangeUncached(ctx, app, defaultBlockNumber) +} + +func (s *Service) checkConsensusForAddressChangeUncached( + ctx context.Context, + app *model.Application, + defaultBlockNumber *big.Int, +) error { + newConsensusAddress, err := s.blockchain.getConsensusAddress(ctx, app, defaultBlockNumber) + if err != nil { + return fmt.Errorf("getting consensus address for app %v: %w", app.IApplicationAddress, err) + } + if app.IConsensusAddress != newConsensusAddress { + err = s.setApplicationCorrupted( + ctx, + app, + "consensus change detected. application: %v.", + app.IApplicationAddress, + ) + return err + } + return nil +} diff --git a/internal/claimer/divergence_test.go b/internal/claimer/divergence_test.go new file mode 100644 index 000000000..e57f378ad --- /dev/null +++ b/internal/claimer/divergence_test.go @@ -0,0 +1,94 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestVerifyClaimOutputsMismatch(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ClaimStagingPeriod = 5 + stagedAt := uint64(50) + currEpoch := makeStagedEpoch(app, 3, stagedAt) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + claim := makeClaimStatus(claimStatusStaged, currEpoch, stagedAt) + claim.StagedOutputsMerkleRoot = common.HexToHash("0xdeadbeef") + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(claim, nil).Once() + // A mismatched finalized root produces no accept candidate. + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + + _, err := m.observeStagedClaims(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err, "chain_claim_outputs_mismatch must surface as an error") +} + +func TestRejectEpochAndSetApplicationDiverged_MirrorsAppliedWrites(t *testing.T) { + t.Run("preserves terminal application mirror", func(t *testing.T) { + service, repo, _ := newServiceMock(t) + defer repo.AssertExpectations(t) + + app := makeApplication() + app.Status = model.ApplicationStatus_MachineHalted + originalReason := "machine halted" + app.Reason = &originalReason + epoch := makeComputedEpoch(app, 3) + + repo.On("RejectEpochAndSetApplicationDiverged", + mock.Anything, app.ID, epoch.Index, mock.Anything). + Return(repository.RejectEpochAndDivergeResult{EpochRejected: true}, nil).Once() + + err := service.rejectEpochAndSetApplicationDiverged(t.Context(), app, epoch, "later claim disagreement") + + require.Error(t, err) + assert.Equal(t, model.ApplicationStatus_MachineHalted, app.Status) + require.NotNil(t, app.Reason) + assert.Equal(t, originalReason, *app.Reason) + assert.Equal(t, model.EpochStatus_ClaimRejected, epoch.Status) + }) + + t.Run("preserves epoch mirror when rejection did not apply", func(t *testing.T) { + service, repo, _ := newServiceMock(t) + defer repo.AssertExpectations(t) + + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + epoch.Status = model.EpochStatus_Closed + reason := "claim disagreement" + + repo.On("RejectEpochAndSetApplicationDiverged", + mock.Anything, app.ID, epoch.Index, reason). + Return(repository.RejectEpochAndDivergeResult{ApplicationDiverged: true}, nil).Once() + + err := service.rejectEpochAndSetApplicationDiverged(t.Context(), app, epoch, reason) + + require.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Diverged, app.Status) + require.NotNil(t, app.Reason) + assert.Equal(t, reason, *app.Reason) + assert.Equal(t, model.EpochStatus_Closed, epoch.Status) + }) +} + +// TestCleanupOrphanedInFlight — entries whose app is no longer in any work +// map (e.g. transitioned to FAILED/DIVERGED/CORRUPTED/DISABLED mid-flight) must be diff --git a/internal/claimer/event_reconciliation_test.go b/internal/claimer/event_reconciliation_test.go new file mode 100644 index 000000000..cf12861a9 --- /dev/null +++ b/internal/claimer/event_reconciliation_test.go @@ -0,0 +1,142 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +// This fixture stores the domain rows only. No receipt or sender state can +// advance an epoch. Each page is selected again after its preceding DB writes. +type eventRepository struct { + *deliveryCandidateRepository + failStageOnce bool +} + +func (r *eventRepository) SelectClaimsToStagePage(_ context.Context, after repository.ClaimCursor, limit uint64) ( + repository.ClaimPage, error, +) { + return r.page(after, limit, model.EpochStatus_ClaimSubmitted) +} +func (*eventRepository) ListApplicationsByID(context.Context, repository.ApplicationFilter, int64, int64, uint64) ( + []*model.Application, error, +) { + return nil, nil +} +func (r *eventRepository) epoch(id int64, index uint64) *model.Epoch { + for address, app := range r.lookup.apps { + if app.ID == id { + return r.lookup.epochs[address][index] + } + } + return nil +} +func (r *eventRepository) UpdateEpochWithSubmittedClaim(_ context.Context, id int64, index uint64, hash common.Hash) error { + e := r.epoch(id, index) + e.Status, e.ClaimTransactionHash = model.EpochStatus_ClaimSubmitted, new(hash) + return nil +} +func (r *eventRepository) UpdateEpochToStaged(_ context.Context, id int64, index, block uint64) error { + if r.failStageOnce { + r.failStageOnce = false + return errors.New("stage commit unavailable") + } + e := r.epoch(id, index) + e.Status, e.StagedAtBlock = model.EpochStatus_ClaimStaged, new(block) + return nil +} +func (r *eventRepository) UpdateEpochWithAcceptedClaim(_ context.Context, id int64, index uint64, hash *common.Hash) error { + e := r.epoch(id, index) + e.Status = model.EpochStatus_ClaimAccepted + if hash != nil { + e.ClaimTransactionHash = new(*hash) + } + return nil +} + +type eventChain struct { + accept bool +} + +func (*eventChain) getDefaultBlockNumber(context.Context) (*big.Int, error) { + return big.NewInt(100), nil +} +func (*eventChain) getConsensusAddress(_ context.Context, app *model.Application, _ *big.Int) (common.Address, error) { + return app.IConsensusAddress, nil +} +func (*eventChain) claimSubmitterAddress() (common.Address, bool) { return common.Address{}, false } +func (*eventChain) findClaimSubmittedEventAndSucc(_ context.Context, app *model.Application, epoch *model.Epoch, _, _ uint64) ( + *iconsensus.IConsensus, []*iconsensus.IConsensusClaimSubmitted, error, +) { + return nil, []*iconsensus.IConsensusClaimSubmitted{makeSubmittedEventWithTxHash(app, epoch, common.HexToHash("0xabc"))}, nil +} +func (*eventChain) findClaimStagedEventAndSucc(_ context.Context, app *model.Application, epoch *model.Epoch, _, _ uint64) ( + *iconsensus.IConsensus, *iconsensus.IConsensusClaimStaged, *iconsensus.IConsensusClaimStaged, error, +) { + return nil, makeStagedEvent(app, epoch), nil, nil +} +func (c *eventChain) findClaimAcceptedEventAndSucc(_ context.Context, app *model.Application, epoch *model.Epoch, _, _ uint64) ( + *iconsensus.IConsensus, *iconsensus.IConsensusClaimAccepted, *iconsensus.IConsensusClaimAccepted, error, +) { + if !c.accept { + return nil, nil, nil, nil + } + event := makeAcceptedEvent(app, epoch) + event.Raw.TxHash = common.HexToHash("0xdef") + return nil, event, nil, nil +} +func (*eventChain) getClaimStatus(_ context.Context, _ *model.Application, epoch *model.Epoch, _ *big.Int) ( + iconsensus.IConsensusClaim, error, +) { + return makeClaimStatus(claimStatusStaged, epoch, 45), nil +} + +func TestEventOnlyReconciliationSurvivesPagingAndRestartAfterStageWriteFailure(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + t.Run(consensus.String(), func(t *testing.T) { + s, _, lookup := newDeliveryFixture(t) + repo := &eventRepository{deliveryCandidateRepository: &deliveryCandidateRepository{lookup: lookup}, failStageOnce: true} + chain := &eventChain{} + s.repository, s.blockchain = repo, chain + for id := int64(1); id <= 17; id++ { + app, epoch := deliveryApplication(id, consensus) + lookup.add(app, epoch) + } + _, err := s.Tick(t.Context()) + require.ErrorContains(t, err, "stage commit unavailable") + var pending int + for address, app := range lookup.apps { + epoch := lookup.epochs[address][3] + if epoch.Status == model.EpochStatus_ClaimSubmitted { + pending++ + require.Equal(t, common.HexToHash("0xabc"), *epoch.ClaimTransactionHash) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + require.Equal(t, 1, pending, "only the failed stage write remains submitted") + // A new observer has no volatile hint, receipt or page cursor to recover. + restarted, _, _ := newServiceMock(t) + restarted.repository, restarted.blockchain = repo, chain + chain.accept = true + for range 18 { + _, err = restarted.Tick(t.Context()) + require.NoError(t, err) + } + for address, app := range lookup.apps { + epoch := lookup.epochs[address][3] + require.Equal(t, model.EpochStatus_ClaimAccepted, epoch.Status) + require.Equal(t, common.HexToHash("0xdef"), *epoch.ClaimTransactionHash) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + }) + } +} diff --git a/internal/claimer/finality_test.go b/internal/claimer/finality_test.go new file mode 100644 index 000000000..ca05a29ea --- /dev/null +++ b/internal/claimer/finality_test.go @@ -0,0 +1,139 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type finalizedViewRPC struct { + final uint64 + conflict bool + tagOnly uint64 +} + +func (v *finalizedViewRPC) GetBlockByNumber(_ context.Context, n rpc.BlockNumber, _ bool) (*types.Header, error) { + var number uint64 + switch { + case n == rpc.FinalizedBlockNumber || n == rpc.LatestBlockNumber || n == rpc.SafeBlockNumber: + number = v.final + if v.tagOnly != 0 { + number = v.tagOnly + } + case n >= 0: + number = uint64(n) + if number > v.final { + return nil, fmt.Errorf("block %d is beyond this replica's head", number) + } + default: + return nil, fmt.Errorf("unsupported block tag: %d", n) + } + header := deliveryHeader(number) + if v.conflict && number == 20 { + header.Extra = []byte("different finalized branch") + } + return header, nil +} + +func TestUnvalidatedFinalizedTagCannotPoisonObservationWatermark(t *testing.T) { + backend := &finalizedViewRPC{final: 10} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chain := &claimerBlockchain{ + client: client, defaultBlock: model.DefaultBlock_Finalized, backend: txsender.ProtectedClient{Client: client}, + } + _, err := chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err) + backend.tagOnly = 1_000_000 + _, err = chain.getDefaultBlockNumber(t.Context()) + require.Error(t, err) + require.EqualValues(t, 10, chain.observed.Number) + backend.tagOnly = 0 + for head := uint64(11); head < 15; head++ { + backend.final = head + block, err := chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err) + require.Equal(t, head, block.Uint64()) + } +} + +func TestFinalizedObservationSkewDefersAndAdvancedContradictionStaysStopped(t *testing.T) { + backend := &finalizedViewRPC{final: 20} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chain := &claimerBlockchain{ + client: client, defaultBlock: model.DefaultBlock_Finalized, backend: txsender.ProtectedClient{Client: client}, + } + observed, err := chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 20, observed.Uint64()) + backend.final = 19 + _, err = chain.getDefaultBlockNumber(t.Context()) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.EqualValues(t, 20, chain.observed.Number) + backend.final = 21 + backend.conflict = true + _, err = chain.getDefaultBlockNumber(t.Context()) + require.ErrorIs(t, err, txsender.ErrFinality) + backend.conflict = false + _, err = chain.getDefaultBlockNumber(t.Context()) + require.ErrorIs(t, err, txsender.ErrFinality, "a stronger contradictory view requires operator investigation") +} + +func TestEqualFinalizedHeightWithDifferentHashStaysStopped(t *testing.T) { + backend := &finalizedViewRPC{final: 20} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chain := &claimerBlockchain{ + client: client, defaultBlock: model.DefaultBlock_Finalized, backend: txsender.ProtectedClient{Client: client}, + } + _, err := chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err) + backend.conflict = true + _, err = chain.getDefaultBlockNumber(t.Context()) + require.ErrorIs(t, err, txsender.ErrFinality) + backend.conflict = false + _, err = chain.getDefaultBlockNumber(t.Context()) + require.ErrorIs(t, err, txsender.ErrFinality) +} + +func TestProvisionalObservationPolicyAcceptsChangedChainWithoutAFinalityLatch(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe} { + t.Run(policy.String(), func(t *testing.T) { + backend := &finalizedViewRPC{final: 20} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + chain := &claimerBlockchain{client: client, defaultBlock: policy, backend: txsender.ProtectedClient{Client: client}} + _, err := chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err) + previous := chain.observed + backend.conflict = true + _, err = chain.getDefaultBlockNumber(t.Context()) + require.NoError(t, err, "configured provisional reads do not promise rollback or finalized immutability") + require.NotEqual(t, previous.Hash, chain.observed.Hash) + require.Nil(t, chain.fault) + }) + } +} diff --git a/internal/claimer/fixtures_test.go b/internal/claimer/fixtures_test.go new file mode 100644 index 000000000..eb762eb87 --- /dev/null +++ b/internal/claimer/fixtures_test.go @@ -0,0 +1,319 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "os" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/lmittmann/tint" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type chainIDRPC struct { + chainID uint64 +} + +func (s *chainIDRPC) ChainId(_ context.Context) (*hexutil.Big, error) { + chainID := hexutil.Big(*new(big.Int).SetUint64(s.chainID)) + return &chainID, nil +} + +func newTestEthClient(t *testing.T, chainID uint64) *ethclient.Client { + server := rpc.NewServer() + t.Cleanup(server.Stop) + + err := server.RegisterName("eth", &chainIDRPC{chainID: chainID}) + require.NoError(t, err) + + rpcClient := rpc.DialInProc(server) + t.Cleanup(rpcClient.Close) + + client := ethclient.NewClient(rpcClient) + t.Cleanup(client.Close) + return client +} + +func newServiceMock(t *testing.T) (*Service, *claimerRepositoryMock, *claimerBlockchainMock) { + opts := &tint.Options{ + Level: slog.LevelDebug, + AddSource: true, + // RFC3339 with milliseconds and without timezone + TimeFormat: "2006-01-02T15:04:05.000", + } + handler := tint.NewTextHandler(os.Stdout, opts) + repository := &claimerRepositoryMock{} + blockchain := &claimerBlockchainMock{ + submitterAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + hasSubmitter: true, + } + + claimer := &Service{ + submissionEnabled: true, + repository: repository, + blockchain: blockchain, + } + service.InitTickServiceTemplate( + &claimer.TickServiceTemplate, + &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Logger: slog.New(handler), + }, + }, + claimer, + ) + return claimer, repository, blockchain +} + +func makeApplication() *model.Application { + return repotest.NewApplicationBuilder(). + WithEpochLength(10). + Build() +} + +func makeEpoch(id int64, status model.EpochStatus, i uint64) *model.Epoch { + outputsMerkleRoot := common.HexToHash("0x01") + proof := repotest.KeccakStateProof(outputsMerkleRoot) + txHash := common.HexToHash("0x02") + e := repotest.NewEpochBuilder(id). + WithIndex(i). + WithBlocks(i*10, i*10+9). + WithStatus(status). + WithClaimTransactionHash(txHash). + WithTxBufferDataBlock(outputsMerkleRoot). + WithMachineHash(proof.MachineHash). + Build() + e.TxBufferProof = stateProofHashes(proof.TxBufferProof) + e.IflagsYDataBlock = model.Pointer(proof.IflagsYDataBlock) + e.IflagsYProof = stateProofHashes(proof.IflagsYProof) + e.HtifTohostDataBlock = model.Pointer(proof.HtifTohostDataBlock) + e.HtifTohostProof = stateProofHashes(proof.HtifTohostProof) + if status == model.EpochStatus_ClaimStaged { + // CHECK constraint: staged_iff_block. + b := uint64(i*10 + 1) + e.StagedAtBlock = &b + } + return e +} + +func stateProofHashes(proof [][32]byte) []common.Hash { + result := make([]common.Hash, len(proof)) + for i := range proof { + result[i] = proof[i] + } + return result +} + +func makeAcceptedEpoch(app *model.Application, i uint64) *model.Epoch { + return makeEpoch(app.ID, model.EpochStatus_ClaimAccepted, i) +} + +func makeSubmittedEpoch(app *model.Application, i uint64) *model.Epoch { + return makeEpoch(app.ID, model.EpochStatus_ClaimSubmitted, i) +} + +func makeComputedEpoch(app *model.Application, i uint64) *model.Epoch { + return makeEpoch(app.ID, model.EpochStatus_ClaimComputed, i) +} +func makeEpochMap(epochs ...*model.Epoch) map[int64]*model.Epoch { + result := map[int64]*model.Epoch{} + for _, epoch := range epochs { + result[epoch.ApplicationID] = epoch + } + return result +} +func makeApplicationMap(apps ...*model.Application) map[int64]*model.Application { + result := map[int64]*model.Application{} + for _, app := range apps { + result[app.ID] = app + } + return result +} + +// testMachineHash returns a stable [32]byte derived from the epoch — good +// enough for fixtures that don't need a real on-chain match. Tests that +// exercise the machineMerkleRoot cross-check should construct their own +// machine hash and use the field-named struct literal. +func testMachineHash(epoch *model.Epoch) [32]byte { + if epoch.MachineHash != nil { + return *epoch.MachineHash + } + return [32]byte{} +} + +func makeSubmittedEvent(app *model.Application, epoch *model.Epoch) *iconsensus.IConsensusClaimSubmitted { + return makeSubmittedEventWithTxHash(app, epoch, *epoch.ClaimTransactionHash) +} + +func makeSubmittedEventWithTxHash( + app *model.Application, + epoch *model.Epoch, + txHash common.Hash, +) *iconsensus.IConsensusClaimSubmitted { + return &iconsensus.IConsensusClaimSubmitted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(epoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, + MachineMerkleRoot: testMachineHash(epoch), + Raw: types.Log{ + TxHash: txHash, + BlockNumber: epoch.LastBlock + 5, + }, + } +} + +func makeSubmittedEventWithRoots( + app *model.Application, + epoch *model.Epoch, + outputs common.Hash, + machine common.Hash, +) *iconsensus.IConsensusClaimSubmitted { + event := makeSubmittedEvent(app, epoch) + event.OutputsMerkleRoot = outputs + event.MachineMerkleRoot = machine + return event +} + +// makeClaimStagedLog creates a types.Log that ParseClaimStaged can decode. +// Used to build receipt logs for the staging fast-path in tests. +func makeClaimStagedLog(app *model.Application, epoch *model.Epoch) types.Log { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(fmt.Sprintf("failed to get IConsensus ABI: %v", err)) + } + event, ok := parsed.Events["ClaimStaged"] + if !ok { + panic("IConsensus ABI does not define ClaimStaged event") + } + data, err := event.Inputs.NonIndexed().Pack( + new(big.Int).SetUint64(epoch.LastBlock), + *epoch.TxBufferDataBlock, + testMachineHash(epoch), + ) + if err != nil { + panic(fmt.Sprintf("failed to pack ClaimStaged event data: %v", err)) + } + return types.Log{ + Address: app.IConsensusAddress, + Topics: []common.Hash{ + event.ID, + common.BytesToHash(app.IApplicationAddress.Bytes()), + }, + Data: data, + } +} + +// makeStagedEvent constructs an IConsensusClaimStaged matching the epoch. +func makeStagedEvent(app *model.Application, epoch *model.Epoch) *iconsensus.IConsensusClaimStaged { + return &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(epoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, + MachineMerkleRoot: testMachineHash(epoch), + Raw: types.Log{ + BlockNumber: epoch.LastBlock + 5, + }, + } +} + +func makeAcceptedEvent(app *model.Application, epoch *model.Epoch) *iconsensus.IConsensusClaimAccepted { + return &iconsensus.IConsensusClaimAccepted{ + LastProcessedBlockNumber: new(big.Int).SetUint64(epoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, + MachineMerkleRoot: testMachineHash(epoch), + Raw: types.Log{ + TxHash: common.HexToHash(epoch.ClaimTransactionHash.Hex()), + BlockNumber: epoch.LastBlock + 5, + }, + } +} + +// withForeclosed returns a copy with the foreclosure block and transaction set. +func withForeclosed(app *model.Application, block uint64) *model.Application { + foreclosed := *app + foreclosed.ForecloseBlock = block + txHash := common.HexToHash("0xcafe") + foreclosed.ForecloseTransaction = &txHash + return &foreclosed +} + +func makeStagedEpoch(app *model.Application, i uint64, stagedAtBlock uint64) *model.Epoch { + e := makeEpoch(app.ID, model.EpochStatus_ClaimStaged, i) + e.StagedAtBlock = &stagedAtBlock + return e +} + +func buildClaimStagedLog(app *model.Application, epoch *model.Epoch, + outputs common.Hash, machine common.Hash) types.Log { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(err) + } + event := parsed.Events["ClaimStaged"] + data, err := event.Inputs.NonIndexed().Pack( + new(big.Int).SetUint64(epoch.LastBlock), + [32]byte(outputs), + [32]byte(machine), + ) + if err != nil { + panic(err) + } + return types.Log{ + Address: app.IConsensusAddress, + Topics: []common.Hash{ + event.ID, + common.BytesToHash(app.IApplicationAddress.Bytes()), + }, + Data: data, + } +} + +const claimSubmittedEventName = "ClaimSubmitted" + +func claimEventLog(app *model.Application, epoch *model.Epoch, name string) *types.Log { + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + if err != nil { + panic(err) + } + event := parsed.Events[name] + args := []any{new(big.Int).SetUint64(epoch.LastBlock), [32]byte(*epoch.TxBufferDataBlock), [32]byte(*epoch.MachineHash)} + topics := []common.Hash{event.ID, common.BytesToHash(app.IApplicationAddress.Bytes())} + if name == claimSubmittedEventName { + topics = []common.Hash{event.ID, common.BytesToHash(common.HexToAddress("0x1").Bytes()), + common.BytesToHash(app.IApplicationAddress.Bytes())} + } + data, err := event.Inputs.NonIndexed().Pack(args...) + if err != nil { + panic(err) + } + return &types.Log{Address: app.IConsensusAddress, Topics: topics, Data: data, BlockNumber: 99} +} + +func failClaimObservation(t *testing.T, s *Service) { + t.Helper() + chain := s.blockchain.(*claimerBlockchainMock) + cause := errors.New("observation provider unavailable") + chain.On("getDefaultBlockNumber", mock.Anything).Return((*big.Int)(nil), cause).Once() + _, err := s.Tick(t.Context()) + require.ErrorIs(t, err, cause) + require.Nil(t, s.failure.Load()) +} diff --git a/internal/claimer/foreclosed_apps_test.go b/internal/claimer/foreclosed_apps_test.go new file mode 100644 index 000000000..2470b703f --- /dev/null +++ b/internal/claimer/foreclosed_apps_test.go @@ -0,0 +1,315 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func unjoinError(err error) []error { + type unwrapper interface { + Unwrap() []error + } + + if uw, ok := err.(unwrapper); ok { + return uw.Unwrap() + } + + if err != nil { + return []error{err} + } + + return nil +} + +// foreclosedAppHelper builds a foreclosed Application instance, optionally +// with a PRT consensus type. ForecloseBlock is non-zero, mirroring what +// the evmreader's checkForForeclosure would have persisted. +// LastInputCheckBlock is parked at the foreclose block so callers that do +// not exercise the bootstrap-readiness guard skip past it; tests that +// exercise the guard override the field explicitly. +func foreclosedAppHelper(id int64, block uint64, consensus model.Consensus) *model.Application { + txHash := common.HexToHash("0xdeadbeef") + return &model.Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(common.Big1), + ConsensusType: consensus, + Enabled: true, + Status: model.ApplicationStatus_OK, + ForecloseBlock: block, + ForecloseTransaction: &txHash, + LastInputCheckBlock: block, + } +} + +// TestListEnabledForeclosedNonPRTApps_UsesAuthorityQuorumFilter verifies the +// repository filter used by the Authority/Quorum drain path. PRT apps have +// their own post-foreclosure path, so the claimer asks the repository for only +// Authority and Quorum apps. +func TestListEnabledForeclosedNonPRTApps_UsesAuthorityQuorumFilter(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + auth := foreclosedAppHelper(1, 100, model.Consensus_Authority) + quorum := foreclosedAppHelper(2, 200, model.Consensus_Quorum) + + // Match the exact filter the service issues so the test catches + // regressions in either side. ForeclosureRecorded must be passed + // as &true, Enabled as &true, and ConsensusTypes as Authority/Quorum. + r.On("ListApplications", + mock.Anything, + mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.Enabled != nil && *f.Enabled && + f.ForeclosureRecorded != nil && *f.ForeclosureRecorded && + assert.ElementsMatch(t, + []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum}, + f.ConsensusTypes, + ) && + assert.ElementsMatch(t, + []model.ApplicationStatus{model.ApplicationStatus_OK, model.ApplicationStatus_GuestException, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_McycleOverflow, + model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot}, + f.Statuses, + ) + }), + mock.Anything, + mock.Anything, + ).Return([]*model.Application{auth, quorum}, 2, nil).Once() + + got, err := s.listEnabledForeclosedNonPRTApps(context.Background()) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Contains(t, got, auth.ID) + assert.Contains(t, got, quorum.ID) +} + +// TestListEnabledForeclosedNonPRTApps_ExcludesIntegrityAndRecoverableFailures verifies the +// selection boundary that keeps terminal foreclosed apps out of the claim +// drain. A DIVERGED or CORRUPTED app is terminal for claim work; EVM reader +// still observes it, but the claimer must not keep re-running drain checks +// every tick. The protection lives in the selecting filter, which restricts +// Statuses to OK/ET/IOR, so FAILED/DC are never handed to processForeclosedApps. +func TestListEnabledForeclosedNonPRTApps_ExcludesIntegrityAndRecoverableFailures(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + r.On("ListApplications", + mock.Anything, + mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return assert.ElementsMatch(t, + []model.ApplicationStatus{model.ApplicationStatus_OK, model.ApplicationStatus_GuestException, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_McycleOverflow, + model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot}, + f.Statuses, + ) + }), + mock.Anything, + mock.Anything, + ).Return([]*model.Application{}, 0, nil).Once() + + got, err := s.listEnabledForeclosedNonPRTApps(context.Background()) + require.NoError(t, err) + require.Empty(t, got) +} + +// TestProcessForeclosedApps_DefersWhenUnreconciled verifies that an app +// whose pre-foreclosure epochs have not all reached CLAIM_ACCEPTED or +// CLAIM_FORECLOSED stays in its current app status. The deferral branch must NOT issue an +// UpdateApplicationStatus call — transitioning before the advancer/validator +// finish would lose the last-known epoch outputs needed for any in-flight +// dispute; firing before claim reconciliation completes would leave the local +// DB final state divergent from the chain. +func TestProcessForeclosedApps_DefersWhenUnreconciled(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + ctx := context.Background() + + r.On("ForecloseUnacceptedEpochsAtOrAfterBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(0, nil).Once() + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + r.On("HasUnreconciledClaimsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + + // No UpdateApplicationStatus expectation — if it fires, the mock + // assertion fails the test because we registered no Setup for it. + + errs := s.processForeclosedApps(ctx, map[int64]*model.Application{app.ID: app}, app.ForecloseBlock) + assert.Empty(t, errs, "deferral is not an error") +} + +// TestProcessForeclosedApps_DrainCheckErrorsAppendAndContinue verifies the +// resilience contract of the drain pass: when a per-app drain-gate repository +// call errors, the error is collected and the loop moves on to the next app — +// one app's DB failure must not abort the whole pass. With two apps both +// failing the same gate, both errors are surfaced (proving the loop did not +// stop after the first). +func TestProcessForeclosedApps_DrainCheckErrorsAppendAndContinue(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + ctx := context.Background() + + app1 := foreclosedAppHelper(1, 100, model.Consensus_Authority) + app2 := foreclosedAppHelper(2, 100, model.Consensus_Authority) + + for _, app := range []*model.Application{app1, app2} { + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, errors.New("db unavailable")).Once() + } + // The drain check runs first; its error makes the per-app branch `continue` + // before terminalizing or reconciling. Neither + // ForecloseUnacceptedEpochsAtOrAfterBlock nor HasUnreconciledClaimsBeforeBlock + // is reached — no expectation registered for either. + + err := s.processForeclosedApps(ctx, map[int64]*model.Application{app1.ID: app1, app2.ID: app2}, + max(app1.ForecloseBlock, app2.ForecloseBlock)) + errs := unjoinError(err) + assert.Len(t, errs, 2, "each app's drain error is appended; the pass does not abort early") +} + +// TestProcessForeclosedApps_NoTransitionWhenDrained verifies that once both +// gates clear (bootstrap-readiness + drain reconciliation), the per-app +// branch is a no-op. No UpdateApplicationStatus call fires — the app stays +// enabled with status OK and foreclose_block set, and the +// post-foreclosure observation work (drive-prove discovery, withdrawal +// indexing) lives in evmreader. CORRUPTED is reserved for genuine corruption. +// +// The mock has no UpdateApplicationStatus expectation registered; +// testify/mock fails the test on an unexpected call, so any regression that +// re-introduces a terminal-state transition trips this test loudly. +func TestProcessForeclosedApps_NoTransitionWhenDrained(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + ctx := context.Background() + + r.On("ForecloseUnacceptedEpochsAtOrAfterBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(0, nil).Once() + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + r.On("HasUnreconciledClaimsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + + // No UpdateApplicationStatus expectation — the assertion is by negation. + + errs := s.processForeclosedApps(ctx, map[int64]*model.Application{app.ID: app}, app.ForecloseBlock) + assert.Empty(t, errs) +} + +// TestProcessForeclosedApps_DefersWhenInputsUndrained verifies the drain-gate +// ordering that protects an input landing in the foreclose block. While any +// pre-foreclosure input is still undrained, the pass defers WITHOUT +// terminalizing. Terminalizing the straddling epoch first would flip it to +// CLAIM_FORECLOSED and strand its unprocessed same-block input (it would vanish +// from both this drain check and the manager's machine-drain gate, and the +// machine would be torn down before advancing it). The absent +// ForecloseUnacceptedEpochsAtOrAfterBlock expectation is the regression guard: +// testify/mock fails on the unexpected call if terminalization runs too early. +func TestProcessForeclosedApps_DefersWhenInputsUndrained(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + ctx := context.Background() + + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + // No ForecloseUnacceptedEpochsAtOrAfterBlock and no + // HasUnreconciledClaimsBeforeBlock: an undrained input defers the whole pass + // before terminalization and before claim reconciliation. + + errs := s.processForeclosedApps(ctx, map[int64]*model.Application{app.ID: app}, app.ForecloseBlock) + assert.Empty(t, errs, "input-drain deferral is not an error") +} + +// TestProcessForeclosedApps_TerminalizesUnacceptedOverlapAfterDrain verifies the +// other side of the gate: once the drain check clears (no undrained inputs), the +// straddling/after epochs that can never be accepted are terminalized to +// CLAIM_FORECLOSED, then reconciliation completes. +func TestProcessForeclosedApps_TerminalizesUnacceptedOverlapAfterDrain(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + ctx := context.Background() + + // Pin the sequence: the drain check MUST run before terminalization (else a + // straddling-epoch input is stranded — the bug this ordering prevents), and + // terminalization before the claim-reconciliation check. + drain := r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + terminalize := r.On("ForecloseUnacceptedEpochsAtOrAfterBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(2, nil).Once() + reconcile := r.On("HasUnreconciledClaimsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + mock.InOrder(drain, terminalize, reconcile) + + errs := s.processForeclosedApps(ctx, map[int64]*model.Application{app.ID: app}, app.ForecloseBlock) + assert.Empty(t, errs) +} + +// TestProcessForeclosedApps_SkipsZeroForecloseBlock is a defensive check on +// the loop's "should have been filtered" guard. partitionForeclosedApps is +// the only intended source of input, but a caller bug or future refactor +// could feed an app with a zero ForecloseBlock here; the loop must skip it +// silently rather than treat block 0 as a real foreclosure marker. +func TestProcessForeclosedApps_SkipsZeroForecloseBlock(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + app := &model.Application{ID: 99, ConsensusType: model.Consensus_Authority} + ctx := context.Background() + + // No mock expectations — the loop must skip before any repo call. + errs := s.processForeclosedApps(ctx, map[int64]*model.Application{app.ID: app}, app.ForecloseBlock) + assert.Empty(t, errs) +} + +// TestProcessForeclosedApps_DefersWhenStillBackfilling verifies the +// bootstrap-readiness guard. When a freshly registered Authority/Quorum +// app encounters an already-foreclosed contract, evmreader sets +// ForecloseBlock before checkForNewInputs has had time to ingest the +// historical inputs. If the drain check fires inside that window, the gate +// sees an empty epoch table and incorrectly returns false, making the app look +// drained before any pre-foreclosure epoch is observed locally. The guard must +// defer the drain check until LastInputCheckBlock >= ForecloseBlock. +// +// Neither HasUndrainedEpochsBeforeBlock, HasUnreconciledClaimsBeforeBlock nor +// UpdateApplicationStatus has an `.On` registered; testify/mock panics on an +// unexpected call, so any reach attempt fails the test loudly. +func TestProcessForeclosedApps_DefersWhenStillBackfilling(t *testing.T) { + s, r, _ := newServiceMock(t) + defer r.AssertExpectations(t) + + app := foreclosedAppHelper(1, 100, model.Consensus_Authority) + app.LastInputCheckBlock = 50 // scanner well below the foreclose block + ctx := context.Background() + + errs := s.processForeclosedApps(ctx, map[int64]*model.Application{app.ID: app}, app.ForecloseBlock) + assert.Empty(t, errs, "bootstrap deferral is not an error") +} diff --git a/internal/claimer/foreclosure.go b/internal/claimer/foreclosure.go new file mode 100644 index 000000000..46601a9b9 --- /dev/null +++ b/internal/claimer/foreclosure.go @@ -0,0 +1,198 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +// listEnabledForeclosedNonPRTApps returns enabled, foreclosed Authority/Quorum +// apps, keyed by Application.ID. +// +// Some foreclosed apps no longer have pending claim work, but operators still +// need to see whether pre-foreclosure work is fully drained. This query keeps +// those apps visible to processForeclosedApps. +func (s *Service) listEnabledForeclosedNonPRTApps(ctx context.Context) (map[int64]*model.Application, error) { + apps, err := s.repository.ListApplicationsByID(ctx, + foreclosedClaimDrainApplicationsFilter(), s.foreclosePageCursor, math.MaxInt64, claimPageSize) + if err != nil { + return nil, fmt.Errorf("listing enabled foreclosed apps: %w", err) + } + if uint64(len(apps)) < claimPageSize { + s.foreclosePageCursor = 0 + } else { + for _, app := range apps { + if app.ID > s.foreclosePageCursor { + s.foreclosePageCursor = app.ID + } + } + } + out := make(map[int64]*model.Application, len(apps)) + for _, app := range apps { + out[app.ID] = app + } + return out, nil +} + +func foreclosedClaimDrainApplicationsFilter() repository.ApplicationFilter { + return repository.ApplicationFilter{ + Enabled: new(true), + Statuses: []model.ApplicationStatus{ + model.ApplicationStatus_OK, model.ApplicationStatus_GuestException, model.ApplicationStatus_MachineHalted, + model.ApplicationStatus_McycleOverflow, model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot, + }, + ConsensusTypes: []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum}, + ForeclosureRecorded: new(true), + } +} + +// processForeclosedApps checks foreclosed apps once per tick and logs whether +// their pre-foreclosure work is still draining. The logs cover three waiting +// points: historical input scan catch-up, pre-foreclosure input advancement, +// and claim reconciliation or CLAIM_FORECLOSED terminalization. +// +// A foreclosed application is identified by foreclose_block, not by status: a +// healthy foreclosed app is enabled with status=OK and foreclose_block set, and +// is drained here. Execution stops also drain their unreachable suffix while +// earlier valid epochs retain their normal reconciliation. FAILED and DC remain +// excluded by the selecting filter. EVM reader still observes enabled apps +// (drive-prove, withdrawals), regardless of their health. +// +// This function does not send transactions. Submit and accept code already skip +// broadcasts when foreclose_block is set. Once all drain checks pass there is no +// final action here. +func (s *Service) processForeclosedApps( + ctx context.Context, + apps map[int64]*model.Application, + observedBlock uint64, +) error { + var errs error + for _, app := range apps { + if app.ForecloseBlock == 0 { + // This should have been filtered by the query. + continue + } + if err := requireObservedBlock(observedBlock, app.ForecloseBlock); err != nil { + s.Logger.Debug("Waiting for Claimer observation to reach foreclosure", "application", app.Name, "error", err) + continue + } + // Bootstrap-readiness invariant. For a newly registered app, EVM reader + // may record foreclose_block before it has scanned old InputAdded + // events. Until the input scanner reaches foreclose_block, the DB may + // look empty even though old inputs still exist on chain. Wait until + // that scan catches up before trusting the drain queries below. + if !app.ForeclosureScanCaughtUp() { + s.Logger.Info( + "Foreclosed application still ingesting pre-foreclosure inputs", + "application", app.Name, + "address", app.IApplicationAddress, + "last_input_check_block", app.LastInputCheckBlock, + "foreclose_block", app.ForecloseBlock, + ) + continue + } + // Drain gate FIRST, terminalize second. An input can land in the + // foreclose block itself (before the foreclose tx, so it is valid and is + // indexed up to and including foreclose_block). Terminalizing the + // straddling epoch before that input is advanced would flip the epoch to + // CLAIM_FORECLOSED, which hides its still-unprocessed input from this + // drain check AND from the manager's machine-drain gate + // (HasUndrainedEpochsBeforeBlock excludes terminal epochs) — so the + // machine is torn down and the input is never processed, leaving the + // final machine state one input behind the chain. Wait for the drain, + // then terminalize. PRT gates terminalization the same way + // (internal/prt/service.go handleForeclosedApp). + undrained, err := s.repository.HasUndrainedEpochsBeforeBlock( + ctx, app.ID, app.ForecloseBlock, + ) + if err != nil { + errs = errors.Join(errs, fmt.Errorf( + "checking input drain progress for foreclosed app %s: %w", + app.IApplicationAddress, err)) + continue + } + if undrained { + s.Logger.Info( + "Foreclosed application still advancing pre-foreclosure inputs", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + ) + continue + } + terminalized, err := s.repository.ForecloseUnacceptedEpochsAtOrAfterBlock( + ctx, app.ID, app.ForecloseBlock, + ) + if err != nil { + errs = errors.Join(errs, fmt.Errorf( + "terminalizing unaccepted epochs for foreclosed app %s: %w", + app.IApplicationAddress, err)) + continue + } + if terminalized > 0 { + s.Logger.Info( + "Foreclosed application terminalized epochs that cannot be accepted", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + "epochs", terminalized, + ) + } + unreconciled, err := s.repository.HasUnreconciledClaimsBeforeBlock( + ctx, app.ID, app.ForecloseBlock, + ) + if err != nil { + errs = errors.Join(errs, fmt.Errorf( + "checking drain progress for foreclosed app %s: %w", + app.IApplicationAddress, err)) + continue + } + if unreconciled { + s.Logger.Info( + "Foreclosed application still draining or reconciling pre-foreclosure epochs", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + ) + continue + } + // All drain checks passed. There is no state change here; EVM reader + // continues post-foreclosure observation. + } + return errs +} + +func (s *Service) forecloseClaim( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + observedBlock uint64, + site string, +) error { + if err := requireObservedBlock(observedBlock, app.ForecloseBlock); err != nil { + return err + } + if err := s.repository.UpdateEpochWithForeclosedClaim( + ctx, app.ID, epoch.Index); err != nil { + return fmt.Errorf("marking epoch %d (%d) CLAIM_FORECLOSED: %w", + epoch.Index, epoch.VirtualIndex, err) + } + s.Logger.Info("Claim made terminal by application foreclosure", + "application", app.Name, + "address", app.IApplicationAddress.String(), + "epoch_index", epoch.Index, + "virtual_epoch_index", epoch.VirtualIndex, + "previous_status", epoch.Status, + "foreclose_block", app.ForecloseBlock, + "site", site, + ) + epoch.Status = model.EpochStatus_ClaimForeclosed + return nil +} diff --git a/internal/claimer/foreclosure_observer_regression_test.go b/internal/claimer/foreclosure_observer_regression_test.go new file mode 100644 index 000000000..8a08a8e72 --- /dev/null +++ b/internal/claimer/foreclosure_observer_regression_test.go @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "log/slog" + "testing" +) + +func TestForecloseClaimDoesNotAnnounceFailedWrite(t *testing.T) { + s, repo, _ := newServiceMock(t) + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := withForeclosed(makeApplication(), 105) + epoch := makeStagedEpoch(app, 3, 50) + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, epoch.Index). + Return(repository.ErrNoUpdate).Once() + err := s.forecloseClaim(t.Context(), app, epoch, 105, "test") + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.NotContains(t, logs.String(), "Claim made terminal by application foreclosure") + repo.AssertExpectations(t) +} diff --git a/internal/claimer/legacy_fees_test.go b/internal/claimer/legacy_fees_test.go new file mode 100644 index 000000000..5d3931885 --- /dev/null +++ b/internal/claimer/legacy_fees_test.go @@ -0,0 +1,97 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "fmt" + "math/big" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCreateWiresLegacyFees(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Finalized, model.DefaultBlock_Latest} { + for _, legacy := range []bool{false, true} { + for _, injectClient := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/legacy=%t/injected=%t", policy, legacy, injectClient), func(t *testing.T) { + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.AUTH_KIND, nil) + viper.Set(config.AUTH_PRIVATE_KEY, nil) + }) + requested := PersistentConfig{ + ChainID: 42, DefaultBlock: policy, ClaimSubmissionEnabled: true, + ResolvedSignerAddress: common.HexToAddress("0x70997970C51812dc3a010C7d01b50e0d17dc79C8"), + } + repo := &claimerCreateRepositoryMock{} + repo.On("RegisterSubmitter", mock.Anything, ClaimerConfigKey, requested).Return(requested, nil).Once() + backend := &claimerLegacyFeeRPC{chainIDRPC: chainIDRPC{chainID: 42}} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + endpoint, err := config.ToURLFromString(httpServer.URL) + require.NoError(t, err) + var client *ethclient.Client + if injectClient { + client = ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + } + s, err := Create(t.Context(), &CreateInfo{ + Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: policy, + BlockchainHttpEndpoint: endpoint, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + BlockchainLegacyEnabled: legacy, + BlockchainTxMaxFeePerGas: "1000000000", + }, + EthConn: client, Repository: repo, + }) + require.NoError(t, err) + blockchain, ok := s.(*Service).blockchain.(*claimerBlockchain) + require.True(t, ok) + if !injectClient { + t.Cleanup(blockchain.client.Close) + } + impl := s.(*Service) + require.NotNil(t, impl.publisher) + require.NotNil(t, impl.publisher.sender) + require.Equal(t, requested.DefaultBlock, impl.publisher.observationPolicy) + require.False(t, impl.Ready(), "Serve waits for the contributing observer before publication") + opts, err := blockchain.txOptsFactory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Nil(t, opts.GasPrice, "the signer factory does not perform unpinned fee reads") + require.Zero(t, backend.prices.Load()) + repo.AssertExpectations(t) + }) + } + } + } +} + +type claimerLegacyFeeRPC struct { + chainIDRPC + prices atomic.Int32 +} + +func (r *claimerLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} diff --git a/internal/claimer/lifecycle.go b/internal/claimer/lifecycle.go new file mode 100644 index 000000000..f5148d9c6 --- /dev/null +++ b/internal/claimer/lifecycle.go @@ -0,0 +1,70 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "time" + + "golang.org/x/sync/errgroup" +) + +const claimProvenanceCheckTimeout = 5 * time.Second + +// Serve runs observation and publication independently. Temporary publication +// failures retain the sender and retry in the publisher's paced loop. +func (s *Service) Serve(ctx context.Context) error { + if !s.submissionEnabled { + return s.TickServiceTemplate.Serve(ctx) + } + if err := s.waitForClaimObserver(ctx); err != nil { + return err + } + s.publicationReady.Store(true) + defer s.publicationReady.Store(false) + group, runCtx := errgroup.WithContext(ctx) + group.Go(func() error { return s.TickServiceTemplate.Serve(runCtx) }) + group.Go(func() error { return s.publisher.run(runCtx) }) + return group.Wait() +} + +// The contributing observer can register after this independent service starts. +// This prerequisite is checked once, without coupling each POST to the database. +func (s *Service) waitForClaimObserver(ctx context.Context) error { + interval := s.publisher.interval + if interval <= 0 || interval > time.Second { + interval = time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + var warnedAt time.Time + for ctx.Err() == nil { + check, cancel := context.WithTimeout(ctx, claimProvenanceCheckTimeout) + err := s.deliveryRepository.CheckSubmitterProvenance(check, ClaimerConfigKey) + cancel() + if ctx.Err() != nil { + return ctx.Err() + } + if err == nil { + return nil + } + if time.Since(warnedAt) >= claimWarningInterval && ctx.Err() == nil { + s.Logger.Warn("Claim publication waits for compatible observer registration", "error", err) + warnedAt = time.Now() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } + return ctx.Err() +} + +func (s *Service) guardClaimPublication(ctx context.Context) error { + if cause := s.failure.Load(); cause != nil { + return *cause + } + return ctx.Err() +} diff --git a/internal/claimer/lifecycle_test.go b/internal/claimer/lifecycle_test.go new file mode 100644 index 000000000..ef79a1ee3 --- /dev/null +++ b/internal/claimer/lifecycle_test.go @@ -0,0 +1,126 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "math/big" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type claimSibling struct { + started chan struct{} + stopped atomic.Bool +} + +func (*claimSibling) String() string { return "sibling" } +func (*claimSibling) Ready() bool { return true } +func (s *claimSibling) Serve(ctx context.Context) error { + close(s.started) + <-ctx.Done() + s.stopped.Store(true) + return ctx.Err() +} + +func TestClaimHardFailureKeepsObserverAndSiblingServingUntilShutdown(t *testing.T) { + for _, origin := range []string{"observer", "publisher"} { + t.Run(origin, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + chain := s.blockchain.(*claimerBlockchainMock) + cause := errors.Join(txsender.ErrFinality, errors.New("finalized block 20 changed hash")) + if origin == "observer" { + chain.On("getDefaultBlockNumber", mock.Anything).Return((*big.Int)(nil), cause) + } else { + queueDelivery(t, backend, lookup, 1) + backend.ownerErr = cause + chain.On("getDefaultBlockNumber", mock.Anything). + Run(func(args mock.Arguments) { <-args.Get(0).(context.Context).Done() }). + Return((*big.Int)(nil), context.Canceled).Once() + } + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "claimer", Logger: s.Logger}, PollInterval: 5 * time.Millisecond, + }, s)) + s.publisher.interval = 5 * time.Millisecond + sibling := &claimSibling{started: make(chan struct{})} + supervisor, err := service.NewSupervisor(t.Context(), &service.SupervisorConfigs{ + BaseConfigs: service.BaseConfigs{Name: "node", Logger: s.Logger}, + Factories: []service.FactoryFunction{ + func(context.Context, service.Supervisor) (service.SupervisedService, error) { return s, nil }, + func(context.Context, service.Supervisor) (service.SupervisedService, error) { return sibling, nil }, + }, + }) + require.NoError(t, err) + finished := make(chan error, 1) + go func() { finished <- supervisor.Serve() }() + defer supervisor.Stop() + <-sibling.started + require.Eventually(t, func() bool { return s.failure.Load() != nil }, time.Second, time.Millisecond) + require.False(t, s.Ready()) + require.ErrorIs(t, *s.failure.Load(), txsender.ErrFinality) + select { + case err := <-finished: + t.Fatalf("hard latch stopped the node: %v", err) + case <-time.After(30 * time.Millisecond): + } + require.False(t, sibling.stopped.Load()) + supervisor.Stop() + require.NoError(t, <-finished) + require.True(t, sibling.stopped.Load()) + require.Empty(t, backend.published) + chain.AssertExpectations(t) + }) + } +} + +func TestClaimShutdownWaitsForBothLoops(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + backend.blockExecution = map[common.Address]bool{app.IApplicationAddress: true} + backend.executionStarted = make(chan struct{}, 1) + observerStarted, observerCanceled, releaseObserver := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var observerExited atomic.Bool + tick := &claimTickFixture{fn: func(ctx context.Context) (bool, error) { + close(observerStarted) + <-ctx.Done() + close(observerCanceled) + <-releaseObserver + observerExited.Store(true) + return false, ctx.Err() + }} + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "claimer", Logger: s.Logger}, PollInterval: time.Hour, + }, tick)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + finished := make(chan error, 1) + go func() { finished <- s.Serve(ctx) }() + <-observerStarted + <-backend.executionStarted + cancel() + <-observerCanceled + synctest.Wait() + select { + case err := <-finished: + t.Fatalf("Serve returned before observer cleanup completed: %v", err) + default: + } + close(releaseObserver) + require.ErrorIs(t, <-finished, context.Canceled) + require.True(t, observerExited.Load()) + require.False(t, s.Ready()) + require.Empty(t, backend.published, "the canceled publisher completed without broadcasting") + + }) +} diff --git a/internal/claimer/matchers.go b/internal/claimer/matchers.go new file mode 100644 index 000000000..01b0c0fd7 --- /dev/null +++ b/internal/claimer/matchers.go @@ -0,0 +1,94 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" +) + +// The full matchers return (matches, ok). +// +// ok=false means local epoch data is missing, so the comparison could not run. +// That is different from matches=false. Missing local data is a DB/state +// problem and should use the local-state corruption path. A real mismatch is a +// chain-vs-local claim disagreement and should use the divergence path. + +func claimSubmittedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimSubmitted) (matches bool, ok bool) { + if application == nil || epoch == nil || event == nil { + return false, false + } + if epoch.TxBufferDataBlock == nil || epoch.MachineHash == nil { + return false, false + } + return application.IApplicationAddress == event.AppContract && + *epoch.TxBufferDataBlock == event.OutputsMerkleRoot && + *epoch.MachineHash == event.MachineMerkleRoot && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64(), true +} + +func claimAcceptedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimAccepted) (matches bool, ok bool) { + if application == nil || epoch == nil || event == nil { + return false, false + } + if epoch.TxBufferDataBlock == nil || epoch.MachineHash == nil { + return false, false + } + return application.IApplicationAddress == event.AppContract && + *epoch.TxBufferDataBlock == event.OutputsMerkleRoot && + *epoch.MachineHash == event.MachineMerkleRoot && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64(), true +} + +// claimAcceptedEventMatchesEpoch checks only app and lastBlock. It ignores the +// Merkle roots. +// +// This lets Quorum detect that another validator's claim was accepted for the +// same epoch, even if its roots differ from ours. +func claimAcceptedEventMatchesEpoch(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimAccepted) bool { + if application == nil || epoch == nil || event == nil { + return false + } + return application.IApplicationAddress == event.AppContract && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() +} + +// claimSubmittedEventMatchesEpoch checks only app and lastBlock. +// +// The event scan first finds events for the same epoch. A later full match +// decides whether the event is our claim or a different claim. +func claimSubmittedEventMatchesEpoch(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimSubmitted) bool { + if application == nil || epoch == nil || event == nil { + return false + } + return application.IApplicationAddress == event.AppContract && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() +} + +// claimStagedEventMatches checks app, lastBlock, outputs, and machine root. +// This is the normal path where our own claim was staged. +func claimStagedEventMatches(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimStaged) (matches bool, ok bool) { + if application == nil || epoch == nil || event == nil { + return false, false + } + if epoch.TxBufferDataBlock == nil || epoch.MachineHash == nil { + return false, false + } + return application.IApplicationAddress == event.AppContract && + *epoch.TxBufferDataBlock == event.OutputsMerkleRoot && + *epoch.MachineHash == event.MachineMerkleRoot && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64(), true +} + +// claimStagedEventMatchesEpoch checks only app and lastBlock. +// +// The staging scan must find any ClaimStaged event for our epoch. A later full +// match decides whether it is our claim or a different claim. +func claimStagedEventMatchesEpoch(application *model.Application, epoch *model.Epoch, event *iconsensus.IConsensusClaimStaged) bool { + if application == nil || epoch == nil || event == nil { + return false + } + return application.IApplicationAddress == event.AppContract && + epoch.LastBlock == event.LastProcessedBlockNumber.Uint64() +} diff --git a/internal/claimer/mocks_test.go b/internal/claimer/mocks_test.go new file mode 100644 index 000000000..6a184c54c --- /dev/null +++ b/internal/claimer/mocks_test.go @@ -0,0 +1,421 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "math/big" + "time" +) + +type claimerRepositoryMock struct { + mock.Mock + pageReads []repository.ClaimCursor +} + +type claimerCreateRepositoryMock struct { + repository.Repository + mock.Mock +} + +func (m *claimerCreateRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *claimerCreateRepositoryMock) SaveNodeConfigRaw( + ctx context.Context, + key string, + rawJSON []byte, +) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *claimerCreateRepositoryMock) LoadNodeConfigRaw(ctx context.Context, key string) ( + rawJSON []byte, + createdAt, updatedAt time.Time, + err error, +) { + args := m.Called(ctx, key) + raw, _ := args.Get(0).([]byte) + return raw, args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) +} + +func (m *claimerRepositoryMock) SelectClaimsToSubmitPerApp(ctx context.Context) ( + map[int64]*model.Epoch, + map[int64]*model.Application, + error, +) { + args := m.Called(ctx) + return args.Get(0).(map[int64]*model.Epoch), + args.Get(1).(map[int64]*model.Application), + args.Error(2) +} + +func (m *claimerRepositoryMock) SelectClaimsToStagePerApp(ctx context.Context) ( + map[int64]*model.Epoch, + map[int64]*model.Application, + error, +) { + args := m.Called(ctx) + return args.Get(0).(map[int64]*model.Epoch), + args.Get(1).(map[int64]*model.Application), + args.Error(2) +} +func (m *claimerRepositoryMock) UpdateEpochWithSubmittedClaim( + ctx context.Context, + appid int64, + index uint64, + txHash common.Hash, +) error { + args := m.Called(ctx, appid, index, txHash) + return args.Error(0) +} + +func (m *claimerRepositoryMock) UpdateApplicationStatus( + ctx context.Context, + appID int64, + state model.ApplicationStatus, + reason *string, +) error { + args := m.Called(ctx, appID, state, reason) + return args.Error(0) +} + +func (m *claimerRepositoryMock) RejectEpochAndSetApplicationDiverged( + ctx context.Context, + appID int64, + index uint64, + reason string, +) (repository.RejectEpochAndDivergeResult, error) { + args := m.Called(ctx, appID, index, reason) + result, _ := args.Get(0).(repository.RejectEpochAndDivergeResult) + return result, args.Error(1) +} + +func (m *claimerRepositoryMock) HasUnreconciledClaimsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *claimerRepositoryMock) HasUndrainedEpochsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *claimerRepositoryMock) ForecloseUnacceptedEpochsAtOrAfterBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (int64, error) { + args := m.Called(ctx, appID, blockBound) + return int64(args.Int(0)), args.Error(1) +} + +func (m *claimerRepositoryMock) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, +) ([]*model.Application, uint64, error) { + args := m.Called(ctx, f, p, descending) + var apps []*model.Application + if a := args.Get(0); a != nil { + apps = a.([]*model.Application) + } + return apps, uint64(args.Int(1)), args.Error(2) +} + +func (m *claimerRepositoryMock) UpdateEpochWithAcceptedClaim( + ctx context.Context, + appid int64, + index uint64, + txHash *common.Hash, +) error { + args := m.Called(ctx, appid, index, txHash) + return args.Error(0) +} + +func (m *claimerRepositoryMock) UpdateEpochWithForeclosedClaim( + ctx context.Context, + appid int64, + index uint64, +) error { + args := m.Called(ctx, appid, index) + return args.Error(0) +} + +func (m *claimerRepositoryMock) SelectClaimsToAcceptPerApp(ctx context.Context) ( + map[int64]*model.Epoch, + map[int64]*model.Application, + error, +) { + args := m.Called(ctx) + return args.Get(0).(map[int64]*model.Epoch), + args.Get(1).(map[int64]*model.Application), + args.Error(2) +} + +func (m *claimerRepositoryMock) UpdateEpochToStaged( + ctx context.Context, + appid int64, + index uint64, + stagedAtBlock uint64, +) error { + args := m.Called(ctx, appid, index, stagedAtBlock) + return args.Error(0) +} + +func (m *claimerRepositoryMock) UpdateEpochReconciledStaged( + ctx context.Context, + appid int64, + index uint64, + stagedAtBlock uint64, +) error { + args := m.Called(ctx, appid, index, stagedAtBlock) + return args.Error(0) +} + +func (m *claimerRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *claimerRepositoryMock) SaveNodeConfigRaw( + ctx context.Context, + key string, + rawJSON []byte, +) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *claimerRepositoryMock) LoadNodeConfigRaw(ctx context.Context, key string) ( + rawJSON []byte, + createdAt, updatedAt time.Time, + err error, +) { + args := m.Called(ctx, key) + return args.Get(0).([]byte), args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) +} + +type claimerBlockchainMock struct { + mock.Mock + submitterAddress common.Address + hasSubmitter bool +} + +func (m *claimerBlockchainMock) claimSubmitterAddress() (common.Address, bool) { + return m.submitterAddress, m.hasSubmitter +} + +func (m *claimerBlockchainMock) findClaimSubmittedEventAndSucc( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + []*iconsensus.IConsensusClaimSubmitted, + error, +) { + args := m.Called(ctx, app, epoch, fromBlock, toBlock) + if len(args) == 4 { + return args.Get(0).(*iconsensus.IConsensus), + compactSubmittedEvents(args.Get(1), args.Get(2)), + args.Error(3) + } + return args.Get(0).(*iconsensus.IConsensus), + submittedEventSliceArg(args.Get(1)), + args.Error(2) +} + +func compactSubmittedEvents(values ...any) []*iconsensus.IConsensusClaimSubmitted { + events := []*iconsensus.IConsensusClaimSubmitted{} + for _, value := range values { + event, ok := value.(*iconsensus.IConsensusClaimSubmitted) + if ok && event != nil { + events = append(events, event) + } + } + return events +} + +func submittedEventSliceArg(value any) []*iconsensus.IConsensusClaimSubmitted { + if value == nil { + return nil + } + events, ok := value.([]*iconsensus.IConsensusClaimSubmitted) + if ok { + return events + } + return compactSubmittedEvents(value) +} + +func (m *claimerBlockchainMock) findClaimAcceptedEventAndSucc( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimAccepted, + *iconsensus.IConsensusClaimAccepted, + error, +) { + args := m.Called(ctx, app, epoch, fromBlock, toBlock) + return args.Get(0).(*iconsensus.IConsensus), + args.Get(1).(*iconsensus.IConsensusClaimAccepted), + args.Get(2).(*iconsensus.IConsensusClaimAccepted), + args.Error(3) +} + +func (m *claimerBlockchainMock) findClaimStagedEventAndSucc( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + fromBlock uint64, + toBlock uint64, +) ( + *iconsensus.IConsensus, + *iconsensus.IConsensusClaimStaged, + *iconsensus.IConsensusClaimStaged, + error, +) { + args := m.Called(ctx, app, epoch, fromBlock, toBlock) + return args.Get(0).(*iconsensus.IConsensus), + args.Get(1).(*iconsensus.IConsensusClaimStaged), + args.Get(2).(*iconsensus.IConsensusClaimStaged), + args.Error(3) +} + +func (m *claimerBlockchainMock) getClaimStatus( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + blockNumber *big.Int, +) (iconsensus.IConsensusClaim, error) { + args := m.Called(ctx, app, epoch, blockNumber) + return args.Get(0).(iconsensus.IConsensusClaim), args.Error(1) +} +func (m *claimerBlockchainMock) getDefaultBlockNumber(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + return args.Get(0).(*big.Int), + args.Error(1) +} + +func (m *claimerBlockchainMock) getConsensusAddress( + ctx context.Context, + app *model.Application, + blockNumber *big.Int, +) (common.Address, error) { + args := m.Called(ctx, app, blockNumber) + return args.Get(0).(common.Address), + args.Error(1) +} + +// expectNoForeignClaimAccepted registers the ClaimAccepted scan expectation +// for a CLAIM_COMPUTED epoch where no foreign claim has been accepted. +// fromBlock matches prevEpoch.LastBlock+1 (if a prev exists) or +// epoch.LastBlock+1 (otherwise) — same logic as submitClaimsAndUpdateDatabase. +func expectNoForeignClaimAccepted(b *claimerBlockchainMock, app *model.Application, epoch *model.Epoch, fromBlock, toBlock uint64) { + b.On("findClaimAcceptedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, toBlock). + Return( + &iconsensus.IConsensus{}, + (*iconsensus.IConsensusClaimAccepted)(nil), + (*iconsensus.IConsensusClaimAccepted)(nil), + nil, + ).Once() +} + +// expectGetClaimStatusUnstaged registers the pre-submit getClaim reconciliation +// expectation for the common case where the chain has not yet seen our claim, +// so the observer can offer a submit candidate. +func expectGetClaimStatusUnstaged(b *claimerBlockchainMock, app *model.Application, epoch *model.Epoch, endBlock *big.Int) { + b.On("getClaimStatus", mock.Anything, app, epoch, endBlock). + Return(iconsensus.IConsensusClaim{Status: 0}, nil).Once() +} + +// expectPreSubmitPath registers the pre-submit pipeline expectations shared by +// computed-claim tests for an epoch with no previous epoch: consensus +// address lookup, no foreign ClaimAccepted, no prior ClaimSubmitted events, +// and an UNSTAGED getClaim read before planning a submit candidate. +func expectPreSubmitPath(b *claimerBlockchainMock, app *model.Application, epoch *model.Epoch, endBlock *big.Int) { + var prevEvent, currEvent *iconsensus.IConsensusClaimSubmitted + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, epoch, epoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, prevEvent, currEvent, nil).Once() + expectGetClaimStatusUnstaged(b, app, epoch, endBlock) +} + +func makeClaimStatus(status uint8, epoch *model.Epoch, stagedAtBlock uint64) iconsensus.IConsensusClaim { + claim := iconsensus.IConsensusClaim{Status: status} + if epoch.TxBufferDataBlock != nil { + claim.StagedOutputsMerkleRoot = *epoch.TxBufferDataBlock + } + if stagedAtBlock != 0 { + claim.StagingBlockNumber = new(big.Int).SetUint64(stagedAtBlock) + } + return claim +} + +func claimMockPage(epochs map[int64]*model.Epoch, apps map[int64]*model.Application, err error) (repository.ClaimPage, error) { + page := repository.ClaimPage{} + for id, epoch := range epochs { + page.Work = append(page.Work, repository.ClaimWork{Application: apps[id], Epoch: epoch}) + page.ApplicationID = id + } + return page, err +} +func (m *claimerRepositoryMock) SelectClaimsToSubmitPage(ctx context.Context, after repository.ClaimCursor, _ uint64) ( + repository.ClaimPage, error, +) { + m.pageReads = append(m.pageReads, after) + return claimMockPage(m.SelectClaimsToSubmitPerApp(ctx)) +} +func (m *claimerRepositoryMock) SelectClaimsToStagePage(ctx context.Context, after repository.ClaimCursor, _ uint64) ( + repository.ClaimPage, error, +) { + m.pageReads = append(m.pageReads, after) + return claimMockPage(m.SelectClaimsToStagePerApp(ctx)) +} +func (m *claimerRepositoryMock) SelectClaimsToAcceptPage(ctx context.Context, after repository.ClaimCursor, _ uint64) ( + repository.ClaimPage, error, +) { + m.pageReads = append(m.pageReads, after) + return claimMockPage(m.SelectClaimsToAcceptPerApp(ctx)) +} + +func (m *claimerCreateRepositoryMock) RegisterSubmitter( + ctx context.Context, key string, requested config.PersistentSubmitterConfig, +) (config.PersistentSubmitterConfig, error) { + args := m.Called(ctx, key, requested) + return args.Get(0).(config.PersistentSubmitterConfig), args.Error(1) +} + +func (m *claimerRepositoryMock) ListApplicationsByID( + ctx context.Context, f repository.ApplicationFilter, _ int64, _ int64, _ uint64, +) ([]*model.Application, error) { + apps, _, err := m.ListApplications(ctx, f, repository.Pagination{}, false) + return apps, err +} diff --git a/internal/claimer/observation.go b/internal/claimer/observation.go new file mode 100644 index 000000000..f9edf9f8f --- /dev/null +++ b/internal/claimer/observation.go @@ -0,0 +1,27 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" +) + +// A canonical observation can predate facts saved by another service or an +// earlier process. Absence in that view cannot contradict the newer facts. +func requireObservedBlock(observed, required uint64) error { + if observed < required { + return fmt.Errorf("%w: observation block %d is below required block %d", txsender.ErrInconclusive, observed, required) + } + return nil +} + +func requireObservedStaging(epoch *model.Epoch, observed uint64) error { + if epoch != nil && epoch.StagedAtBlock != nil { + return requireObservedBlock(observed, *epoch.StagedAtBlock) + } + return nil +} diff --git a/internal/claimer/observation_rpc_test.go b/internal/claimer/observation_rpc_test.go new file mode 100644 index 000000000..6efd08ce3 --- /dev/null +++ b/internal/claimer/observation_rpc_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type observationCallRPC struct { + requests chan rpc.BlockNumberOrHash + consensus common.Address +} + +func (*observationCallRPC) GetBlockByNumber(_ context.Context, number rpc.BlockNumber, _ bool) *types.Header { + block := big.NewInt(int64(number)) + if !block.IsUint64() { + return nil + } + return deliveryHeader(block.Uint64()) +} + +func (s *observationCallRPC) Call(_ context.Context, call struct { + Input hexutil.Bytes + Gas *hexutil.Uint64 +}, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + s.requests <- block + if call.Gas != nil { + return nil, errors.New("a binding view must leave simulation gas unspecified") + } + for _, meta := range []*bind.MetaData{iapplication.IApplicationMetaData, iconsensus.IConsensusMetaData} { + contractABI, err := meta.GetAbi() + if err != nil { + return nil, err + } + method, err := contractABI.MethodById(call.Input[:4]) + if err != nil { + continue + } + switch method.Name { + case "getOutputsMerkleRootValidator": + return method.Outputs.Pack(s.consensus) + case "getClaim": + return method.Outputs.Pack(iconsensus.IConsensusClaim{Status: claimStatusUnstaged, StagingBlockNumber: new(big.Int)}) + case "getNumberOfSubmittedClaims", "getNumberOfStagedClaims", "getNumberOfAcceptedClaims": + return method.Outputs.Pack(big.NewInt(2)) + } + } + return nil, errors.New("unexpected observation binding call") +} + +func TestFinalizedObservationBindingsRequireCanonicalHashesOnTheWire(t *testing.T) { + app, epoch := deliveryApplication(1, "Authority") + api := &observationCallRPC{requests: make(chan rpc.BlockNumberOrHash, 6), consensus: app.IConsensusAddress} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", api)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + facts := txsender.BlockRef{Number: 45, Hash: deliveryHeader(45).Hash()} + cb := &claimerBlockchain{client: client, observed: facts} + address, err := cb.getConsensusAddress(t.Context(), app, big.NewInt(45)) + require.NoError(t, err) + require.Equal(t, app.IConsensusAddress, address) + _, err = cb.getClaimStatus(t.Context(), app, epoch, big.NewInt(45)) + require.NoError(t, err) + caller, err := iconsensus.NewIConsensusCaller(app.IConsensusAddress, txsender.ProtectedClient{Client: client}) + require.NoError(t, err) + for _, counter := range []func(*bind.CallOpts, common.Address) (*big.Int, error){ + caller.GetNumberOfSubmittedClaims, caller.GetNumberOfStagedClaims, caller.GetNumberOfAcceptedClaims, + } { + _, err = cb.newOracle(app.IApplicationAddress, counter)(t.Context(), 45) + require.NoError(t, err) + } + _, err = cb.newOracle(app.IApplicationAddress, caller.GetNumberOfSubmittedClaims)(t.Context(), 44) + require.NoError(t, err) + for index := range 6 { + request := <-api.requests + expected := facts.Hash + if index == 5 { + expected = deliveryHeader(44).Hash() + } + require.NotNil(t, request.BlockHash) + require.Equal(t, expected, *request.BlockHash) + require.True(t, request.RequireCanonical, "the actual RPC selector must reject orphaned branches") + require.Nil(t, request.BlockNumber, "binding views must not fall back to numeric/latest reads") + } +} diff --git a/internal/claimer/observation_scheduling_test.go b/internal/claimer/observation_scheduling_test.go new file mode 100644 index 000000000..c3419998a --- /dev/null +++ b/internal/claimer/observation_scheduling_test.go @@ -0,0 +1,40 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/require" + "testing" +) + +func TestStagedAcceptanceDoesNotDependOnComputedObservationCompletion(t *testing.T) { + s, b, r := newDeliveryFixture(t) + app, computed := queueDelivery(t, b, r, 1) + staged := makeStagedEpoch(app, 1, 20) + r.add(app, staged) + b.claims[app.ID] = makeClaimStatus(claimStatusStaged, staged, 20) + // The failed new submission cannot complete at live; an independently ready + // acceptance remains selectable from committed rows. + computed.Status = model.EpochStatus_ClaimSubmitted + failClaimObservation(t, s) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, b.published, 1) + require.Equal(t, "acceptClaim", publishedClaimMethod(t, b.published[0].Data())) +} + +func TestUnsupportedProtectedReadPausesOnlyTheCurrentPublicationIteration(t *testing.T) { + s, b, r := newDeliveryFixture(t) + app, _ := queueDelivery(t, b, r, 1) + b.ownerErr = txsender.ErrUnsupported + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrUnsupported) + require.Empty(t, b.published) + require.Nil(t, s.failure.Load()) + b.ownerErr = nil + clear(s.publisher.cooldowns) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, b.published, 1) + require.Equal(t, model.ApplicationStatus_OK, app.Status) +} diff --git a/internal/claimer/observation_test.go b/internal/claimer/observation_test.go new file mode 100644 index 000000000..20ba664ce --- /dev/null +++ b/internal/claimer/observation_test.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +type observationIterator struct { + remaining int + err error + closeErr error + closed bool +} + +func TestLatchedClaimFinalityFailureBlocksLaterHealthyPublication(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + cause := errors.Join(txsender.ErrFinality, errors.New("canonical finalized hash changed")) + s.failure.Store(&cause) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrFinality) + require.Empty(t, backend.published) + require.Empty(t, backend.callRefs) + require.Empty(t, s.publisher.cooldowns) +} + +func TestProtectedRPCRecoveryNeedsNoCapabilityStateMachine(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + refusal := errors.New("application RPC temporarily unavailable") + backend.fail[app.IApplicationAddress] = refusal + require.ErrorIs(t, s.publisher.tick(t.Context()), refusal) + require.Nil(t, s.failure.Load()) + delete(backend.fail, app.IApplicationAddress) + clear(s.publisher.cooldowns) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) +} + +func TestHardObservationFailureBlocksTheIndependentPublisher(t *testing.T) { + for _, cause := range []error{txsender.ErrFinality, txsender.ErrContent} { + t.Run(cause.Error(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + chain := s.blockchain.(*claimerBlockchainMock) + chain.On("getDefaultBlockNumber", mock.Anything).Return((*big.Int)(nil), cause).Once() + _, err := s.Tick(t.Context()) + require.ErrorIs(t, err, cause) + require.NotNil(t, s.failure.Load()) + require.ErrorIs(t, s.publisher.tick(t.Context()), cause) + require.Empty(t, backend.published) + chain.AssertExpectations(t) + }) + } +} + +func (it *observationIterator) Next() bool { + if it.remaining == 0 { + return false + } + it.remaining-- + return true +} +func (it *observationIterator) Close() error { + it.closed = true + return it.closeErr +} +func (it *observationIterator) Error() error { return it.err } + +func TestObservedEventWindowRetainsIteratorCloseFailures(t *testing.T) { + closeErr := errors.New("event iterator close failed") + callbackErr := errors.New("observed log is invalid") + iteratorErr := errors.New("event subscription failed") + for _, failure := range []struct { + name string + callback error + iterator error + }{ + {"complete window", nil, nil}, + {"callback failure", callbackErr, nil}, + {"iterator failure", nil, iteratorErr}, + } { + t.Run(failure.name, func(t *testing.T) { + it := &observationIterator{remaining: 1, err: failure.iterator, closeErr: closeErr} + onHit := newOnHit(t.Context(), common.HexToAddress("0x1"), + func(*bind.FilterOpts, []common.Address, []common.Address) (*observationIterator, error) { + return it, nil + }, + func(_ context.Context, block uint64) (*big.Int, error) { return new(big.Int).SetUint64(block), nil }, + func(*observationIterator) error { return failure.callback }) + err := onHit(50) + require.ErrorIs(t, err, closeErr) + if failure.callback != nil { + require.ErrorIs(t, err, failure.callback) + } + if failure.iterator != nil { + require.ErrorIs(t, err, failure.iterator) + } + require.True(t, it.closed) + }) + } +} + +func TestCounterWindowRejectsIncompleteLogs(t *testing.T) { + for _, count := range []int{0, 1, 2, 3} { + t.Run(big.NewInt(int64(count)).String(), func(t *testing.T) { + onHit := newOnHit(t.Context(), common.HexToAddress("0x1"), + func(opts *bind.FilterOpts, _, addresses []common.Address) (*observationIterator, error) { + require.Equal(t, uint64(50), opts.Start) + require.Equal(t, uint64(50), *opts.End) + require.Equal(t, []common.Address{common.HexToAddress("0x1")}, addresses) + return &observationIterator{remaining: count}, nil + }, func(_ context.Context, block uint64) (*big.Int, error) { + if block == 49 { + return big.NewInt(7), nil + } + require.Equal(t, uint64(50), block) + return big.NewInt(9), nil + }, func(*observationIterator) error { return nil }) + if count == 2 { + require.NoError(t, onHit(50)) + } else { + require.ErrorIs(t, onHit(50), txsender.ErrInconclusive) + } + }) + } +} diff --git a/internal/claimer/observation_visibility_test.go b/internal/claimer/observation_visibility_test.go new file mode 100644 index 000000000..f48aff0dc --- /dev/null +++ b/internal/claimer/observation_visibility_test.go @@ -0,0 +1,217 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + computedVisibilityPath = "computed" + submittedVisibilityPath = "submitted" + stagedVisibilityPath = "staged" +) + +func TestForeclosureWaitsForObservationAcrossClaimPaths(t *testing.T) { + for _, path := range []string{computedVisibilityPath, submittedVisibilityPath, stagedVisibilityPath} { + t.Run(path, func(t *testing.T) { + s, r, b := newServiceMock(t) + app := withForeclosed(makeApplication(), 105) + epoch := makeComputedEpoch(app, 3) + b.On("getConsensusAddress", mock.Anything, app, mock.Anything).Return(app.IConsensusAddress, nil).Maybe() + var run func(uint64) claimStepResult + switch path { + case computedVisibilityPath: + run = func(head uint64) claimStepResult { + expectNoForeignClaimAccepted(b, app, epoch, epoch.LastBlock+1, head) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, head). + Return(&iconsensus.IConsensus{}, nil, nil, nil).Once() + expectVisibilityClaimStatus(b, app, epoch, head) + return s.processComputedClaim(t.Context(), claimWork{app: app, epoch: epoch}, new(big.Int).SetUint64(head)) + } + case submittedVisibilityPath: + epoch.Status = model.EpochStatus_ClaimSubmitted + run = func(head uint64) claimStepResult { + var staged *iconsensus.IConsensusClaimStaged + if head >= 103 { + staged = makeStagedEvent(app, epoch) + staged.Raw.BlockNumber = 101 + } + b.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, head). + Return(&iconsensus.IConsensus{}, staged, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + return s.processSubmittedClaim(t.Context(), claimWork{app: app, epoch: epoch}, new(big.Int).SetUint64(head)) + } + default: + epoch = makeStagedEpoch(app, 3, 50) + run = func(head uint64) claimStepResult { + expectVisibilityClaimStatus(b, app, epoch, head) + return s.processStagedClaim(t.Context(), claimWork{app: app, epoch: epoch}, new(big.Int).SetUint64(head)) + } + } + + lagged := run(100) + require.ErrorIs(t, lagged.err, txsender.ErrInconclusive) + require.Zero(t, lagged.progress) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + + if path == submittedVisibilityPath { + r.On("UpdateEpochToStaged", mock.Anything, app.ID, epoch.Index, uint64(101)).Return(nil).Once() + } else { + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, mock.Anything).Return(nil).Once() + } + caughtUp := run(106) + require.NoError(t, caughtUp.err) + require.Equal(t, 1, caughtUp.progress) + if path == submittedVisibilityPath { + // The next phase reloads the staged row and observes acceptance. + epoch = makeStagedEpoch(app, epoch.Index, 101) + expectVisibilityClaimStatus(b, app, epoch, 106) + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, mock.Anything).Return(nil).Once() + result := s.processStagedClaim(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(106)) + require.NoError(t, result.err) + require.Equal(t, 1, result.progress) + } + r.AssertExpectations(t) + b.AssertExpectations(t) + }) + } +} + +func expectVisibilityClaimStatus(b *claimerBlockchainMock, app *model.Application, epoch *model.Epoch, head uint64) { + status := claimStatusUnstaged + stagedAt := uint64(0) + if epoch.StagedAtBlock != nil && head >= *epoch.StagedAtBlock { + status, stagedAt = claimStatusStaged, *epoch.StagedAtBlock + } + if head >= 103 { + status = claimStatusAccepted + } + b.On("getClaimStatus", mock.Anything, app, epoch, new(big.Int).SetUint64(head)). + Return(makeClaimStatus(status, epoch, stagedAt), nil).Once() +} + +func TestFutureForeclosureDoesNotBypassStagingVisibility(t *testing.T) { + s, r, b := newServiceMock(t) + app := withForeclosed(makeApplication(), 107) + epoch := makeStagedEpoch(app, 3, 105) + b.On("getConsensusAddress", mock.Anything, app, mock.Anything).Return(app.IConsensusAddress, nil).Once() + result := s.processStagedClaim(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(100)) + require.ErrorIs(t, result.err, txsender.ErrInconclusive) + require.Empty(t, r.Calls) + b.AssertNotCalled(t, "getClaimStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestBulkForeclosureWaitsForClaimerView(t *testing.T) { + s, r, _ := newServiceMock(t) + app := foreclosedAppHelper(1, 105, model.Consensus_Authority) + require.True(t, app.ForeclosureScanCaughtUp(), "reader catch-up is independent of Claimer view") + require.NoError(t, s.processForeclosedApps(t.Context(), map[int64]*model.Application{app.ID: app}, 100)) + require.Empty(t, r.Calls) +} + +func TestForeclosureAtObservationBoundaryStillTerminalizes(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_OK, model.ApplicationStatus_Failed, + model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted} { + for _, path := range []string{computedVisibilityPath, submittedVisibilityPath, stagedVisibilityPath} { + t.Run(status.String()+"/"+path, func(t *testing.T) { + s, r, b := newServiceMock(t) + app := withForeclosed(makeApplication(), 105) + app.Status = status + epoch := makeComputedEpoch(app, 3) + head := new(big.Int).SetUint64(app.ForecloseBlock) + b.On("getConsensusAddress", mock.Anything, app, mock.Anything).Return(app.IConsensusAddress, nil).Maybe() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, epoch.Index).Return(nil).Once() + var result claimStepResult + switch path { + case computedVisibilityPath: + expectNoForeignClaimAccepted(b, app, epoch, epoch.LastBlock+1, head.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, head.Uint64()). + Return(&iconsensus.IConsensus{}, nil, nil, nil).Once() + expectGetClaimStatusUnstaged(b, app, epoch, head) + result = s.processComputedClaim(t.Context(), claimWork{app: app, epoch: epoch}, head) + case submittedVisibilityPath: + epoch.Status = model.EpochStatus_ClaimSubmitted + b.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, head.Uint64()). + Return(&iconsensus.IConsensus{}, (*iconsensus.IConsensusClaimStaged)(nil), + (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + result = s.processSubmittedClaim(t.Context(), claimWork{app: app, epoch: epoch}, head) + default: + epoch = makeStagedEpoch(app, 3, 50) + b.On("getClaimStatus", mock.Anything, app, epoch, head). + Return(makeClaimStatus(claimStatusStaged, epoch, 50), nil).Once() + result = s.processStagedClaim(t.Context(), claimWork{app: app, epoch: epoch}, head) + } + require.NoError(t, result.err) + require.Equal(t, 1, result.progress) + require.Equal(t, model.EpochStatus_ClaimForeclosed, epoch.Status) + require.Equal(t, status, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) + b.AssertExpectations(t) + }) + } + } +} + +func TestPreAcceptUnstagedContradictionRequiresVisibleStagingBlock(t *testing.T) { + for _, head := range []uint64{49, 50} { + t.Run(new(big.Int).SetUint64(head).String(), func(t *testing.T) { + s, r, b := newServiceMock(t) + app := makeApplication() + app.ClaimStagingPeriod = 0 + epoch := makeStagedEpoch(app, 3, 50) + b.On("getConsensusAddress", mock.Anything, app, mock.Anything).Return(app.IConsensusAddress, nil).Once() + if head == 50 { + expectGetClaimStatusUnstaged(b, app, epoch, new(big.Int).SetUint64(head)) + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(nil).Once() + } + result := s.processStagedClaim(t.Context(), claimWork{app: app, epoch: epoch}, new(big.Int).SetUint64(head)) + require.Zero(t, result.progress) + require.Nil(t, s.publisher, "observation owns no publication queue") + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + if head < 50 { + require.ErrorIs(t, result.err, txsender.ErrInconclusive) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, r.Calls) + b.AssertNotCalled(t, "getClaimStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + require.NoError(t, result.err) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + } + r.AssertExpectations(t) + b.AssertExpectations(t) + }) + } +} + +func TestForeclosureObservationWaitRetainsHeldTransaction(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + // A lost volatile receipt hint must not release the sender's signed bytes. + app.ForecloseBlock = 105 // Another service advances the saved application. + chain := s.blockchain.(*claimerBlockchainMock) + chain.On("getConsensusAddress", mock.Anything, app, mock.Anything).Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(chain, app, epoch, epoch.LastBlock+1, 100) + chain.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(100)). + Return(&iconsensus.IConsensus{}, nil, nil, nil).Once() + expectVisibilityClaimStatus(chain, app, epoch, 100) + _, err := s.submitClaimsAndUpdateDatabase(t.Context(), makeEpochMap(epoch), + makeApplicationMap(app), big.NewInt(100)) + + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Len(t, backend.published, 1) + chain.AssertExpectations(t) +} diff --git a/internal/claimer/pages.go b/internal/claimer/pages.go new file mode 100644 index 000000000..bcf715d6d --- /dev/null +++ b/internal/claimer/pages.go @@ -0,0 +1,34 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "github.com/cartesi/rollups-node/internal/repository" +) + +const claimPageSize uint64 = 16 + +const ( + claimSubmitPage = iota + claimStagePage + claimAcceptPage +) + +// Observation has its own application rotation and per-app epoch positions. +func (s *Service) selectClaimPage(ctx context.Context, kind int) (repository.ClaimPage, error) { + selectPage := s.repository.SelectClaimsToSubmitPage + if kind == claimStagePage { + selectPage = s.repository.SelectClaimsToStagePage + } + if kind == claimAcceptPage { + selectPage = s.repository.SelectClaimsToAcceptPage + } + page, err := selectPage(ctx, s.pageCursors[kind], claimPageSize) + if err != nil { + return page, err + } + s.pageCursors[kind].AfterApplicationID = page.ApplicationID + return page, nil +} diff --git a/internal/claimer/paging_recovery_test.go b/internal/claimer/paging_recovery_test.go new file mode 100644 index 000000000..00689b97c --- /dev/null +++ b/internal/claimer/paging_recovery_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestOlderUnreadyClaimCannotHideLaterAcceptanceOrVoteRepair(t *testing.T) { + for _, status := range []model.EpochStatus{model.EpochStatus_ClaimStaged, model.EpochStatus_ClaimSubmitted} { + t.Run(status.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + app, older := deliveryApplication(1, model.Consensus_Quorum) + app.ClaimStagingPeriod = 10 + older.Status, older.StagedAtBlock = status, new(uint64(49)) + later := makeComputedEpoch(app, older.Index+1) + later.Status, later.StagedAtBlock = status, new(uint64(30)) + lookup.add(app, older) + lookup.add(app, later) + backend.apps[app.IApplicationAddress] = app + backend.claimsAt = map[uint64]iconsensus.IConsensusClaim{} + if status == model.EpochStatus_ClaimStaged { + backend.claimsAt[older.LastBlock] = makeClaimStatus(claimStatusStaged, older, 49) + backend.claimsAt[later.LastBlock] = makeClaimStatus(claimStatusStaged, later, 30) + } else { + older.StagedAtBlock, later.StagedAtBlock = nil, nil + backend.votesAt = map[uint64]bool{older.LastBlock: true, later.LastBlock: false} + backend.matchingVotesAt = map[uint64]bool{older.LastBlock: true} + } + for range 3 { + _ = s.publisher.tick(t.Context()) + if len(backend.published) != 0 { + break + } + } + require.Len(t, backend.published, 1) + contractABI, err := iconsensus.IConsensusMetaData.GetAbi() + require.NoError(t, err) + method, err := contractABI.MethodById(backend.published[0].Data()[:4]) + require.NoError(t, err) + args, err := method.Inputs.Unpack(backend.published[0].Data()[4:]) + require.NoError(t, err) + require.Equal(t, later.LastBlock, args[1].(*big.Int).Uint64()) + if status == model.EpochStatus_ClaimStaged { + require.Equal(t, "acceptClaim", method.Name) + } else { + require.Equal(t, "submitClaim", method.Name) + } + }) + } +} diff --git a/internal/claimer/planner_test.go b/internal/claimer/planner_test.go new file mode 100644 index 000000000..7d6901591 --- /dev/null +++ b/internal/claimer/planner_test.go @@ -0,0 +1,431 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestClaimPlannerPublishesThroughRealSender(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + t.Run(consensus.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := deliveryApplication(1, consensus) + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + candidate, state, err := s.publisher.decide(t.Context(), txsender.BlockRef{Number: backend.last}, + repository.ClaimWork{Application: app, Epoch: epoch}, false) + require.NoError(t, err) + require.Equal(t, txsender.Useful, state) + require.Equal(t, app.IApplicationAddress, candidate.action.Address) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + require.Equal(t, candidate.request.Data, backend.published[0].Data()) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status, "publication does not write observed status") + require.Equal(t, model.ApplicationStatus_OK, app.Status) + if consensus == model.Consensus_Quorum { + require.Equal(t, quorumSubmitGasLimit, backend.published[0].Gas()) + } + }) + } +} + +func TestClaimPlannerUnknownReadAloneDoesNotJustifyCancellationOrReplacement(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + queueDelivery(t, backend, lookup, 2) + failure := errors.New("owner RPC unavailable") + backend.ownerErr = failure + require.ErrorIs(t, s.publisher.tick(t.Context()), failure) + require.Len(t, backend.published, 1) +} + +func TestClaimPlannerRetainsBytesAcrossLostReply(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + backend.publishErr = errors.New("transport reply lost") + require.Error(t, s.publisher.tick(t.Context())) + backend.publishErr = nil + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.raw, 2) + require.Equal(t, backend.raw[0], backend.raw[1]) +} + +func TestClaimPlannerAuthorizationAndPermissionlessAcceptance(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + t.Run(consensus.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := deliveryApplication(1, consensus) + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + backend.owner = common.HexToAddress("0xdead") + backend.validatorID = new(big.Int) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrIneligible) + require.Empty(t, backend.published) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 30) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1, "acceptance does not require ownership or validator membership") + }) + } +} + +func TestClaimPlannerFreshAcceptedEpochIsExcluded(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + _, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status = model.EpochStatus_ClaimAccepted + require.NoError(t, s.publisher.tick(t.Context())) + require.Empty(t, backend.published) +} + +func TestClaimPlannerReadFailureHasNoHealthWrite(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + backend.claimReadErr = context.DeadlineExceeded + require.ErrorIs(t, s.publisher.tick(t.Context()), context.DeadlineExceeded) + require.Empty(t, backend.published) + require.Equal(t, model.ApplicationStatus_OK, app.Status) +} + +func publishedClaimMethod(t *testing.T, data []byte) string { + t.Helper() + contractABI, err := iconsensus.IConsensusMetaData.GetAbi() + require.NoError(t, err) + require.GreaterOrEqual(t, len(data), 4) + method, err := contractABI.MethodById(data[:4]) + require.NoError(t, err) + return method.Name +} + +func TestClaimPlannerPositiveObsolescenceReleasesCurrentNonce(t *testing.T) { + for _, reason := range []string{"disabled", "deleted", "foreclosed", "health", "accepted", "owner-changed"} { + t.Run(reason, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + switch reason { + case "disabled": + app.Enabled = false + case "deleted": + delete(lookup.apps, app.IApplicationAddress.Hex()) + case "foreclosed": + app.ForecloseBlock = backend.last + case "health": + app.Status = model.ApplicationStatus_Failed + case "accepted": + backend.claims[app.ID] = makeClaimStatus(claimStatusAccepted, epoch, 30) + case "owner-changed": + backend.owner = common.HexToAddress("0xdead") + } + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + cancel := backend.published[1] + require.Empty(t, cancel.Data()) + require.Equal(t, s.signer, *cancel.To()) + require.Zero(t, cancel.Value().Sign()) + require.Equal(t, backend.published[0].Nonce(), cancel.Nonce()) + }) + } +} + +func TestClaimPlannerImmaturePeerStageCancelsCompletedSubmit(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + t.Run(consensus.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := deliveryApplication(1, consensus) + app.ClaimStagingPeriod = 100 + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + require.NoError(t, s.publisher.tick(t.Context())) + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 49) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Empty(t, backend.published[1].Data(), "matching staging has completed the held submit action") + }) + } +} + +func TestClaimPlannerFutureObservationCannotAuthorizeCancellation(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + app.ForecloseBlock = backend.last + 1 + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Len(t, backend.published, 1) +} + +func TestClaimPlannerDoesNotHoldAlternativeBehindObsoleteCache(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + old, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + old.Enabled = false + next, bad := queueDelivery(t, backend, lookup, 2) + good := makeComputedEpoch(next, 4) + lookup.add(next, good) + failure := errors.New("bad candidate simulation reverted") + backend.failEpochs = map[uint64]error{bad.LastBlock: failure} + require.ErrorIs(t, s.publisher.tick(t.Context()), failure) + require.Len(t, backend.published, 2, "failed candidate falls through while obsolete cached bytes still exist") + require.Equal(t, claimSubmitMethod, publishedClaimMethod(t, backend.published[1].Data())) + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + require.NoError(t, err) + values, err := parsed.Methods[claimSubmitMethod].Inputs.Unpack(backend.published[1].Data()[4:]) + require.NoError(t, err) + require.Equal(t, new(big.Int).SetUint64(good.LastBlock), values[1]) + require.Equal(t, backend.published[0].Nonce(), backend.published[1].Nonce()) +} + +func TestClaimPlannerInspectsLaterWindowBeforeReadyOldestSend(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, oldest := queueDelivery(t, backend, lookup, 1) + var tail []*model.Epoch + for index := uint64(4); index < 44; index++ { + epoch := makeComputedEpoch(app, index) + lookup.add(app, epoch) + tail = append(tail, epoch) + } + backend.last = 1000 + // Every pass deliberately leaves the oldest computed row eligible. Cache + // retirement is modeled by latest mined nonce advancement, not receipts. + for range 12 { + require.NoError(t, s.publisher.tick(t.Context())) + require.NotEmpty(t, backend.published) + backend.finalize(t) + } + require.Contains(t, backend.claimReads, oldest.LastBlock) + for _, epoch := range tail { + require.Contains(t, backend.claimReads, epoch.LastBlock, "ready oldest rows cannot hide later read attempts") + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + } + require.Len(t, backend.published, 12, "one publication ends each pass after every window row was considered") +} + +func TestClaimPlannerRepairsVanishedStageOnlyUnderProvisionalPolicy(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe, model.DefaultBlock_Finalized} { + t.Run(policy.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, policy) + app, epoch := queueDelivery(t, backend, lookup, 1) + var tags []string + backend.onHeader = func(tag string) { tags = append(tags, tag) } + require.NoError(t, s.publisher.tick(t.Context())) + original := backend.published[0] + backend.finalize(t) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(45)) + backend.claims[app.ID] = makeClaimStatus(claimStatusAccepted, epoch, 45) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + // A provisional reorg can make the old nonce available again. + // Under finalized policy this instead contradicts stored history; + // leave that diagnosis to the independent observer. + backend.nonce, backend.consumedAt = 0, 0 + delete(backend.claims, app.ID) + err := s.publisher.tick(t.Context()) + if policy.Provisional() { + require.NoError(t, err) + require.Len(t, backend.published, 2) + require.Equal(t, original.Nonce(), backend.published[1].Nonce()) + require.Equal(t, original.Data(), backend.published[1].Data()) + } else { + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Len(t, backend.published, 1) + } + for _, tag := range tags { + require.Equal(t, "latest", tag, "sender policy has no hidden finality dependency") + } + }) + } +} + +func TestClaimPlannerIncompleteSavedContentIsAnActionableError(t *testing.T) { + for _, missing := range []string{"machine-root", "outputs-root", "proof"} { + t.Run(missing, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + switch missing { + case "machine-root": + epoch.MachineHash = nil + case "outputs-root": + epoch.TxBufferDataBlock = nil + case "proof": + epoch.TxBufferProof = nil + } + err := s.publisher.tick(t.Context()) + require.Error(t, err) + require.False(t, txsender.WaitOnly(err)) + require.Contains(t, err.Error(), app.IApplicationAddress.Hex()) + require.Contains(t, err.Error(), "epoch 3") + require.Empty(t, backend.published) + require.Equal(t, model.ApplicationStatus_OK, app.Status, "only Observe persists health") + }) + } +} + +func TestClaimPlannerRotatesApplicationsAfterEachPublication(t *testing.T) { + for _, count := range []int{0, 1, 16, 17, 40} { + t.Run(fmt.Sprintf("apps-%d", count), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + for index := range count { + queueDelivery(t, backend, lookup, int64(index+1)) + } + seen := make(map[common.Address]bool) + for range max(count, 1) { + before := len(backend.published) + require.NoError(t, s.publisher.tick(t.Context())) + if count == 0 { + require.Empty(t, backend.published) + continue + } + require.Len(t, backend.published, before+1) + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + require.NoError(t, err) + values, err := parsed.Methods[claimSubmitMethod].Inputs.Unpack(backend.published[before].Data()[4:]) + require.NoError(t, err) + app := values[0].(common.Address) + require.False(t, seen[app], "another app must get a turn before this oldest is retried") + seen[app] = true + backend.finalize(t) + } + require.Len(t, seen, count) + }) + } +} + +func TestClaimPlannerOlderQuorumVoteDoesNotHideMatureAcceptance(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, old := deliveryApplication(1, model.Consensus_Quorum) + old.Status = model.EpochStatus_ClaimSubmitted + ready := makeComputedEpoch(app, 4) + ready.Status, ready.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(51)) + backend.last = 60 + require.GreaterOrEqual(t, backend.last, *ready.StagedAtBlock+app.ClaimStagingPeriod) + lookup.add(app, old) + lookup.add(app, ready) + backend.apps[app.IApplicationAddress] = app + backend.claimsAt = map[uint64]iconsensus.IConsensusClaim{ready.LastBlock: makeClaimStatus(claimStatusStaged, ready, 51)} + backend.votesAt = map[uint64]bool{old.LastBlock: true} + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + require.Equal(t, "acceptClaim", publishedClaimMethod(t, backend.published[0].Data())) + require.Equal(t, model.EpochStatus_ClaimSubmitted, old.Status) +} + +func TestClaimPlannerMissingHeldEpochIsUnusableEvidence(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + delete(lookup.epochs[app.IApplicationAddress.Hex()], epoch.Index) + lookup.missingNil = true + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Len(t, backend.published, 1, "missing content does not authorize cancellation") +} + +func TestClaimPlannerNeutralRequestCanReturnToUsefulProtocolWork(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + app.Enabled = false + require.NoError(t, s.publisher.tick(t.Context())) + require.Empty(t, backend.published[1].Data()) + app.Enabled = true + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 3) + require.Equal(t, claimSubmitMethod, publishedClaimMethod(t, backend.published[2].Data())) + require.Equal(t, backend.published[0].Nonce(), backend.published[2].Nonce()) +} + +func TestClaimPlannerSimulationDeadlineFallsThroughWithoutHealthWrite(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, bad := queueDelivery(t, backend, lookup, 1) + good := makeComputedEpoch(app, 4) + lookup.add(app, good) + backend.failEpochs = map[uint64]error{bad.LastBlock: context.DeadlineExceeded} + require.ErrorIs(t, s.publisher.tick(t.Context()), context.DeadlineExceeded) + require.Len(t, backend.published, 1) + require.Equal(t, model.ApplicationStatus_OK, app.Status) +} + +type claimSigningHookFactory struct { + ethutil.TransactOptsFactory + after func() +} + +func (f claimSigningHookFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts, err := f.TransactOptsFactory.NewTransactOpts(ctx) + if err != nil { + return nil, err + } + sign := opts.Signer + opts.Signer = func(address common.Address, tx *types.Transaction) (*types.Transaction, error) { + signed, err := sign(address, tx) + if err == nil { + f.after() + } + return signed, err + } + return opts, nil +} + +const ( + claimPostSignReadFailure = "failed-read" + claimPostSignHeadAdvances = "head-advances" +) + +func TestClaimPlannerPostSignRechecksTheChosenAction(t *testing.T) { + for _, change := range []string{"owner", "foreclosure", "deleted-app", claimPostSignReadFailure, claimPostSignHeadAdvances} { + t.Run(change, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + key, err := crypto.HexToECDSA("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(31337)) + require.NoError(t, err) + readFailure := errors.New("post-sign owner RPC failed") + factory := claimSigningHookFactory{TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(opts), after: func() { + switch change { + case "owner": + backend.owner = common.HexToAddress("0xdead") + case "foreclosure": + app.ForecloseBlock = backend.last + case "deleted-app": + delete(lookup.apps, app.IApplicationAddress.Hex()) + case claimPostSignReadFailure: + backend.ownerErr = readFailure + case claimPostSignHeadAdvances: + backend.last++ + } + }} + s.publisher.sender, err = txsender.New[claimAction](backend, factory, 31337, + txsender.Policy{MaxFee: big.NewInt(1000)}, func(context.Context) error { return nil }, s.Logger) + require.NoError(t, err) + err = s.publisher.tick(t.Context()) + if change == claimPostSignHeadAdvances { + require.NoError(t, err) + require.Len(t, backend.published, 1, "ordinary forward heads do not starve KMS publication") + } else { + require.Error(t, err) + if change == claimPostSignReadFailure { + require.ErrorIs(t, err, readFailure) + } + require.Empty(t, backend.published, "unknown or changed chosen-action eligibility blocks POST") + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + }) + } +} diff --git a/internal/claimer/prefix_regression_test.go b/internal/claimer/prefix_regression_test.go new file mode 100644 index 000000000..b46d55083 --- /dev/null +++ b/internal/claimer/prefix_regression_test.go @@ -0,0 +1,131 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestFinalizedStagingContradictionStopsEarlierClaimWork(t *testing.T) { + for _, stop := range []model.ApplicationStatus{model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(stop.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + setClaimTestStop(lookup, app, epoch.Index+1, stop) + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 30) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + action := s.publisher.lastChoice + + // Publication reads a separate committed object. Mutating only the + // observer's copy would leave the previously authorized work useful. + observed := *app + repo := s.repository.(*claimerRepositoryMock) + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Run(func(args mock.Arguments) { + app.Status = args.Get(2).(model.ApplicationStatus) + reason := *args.Get(3).(*string) + app.Reason = &reason + }).Return(nil).Once() + chain := s.blockchain.(*claimerBlockchainMock) + chain.On("getConsensusAddress", mock.Anything, &observed, big.NewInt(50)). + Return(app.IConsensusAddress, nil).Once() + expectGetClaimStatusUnstaged(chain, &observed, epoch, big.NewInt(50)) + + result := s.processStagedClaim(t.Context(), claimWork{app: &observed, epoch: epoch}, big.NewInt(50)) + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + require.Equal(t, model.ApplicationStatus_Corrupted, observed.Status) + require.ErrorContains(t, result.err, "UNSTAGED") + require.Zero(t, result.progress) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + + block := txsender.BlockRef{Number: backend.last, Hash: deliveryHeader(backend.last).Hash()} + for _, held := range []bool{false, true} { + _, eligibility, err := s.publisher.decide(t.Context(), block, repository.ClaimWork{Application: app, Epoch: epoch}, held) + require.NoError(t, err) + require.Equal(t, txsender.Obsolete, eligibility) + } + eligibility, err := s.publisher.Recheck(t.Context(), block, action) + require.NoError(t, err) + require.Equal(t, txsender.Obsolete, eligibility) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Empty(t, backend.published[1].Data(), "only neutralization is authorized after the persisted finding") + repo.AssertExpectations(t) + chain.AssertExpectations(t) + }) + } +} + +func TestProvisionalMissingStagePreservesExecutionStop(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe} { + for _, stop := range []model.ApplicationStatus{model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(policy.String()+"/"+stop.String(), func(t *testing.T) { + s, repo, chain := newServiceMock(t) + s.defaultBlock = policy + app := makeApplication() + app.Status = stop + epoch := makeStagedEpoch(app, 3, 30) + chain.On("getConsensusAddress", mock.Anything, app, big.NewInt(50)).Return(app.IConsensusAddress, nil).Once() + expectGetClaimStatusUnstaged(chain, app, epoch, big.NewInt(50)) + result := s.processStagedClaim(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(50)) + require.ErrorIs(t, result.err, txsender.ErrInconclusive) + require.True(t, txsender.WaitOnly(result.err)) + require.Equal(t, stop, app.Status) + require.Empty(t, repo.Calls, "a provisional absence is not an integrity finding") + chain.AssertExpectations(t) + }) + } + } +} + +func TestMissingClaimEpochWaitsWithoutRetainedReplacement(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + action := s.publisher.lastChoice + delete(lookup.epochs[app.IApplicationAddress.Hex()], epoch.Index) + queueDelivery(t, backend, lookup, 2) // No independently established priority replacement. + block := txsender.BlockRef{Number: backend.last, Hash: deliveryHeader(backend.last).Hash()} + _, eligibility, err := s.publisher.decide(t.Context(), block, repository.ClaimWork{Application: app, Epoch: epoch}, false) + require.NoError(t, err) + require.Equal(t, txsender.Obsolete, eligibility) + _, err = s.publisher.Recheck(t.Context(), block, action) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.True(t, txsender.WaitOnly(err)) + err = s.publisher.tick(t.Context()) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.True(t, txsender.WaitOnly(err)) + require.Len(t, backend.published, 1, "missing row alone cannot justify cancelling or replacing retained work") + lookup.add(app, epoch) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Equal(t, backend.published[0].Hash(), backend.published[1].Hash()) +} + +// The publication page fixture is intentionally independent of the domain +// predicate. Its contract mirrors the stored status/cut filters, not the +// validation performed by AllowsEpochCompletion after the page is selected. +func fixtureClaimPublishable(app *model.Application, epoch *model.Epoch, problem *model.ApplicationProblem) bool { + if app.ForecloseBlock != 0 { + return false + } + switch app.Status { + case model.ApplicationStatus_OK, model.ApplicationStatus_GuestException, model.ApplicationStatus_MachineHalted, + model.ApplicationStatus_McycleOverflow, model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot: + return problem == nil && app.Status == model.ApplicationStatus_OK || problem != nil && epoch.Index < problem.EpochIndex + case model.ApplicationStatus_Failed, model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted: + return false + default: + return false + } +} diff --git a/internal/claimer/prefix_test.go b/internal/claimer/prefix_test.go new file mode 100644 index 000000000..ce2a41727 --- /dev/null +++ b/internal/claimer/prefix_test.go @@ -0,0 +1,151 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +const ( + missingBoundaryCase = "missing boundary" + snapshotReadFailedCase = "snapshot read failed" + divergedApplicationCase = "diverged" + failedApplicationCase = "failed" + corruptedApplicationCase = "corrupted" +) + +func setClaimTestStop(lookup *deliveryLookup, app *model.Application, cutoff uint64, kind model.ApplicationStatus) { + app.Status = kind + if lookup.problems == nil { + lookup.problems = map[int64]*model.ApplicationProblem{} + } + problem := &model.ApplicationProblem{EpochIndex: cutoff, Kind: kind} + if kind.IsExecutionTerminal() { + problem.InputIndex = new(uint64(99)) + } + lookup.problems[app.ID] = problem +} + +func TestClaimPublicationCompletesTerminalPrefix(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_GuestException, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_McycleOverflow, + model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot} { + for _, accept := range []bool{false, true} { + name := consensus.String() + "/" + status.String() + "/submit" + if accept { + name = consensus.String() + "/" + status.String() + "/accept" + } + t.Run(name, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := deliveryApplication(1, consensus) + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + setClaimTestStop(lookup, app, epoch.Index+1, status) + if accept { + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 30) + } + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + method := "submitClaim" + if accept { + method = "acceptClaim" + } + require.Equal(t, method, publishedClaimMethod(t, backend.published[0].Data())) + // Retained same-nonce work remains useful after the global + // status changes; Next and post-sign Recheck agree. + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Equal(t, backend.published[0].Hash(), backend.published[1].Hash()) + // Defensive case: tightening to an already computed epoch + // is unreachable with the ordered, single validator. If + // that premise is violated, positive cutoff evidence must + // still veto another resend. + lookup.problems[app.ID].EpochIndex = epoch.Index + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 3) + require.Empty(t, backend.published[2].Data()) + require.Equal(t, backend.published[0].Nonce(), backend.published[2].Nonce()) + }) + } + } + } +} + +func TestClaimMissingBoundaryIsInconclusiveForRetainedWork(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + app.Status = model.ApplicationStatus_MachineHalted + queueDelivery(t, backend, lookup, 2) // No independently established priority replacement. + + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Len(t, backend.published, 1, "missing metadata alone cannot justify cancelling or replacing retained work") + setClaimTestStop(lookup, app, epoch.Index+1, app.Status) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Equal(t, backend.published[0].Hash(), backend.published[1].Hash()) +} + +func TestClaimPostSignRechecksTerminalPrefix(t *testing.T) { + for _, change := range []string{"later stop", "cutoff", failedApplicationCase, divergedApplicationCase, + corruptedApplicationCase, missingBoundaryCase, snapshotReadFailedCase} { + t.Run(change, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + key, err := crypto.HexToECDSA("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(31337)) + require.NoError(t, err) + readFailure := errors.New("application epoch snapshot unavailable") + factory := claimSigningHookFactory{TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(opts), after: func() { + setClaimTestStop(lookup, app, epoch.Index+1, model.ApplicationStatus_MachineHalted) + switch change { + case "cutoff": + lookup.problems[app.ID].EpochIndex = epoch.Index + case failedApplicationCase: + app.Status = model.ApplicationStatus_Failed + case divergedApplicationCase: + app.Status = model.ApplicationStatus_Diverged + case corruptedApplicationCase: + app.Status = model.ApplicationStatus_Corrupted + case missingBoundaryCase: + delete(lookup.problems, app.ID) + case snapshotReadFailedCase: + lookup.epochErrors = map[string]error{app.IApplicationAddress.Hex(): readFailure} + } + }} + s.publisher.sender, err = txsender.New[claimAction](backend, factory, 31337, + txsender.Policy{MaxFee: big.NewInt(1000)}, func(context.Context) error { return nil }, s.Logger) + require.NoError(t, err) + + err = s.publisher.tick(t.Context()) + + if change == "later stop" { + require.NoError(t, err) + require.Len(t, backend.published, 1) + } else { + require.Error(t, err) + require.Empty(t, backend.published, "post-sign state is checked before POST") + if change == missingBoundaryCase { + require.ErrorIs(t, err, txsender.ErrInconclusive) + } + if change == snapshotReadFailedCase { + require.ErrorIs(t, err, readFailure) + } + } + }) + } +} diff --git a/internal/claimer/prior_counter_test.go b/internal/claimer/prior_counter_test.go new file mode 100644 index 000000000..b4fc8ccb5 --- /dev/null +++ b/internal/claimer/prior_counter_test.go @@ -0,0 +1,181 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeStepCounter returns an oracle whose value at block b is the count of +// transitions in `transitions` that are <= b. The transition list must be +// sorted ascending. This is a faithful stand-in for the on-chain +// GetNumberOfSubmittedClaims / GetNumberOfAcceptedClaims counters: monotonic, +// integer, increments at the event block. +func makeStepCounter(transitions []uint64, calls *[]uint64) ethutil.TransitionQueryFn { + return func(_ context.Context, block uint64) (*big.Int, error) { + if calls != nil { + *calls = append(*calls, block) + } + var n int64 + for _, t := range transitions { + if t <= block { + n++ + } + } + return big.NewInt(n), nil + } +} + +func TestPriorCounter_QueriesFromBlockMinusOne(t *testing.T) { + // fromBlock = 70 should hit oracle exactly once, at block 69. Counter + // at block 69 is 0 (the only acceptance fires at block 80), so + // priorCounter must return *big.Int(0), not nil and not the value + // at block 70 itself (which is also 0 here, indistinguishable) and + // definitely not the value at epoch.LastBlock=90 (which is 1). + calls := []uint64{} + oracle := makeStepCounter([]uint64{80}, &calls) + + got, err := priorCounter(context.Background(), oracle, 70) + require.NoError(t, err) + require.NotNil(t, got, "priorCounter must return a value (non-nil *big.Int) when fromBlock > 0") + assert.Equal(t, int64(0), got.Int64()) + require.Len(t, calls, 1, "priorCounter must make exactly one oracle call") + assert.Equal(t, uint64(69), calls[0], + "priorCounter must query block fromBlock-1, not fromBlock and not epoch.LastBlock") +} + +func TestPriorCounter_FromBlockOne(t *testing.T) { + // fromBlock = 1 is the smallest non-zero value; oracle must be called + // at block 0 (not block 1, not block -1). + calls := []uint64{} + oracle := makeStepCounter([]uint64{0}, &calls) + + got, err := priorCounter(context.Background(), oracle, 1) + require.NoError(t, err) + require.NotNil(t, got) + // Counter at block 0 with a transition AT block 0 is 1 (the step is + // "count of transitions <= block"). This pins that priorCounter does + // NOT off-by-one in the other direction (block 0 - 1 wrap-around). + assert.Equal(t, int64(1), got.Int64(), + "priorCounter(1) must query oracle(0); a counter that fired at block 0 must be visible") + require.Len(t, calls, 1) + assert.Equal(t, uint64(0), calls[0]) +} + +func TestPriorCounter_FromBlockZero(t *testing.T) { + // fromBlock = 0 has no "block before"; querying oracle(uint64(0)-1) + // would wrap to math.MaxUint64 and either error at the RPC layer or + // return a misleading head-of-chain counter. priorCounter must + // short-circuit and return (nil, nil) without calling the oracle. + calls := []uint64{} + oracle := makeStepCounter([]uint64{0, 5, 10}, &calls) + + got, err := priorCounter(context.Background(), oracle, 0) + require.NoError(t, err) + assert.Nil(t, got, + "priorCounter(fromBlock=0) must return nil (signaling FindTransitions to skip the boundary monotonic check)") + assert.Empty(t, calls, + "priorCounter(fromBlock=0) must NOT call the oracle — there is no fromBlock-1 to query") +} + +func TestPriorCounter_PropagatesOracleError(t *testing.T) { + // Oracle errors must surface unchanged so the caller can fail the + // claim cycle rather than silently treat a transient RPC failure as + // "no prior counter". + sentinel := errors.New("rpc unavailable") + oracle := func(_ context.Context, _ uint64) (*big.Int, error) { + return nil, sentinel + } + + got, err := priorCounter(context.Background(), oracle, 70) + assert.Nil(t, got) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) +} + +// TestFindTransitions_PrevValueRegression pins the prevValue contract of +// ethutil.FindTransitions: the caller must pass oracle(fromBlock-1), not +// oracle(epoch.LastBlock). Using the counter at any block past the scan +// window violates FindTransitions' monotonic invariant +// (prevValue <= oracle(fromBlock)) as soon as a transition fires inside +// the window, aborting the whole scan. +// +// Setup mirrors the multi-epoch foreclosure-replay scenario: +// +// fromBlock = 70 (prevEpoch.LastBlock + 1; scan starts here) +// currEpoch.LastBlock = 90 +// transitions at blocks 75, 85 (two acceptance events inside [70, 90]) +// oracle(69) = 0 (priorCounter — correct prevValue) +// oracle(70) = 0 (startValue — same block the scan begins from) +// oracle(90) = 2 (the buggy prevValue: counter at currEpoch.LastBlock) +// +// With prevValue = 2 (the bug) FindTransitions returns +// "monotonic assumption violated: prevValue 2 > startValue 0 at block 70" +// and never scans the interior. With prevValue = priorCounter(...) = 0 the +// scan completes and surfaces both transition blocks in chronological order. +func TestFindTransitions_PrevValueRegression(t *testing.T) { + ctx := context.Background() + const ( + fromBlock uint64 = 70 + currEpochLastBlk uint64 = 90 + ) + transitions := []uint64{75, 85} + + t.Run("BuggyOracleAtEpochLastBlockTripsMonotonicCheck", func(t *testing.T) { + oracle := makeStepCounter(transitions, nil) + + // The buggy pattern: pass the counter at the CURRENT epoch's + // LastBlock as prevValue. This is "the counter at some unrelated + // block" — specifically a block past several in-scan-window + // transitions. + buggyPrevValue, err := oracle(ctx, currEpochLastBlk) + require.NoError(t, err) + require.Equal(t, int64(2), buggyPrevValue.Int64(), + "sanity: oracle(currEpoch.LastBlock=90) must observe both transitions") + + hits := []uint64{} + onHit := func(block uint64) error { + hits = append(hits, block) + return nil + } + + _, err = ethutil.FindTransitions(ctx, fromBlock, currEpochLastBlk, + buggyPrevValue, oracle, onHit) + require.Error(t, err, "the buggy prevValue MUST trip the monotonic-assumption check") + assert.Contains(t, err.Error(), "monotonic assumption violated", + "the specific error string is the reason this bug went undetected for so long; pin it") + assert.Empty(t, hits, + "on monotonic violation the scan aborts before any interior split; no onHit call must fire") + }) + + t.Run("PriorCounterFixCompletesScan", func(t *testing.T) { + oracle := makeStepCounter(transitions, nil) + + fixedPrevValue, err := priorCounter(ctx, oracle, fromBlock) + require.NoError(t, err) + require.NotNil(t, fixedPrevValue) + require.Equal(t, int64(0), fixedPrevValue.Int64(), + "sanity: priorCounter at fromBlock=70 must read oracle(69)=0 (no transitions yet)") + + hits := []uint64{} + onHit := func(block uint64) error { + hits = append(hits, block) + return nil + } + + count, err := ethutil.FindTransitions(ctx, fromBlock, currEpochLastBlk, + fixedPrevValue, oracle, onHit) + require.NoError(t, err, "priorCounter MUST satisfy FindTransitions' monotonic invariant") + assert.Equal(t, uint64(len(transitions)), count) + assert.Equal(t, transitions, hits, + "every transition block in [fromBlock, currEpoch.LastBlock] must be reported in chronological order") + }) +} diff --git a/internal/claimer/provisional_policy_test.go b/internal/claimer/provisional_policy_test.go new file mode 100644 index 000000000..fa53030c8 --- /dev/null +++ b/internal/claimer/provisional_policy_test.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCreateWarnsOnceForProvisionalClaimObservation(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe, model.DefaultBlock_Finalized} { + t.Run(policy.String(), func(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(42)) + require.NoError(t, err) + requested := PersistentConfig{ + ChainID: 42, DefaultBlock: policy, ClaimSubmissionEnabled: true, ResolvedSignerAddress: opts.From, + } + repo := &claimerCreateRepositoryMock{} + repo.On("RegisterSubmitter", mock.Anything, ClaimerConfigKey, requested).Return(requested, nil).Once() + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + _, err = Create(t.Context(), &CreateInfo{ + Config: config.ClaimerConfig{ + BlockchainId: 42, BlockchainDefaultBlock: policy, ClaimerPollingInterval: time.Second, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + }, + Logger: logger, EthConn: newTestEthClient(t, 42), Repository: repo, + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(opts), + }) + require.NoError(t, err) + wanted := 1 + if policy == model.DefaultBlock_Finalized { + wanted = 0 + } + require.Equal(t, wanted, strings.Count(output.String(), "reorg rollback is unsupported")) + if wanted != 0 { + require.Contains(t, output.String(), "default_block="+policy.String()) + } + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/claimer/publication_closure_test.go b/internal/claimer/publication_closure_test.go new file mode 100644 index 000000000..a9175029c --- /dev/null +++ b/internal/claimer/publication_closure_test.go @@ -0,0 +1,156 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type publicationRefusal string + +func (e publicationRefusal) Error() string { return string(e) } +func (publicationRefusal) ErrorCode() int { return -32000 } + +func TestClaimRefusedPostEndsCandidateSelection(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + lookup.add(app, makeComputedEpoch(app, 4)) + backend.publishErr = publicationRefusal("insufficient funds for gas * price + value") + require.Error(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1, "one POST per pass") +} + +func TestClaimPublisherDoesNotAcceptForeignStagedRoot(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + app, epoch := deliveryApplication(1, model.Consensus_Authority) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + claim := makeClaimStatus(claimStatusStaged, epoch, 30) + claim.StagedOutputsMerkleRoot = [32]byte{0xbe, 0xef} + backend.claims[app.ID] = claim + require.NoError(t, s.publisher.tick(t.Context())) + require.Empty(t, backend.published, "a divergent staged claim is not accepted by our signer") +} + +func TestClaimPublisherAcceptanceMaturityBoundary(t *testing.T) { + for _, staged := range []uint64{40, 41} { // latest 50, period 10: 40 is mature, 41 is not + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + app, epoch := deliveryApplication(1, model.Consensus_Authority) + app.ClaimStagingPeriod = 10 + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(staged) + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, staged) + require.NoError(t, s.publisher.tick(t.Context())) + require.Equal(t, staged == 40, len(backend.published) == 1, "staged at %d", staged) + } +} + +func TestClaimPublisherPrefersReadyAcceptanceAcrossSources(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + app, resubmit := deliveryApplication(1, model.Consensus_Authority) + resubmit.Status = model.EpochStatus_ClaimSubmitted // chain lost it (reorg): resubmission candidate + ready := makeComputedEpoch(app, 4) // DB lags; chain shows it staged and mature + lookup.add(app, resubmit) + lookup.add(app, ready) + backend.apps[app.IApplicationAddress] = app + backend.claimsAt = map[uint64]iconsensus.IConsensusClaim{ready.LastBlock: makeClaimStatus(claimStatusStaged, ready, 30)} + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + require.Equal(t, "acceptClaim", publishedClaimMethod(t, backend.published[0].Data())) +} + +// The factory observes the deadline installed by the sender constructed in +// Create. Returning a sentinel ends this fixture before signing or publication. +type captureSigningDeadline struct { + until time.Time + failure error +} + +func (*captureSigningDeadline) From() common.Address { return common.HexToAddress("0xa1") } +func (f *captureSigningDeadline) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + f.until, _ = ctx.Deadline() + return nil, f.failure +} + +type signingPolicyRPC struct { + chainIDRPC + finalizedViewRPC +} + +func (*signingPolicyRPC) GetTransactionCount(context.Context, common.Address, rpc.BlockNumberOrHash) hexutil.Uint64 { + return 0 +} +func (*signingPolicyRPC) GetBalance(context.Context, common.Address, rpc.BlockNumberOrHash) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1_000_000_000_000_000_000)) +} +func (*signingPolicyRPC) GetCode(context.Context, common.Address, rpc.BlockNumberOrHash) hexutil.Bytes { + return nil +} +func (*signingPolicyRPC) MaxPriorityFeePerGas(context.Context) *hexutil.Big { + return (*hexutil.Big)(big.NewInt(1)) +} +func (*signingPolicyRPC) EstimateGas(context.Context, map[string]any, rpc.BlockNumberOrHash) hexutil.Uint64 { + return 21_000 +} +func (*signingPolicyRPC) Call(context.Context, map[string]any, rpc.BlockNumberOrHash) hexutil.Bytes { + return nil +} + +type signingPolicyPlanner struct{} + +func (signingPolicyPlanner) Next(context.Context, txsender.BlockRef, *txsender.Current[claimAction]) ( + txsender.Decision[claimAction], error, +) { + return txsender.Publish(claimAction{}, txsender.Request{To: common.HexToAddress("0xb1")}), nil +} +func (signingPolicyPlanner) Recheck(_ context.Context, _ txsender.BlockRef, _ claimAction) (txsender.Eligibility, error) { + return txsender.Useful, nil +} + +func TestClaimCreateWiresSigningTimeout(t *testing.T) { + const signingTimeout = 7 * time.Second + factory := &captureSigningDeadline{failure: errors.New("signing deadline captured")} + requested := PersistentConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, + ClaimSubmissionEnabled: true, ResolvedSignerAddress: factory.From()} + repo := &claimerCreateRepositoryMock{} + repo.On("RegisterSubmitter", mock.Anything, ClaimerConfigKey, requested).Return(requested, nil).Once() + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", &signingPolicyRPC{ + chainIDRPC: chainIDRPC{chainID: 42}, finalizedViewRPC: finalizedViewRPC{final: 50}})) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + svc, err := Create(t.Context(), &CreateInfo{Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: signingTimeout, + }, EthConn: client, Repository: repo, TransactOptsFactory: factory}) + require.NoError(t, err) + s := svc.(*Service) + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + before := time.Now() + _, err = s.publisher.sender.PublishNext(ctx, signingPolicyPlanner{}) + require.ErrorIs(t, err, factory.failure) + require.WithinDuration(t, before.Add(signingTimeout), factory.until, time.Second, + "the configured request timeout bounds signing, rather than inheriting the one-minute caller deadline") + repo.AssertExpectations(t) +} diff --git a/internal/claimer/publication_nonce_race_test.go b/internal/claimer/publication_nonce_race_test.go new file mode 100644 index 000000000..6cbb8f223 --- /dev/null +++ b/internal/claimer/publication_nonce_race_test.go @@ -0,0 +1,101 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/require" +) + +func TestClaimLostNonceRacesWarnWithoutRetainedBytesAndRecover(t *testing.T) { + for _, beforePost := range []bool{true, false} { + t.Run(map[bool]string{true: "stale before POST", false: "nonce-low at POST"}[beforePost], func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + logs := capturePublicationLogs(s) + reads, simulations := 0, 0 + consumeNonce := func() { + backend.last++ + backend.consumedAt = backend.last + backend.nonce++ + } + if beforePost { + backend.onHeader = func(tag string) { + if tag == "latest" { + reads++ + if reads == 2 { + consumeNonce() + } + } + } + } else { + backend.publishErr = publicationRefusal("nonce too low") + backend.onCall = func(method string) { + if method == claimSubmitMethod { + simulations++ + if simulations == 2 { + consumeNonce() + } + } + } + } + conflict := func() { + t.Helper() + reads, simulations = 0, 0 + err := s.publisher.tick(t.Context()) + require.Error(t, err) + require.True(t, txsender.WaitOnly(err), "%v", err) + if beforePost { + require.ErrorIs(t, err, txsender.ErrStale) + } else { + var reply *txsender.PublicationError + require.ErrorAs(t, err, &reply) + require.True(t, reply.NonceLowOnly()) + } + } + conflict() + require.NotContains(t, logs.String(), "level=WARN", "one race is not a repeated nonce-progress symptom") + for range 5 { + conflict() + } + const warning = "Claim publication lost a race while signer nonce advanced; inspect other signer users" + require.Equal(t, 1, strings.Count(logs.String(), warning), logs.String()) + require.Contains(t, logs.String(), app.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), "authority-submit") + require.NotContains(t, logs.String(), "level=ERROR") + s.publisher.warnedAt = time.Now().Add(-claimWarningInterval) + conflict() + require.Equal(t, 2, strings.Count(logs.String(), warning)) + if beforePost { + require.Empty(t, backend.published, "a stale preparation must never POST") + } else { + require.Len(t, backend.published, 7, "one definite refusal ends each pass") + } + + backend.onHeader, backend.onCall, backend.publishErr = nil, nil, nil + logs.Reset() + before := len(backend.published) + require.NoError(t, s.publisher.tick(t.Context()), "exclusive use restores publication") + require.Len(t, backend.published, before+1) + require.Equal(t, backend.nonce, backend.published[before].Nonce()) + require.NotContains(t, logs.String(), "level=WARN", "normal publication after recovery is quiet") + }) + } +} + +func TestClaimIdleNonceProgressDoesNotWarnAboutSignerContention(t *testing.T) { + s, backend, _ := newDeliveryFixture(t) + logs := capturePublicationLogs(s) + for range 4 { + backend.last++ + backend.consumedAt, backend.nonce = backend.last, backend.nonce+1 + require.NoError(t, s.publisher.tick(t.Context())) + } + require.Empty(t, backend.published) + require.Empty(t, logs.String()) +} diff --git a/internal/claimer/publication_regression_test.go b/internal/claimer/publication_regression_test.go new file mode 100644 index 000000000..0322f0655 --- /dev/null +++ b/internal/claimer/publication_regression_test.go @@ -0,0 +1,484 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func capturePublicationLogs(s *Service) *bytes.Buffer { + var output bytes.Buffer + s.publisher.Logger = slog.New(slog.NewTextHandler(&output, nil)) + return &output +} + +func TestClaimOperationalRefusalsRecoverWithoutApplicationPause(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + backend.balance = big.NewInt(1) + for range 7 { + clear(s.publisher.cooldowns) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrLimit) + require.Empty(t, backend.published) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + backend.balance = nil + clear(s.publisher.cooldowns) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1, "funding restores publication without operator action") +} + +func TestHeldClaimReadFailureAloneDoesNotJustifyReplacement(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + queueDelivery(t, backend, lookup, 2) + failure := errors.New("current application's epoch cannot be read") + lookup.epochErrors = map[string]error{app.IApplicationAddress.Hex(): failure} + err := s.publisher.tick(t.Context()) + require.ErrorIs(t, err, failure) + require.ErrorContains(t, err, "application "+app.IApplicationAddress.Hex()) + require.ErrorContains(t, err, "action authority-submit, epoch 3, nonce 0") + require.Len(t, backend.published, 1, "read failure alone supplies no cancellation or replacement evidence") +} + +func TestClaimSubmissionWaitsPastFinalBlockWithoutDelayingAcceptance(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + t.Run(consensus.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := deliveryApplication(1, consensus) + lookup.add(app, epoch) + backend.apps[app.IApplicationAddress] = app + backend.last = epoch.LastBlock + require.NoError(t, s.publisher.tick(t.Context())) + require.Empty(t, backend.published, "submitClaim reverts at the epoch's final block") + backend.last++ + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + backend.last = epoch.LastBlock + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Len(t, backend.published, 1, "a held submission waits rather than being cancelled at the lower head") + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 1) + app.ClaimStagingPeriod = 0 + candidate, state, err := s.publisher.decide(t.Context(), + txsender.BlockRef{Number: epoch.LastBlock}, repository.ClaimWork{Application: app, Epoch: epoch}, false) + require.NoError(t, err) + require.Equal(t, txsender.Useful, state) + require.Equal(t, acceptClaimKind, candidate.action.Kind, "the submit-only boundary does not delay acceptance") + }) + } +} + +func TestHeldClaimIgnoresProvisionalAcceptedStatusWhenChainStillNeedsIt(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + app, epoch := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + epoch.Status = model.EpochStatus_ClaimAccepted + _, state, err := s.publisher.decide(t.Context(), txsender.BlockRef{Number: backend.last}, + repository.ClaimWork{Application: app, Epoch: epoch}, false) + require.NoError(t, err) + require.Equal(t, txsender.Obsolete, state, "fresh discovery still excludes accepted rows") + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Equal(t, backend.published[0].Hash(), backend.published[1].Hash()) +} + +func TestHeldClaimLimitsWarnWithContextAndBackoff(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + logs := capturePublicationLogs(s) + app, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + backend.balance = big.NewInt(1) + for range 7 { + err := s.publisher.tick(t.Context()) + require.ErrorIs(t, err, txsender.ErrLimit) + require.True(t, txsender.WaitOnly(err, txsender.ErrLimit), "run must not add a generic warning every poll") + } + const message = "Claim publication waiting for funding or a policy limit" + require.Equal(t, 1, strings.Count(logs.String(), message)) + require.Contains(t, logs.String(), "address="+strings.ToLower(app.IApplicationAddress.Hex())) + require.Contains(t, logs.String(), "action=authority-submit") + require.Contains(t, logs.String(), "epoch=3") + require.Contains(t, logs.String(), "nonce=0") + clear(s.publisher.cooldowns) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrLimit) + require.Equal(t, 2, strings.Count(logs.String(), message)) + backend.balance = nil + require.NoError(t, s.publisher.tick(t.Context()), "a held request retries immediately after funding returns") + // Cancellation has no new lastChoice; its refusal still names the held action. + clear(s.publisher.cooldowns) + app.Enabled, backend.balance = false, big.NewInt(1) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrLimit) + require.Equal(t, 3, strings.Count(logs.String(), message)) + require.Contains(t, logs.String(), "action=\"cancel obsolete authority-submit\"") +} + +func TestClaimHeldPublicationErrorsWarnWithBackoff(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + logs := capturePublicationLogs(s) + failure := errors.New("held claim read unavailable") + lookup.epochErrors = map[string]error{app.IApplicationAddress.Hex(): failure} + for range 7 { + err := s.publisher.tick(t.Context()) + require.ErrorIs(t, err, failure) + s.publisher.reportPublicationError(t.Context(), &s.publisher.deferredWarnedAt, err) + } + require.Equal(t, 1, strings.Count(logs.String(), "Claim publication deferred")) + require.NotContains(t, logs.String(), "level=ERROR") + require.Contains(t, logs.String(), app.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), "action authority-submit") +} + +func TestClaimPlanningWarningDoesNotHideAnotherApplicationsPublicationFailure(t *testing.T) { + for _, sameSource := range []bool{false, true} { + t.Run(fmt.Sprintf("same_source=%t", sameSource), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + broken, stored := queueDelivery(t, backend, lookup, 1) + stored.MachineHash = nil + if !sameSource { + stored.Status = model.EpochStatus_ClaimSubmitted + } + ready, _ := queueDelivery(t, backend, lookup, 2) + failure := errors.New("ready application's exact simulation reverted") + backend.fail[ready.IApplicationAddress] = failure + logs := capturePublicationLogs(s) + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + s.publisher.interval = time.Nanosecond + passes, refused := 0, 0 + started := time.Now() + backend.onHeader = func(string) { + if s.publisher.prepared { + return // Later preparation attempts remain in the same pass. + } + // Advance clocks at each pass's first account read. Warning and + // cooldown clocks move together; candidates keep their waits. + if passes == 200 { + cancel() + return + } + if passes > 0 { + shiftClaimPublicationClock(s.publisher, 3*time.Second-time.Since(started)) + } + started = time.Now() + passes++ + } + backend.onCall = func(method string) { + if method == claimSubmitMethod { + refused++ + } + } + require.ErrorIs(t, s.publisher.run(ctx), context.Canceled) + planningWarnings, publicationWarnings := 0, 0 + for _, line := range strings.Split(logs.String(), "\n") { + if !strings.Contains(line, "level=WARN") || !strings.Contains(line, "Claim publication deferred") { + continue + } + if strings.Contains(line, "persisted claim is missing its machine or outputs root") { + planningWarnings++ + require.Contains(t, line, broken.IApplicationAddress.Hex()) + } + if strings.Contains(line, failure.Error()) { + publicationWarnings++ + require.Contains(t, line, ready.IApplicationAddress.Hex()) + } + } + require.Equal(t, 200, passes) + require.InDelta(t, 10, planningWarnings, 1, "planning warnings stay visible and rate-limited over ten virtual minutes") + require.InDelta(t, 10, publicationWarnings, 1, "another application's failure must not take this warning slot") + require.Greater(t, refused, publicationWarnings) + require.Less(t, refused, passes/2, "the 30-second candidate cooldown remains active") + require.Empty(t, backend.published) + require.NotContains(t, logs.String(), "level=ERROR") + }) + } +} + +// Only these test clocks move. Selection and run-loop routing stay in production. +func shiftClaimPublicationClock(p *claimPublisher, elapsed time.Duration) { + for _, warnedAt := range []*time.Time{&p.deferredWarnedAt, &p.planningWarnedAt, &p.warnedAt, &p.hardWarnedAt, + &p.headChangedAt, &p.nonceChangedAt} { + if !warnedAt.IsZero() { + *warnedAt = warnedAt.Add(-elapsed) + } + } + for key, until := range p.cooldowns { + p.cooldowns[key] = until.Add(-elapsed) + } +} + +type publicationLogWriter func([]byte) (int, error) + +func (write publicationLogWriter) Write(data []byte) (int, error) { return write(data) } + +func TestClaimRunRoutesPlanningAndPublicationWarningsSeparately(t *testing.T) { + for _, canceling := range []bool{false, true} { + t.Run(fmt.Sprintf("canceling=%t", canceling), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + useful, _ := queueDelivery(t, backend, lookup, 2) + if canceling { + require.NoError(t, s.publisher.tick(t.Context())) + useful.Enabled = false + } + _, broken := queueDelivery(t, backend, lookup, 1) + broken.Status, broken.MachineHash = model.EpochStatus_ClaimSubmitted, nil + backend.publishErr = publicationRefusal("insufficient funds for gas * price + value") + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + var output bytes.Buffer + writer := publicationLogWriter(func(data []byte) (int, error) { + n, err := output.Write(data) + if strings.Count(output.String(), "level=WARN msg=\"Claim publication deferred\"") == 2 { + cancel() + } + return n, err + }) + s.publisher.Logger = slog.New(slog.NewTextHandler(writer, nil)) + s.publisher.interval = time.Hour // Both warning channels must fire in the first pass. + require.ErrorIs(t, s.publisher.run(ctx), context.Canceled, "both warnings must arrive before the test deadline") + require.Equal(t, 2, strings.Count(output.String(), "level=WARN msg=\"Claim publication deferred\"")) + require.Contains(t, output.String(), "persisted claim is missing its machine or outputs root") + require.Contains(t, output.String(), "insufficient funds") + require.Equal(t, canceling, strings.Contains(output.String(), "before cancellation")) + }) + } +} + +func TestClaimRunShutdownKeepsRealFailuresVisible(t *testing.T) { + failure := errors.New("provider returned malformed account state") + for _, test := range []struct { + name string + err error + warn bool + }{ + {"pure cancellation", context.Canceled, false}, + {"wait and cancellation", errors.Join(txsender.ErrInconclusive, context.Canceled), false}, + {"real failure and cancellation", errors.Join(failure, context.Canceled), true}, + {"operation deadline", context.DeadlineExceeded, true}, + } { + t.Run(test.name, func(t *testing.T) { + s, backend, _ := newDeliveryFixture(t) + logs := capturePublicationLogs(s) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + backend.onHeader = func(string) { cancel() } + backend.headerErr = test.err + require.ErrorIs(t, s.publisher.run(ctx), context.Canceled) + require.Equal(t, test.warn, strings.Contains(logs.String(), "level=WARN"), logs.String()) + if test.warn { + if errors.Is(test.err, failure) { + require.Contains(t, logs.String(), failure.Error()) + } else { + require.Contains(t, logs.String(), context.DeadlineExceeded.Error()) + } + } + require.NotContains(t, logs.String(), "level=ERROR") + }) + } +} + +func TestClaimFundingWarningNamesUsefulWorkWhileCancellationIsPending(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + first, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + first.Enabled = false + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Equal(t, s.publisher.signer, *backend.published[1].To()) + second, _ := queueDelivery(t, backend, lookup, 2) + logs := capturePublicationLogs(s) + backend.balance = big.NewInt(1) + clear(s.publisher.cooldowns) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrLimit) + var warning string + for _, line := range strings.Split(logs.String(), "\n") { + if strings.Contains(line, "Claim publication waiting for funding or a policy limit") && + strings.Contains(line, "address="+strings.ToLower(second.IApplicationAddress.Hex())) { + warning = line + } + } + require.NotEmpty(t, warning, logs.String()) + require.Contains(t, warning, "action=authority-submit") + require.NotContains(t, warning, "cancel obsolete") +} + +func TestClaimStalledNonceWarningIncludesOnlyThisPassPublicationReply(t *testing.T) { + for _, attempted := range []bool{false, true} { + t.Run(fmt.Sprintf("attempted=%t", attempted), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + logs := capturePublicationLogs(s) + s.publisher.nonceChangedAt = time.Now().Add(-2 * claimWarningInterval) + backend.publishErr = publicationRefusal("replacement transaction underpriced") + report, err := s.publisher.sender.PublishNext(t.Context(), s.publisher) + require.NoError(t, err) + require.Equal(t, txsender.ReplacementUnderpriced, report.Class) + report.Attempted = attempted + s.publisher.reportStalls(report, nil) + var warning string + for _, line := range strings.Split(logs.String(), "\n") { + if strings.Contains(line, "Claim publication nonce has not advanced") { + warning = line + } + } + require.NotEmpty(t, warning, logs.String()) + require.Equal(t, attempted, strings.Contains(warning, "reply=replacement-underpriced")) + }) + } +} + +func TestClaimIncompleteReportDoesNotResetProgress(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + logs := capturePublicationLogs(s) + block, nonce := s.publisher.lastLive, s.publisher.lastNonce + s.publisher.headChangedAt = time.Now().Add(-2 * claimWarningInterval) + for range 3 { + s.publisher.reportStalls(txsender.Report[claimAction]{}, nil) + } + require.Equal(t, block, s.publisher.lastLive) + require.Equal(t, nonce, s.publisher.lastNonce) + require.Empty(t, logs.String()) + s.publisher.reportStalls(txsender.Report[claimAction]{Block: block, Nonce: nonce}, nil) + require.Contains(t, logs.String(), "Claim publication head has not advanced") + require.NotContains(t, logs.String(), "block=0") +} + +func TestHeldClaimOperationalErrorsKeepContextAndRemainVisible(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, _ := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + failure := errors.New("RPC execution read failed") + backend.fail = map[common.Address]error{app.IApplicationAddress: errors.Join(txsender.ErrLimit, failure)} + err := s.publisher.tick(t.Context()) + require.ErrorIs(t, err, failure) + require.ErrorContains(t, err, "application "+app.IApplicationAddress.Hex()) + require.ErrorContains(t, err, "action authority-submit, epoch 3, nonce 0") + require.False(t, txsender.WaitOnly(err, txsender.ErrLimit), "a joined RPC error cannot be hidden as a policy wait") +} + +func TestOrphanedProvisionalClaimAndRepeatedSelectionAreVisible(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + logs := capturePublicationLogs(s) + _, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(10)) + for range 3 { + require.NoError(t, s.publisher.tick(t.Context())) + } + require.Equal(t, 1, strings.Count(logs.String(), "Re-publishing recorded claim absent from the current chain view")) + backend.finalize(t) // The nonce advances, but this fake chain leaves the action useful. + require.NoError(t, s.publisher.tick(t.Context())) + require.Contains(t, logs.String(), "Claim action selected again after signer nonce advanced") + require.Contains(t, logs.String(), "other signer users") + require.Equal(t, uint64(1), backend.published[len(backend.published)-1].Nonce()) + require.Equal(t, backend.published[0].Data(), backend.published[len(backend.published)-1].Data(), + "nonce consumption cannot suppress the still-outstanding claim") + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status, "publication does not persist assumed protocol success") + require.NotContains(t, logs.String(), "transaction reverted", "a repeated action does not prove a receipt result") +} + +type deadlineClaimSigner struct { + ethutil.TransactOptsFactory + block bool + started chan struct{} +} + +func (f *deadlineClaimSigner) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts, err := f.TransactOptsFactory.NewTransactOpts(ctx) + if err == nil && f.block { + opts.Signer = func(common.Address, *types.Transaction) (*types.Transaction, error) { + f.started <- struct{}{} + <-ctx.Done() + return nil, ctx.Err() + } + } + return opts, err +} + +func TestClaimSigningDeadlineReleasesBlockedSigner(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + key, err := crypto.HexToECDSA("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(31337)) + require.NoError(t, err) + factory := &deadlineClaimSigner{ + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(opts), block: true, started: make(chan struct{}, 1), + } + s.publisher.sender, err = txsender.New[claimAction](backend, factory, 31337, + txsender.Policy{SigningTimeout: 100 * time.Millisecond}, + func(context.Context) error { return nil }, s.Logger) + require.NoError(t, err) + require.ErrorIs(t, s.publisher.tick(t.Context()), context.DeadlineExceeded) + require.Len(t, factory.started, 1, "the test must reach the blocked signer") + require.Empty(t, backend.published) + factory.block = false + clear(s.publisher.cooldowns) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1, "a timed-out signing request cannot permanently hold the publisher") + backend.finalize(t) + backend.onHeader = func(string) { time.Sleep(75 * time.Millisecond) } + started := time.Now() + require.NoError(t, s.publisher.tick(t.Context())) + require.Greater(t, time.Since(started), 100*time.Millisecond, + "ordinary reads may collectively take longer than the signing timeout") + require.Len(t, backend.published, 2, "a slow discovery pass must still reach publication") +} + +type cursorObservationChain struct { + *eventChain + visited map[uint64]bool +} + +func (*cursorObservationChain) getDefaultBlockNumber(context.Context) (*big.Int, error) { + return big.NewInt(10000), nil +} + +func (c *cursorObservationChain) findClaimStagedEventAndSucc( + _ context.Context, _ *model.Application, epoch *model.Epoch, _, _ uint64, +) (*iconsensus.IConsensus, *iconsensus.IConsensusClaimStaged, *iconsensus.IConsensusClaimStaged, error) { + c.visited[epoch.Index] = true + return nil, nil, nil, nil +} + +func TestClaimObserverAdvancesPastUnchangedSubmittedWindow(t *testing.T) { + s, _, lookup := newDeliveryFixture(t) + app, _ := deliveryApplication(1, model.Consensus_Authority) + for index := range uint64(32) { + epoch := makeComputedEpoch(app, index) + epoch.Status = model.EpochStatus_ClaimSubmitted + lookup.add(app, epoch) + } + s.repository = &eventRepository{deliveryCandidateRepository: &deliveryCandidateRepository{lookup: lookup}} + chain := &cursorObservationChain{eventChain: &eventChain{}, visited: map[uint64]bool{}} + s.blockchain = chain + for range 3 { + _, err := s.Tick(t.Context()) + require.NoError(t, err) + } + require.Len(t, chain.visited, 32, "unchanged first-window rows cannot hide later submitted claims") +} diff --git a/internal/claimer/publisher.go b/internal/claimer/publisher.go new file mode 100644 index 000000000..8b6ce50a3 --- /dev/null +++ b/internal/claimer/publisher.go @@ -0,0 +1,401 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "log/slog" + "slices" + "sync/atomic" + "time" +) + +const claimCandidateRetryDelay = 30 * time.Second +const ( + submitCandidatePage = iota + acceptCandidatePage + repairCandidatePage + candidatePageCount +) + +// The publisher owns its traversal and short-lived candidate window. It stores +// no copy of the sender's current request or historical transaction outcomes. +type claimPublisher struct { + repository iclaimerRepository + lookup claimLookup + backend txsender.Backend + sender *txsender.Sender[claimAction] + signer common.Address + observationPolicy model.DefaultBlock + Logger *slog.Logger + interval time.Duration + cooldowns map[claimAction]time.Time + stagingWarnings map[claimAction]time.Time + pages [candidatePageCount]repository.ClaimCursor + failure *atomic.Pointer[error] + candidates []claimCandidate + prepared bool + lastChoice claimAction + lastPending claimAction + lastLive txsender.BlockRef + lastNonce uint64 + hadPending bool + headChangedAt, nonceChangedAt, warnedAt, hardWarnedAt time.Time + deferredWarnedAt, planningWarnedAt time.Time + planningOnly bool // the last pass failed before choosing work +} + +func (s *claimPublisher) run(ctx context.Context) error { + interval := s.interval + if interval <= 0 { + interval = time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + var err error + if cause := s.failure.Load(); cause != nil { + err = *cause + } else { + err = s.tick(ctx) + if txsender.HardFailure(err) { + s.failure.CompareAndSwap(nil, &err) + } + } + if txsender.ShutdownOnly(ctx, err, txsender.ErrLimit) || ctx.Err() != nil && err == nil { + return ctx.Err() + } + if txsender.HardFailure(err) { + if time.Since(s.hardWarnedAt) >= claimWarningInterval { + s.Logger.Error("Claim publication stopped; operator action required", "error", err) + s.hardWarnedAt = time.Now() + } + } else if err != nil && !txsender.WaitOnly(err, txsender.ErrLimit) { + warnedAt := &s.deferredWarnedAt + if s.planningOnly { + warnedAt = &s.planningWarnedAt + } + s.reportPublicationError(ctx, warnedAt, err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func (s *claimPublisher) tick(ctx context.Context) error { + if cause := s.failure.Load(); cause != nil { + return *cause + } + s.prepared, s.candidates, s.planningOnly = false, nil, false + defer func() { s.candidates = nil }() + now := time.Now() + for action, until := range s.cooldowns { + if !now.Before(until) { + delete(s.cooldowns, action) + } + } + for epoch, until := range s.stagingWarnings { + if !now.Before(until) { + delete(s.stagingWarnings, epoch) + } + } + var result error + // Only preparation failures fall through within this pass. A POST may + // already have reached the network and must end candidate selection. + for range claimPageSize { + s.lastChoice = claimAction{} + report, err := s.sender.PublishNext(ctx, s) + if action := s.reportAction(report); err != nil && action != (claimAction{}) { + kind := claimKindName(action.Kind) + if report.Canceling { + kind = "cancel obsolete " + kind + } + err = fmt.Errorf("application %s, action %s, epoch %d, nonce %d: %w", + action.Address, kind, action.Epoch, report.Nonce, err) + } + s.reportStalls(report, err) + s.reportLimit(report, err) + retainedChoice := report.Current != nil && !report.Current.Neutral && report.Current.Action == s.lastChoice + if err == nil || report.Attempted || retainedChoice || txsender.PassInvalid(err) || ctx.Err() != nil { + return errors.Join(result, err) + } + if s.lastChoice == (claimAction{}) { + s.planningOnly = result == nil && report.Current == nil + return errors.Join(result, err) + } + s.cooldowns[s.lastChoice] = time.Now().Add(claimCandidateRetryDelay) + result = errors.Join(result, err) + } + return result +} + +func (s *claimPublisher) Next( + ctx context.Context, block txsender.BlockRef, current *txsender.Current[claimAction], +) (txsender.Decision[claimAction], error) { + if current != nil && !current.Neutral { + candidate, state, err := s.currentDecision(ctx, block, current.Action) + if err != nil { + return txsender.Wait[claimAction]("current claim read failed"), err + } + if state == txsender.ProtocolWait { + return txsender.Wait[claimAction]("claim is not mature"), nil + } + if state == txsender.Useful { + s.lastChoice = candidate.action + return txsender.Publish(candidate.action, s.decorate(candidate)), nil + } + } + if !s.prepared { + s.prepared = true + if err := s.prepareCandidates(ctx, block); err != nil { + if current == nil { + return txsender.Wait[claimAction]("candidate reads failed"), err + } + // Current obsolescence was established independently above. + s.reportPublicationError(ctx, &s.planningWarnedAt, + fmt.Errorf("other claim work could not be read before cancellation: %w", err)) + } + } + if len(s.candidates) != 0 { + candidate := s.candidates[0] + s.candidates = s.candidates[1:] + s.lastChoice = candidate.action + return txsender.Publish(candidate.action, s.decorate(candidate)), nil + } + if current != nil { + return txsender.Cancel[claimAction]("claim action is no longer useful"), nil + } + return txsender.Wait[claimAction]("no useful claim work"), nil +} + +func (s *claimPublisher) Recheck(ctx context.Context, block txsender.BlockRef, action claimAction) (txsender.Eligibility, error) { + _, state, err := s.currentDecision(ctx, block, action) + return state, err +} + +func (s *claimPublisher) currentDecision( + ctx context.Context, block txsender.BlockRef, action claimAction, +) (claimCandidate, txsender.Eligibility, error) { + work := repository.ClaimWork{Application: &model.Application{ID: action.ApplicationID, IApplicationAddress: action.Address}, + Epoch: &model.Epoch{Index: action.Epoch}} + candidate, state, err := s.decide(ctx, block, work, true) + var mismatch *claimSignerMismatch + if errors.As(err, &mismatch) { + if !time.Now().Before(s.cooldowns[action]) { + s.Logger.Warn("Held claim signer authorization changed; replacing obsolete request", "address", action.Address, + "epoch", action.Epoch, "action", claimKindName(action.Kind), "signer", s.signer, "error", err) + s.cooldowns[action] = time.Now().Add(claimCandidateRetryDelay) + } + return claimCandidate{}, txsender.Obsolete, nil + } + if err == nil && ((state == txsender.Useful && candidate.action != action) || + (state == txsender.ProtocolWait && isClaimSubmission(action.Kind) && candidate.action.Kind == acceptClaimKind)) { + state = txsender.Obsolete + } + return candidate, state, err +} + +func (s *claimPublisher) decorate(candidate claimCandidate) txsender.Request { + work := repository.ClaimWork{Application: candidate.app, Epoch: candidate.epoch} + if isClaimSubmission(candidate.action.Kind) && candidate.epoch.Status != model.EpochStatus_ClaimComputed && + !time.Now().Before(s.cooldowns[claimPlanningRefusal(work)]) { + s.Logger.Warn("Re-publishing recorded claim absent from the current chain view", "application", candidate.app.Name, + "address", candidate.app.IApplicationAddress, "epoch", candidate.epoch.Index, "stored_status", candidate.epoch.Status) + s.cooldowns[claimPlanningRefusal(work)] = time.Now().Add(claimCandidateRetryDelay) + } + request := candidate.request + request.Logger = s.Logger.With("application", candidate.app.Name, "address", candidate.app.IApplicationAddress, + "action", claimKindName(candidate.action.Kind), "epoch", candidate.epoch.Index) + return request +} + +// Staging warnings have their own per-epoch clock: a different refusal must +// not hide this cause, and a diagnostic must not delay an otherwise useful action. +func (s *claimPublisher) warnStagingContradiction(app *model.Application, epoch *model.Epoch, latest uint64) { + key := claimAction{ApplicationID: app.ID, Address: app.IApplicationAddress, Epoch: epoch.Index} + now := time.Now() + if now.Before(s.stagingWarnings[key]) { + return + } + if s.stagingWarnings == nil { + s.stagingWarnings = make(map[claimAction]time.Time) + } + s.Logger.Warn("Claim publication waiting: recorded staging contradicts latest view", + "application", app.Name, "address", app.IApplicationAddress, "epoch", epoch.Index, + "staged_at_block", *epoch.StagedAtBlock, "latest_block", latest, "observation_policy", s.observationPolicy) + s.stagingWarnings[key] = now.Add(claimWarningInterval) +} + +func (s *claimPublisher) prepareCandidates(ctx context.Context, block txsender.BlockRef) error { + var result error + // Each source reserves later-row capacity. All rows in these windows are + // inspected before choosing: a useful oldest row cannot hide its tail. + for _, source := range []struct { + kind int + size uint64 + }{ + {acceptCandidatePage, 6}, {repairCandidatePage, 5}, {submitCandidatePage, 5}, + } { + query := s.repository.SelectClaimsToSubmitPage + if source.kind == acceptCandidatePage { + query = s.repository.SelectClaimsToAcceptPage + } + if source.kind == repairCandidatePage { + query = s.repository.SelectClaimsToStagePage + } + s.pages[source.kind].PublishableOnly = true + page, err := query(ctx, s.pages[source.kind], source.size) + if err != nil { + result = errors.Join(result, err) + continue + } + s.pages[source.kind].AfterApplicationID = page.ApplicationID + for _, work := range page.Work { + s.pages[source.kind].Advance(work, page) + candidate, state, err := s.decide(ctx, block, work, false) + if err != nil { + var mismatch *claimSignerMismatch + if errors.As(err, &mismatch) || errors.Is(err, txsender.ErrUnsupported) { + if time.Now().Before(s.cooldowns[claimPlanningRefusal(work)]) { + continue + } + s.cooldowns[claimPlanningRefusal(work)] = time.Now().Add(claimCandidateRetryDelay) + s.Logger.Warn("Claim candidate refused; retrying after cooldown", "application", work.Application.Name, + "address", work.Application.IApplicationAddress, "epoch", work.Epoch.Index, "signer", s.signer, "error", err) + } + result = errors.Join(result, fmt.Errorf("application %s (%s), epoch %d: %w", + work.Application.Name, work.Application.IApplicationAddress, work.Epoch.Index, err)) + continue + } + if state != txsender.Useful || time.Now().Before(s.cooldowns[candidate.action]) { + continue + } + s.candidates = append(s.candidates, candidate) + } + } + slices.SortStableFunc(s.candidates, func(a, b claimCandidate) int { + if a.action.Kind == acceptClaimKind && b.action.Kind != acceptClaimKind { + return -1 + } + if b.action.Kind == acceptClaimKind && a.action.Kind != acceptClaimKind { + return 1 + } + return 0 + }) + // A failed unrelated read does not discard candidates established useful. + if len(s.candidates) != 0 { + if result != nil { + s.reportPublicationError(ctx, &s.planningWarnedAt, result) + } + return nil + } + return result +} + +// A zero-kind key throttles a planning refusal before immutable calldata can be +// selected. It is local to the service; it is never a sender action identity. +func claimPlanningRefusal(work repository.ClaimWork) claimAction { + return claimAction{ApplicationID: work.Application.ID, Address: work.Application.IApplicationAddress, Epoch: work.Epoch.Index} +} + +func (s *claimPublisher) reportAction(report txsender.Report[claimAction]) claimAction { + if s.lastChoice != (claimAction{}) { + return s.lastChoice + } + if report.Current != nil { + return report.Current.Action + } + return claimAction{} +} + +func (s *claimPublisher) reportLimit(report txsender.Report[claimAction], err error) { + action := s.reportAction(report) + if !errors.Is(err, txsender.ErrLimit) || action == (claimAction{}) { + return + } + if time.Now().Before(s.cooldowns[action]) { + return + } + kind := claimKindName(action.Kind) + if report.Canceling { + kind = "cancel obsolete " + kind + } + s.Logger.Warn("Claim publication waiting for funding or a policy limit", "address", action.Address, + "action", kind, "epoch", action.Epoch, "signer", s.signer, "nonce", report.Nonce, "error", err) + s.cooldowns[action] = time.Now().Add(claimCandidateRetryDelay) +} + +func (s *claimPublisher) reportStalls(report txsender.Report[claimAction], err error) { + if report.Block == (txsender.BlockRef{}) { + return + } + now := time.Now() + if report.Current == nil && s.lastChoice != (claimAction{}) && s.lastLive != (txsender.BlockRef{}) && + report.Nonce > s.lastNonce && (errors.Is(err, txsender.ErrStale) || report.Class == txsender.NonceLow) && + now.Sub(s.warnedAt) >= claimWarningInterval { + s.Logger.Warn("Claim publication lost a race while signer nonce advanced; inspect other signer users", + "address", s.lastChoice.Address, "action", claimKindName(s.lastChoice.Kind), "epoch", s.lastChoice.Epoch, + "signer", s.signer, "previous_nonce", s.lastNonce, "nonce", report.Nonce, "error", err) + s.warnedAt = now + } + if report.Attempted && report.Current != nil && s.hadPending && report.Nonce > s.lastNonce && + report.Current.Action == s.lastPending && now.Sub(s.warnedAt) >= claimWarningInterval { + action := report.Current.Action + s.Logger.Warn("Claim action selected again after signer nonce advanced; inspect execution and other signer users", + "address", action.Address, + "action", claimKindName(action.Kind), "epoch", action.Epoch, "signer", s.signer, "nonce", report.Nonce) + s.warnedAt = now + } + if s.headChangedAt.IsZero() || s.lastLive != report.Block { + s.headChangedAt = now + } + pending := report.Current != nil + if pending { + s.lastPending = report.Current.Action + } + if !pending || !s.hadPending || s.nonceChangedAt.IsZero() || s.lastNonce != report.Nonce { + s.nonceChangedAt = now + } + s.hadPending = pending + s.lastLive, s.lastNonce = report.Block, report.Nonce + if now.Sub(s.warnedAt) < claimWarningInterval { + return + } + logger := s.Logger.With("signer", s.signer, "nonce", report.Nonce) + if report.Attempted { + logger = logger.With("reply", report.Class) + } + if pending { + action := report.Current.Action + logger = logger.With("address", action.Address, "action", claimKindName(action.Kind), "epoch", action.Epoch) + } + if now.Sub(s.headChangedAt) >= claimWarningInterval { + logger.Warn("Claim publication head has not advanced", "block", report.Block.Number, "hash", report.Block.Hash) + s.warnedAt = now + } else if pending && now.Sub(s.nonceChangedAt) >= claimWarningInterval { + logger.Warn("Claim publication nonce has not advanced", "block", report.Block.Number) + s.warnedAt = now + } +} + +func (s *claimPublisher) reportPublicationError(ctx context.Context, warnedAt *time.Time, err error) { + if txsender.ShutdownOnly(ctx, err, txsender.ErrLimit) { + return + } + if time.Since(*warnedAt) < claimWarningInterval { + s.Logger.Debug("Claim publication deferred", "error", err) + return + } + s.Logger.Warn("Claim publication deferred", "error", err) + *warnedAt = time.Now() +} diff --git a/internal/claimer/publisher_selection_test.go b/internal/claimer/publisher_selection_test.go new file mode 100644 index 000000000..7869e78dc --- /dev/null +++ b/internal/claimer/publisher_selection_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/stretchr/testify/require" +) + +func TestClaimPublisherSkipsStoppedApplicationsBeforePagination(t *testing.T) { + // Exercise the repair and acceptance sources as well as fresh submission. + for _, status := range []model.EpochStatus{ + model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged, + } { + t.Run(status.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + for id := int64(1); id <= 40; id++ { + app, _ := queueDelivery(t, backend, lookup, id) + app.Status = model.ApplicationStatus_Diverged + } + foreclosed, _ := queueDelivery(t, backend, lookup, 41) + foreclosed.ForecloseBlock = 1 + ready, epoch := queueDelivery(t, backend, lookup, 42) + for _, epochs := range lookup.epochs { + for _, stored := range epochs { + stored.Status = status + } + } + backend.claims[ready.ID] = makeClaimStatus(claimStatusUnstaged, epoch, 0) + if status == model.EpochStatus_ClaimStaged { + ready.ClaimStagingPeriod = 0 + backend.claims[ready.ID] = makeClaimStatus(claimStatusStaged, epoch, 1) + } + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1, "healthy work must publish in the first pass") + contractABI, err := iconsensus.IConsensusMetaData.GetAbi() + require.NoError(t, err) + method, err := contractABI.MethodById(backend.published[0].Data()[:4]) + require.NoError(t, err) + args, err := method.Inputs.Unpack(backend.published[0].Data()[4:]) + require.NoError(t, err) + require.Equal(t, ready.IApplicationAddress, args[0]) + }) + } +} diff --git a/internal/claimer/publisher_test.go b/internal/claimer/publisher_test.go new file mode 100644 index 000000000..582c495c8 --- /dev/null +++ b/internal/claimer/publisher_test.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestPublisherCancellationStopsItsLoop(t *testing.T) { + s, _, _ := newDeliveryFixture(t) + s.publisher.interval = time.Hour + ctx, cancel := context.WithCancel(t.Context()) + cancel() + finished := make(chan error, 1) + go func() { finished <- s.publisher.run(ctx) }() + select { + case err := <-finished: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("publisher loop ignored cancellation") + } +} diff --git a/internal/claimer/quorum_audit_test.go b/internal/claimer/quorum_audit_test.go new file mode 100644 index 000000000..9dadec698 --- /dev/null +++ b/internal/claimer/quorum_audit_test.go @@ -0,0 +1,125 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestQuorumStagingPreservesLocalVoteIdentityThroughBothObservationPaths(t *testing.T) { + const submittedPath, canonicalLocalVote = "submitted to staged", "canonical local vote" + const readerEvidence = "reader mode" + for _, path := range []string{submittedPath, "refresh staged"} { + for _, evidence := range []string{canonicalLocalVote, "replacement local vote", "only peer votes", readerEvidence} { + t.Run(path+"/"+evidence, func(t *testing.T) { + s, repo, chain := newServiceMock(t) + s.defaultBlock = model.DefaultBlock_Latest + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + epoch := makeStagedEpoch(app, 3, 44) + if path == submittedPath { + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimSubmitted, nil + } + old := *epoch.ClaimTransactionHash + staged := makeStagedEvent(app, epoch) + staged.Raw.BlockNumber, staged.Raw.TxHash = 46, common.HexToHash("0xdec1de") + local := makeSubmittedEventWithTxHash(app, epoch, common.HexToHash("0x10ca1")) + local.Raw.BlockNumber, local.Submitter = 45, chain.submitterAddress + foreign := makeSubmittedEventWithTxHash(app, epoch, common.HexToHash("0xf0e1")) + foreign.Submitter = common.HexToAddress("0xbeef") + want := new(old) + if evidence == readerEvidence { + s.submissionEnabled = false + want = new(staged.Raw.TxHash) + } else { + chain.On("claimTransactionCanonical", mock.Anything, old, uint64(60)). + Return(evidence == canonicalLocalVote, nil).Once() + if evidence != canonicalLocalVote { + events := []*iconsensus.IConsensusClaimSubmitted{foreign} + want = nil + if evidence == "replacement local vote" { + events = append(events, local) + want = new(local.Raw.TxHash) + } + chain.On("findClaimSubmittedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, events, nil).Once() + } + } + chain.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, staged, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + if path == submittedPath { + chain.On("getConsensusAddress", mock.Anything, app, big.NewInt(60)).Return(app.IConsensusAddress, nil).Once() + if evidence != canonicalLocalVote { + repo.On("RefreshClaimObservation", mock.Anything, epoch, want, (*uint64)(nil)).Return(nil).Once() + } + repo.On("UpdateEpochToStaged", mock.Anything, app.ID, epoch.Index, uint64(46)).Return(nil).Once() + result := s.processSubmittedClaim(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(60)) + require.NoError(t, result.err) + require.Equal(t, 1, result.progress, "a peer majority stages even when our orphaned vote has no replacement") + } else { + repo.On("RefreshClaimObservation", mock.Anything, epoch, want, new(uint64(46))).Return(nil).Once() + require.NoError(t, s.refreshStagedClaim(t.Context(), app, epoch, makeClaimStatus(claimStatusStaged, epoch, 46), 60)) + require.EqualValues(t, 46, *epoch.StagedAtBlock) + } + require.Equal(t, want, epoch.ClaimTransactionHash) + if evidence != readerEvidence && want != nil { + require.NotEqual(t, staged.Raw.TxHash, *want, "a peer's deciding vote cannot become our vote hash") + } + chain.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } + } +} + +func TestUnrelatedClaimEventCannotSetDivergenceForTheSelectedEpoch(t *testing.T) { + const wrongApplication = "application" + for _, path := range []string{"computed acceptance", "submitted staging"} { + for _, unrelated := range []string{wrongApplication, "epoch"} { + t.Run(path+"/"+unrelated, func(t *testing.T) { + s, repo, chain := newServiceMock(t) + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + if path == "computed acceptance" { + event := makeAcceptedEvent(app, epoch) + event.MachineMerkleRoot = common.HexToHash("0xbad") + if unrelated == wrongApplication { + event.AppContract = common.HexToAddress("0xbeef") + } else { + event.LastProcessedBlockNumber = new(big.Int).SetUint64(epoch.LastBlock + 10) + } + chain.On("findClaimAcceptedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, event, (*iconsensus.IConsensusClaimAccepted)(nil), nil).Once() + result, done := s.reconcileComputedAcceptedEvent(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(60)) + require.NoError(t, result.err) + require.False(t, done) + } else { + epoch.Status = model.EpochStatus_ClaimSubmitted + event := makeStagedEvent(app, epoch) + event.MachineMerkleRoot = common.HexToHash("0xbad") + if unrelated == wrongApplication { + event.AppContract = common.HexToAddress("0xbeef") + } else { + event.LastProcessedBlockNumber = new(big.Int).SetUint64(epoch.LastBlock + 10) + } + chain.On("getConsensusAddress", mock.Anything, app, big.NewInt(60)).Return(app.IConsensusAddress, nil).Once() + chain.On("findClaimStagedEventAndSucc", mock.Anything, app, epoch, epoch.LastBlock+1, uint64(60)). + Return(&iconsensus.IConsensus{}, event, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + require.NoError(t, s.processSubmittedClaim(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(60)).err) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "RejectEpochAndSetApplicationDiverged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + chain.AssertExpectations(t) + }) + } + } +} diff --git a/internal/claimer/refusal_test.go b/internal/claimer/refusal_test.go new file mode 100644 index 000000000..ece6edb2f --- /dev/null +++ b/internal/claimer/refusal_test.go @@ -0,0 +1,24 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestClaimerHardCapabilityFailureAffectsReadinessWithoutAHealthWrite(t *testing.T) { + s, _, blockchain := newServiceMock(t) + s.submissionEnabled = false + blockchain.On("getDefaultBlockNumber", mock.Anything).Return((*big.Int)(nil), txsender.ErrUnsupported).Once() + require.True(t, s.Ready()) + progress, err := s.Tick(t.Context()) + require.False(t, progress) + require.ErrorIs(t, err, txsender.ErrUnsupported) + require.True(t, s.Ready(), "a retryable unsupported read is not a permanent prerequisite fault") + blockchain.AssertExpectations(t) +} diff --git a/internal/claimer/repository.go b/internal/claimer/repository.go new file mode 100644 index 000000000..039c548af --- /dev/null +++ b/internal/claimer/repository.go @@ -0,0 +1,74 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + + "github.com/ethereum/go-ethereum/common" +) + +type iclaimerRepository interface { + ListApplicationsByID(context.Context, repository.ApplicationFilter, int64, int64, uint64) ([]*model.Application, error) + SelectClaimsToSubmitPage(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error) + SelectClaimsToStagePage(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error) + SelectClaimsToAcceptPage(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error) + + UpdateEpochWithSubmittedClaim( + ctx context.Context, + applicationID int64, + index uint64, + transactionHash common.Hash, + ) error + RefreshClaimObservation( + ctx context.Context, expected *model.Epoch, transactionHash *common.Hash, stagedAtBlock *uint64, + ) error + + UpdateEpochToStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + + UpdateEpochReconciledStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + + UpdateEpochWithAcceptedClaim( + ctx context.Context, + applicationID int64, + index uint64, + txHash *common.Hash, + ) error + UpdateEpochWithForeclosedClaim( + ctx context.Context, + applicationID int64, + index uint64, + ) error + + RejectEpochAndSetApplicationDiverged( + ctx context.Context, + applicationID int64, + index uint64, + reason string, + ) (repository.RejectEpochAndDivergeResult, error) + + UpdateApplicationStatus( + ctx context.Context, + appID int64, + status model.ApplicationStatus, + reason *string, + ) error + + HasUndrainedEpochsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + HasUnreconciledClaimsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + ForecloseUnacceptedEpochsAtOrAfterBlock(ctx context.Context, appID int64, blockBound uint64) (int64, error) +} diff --git a/internal/claimer/selection_failure_test.go b/internal/claimer/selection_failure_test.go new file mode 100644 index 000000000..3be543d99 --- /dev/null +++ b/internal/claimer/selection_failure_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClaimSelectionFailureDoesNotBlockHeldMaintenanceOrCompletion(t *testing.T) { + for _, completed := range []bool{false, true} { + name := "maintenance" + if completed { + name = "completion" + } + t.Run(name, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + require.NoError(t, s.publisher.tick(t.Context())) + lookup.pageErr = errors.New("selection unavailable") + backend.last++ + if completed { + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 40) + } + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 2) + require.Equal(t, backend.published[0].Nonce(), backend.published[1].Nonce()) + if completed { + require.Empty(t, backend.published[1].Data()) + } else { + require.Equal(t, backend.raw[0], backend.raw[1]) + } + }) + } +} + +func TestClaimSelectionFailureIsVisibleAndDoesNotLoseCommittedCandidate(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + queueDelivery(t, backend, lookup, 1) + lookup.pageErr = errors.New("database selection unavailable") + require.ErrorIs(t, s.publisher.tick(t.Context()), lookup.pageErr) + require.Empty(t, backend.published) + lookup.pageErr = nil + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) +} diff --git a/internal/claimer/service.go b/internal/claimer/service.go new file mode 100644 index 000000000..7fc73bfb6 --- /dev/null +++ b/internal/claimer/service.go @@ -0,0 +1,206 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync/atomic" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +type CreateInfo struct { + Config config.ClaimerConfig + Logger *slog.Logger + EthConn *ethclient.Client + Repository repository.Repository + TransactOptsFactory ethutil.TransactOptsFactory +} + +type Service struct { + service.TickServiceTemplate + + repository iclaimerRepository + blockchain iclaimerBlockchain + claimObserver *independentClaimObserver + + // publisher owns all volatile transaction state. PostgreSQL is the only + // handoff to the observer's independent domain traversal. + publisher *claimPublisher + pageCursors [3]repository.ClaimCursor + foreclosePageCursor int64 + + // consensusAddressChecks memoizes consensus-address drift checks during one + // Tick. An app can move through multiple claim stages in a single tick, and + // each stage guards against consensus replacement. The underlying eth_call is + // block-pinned, so one result per (app, block) is enough. + consensusAddressChecks map[consensusAddressCheckKey]error + + submissionEnabled bool + defaultBlock model.DefaultBlock + observationHardWarnedAt time.Time + failure atomic.Pointer[error] + publicationReady atomic.Bool + deliveryRepository repository.DeliveryRepository + signer common.Address +} + +const ClaimerConfigKey = "claimer" + +type PersistentConfig = config.PersistentSubmitterConfig + +func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { + var err error + + if c == nil { + return nil, errors.New("invalid CreateInfo is nil") + } + if err = ctx.Err(); err != nil { + return nil, err // This returns context.Canceled or context.DeadlineExceeded. + } + if c.Repository == nil { + return nil, fmt.Errorf("repository on claimer service Create is nil") + } + + s := &Service{} + tickCfg := &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: config.ServiceClaimer, + Logger: c.Logger, + LogLevel: c.Config.LogLevel, + LogColor: c.Config.LogColor, + }, + PollInterval: c.Config.ClaimerPollingInterval, + } + err = service.InitTickServiceTemplate(&s.TickServiceTemplate, tickCfg, s) + if err != nil { + return nil, err + } + + authOpt, err := config.HTTPAuthorizationOption() + if err != nil { + return nil, err + } + + ethClient := c.EthConn + if ethClient == nil { + //nolint:forbidigo // Protected Ethereum connection. + ethClient, err = ethutil.NewEthClient(ctx, c.Config.BlockchainHttpEndpoint.Raw(), s.Logger, + ethutil.RetryConfig{ + MaxRetries: c.Config.BlockchainHttpMaxRetries, + RetryMinWait: c.Config.BlockchainHttpRetryMinWait, + RetryMaxWait: c.Config.BlockchainHttpRetryMaxWait, + RequestTimeout: c.Config.BlockchainHttpRequestTimeout, + }, authOpt) + if err != nil { + return nil, err + } + } + + chainID, err := ethClient.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("querying chain ID: %w", err) + } + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err + } + var txOptsFactory ethutil.TransactOptsFactory + if c.Config.FeatureClaimSubmissionEnabled { + if c.Config.BlockchainHttpRequestTimeout <= 0 { + return nil, fmt.Errorf("BlockchainHttpRequestTimeout must be positive") + } + txOptsFactory = c.TransactOptsFactory + if txOptsFactory == nil { + txOptsFactory, err = auth.GetTransactOptsFactory(ctx, chainID) + if err != nil { + return nil, fmt.Errorf("getting transaction options: %w", err) + } + } + s.signer = txOptsFactory.From() + s.Logger.Info("Claim submitter identity", "address", s.signer) + } + nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config, s.signer) + if err != nil { + return nil, fmt.Errorf("setting up persistent config: %w", err) + } + s.submissionEnabled = nodeConfig.ClaimSubmissionEnabled + s.defaultBlock = nodeConfig.DefaultBlock + if nodeConfig.DefaultBlock == model.DefaultBlock_Latest || nodeConfig.DefaultBlock == model.DefaultBlock_Safe { + s.Logger.Warn("Claim observation stores provisional chain facts; reorg rollback is unsupported", + "default_block", nodeConfig.DefaultBlock) + } + s.deliveryRepository = c.Repository + backend := txsender.ProtectedClient{Client: ethClient} + if s.submissionEnabled { + policy, policyErr := txsender.NewPolicy(txsender.FinancialConfig{ + MaxFeePerGas: c.Config.BlockchainTxMaxFeePerGas, + Legacy: c.Config.BlockchainLegacyEnabled, + SigningTimeout: c.Config.BlockchainHttpRequestTimeout, + }) + if policyErr != nil { + return nil, policyErr + } + publisher := &claimPublisher{ + repository: c.Repository, lookup: c.Repository, backend: backend, + signer: s.signer, Logger: s.Logger, interval: c.Config.ClaimerPollingInterval, + failure: &s.failure, cooldowns: map[claimAction]time.Time{}, + observationPolicy: nodeConfig.DefaultBlock, + } + publisher.sender, err = txsender.New[claimAction](backend, txOptsFactory, chainID.Uint64(), policy, + s.guardClaimPublication, s.Logger) + if err != nil { + return nil, err + } + s.publisher = publisher + } + + s.repository = c.Repository + blockchain := &claimerBlockchain{ + logger: s.Logger, + client: ethClient, + txOptsFactory: txOptsFactory, + defaultBlock: nodeConfig.DefaultBlock, + backend: backend, + } + s.blockchain = blockchain + s.claimObserver = &independentClaimObserver{repository: c.Repository, blockchain: blockchain, logger: s.Logger, + policy: nodeConfig.DefaultBlock, maxRange: c.Config.BlockchainMaxBlockRange, budget: tickCfg.PollInterval} + + s.Logger.Info("Created", "config", c.Config) + + return s, nil +} + +func setupPersistentConfig( + ctx context.Context, + logger *slog.Logger, + repo repository.DeliveryRepository, + c *config.ClaimerConfig, + signer common.Address, +) (*PersistentConfig, error) { + requested := PersistentConfig{ + DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId, + ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + ResolvedSignerAddress: signer, + } + saved, err := repo.RegisterSubmitter(ctx, ClaimerConfigKey, requested) + if err != nil { + return nil, err + } + logger.Info("Claimer persistent config matches requested config", "config", saved) + return &saved, nil +} diff --git a/internal/claimer/service_test.go b/internal/claimer/service_test.go new file mode 100644 index 000000000..af8a1452a --- /dev/null +++ b/internal/claimer/service_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "errors" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSetupPersistentConfigUsesAtomicRegistration(t *testing.T) { + for _, enabled := range []bool{false, true} { + signer := common.Address{} + if enabled { + signer = common.HexToAddress("0x1") + } + requested := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: enabled, ResolvedSignerAddress: signer, + } + for _, registrationErr := range []error{nil, errors.New("concurrent config differs")} { + s, _, _ := newServiceMock(t) + repo := &claimerCreateRepositoryMock{} + repo.On("RegisterSubmitter", mock.Anything, ClaimerConfigKey, requested).Return(requested, registrationErr).Once() + got, err := setupPersistentConfig(t.Context(), s.Logger, repo, &config.ClaimerConfig{ + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, FeatureClaimSubmissionEnabled: enabled, + }, signer) + if registrationErr == nil { + require.NoError(t, err) + require.Equal(t, requested, *got) + } else { + require.ErrorIs(t, err, registrationErr) + require.Nil(t, got) + } + repo.AssertExpectations(t) + } + } +} + +func TestCreateRegistersInjectedPublicSignerBeforePublication(t *testing.T) { + viper.Set(config.AUTH_KIND, "invalid") + t.Cleanup(func() { viper.Set(config.AUTH_KIND, nil) }) + signer := common.HexToAddress("0xa1") + factory := ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: signer}) + requested := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: true, ResolvedSignerAddress: signer, + } + repo := &claimerCreateRepositoryMock{} + registrationErr := errors.New("saved observer policy differs") + repo.On("RegisterSubmitter", mock.Anything, ClaimerConfigKey, requested). + Return(PersistentConfig{}, registrationErr).Once() + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + }, + EthConn: newTestEthClient(t, 42), Repository: repo, TransactOptsFactory: factory, + }) + require.Nil(t, svc) + require.ErrorIs(t, err, registrationErr, "the injected identity is used without resolving auth again") + repo.AssertExpectations(t) +} + +func TestCreateRejectsUnresolvedSignerBeforeRegistration(t *testing.T) { + viper.Set(config.AUTH_KIND, "invalid") + t.Cleanup(func() { viper.Set(config.AUTH_KIND, nil) }) + repo := &claimerCreateRepositoryMock{} + svc, err := Create(t.Context(), &CreateInfo{Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: true, BlockchainHttpRequestTimeout: time.Second, + }, EthConn: newTestEthClient(t, 42), Repository: repo}) + require.Nil(t, svc) + require.ErrorContains(t, err, "getting transaction options") + repo.AssertNotCalled(t, "RegisterSubmitter", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestCreateReaderUsesRegisteredPolicy(t *testing.T) { + persisted := PersistentConfig{DefaultBlock: model.DefaultBlock_Latest, ChainID: 42, ClaimSubmissionEnabled: false} + repo := &claimerCreateRepositoryMock{} + repo.On("RegisterSubmitter", mock.Anything, ClaimerConfigKey, persisted).Return(persisted, nil).Once() + svc, err := Create(t.Context(), &CreateInfo{Config: config.ClaimerConfig{ + ClaimerPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Latest, + }, EthConn: newTestEthClient(t, 42), Repository: repo}) + require.NoError(t, err) + impl := svc.(*Service) + require.False(t, impl.submissionEnabled) + require.Equal(t, model.DefaultBlock_Latest, impl.blockchain.(*claimerBlockchain).defaultBlock) + require.True(t, impl.Ready()) + require.Nil(t, impl.publisher) + require.NotNil(t, impl.claimObserver, "reader mode must observe claims independently of local publication") + require.Equal(t, model.DefaultBlock_Latest, impl.claimObserver.policy) + repo.AssertExpectations(t) +} diff --git a/internal/claimer/stage.go b/internal/claimer/stage.go new file mode 100644 index 000000000..8ad861876 --- /dev/null +++ b/internal/claimer/stage.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" +) + +// stageClaimsAndUpdateDatabase looks for ClaimStaged events on chain and moves +// local epochs from CLAIM_SUBMITTED to CLAIM_STAGED. +// +// If the event is for our epoch but has a different machine root, the app is +// set to DIVERGED. The reason tells the guardian to call foreclose() before +// the staging period ends. +func (s *Service) stageClaimsAndUpdateDatabase( + ctx context.Context, + submittedEpochs map[int64]*model.Epoch, + apps map[int64]*model.Application, + defaultBlockNumber *big.Int, +) (int, error) { + return s.observeClaims(ctx, submittedEpochs, apps, defaultBlockNumber, s.processSubmittedClaim) +} + +func (s *Service) processSubmittedClaim( + ctx context.Context, + work claimWork, + defaultBlockNumber *big.Int, +) claimStepResult { + app := work.app + currEpoch := work.epoch + if err := requireObservedStaging(currEpoch, defaultBlockNumber.Uint64()); err != nil { + return claimRetryLater(err) + } + + if err := s.checkConsensusForAddressChange(ctx, app, defaultBlockNumber); err != nil { + return claimDropped(err) + } + + fromBlock := currEpoch.LastBlock + 1 + + _, currEvent, _, err := s.blockchain.findClaimStagedEventAndSucc( + ctx, app, currEpoch, fromBlock, defaultBlockNumber.Uint64(), + ) + if err != nil { + return claimDropped(err) + } + + if currEvent != nil && !claimStagedEventMatchesEpoch(app, currEpoch, currEvent) { + currEvent = nil + } + if currEvent != nil { + s.Logger.Debug("Found ClaimStaged Event", + "app", currEvent.AppContract, + "outputs_merkle_root", fmt.Sprintf("%x", currEvent.OutputsMerkleRoot), + "machine_hash", fmt.Sprintf("%x", currEvent.MachineMerkleRoot), + "last_block", currEvent.LastProcessedBlockNumber.Uint64(), + ) + matches, ok := claimStagedEventMatches(app, currEpoch, currEvent) + if !ok { + return claimDropped(s.markMatcherPrecondFailure(ctx, app, currEpoch, "stageClaimsAndUpdateDatabase")) + } + if !matches { + return claimDropped(s.markStagingDivergence(ctx, app, currEpoch, currEvent, "stageClaimsAndUpdateDatabase")) + } + if s.defaultBlock.Provisional() { + hash, err := s.stagedClaimAuditHash(ctx, app, currEpoch, currEvent.Raw.TxHash, defaultBlockNumber.Uint64()) + if err != nil { + return claimRetryLater(err) + } + if err := s.refreshClaimMetadata(ctx, currEpoch, hash, nil); err != nil { + return claimRetryLater(err) + } + } + err = s.repository.UpdateEpochToStaged( + ctx, currEpoch.ApplicationID, currEpoch.Index, + currEvent.Raw.BlockNumber) + if err != nil { + return claimDropped(err) + } + s.Logger.Info("Claim staged", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), + "last_block", currEpoch.LastBlock, + "staged_at_block", currEvent.Raw.BlockNumber, + ) + return claimWorkCompleted(1) + } + + // Foreclosed apps cannot stage new claims. If no matching ClaimStaged event + // exists up to this block, this submitted claim has no remaining on-chain + // path. + if app.ForecloseBlock != 0 { + if ferr := s.forecloseClaim(ctx, app, currEpoch, defaultBlockNumber.Uint64(), "stageClaimsAndUpdateDatabase"); ferr != nil { + return claimDropped(ferr) + } + return claimWorkCompleted(1) + } + if s.defaultBlock.Provisional() { + if err := s.refreshSubmittedClaim(ctx, app, currEpoch, defaultBlockNumber.Uint64()); err != nil { + return claimRetryLater(err) + } + } + return claimNoProgress() +} diff --git a/internal/claimer/stage_test.go b/internal/claimer/stage_test.go new file mode 100644 index 000000000..85ee85080 --- /dev/null +++ b/internal/claimer/stage_test.go @@ -0,0 +1,202 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestStageByObservation(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeSubmittedEpoch(app, 3) + currEvent := makeStagedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("UpdateEpochToStaged", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.BlockNumber). + Return(nil).Once() + + transitions, err := m.stageClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 1, transitions) +} + +func TestStageForeclosesSubmittedForeclosedApp(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := withForeclosed(makeApplication(), 80) + currEpoch := makeSubmittedEpoch(app, 3) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, (*iconsensus.IConsensusClaimStaged)(nil), (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, currEpoch.Index). + Return(nil).Once() + + transitions, err := m.stageClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 1, transitions) + assert.Equal(t, model.EpochStatus_ClaimForeclosed, currEpoch.Status) +} + +// TestStagingDivergence_Quorum — Quorum case where ClaimStaged is observed +// with a machineMerkleRoot != ours → CLAIM_REJECTED and DIVERGED with +// quorum_divergence_at_staging. +func TestStagingDivergence_Quorum(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeSubmittedEpoch(app, 3) + + // Divergent event: different machineMerkleRoot. + differentMachineMerkleRoot := common.HexToHash("0xfeed") + divergent := &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: differentMachineMerkleRoot, + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("RejectEpochAndSetApplicationDiverged", mock.Anything, app.ID, currEpoch.Index, mock.MatchedBy(func(reason string) bool { + return strings.Contains(reason, "quorum_divergence_at_staging") + })). + Return(repository.RejectEpochAndDivergeResult{ + EpochRejected: true, + ApplicationDiverged: true, + }, nil).Once() + + _, err := m.stageClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Diverged, app.Status) + assert.Equal(t, model.EpochStatus_ClaimRejected, currEpoch.Status) +} + +func TestStagingDivergence_AuthorityDoesNotRejectEpoch(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeSubmittedEpoch(app, 3) + + divergent := &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: common.HexToHash("0xfeed"), + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + + _, err := m.stageClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Diverged, app.Status) + assert.Equal(t, model.EpochStatus_ClaimSubmitted, currEpoch.Status) +} + +func TestStagingMatcherPreconditionFailureMarksApplicationCorrupted(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeSubmittedEpoch(app, 3) + event := makeStagedEvent(app, currEpoch) + currEpoch.MachineHash = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, event, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "cannot compare epoch") + })). + Return(nil).Once() + + _, err := m.stageClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, model.ApplicationStatus_Corrupted, app.Status) +} + +// TestAcceptStagedFrontRunner — staging period elapsed; pre-flight getClaim +// returns ACCEPTED (status=2) before our acceptClaim → reconcile to + +func TestStagingDivergenceReaderMode_Quorum(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + m.submissionEnabled = false + + endBlock := big.NewInt(100) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeSubmittedEpoch(app, 3) + + differentMachineMerkleRoot := common.HexToHash("0xfeed") + divergent := &iconsensus.IConsensusClaimStaged{ + LastProcessedBlockNumber: new(big.Int).SetUint64(currEpoch.LastBlock), + AppContract: app.IApplicationAddress, + OutputsMerkleRoot: *currEpoch.TxBufferDataBlock, + MachineMerkleRoot: differentMachineMerkleRoot, + } + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + b.On("findClaimStagedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, divergent, (*iconsensus.IConsensusClaimStaged)(nil), nil).Once() + r.On("RejectEpochAndSetApplicationDiverged", mock.Anything, app.ID, currEpoch.Index, mock.MatchedBy(func(reason string) bool { + return strings.Contains(reason, "quorum_divergence_at_staging") + })). + Return(repository.RejectEpochAndDivergeResult{ + EpochRejected: true, + ApplicationDiverged: true, + }, nil).Once() + + _, err := m.stageClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err, "divergence detection must fire in reader mode") + assert.Equal(t, model.EpochStatus_ClaimRejected, currEpoch.Status) +} + +// TestAcceptanceDivergenceReaderMode_Quorum — reader-mode parity for the +// acceptance stage. submissionEnabled doesn't gate event-based divergence +// detection; the DIVERGED transition must fire identically, but a staged +// epoch remains CLAIM_STAGED because a different accepted claim is an invariant diff --git a/internal/claimer/staging_contradiction_test.go b/internal/claimer/staging_contradiction_test.go new file mode 100644 index 000000000..1e95dec30 --- /dev/null +++ b/internal/claimer/staging_contradiction_test.go @@ -0,0 +1,180 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + claimFreshEntry = "fresh" + claimRetainedEntry = "retained" + claimRecheckEntry = "recheck" +) + +func TestPublisherMissingPersistedStageBeforeObserver(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Finalized, model.DefaultBlock_Latest, model.DefaultBlock_Safe} { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_OK, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(policy.String()+"/"+status.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, policy) + app, epoch := queueDelivery(t, backend, lookup, 1) + if status != model.ApplicationStatus_OK { + setClaimTestStop(lookup, app, epoch.Index+1, status) + } + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + // Only latest/safe may repair the vanished stage before the observer runs. + err := s.publisher.tick(t.Context()) + if policy.Provisional() { + require.NoError(t, err) + require.Len(t, backend.published, 1) + require.Equal(t, claimSubmitMethod, publishedClaimMethod(t, backend.published[0].Data())) + } else { + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.True(t, txsender.WaitOnly(err)) + require.Empty(t, backend.published) + } + }) + } + } +} + +// This contradictory read alone supplies no authorization to submit, cancel +// or replace retained work. A priority decision with independent evidence keeps +// its own rules. The observer outcome is covered separately below. +func TestFinalizedMissingPersistedStageIsLeftForObserver(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_OK, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + for _, entry := range []string{claimFreshEntry, claimRetainedEntry, claimRecheckEntry} { + t.Run(status.String()+"/"+entry, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Finalized) + app, epoch := queueDelivery(t, backend, lookup, 1) + if status != model.ApplicationStatus_OK { + setClaimTestStop(lookup, app, epoch.Index+1, status) + } + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 30) + unstage := func() { delete(backend.claims, app.ID) } + signed := false + switch entry { + case claimFreshEntry: + unstage() + case claimRetainedEntry: + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + require.Equal(t, "acceptClaim", publishedClaimMethod(t, backend.published[0].Data())) + action := s.publisher.lastChoice + unstage() + block := txsender.BlockRef{Number: backend.last, Hash: deliveryHeader(backend.last).Hash()} + _, err := s.publisher.Recheck(t.Context(), block, action) + require.ErrorIs(t, err, txsender.ErrInconclusive) + case claimRecheckEntry: + useClaimSigningHook(t, s, backend, func() { signed = true; unstage() }) + } + posts := len(backend.published) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + if entry == claimRecheckEntry { + require.True(t, signed, "the initial decision must reach the signer before the stage disappears") + } + require.Len(t, backend.published, posts, "a read of the contradiction must not authorize POST or cancellation") + }) + } + } +} + +// useClaimSigningHook invokes after a successful signature, before the pre-send recheck. +func useClaimSigningHook(t *testing.T, s *Service, backend *deliveryBackend, after func()) { + t.Helper() + key, err := crypto.HexToECDSA("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(31337)) + require.NoError(t, err) + factory := claimSigningHookFactory{TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(opts), after: after} + s.publisher.sender, err = txsender.New[claimAction](backend, factory, 31337, + txsender.Policy{MaxFee: big.NewInt(1000)}, func(context.Context) error { return nil }, s.Logger) + require.NoError(t, err) +} + +func TestSubmittedClaimWithoutPersistedStageCanBeRepublished(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + _, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status = model.EpochStatus_ClaimSubmitted + require.Nil(t, epoch.StagedAtBlock) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1, "submission alone is not evidence of staging") + require.Equal(t, claimSubmitMethod, publishedClaimMethod(t, backend.published[0].Data())) +} + +func TestUnknownClaimStatusPreservesStopAndRefusesPublication(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + setClaimTestStop(lookup, app, epoch.Index+1, status) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + claim := makeClaimStatus(3, epoch, 30) + backend.claims[app.ID] = claim + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrUnsupported) + require.Empty(t, backend.published) + result, done := s.handlePreAcceptClaimStatus(t.Context(), app, epoch, claim, backend.last) + require.True(t, done) + require.NoError(t, result.err) + require.Equal(t, status, app.Status) + require.Empty(t, s.repository.(*claimerRepositoryMock).Calls) + }) + } +} + +func TestStagingContradictionRecoveryReason(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_OK, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + s, _, _ := newDeliveryFixture(t) + app := makeApplication() + app.Status, app.Reason = status, new("original execution or root diagnosis") + epoch := makeStagedEpoch(app, 3, 30) + expected := model.ApplicationStatus_Corrupted + if status == model.ApplicationStatus_OK { + expected = model.ApplicationStatus_Failed + } + repo := s.repository.(*claimerRepositoryMock) + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, expected, mock.Anything).Return(nil).Once() + _, done := s.handlePreAcceptClaimStatus(t.Context(), app, epoch, makeClaimStatus(claimStatusUnstaged, epoch, 0), 50) + require.True(t, done) + require.Equal(t, expected, app.Status) + require.Contains(t, *app.Reason, "UNSTAGED") + if status == model.ApplicationStatus_OK { + require.Contains(t, *app.Reason, "endpoint") + require.Contains(t, *app.Reason, "re-enabling") + require.Contains(t, *app.Reason, "fresh database") + } else { + require.Contains(t, *app.Reason, "fresh database") + require.Contains(t, *app.Reason, "foreclosure drain") + } + repo.AssertExpectations(t) + }) + } +} + +// A head behind the persisted staging fact is inconclusive even under a +// provisional policy. This tests the observed result, not the ordering of guards. +func TestMissingStageBelowPersistedStagingHeightWaits(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t, model.DefaultBlock_Latest) + app, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(backend.last+1) + block := txsender.BlockRef{Number: backend.last, Hash: deliveryHeader(backend.last).Hash()} + _, _, err := s.publisher.decide(t.Context(), block, repository.ClaimWork{Application: app, Epoch: epoch}, false) + require.ErrorIs(t, err, txsender.ErrInconclusive) +} diff --git a/internal/claimer/staging_warning_test.go b/internal/claimer/staging_warning_test.go new file mode 100644 index 000000000..d50ef28f0 --- /dev/null +++ b/internal/claimer/staging_warning_test.go @@ -0,0 +1,150 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "errors" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const stagingContradictionWarning = "Claim publication waiting: recorded staging contradicts latest view" + +func TestStagingContradictionWarnsAcrossPublicationPaths(t *testing.T) { + for _, entry := range []string{claimFreshEntry, claimRetainedEntry, claimRecheckEntry} { + t.Run(entry, func(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 30) + switch entry { + case claimFreshEntry: + delete(backend.claims, app.ID) + case claimRetainedEntry: + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1) + delete(backend.claims, app.ID) + case claimRecheckEntry: + useClaimSigningHook(t, s, backend, func() { delete(backend.claims, app.ID) }) + } + logs := capturePublicationLogs(s) + before := len(backend.published) + for range 3 { + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + } + require.Len(t, backend.published, before, "the warning cannot authorize publication or cancellation") + require.Equal(t, 1, strings.Count(logs.String(), stagingContradictionWarning)) + for _, field := range []string{"level=WARN", "application=" + app.Name, "epoch=3", + "staged_at_block=30", "latest_block=50", "observation_policy=FINALIZED"} { + require.Contains(t, logs.String(), field) + } + key := claimAction{ApplicationID: app.ID, Address: app.IApplicationAddress, Epoch: epoch.Index} + s.publisher.stagingWarnings[key] = time.Now().Add(-time.Second) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Equal(t, 2, strings.Count(logs.String(), stagingContradictionWarning)) + }) + } +} + +func TestStagingWarningKeysDoNotShareOtherRefusalClocks(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + logs := capturePublicationLogs(s) + app, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + work := repository.ClaimWork{Application: app, Epoch: epoch} + s.publisher.cooldowns[claimPlanningRefusal(work)] = time.Now().Add(time.Hour) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Equal(t, 1, strings.Count(logs.String(), stagingContradictionWarning), "other refusals must not hide this cause") + + block := txsender.BlockRef{Number: backend.last, Hash: deliveryHeader(backend.last).Hash()} + later := makeComputedEpoch(app, epoch.Index+1) + later.Status, later.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + lookup.add(app, later) + _, _, err := s.publisher.decide(t.Context(), block, repository.ClaimWork{Application: app, Epoch: later}, false) + require.ErrorIs(t, err, txsender.ErrInconclusive) + other, otherEpoch := queueDelivery(t, backend, lookup, 2) + otherEpoch.Status, otherEpoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + _, _, err = s.publisher.decide(t.Context(), block, repository.ClaimWork{Application: other, Epoch: otherEpoch}, false) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, 3, strings.Count(logs.String(), stagingContradictionWarning), + "different apps and epochs have their own diagnostic clocks") +} + +func TestStagingWarningDoesNotDelayRecoveredPublication(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + key := claimAction{ApplicationID: app.ID, Address: app.IApplicationAddress, Epoch: epoch.Index} + require.True(t, time.Now().Before(s.publisher.stagingWarnings[key])) + backend.claims[app.ID] = makeClaimStatus(claimStatusStaged, epoch, 30) + require.NoError(t, s.publisher.tick(t.Context())) + require.Len(t, backend.published, 1, "the diagnostic interval is not an action cooldown") + require.Equal(t, "acceptClaim", publishedClaimMethod(t, backend.published[0].Data())) +} + +func TestStagingWarningPrecedesObserverMaturityWithoutDiagnosing(t *testing.T) { + s, backend, lookup := newDeliveryFixture(t) + app, epoch := queueDelivery(t, backend, lookup, 1) + app.ClaimStagingPeriod = 100 + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + logs := capturePublicationLogs(s) + require.ErrorIs(t, s.publisher.tick(t.Context()), txsender.ErrInconclusive) + require.Contains(t, logs.String(), stagingContradictionWarning) + chain := s.blockchain.(*claimerBlockchainMock) + chain.On("getConsensusAddress", mock.Anything, app, big.NewInt(45)).Return(app.IConsensusAddress, nil).Once() + result := s.processStagedClaim(t.Context(), claimWork{app: app, epoch: epoch}, big.NewInt(45)) + require.NoError(t, result.err) + require.Zero(t, result.progress) + require.Empty(t, s.repository.(*claimerRepositoryMock).Calls, "no status write before the observer's maturity check") + chain.AssertExpectations(t) // An unexpected getClaim call would fail the mock. +} + +func TestStagingWarningRequiresTheSpecificContradiction(t *testing.T) { + const ( + latestCase = "latest" + safeCase = "safe" + submittedCase = "submitted" + rpcFailureCase = "RPC error" + laggingHeadCase = "lagging head" + ) + for _, scenario := range []string{latestCase, safeCase, submittedCase, laggingHeadCase, rpcFailureCase} { + t.Run(scenario, func(t *testing.T) { + policy := model.DefaultBlock_Finalized + if scenario == latestCase { + policy = model.DefaultBlock_Latest + } + if scenario == safeCase { + policy = model.DefaultBlock_Safe + } + s, backend, lookup := newDeliveryFixture(t, policy) + _, epoch := queueDelivery(t, backend, lookup, 1) + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(30)) + switch scenario { + case submittedCase: + epoch.Status, epoch.StagedAtBlock = model.EpochStatus_ClaimSubmitted, nil + case laggingHeadCase: + epoch.StagedAtBlock = new(backend.last + 1) + case rpcFailureCase: + backend.claimReadErr = errors.New("claim RPC unavailable") + } + logs := capturePublicationLogs(s) + err := s.publisher.tick(t.Context()) + if scenario == laggingHeadCase || scenario == rpcFailureCase { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.NotContains(t, logs.String(), stagingContradictionWarning) + }) + } +} diff --git a/internal/claimer/startup_test.go b/internal/claimer/startup_test.go new file mode 100644 index 000000000..98cecbac1 --- /dev/null +++ b/internal/claimer/startup_test.go @@ -0,0 +1,157 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +const ( + startupCheckTimeoutCase = "check timeout" + startupDatabaseUnavailableCase = "database unavailable" +) + +type claimProvenanceRepository struct { + repository.DeliveryRepository + check func(context.Context, string) error +} + +func (r *claimProvenanceRepository) CheckSubmitterProvenance(ctx context.Context, key string) error { + if r.check != nil { + return r.check(ctx, key) + } + return nil +} + +type claimTickFixture struct { + fn func(context.Context) (bool, error) +} + +func (t *claimTickFixture) Tick(ctx context.Context) (bool, error) { return t.fn(ctx) } + +func TestClaimStartupRetriesRegistrationFailuresBeforeRunningLoops(t *testing.T) { + for _, failure := range []string{"missing observer", startupDatabaseUnavailableCase, startupCheckTimeoutCase} { + t.Run(failure, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s, backend, _ := newDeliveryFixture(t) + s.publisher.interval = time.Hour + original := s.publisher.sender + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + attempts, failures := 0, 1 + if failure == startupDatabaseUnavailableCase { + failures = 3 + } + s.deliveryRepository = &claimProvenanceRepository{check: func(check context.Context, key string) error { + require.Equal(t, ClaimerConfigKey, key) + require.False(t, s.Ready(), "publication must remain unready throughout registration") + require.Empty(t, backend.published) + deadline, bounded := check.Deadline() + require.True(t, bounded) + require.LessOrEqual(t, time.Until(deadline), claimProvenanceCheckTimeout) + attempts++ + if attempts > failures { + return nil + } + cause := repository.ErrObserverNotRegistered + switch failure { + case startupDatabaseUnavailableCase: + cause = errors.New("database connection reset") + case startupCheckTimeoutCase: + <-check.Done() + require.ErrorIs(t, check.Err(), context.DeadlineExceeded) + require.NoError(t, ctx.Err(), "the check deadline must not stop the service") + cause = check.Err() + } + return errors.Join(repository.ErrSubmitterProvenance, cause) + }} + var observedReady bool + var attemptsAtStart int + tick := &claimTickFixture{fn: func(context.Context) (bool, error) { + observedReady, attemptsAtStart = s.Ready(), attempts + cancel() + return false, ctx.Err() + }} + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Logger: s.Logger}, PollInterval: time.Hour, + }, tick)) + require.False(t, s.Ready()) + before := time.Now() + require.ErrorIs(t, s.Serve(ctx), context.Canceled) + require.Equal(t, failures+1, attemptsAtStart, "both loops must wait for successful registration") + require.True(t, observedReady, "successful registration must enable readiness") + elapsed := time.Duration(failures) * time.Second + if failure == startupCheckTimeoutCase { + elapsed = claimProvenanceCheckTimeout + } + require.Equal(t, elapsed, time.Since(before), "retry must be paced without waiting for the normal hourly poll") + require.Equal(t, 1, strings.Count(logs.String(), "Claim publication waits for compatible observer registration")) + require.Contains(t, logs.String(), "level=WARN") + require.Same(t, original, s.publisher.sender) + require.False(t, s.Ready(), "shutdown must clear publication readiness") + require.Empty(t, backend.published) + }) + }) + } +} + +func TestClaimStartupParentStopTakesPrecedenceOverRegistrationResult(t *testing.T) { + for _, result := range []string{"joined cancellation", "successful read", "parent deadline"} { + t.Run(result, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s, backend, _ := newDeliveryFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + if result == "parent deadline" { + cancel() + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + } else { + go func() { + time.Sleep(time.Second) + cancel() + }() + } + defer cancel() + s.deliveryRepository = &claimProvenanceRepository{check: func(check context.Context, _ string) error { + <-check.Done() + if result == "successful read" { + return nil + } + return errors.Join(repository.ErrSubmitterProvenance, check.Err()) + }} + err := s.Serve(ctx) + require.Equal(t, ctx.Err(), err, "shutdown must return only the parent cause, without a provenance fault") + require.False(t, s.Ready()) + require.Empty(t, backend.published) + }) + }) + } +} + +func TestClaimPublicationGuardUsesFailureAndCancellationOnly(t *testing.T) { + s, _, _ := newDeliveryFixture(t) + s.deliveryRepository = &claimProvenanceRepository{check: func(context.Context, string) error { + t.Fatal("publication guard must not query database registration") + return nil + }} + require.NoError(t, s.guardClaimPublication(t.Context())) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, s.guardClaimPublication(ctx), context.Canceled) + cause := errors.New("publication stopped") + s.failure.Store(&cause) + require.ErrorIs(t, s.guardClaimPublication(t.Context()), cause) +} diff --git a/internal/claimer/state_proof.go b/internal/claimer/state_proof.go new file mode 100644 index 000000000..df0a4b342 --- /dev/null +++ b/internal/claimer/state_proof.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" +) + +func consensusMachineValidityProof(proof model.StateProof) iconsensus.MachineValidityProof { + return iconsensus.MachineValidityProof{ + IflagsYProof: iconsensus.LeafProof{ + DataBlock: proof.IflagsYDataBlock, + Siblings: append([][32]byte(nil), proof.IflagsYProof...), + }, + HtifTohostProof: iconsensus.LeafProof{ + DataBlock: proof.HtifTohostDataBlock, + Siblings: append([][32]byte(nil), proof.HtifTohostProof...), + }, + TxBufferProof: iconsensus.LeafProof{ + DataBlock: proof.TxBufferDataBlock, + Siblings: append([][32]byte(nil), proof.TxBufferProof...), + }, + } +} diff --git a/internal/claimer/state_proof_test.go b/internal/claimer/state_proof_test.go new file mode 100644 index 000000000..89395ec1c --- /dev/null +++ b/internal/claimer/state_proof_test.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestConsensusMachineValidityProofUsesContractFieldOrder(t *testing.T) { + canonical := repotest.KeccakStateProof(common.HexToHash("0xa1")) + + wire := consensusMachineValidityProof(canonical) + require.Equal(t, [32]byte(canonical.IflagsYDataBlock), wire.IflagsYProof.DataBlock) + require.Equal(t, canonical.IflagsYProof, wire.IflagsYProof.Siblings) + require.Equal(t, [32]byte(canonical.HtifTohostDataBlock), wire.HtifTohostProof.DataBlock) + require.Equal(t, canonical.HtifTohostProof, wire.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(canonical.TxBufferDataBlock), wire.TxBufferProof.DataBlock) + require.Equal(t, canonical.TxBufferProof, wire.TxBufferProof.Siblings) + + wire.IflagsYProof.Siblings[0][0] ^= 0xff + require.NotEqual(t, canonical.IflagsYProof[0], wire.IflagsYProof.Siblings[0], + "the contract proof must own its sibling storage") +} + +func TestClaimCallUsesMachineRootAndCompleteProof(t *testing.T) { + app := makeApplication() + epoch := makeComputedEpoch(app, 3) + proof, err := epoch.StateProof() + require.NoError(t, err) + call, err := claimCall(app, epoch, proof, true) + require.NoError(t, err) + parsed, err := iconsensus.IConsensusMetaData.GetAbi() + require.NoError(t, err) + values, err := parsed.Methods[claimSubmitMethod].Inputs.Unpack(call.request.Data[4:]) + require.NoError(t, err) + require.Equal(t, app.IApplicationAddress, values[0]) + require.Equal(t, new(big.Int).SetUint64(epoch.LastBlock), values[1]) + require.Equal(t, [32]byte(proof.MachineHash), values[2]) + wire := *abi.ConvertType(values[3], new(iconsensus.MachineValidityProof)).(*iconsensus.MachineValidityProof) + require.Equal(t, consensusMachineValidityProof(proof), wire) + require.Equal(t, app.IConsensusAddress, call.request.To) + require.Equal(t, submitClaimKind, call.action.Kind) + changed := *epoch + root := common.HexToHash("0x1234") + changed.MachineHash = &root + changedProof := proof + changedProof.MachineHash = root + other, err := claimCall(app, &changed, changedProof, true) + require.NoError(t, err) + require.NotEqual(t, call.action, other.action, "changed protocol content must have a new identity") +} diff --git a/internal/claimer/step_result.go b/internal/claimer/step_result.go new file mode 100644 index 000000000..09ac15d3d --- /dev/null +++ b/internal/claimer/step_result.go @@ -0,0 +1,34 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +// claimStepResult tells the outer loop what happened after one work item. +// The handler already did the real work, such as DB writes or chain reads. +// The loop only needs to count progress, return one error, and maybe remove +// the item from the in-memory work map. +type claimStepResult struct { + progress int + drop bool + err error +} + +func claimNoProgress() claimStepResult { + return claimStepResult{} +} + +func claimProgressed(n int) claimStepResult { + return claimStepResult{progress: n} +} + +func claimDropped(err error) claimStepResult { + return claimStepResult{drop: true, err: err} +} + +func claimWorkCompleted(n int) claimStepResult { + return claimStepResult{progress: n, drop: true} +} + +func claimRetryLater(err error) claimStepResult { + return claimStepResult{err: err} +} diff --git a/internal/claimer/submit.go b/internal/claimer/submit.go new file mode 100644 index 000000000..ae5ffdeeb --- /dev/null +++ b/internal/claimer/submit.go @@ -0,0 +1,384 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + + "github.com/ethereum/go-ethereum/common" +) + +func (s *Service) classifyClaimSubmittedEvents( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + events []*iconsensus.IConsensusClaimSubmitted, + site string, +) ( + *iconsensus.IConsensusClaimSubmitted, + bool, + error, +) { + for _, event := range events { + if !claimSubmittedEventMatchesEpoch(app, epoch, event) { + continue + } + s.Logger.Debug("Found ClaimSubmitted Event", + "app", event.AppContract, + "outputs_merkle_root", fmt.Sprintf("%x", event.OutputsMerkleRoot), + "last_block", event.LastProcessedBlockNumber.Uint64(), + ) + matches, ok := claimSubmittedEventMatches(app, epoch, event) + if !ok { + return nil, true, s.markMatcherPrecondFailure(ctx, app, epoch, site) + } + if matches { + if !s.shouldRecordMatchingClaimSubmitted(app, epoch, event) { + continue + } + return event, false, nil + } + + if s.shouldIgnoreQuorumSubmittedMismatch(app, epoch, event, site) { + continue + } + return nil, true, s.markSubmittedDivergence(ctx, app, epoch, event, site) + } + return nil, false, nil +} + +func (s *Service) shouldIgnoreQuorumSubmittedMismatch( + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimSubmitted, + site string, +) bool { + if app.ConsensusType != model.Consensus_Quorum { + return false + } + + submitter, ok := s.blockchain.claimSubmitterAddress() + if ok && event.Submitter != (common.Address{}) && event.Submitter == submitter { + return false + } + + ourOutputsMerkleRoot := common.Hash{} + if epoch.TxBufferDataBlock != nil { + ourOutputsMerkleRoot = *epoch.TxBufferDataBlock + } + outputsMatch := common.Hash(event.OutputsMerkleRoot) == ourOutputsMerkleRoot + + level := s.Logger.Info + if outputsMatch { + level = s.Logger.Warn + } + level("Quorum: observed non-matching ClaimSubmitted from a non-local or unknown submitter; continuing", + "site", site, + "app", app.IApplicationAddress, + "event_submitter", event.Submitter, + "our_submitter", submitter, + "has_our_submitter", ok, + "event_outputs", fmt.Sprintf("%x", event.OutputsMerkleRoot), + "our_outputs", ourOutputsMerkleRoot.Hex(), + "event_machine", fmt.Sprintf("%x", event.MachineMerkleRoot), + "our_machine", hashToHex(epoch.MachineHash), + "last_block", epoch.LastBlock, + ) + return true +} + +func (s *Service) shouldRecordMatchingClaimSubmitted( + app *model.Application, + epoch *model.Epoch, + event *iconsensus.IConsensusClaimSubmitted, +) bool { + if app.ConsensusType != model.Consensus_Quorum || !s.submissionEnabled { + return true + } + submitter, ok := s.blockchain.claimSubmitterAddress() + if !ok || event.Submitter == (common.Address{}) || event.Submitter == submitter { + return true + } + s.Logger.Info("Quorum: observed matching ClaimSubmitted from another validator; submitting local vote", + "app", app.IApplicationAddress, + "event_submitter", event.Submitter, + "our_submitter", submitter, + "outputs_merkle_root", hashToHex(epoch.TxBufferDataBlock), + "last_block", epoch.LastBlock, + ) + return false +} + +// submitClaimsAndUpdateDatabase moves epochs from CLAIM_COMPUTED toward +// CLAIM_SUBMITTED. It returns the number of successful state changes and any +// errors. +func (s *Service) submitClaimsAndUpdateDatabase( + ctx context.Context, + computedEpochs map[int64]*model.Epoch, + apps map[int64]*model.Application, + defaultBlockNumber *big.Int, +) (int, error) { + return s.observeClaims(ctx, computedEpochs, apps, defaultBlockNumber, s.processComputedClaim) +} + +func (s *Service) processComputedClaim( + ctx context.Context, + work claimWork, + defaultBlockNumber *big.Int, +) claimStepResult { + app := work.app + currEpoch := work.epoch + if err := requireObservedStaging(currEpoch, defaultBlockNumber.Uint64()); err != nil { + return claimRetryLater(err) + } + + // Stop if the consensus contract address changed on chain. + if err := s.checkConsensusForAddressChange(ctx, app, defaultBlockNumber); err != nil { + return claimDropped(err) + } + + if result, done := s.reconcileComputedAcceptedEvent(ctx, work, defaultBlockNumber); done { + return result + } + + _, submittedEvents, result, done := s.findSubmittedEventsForComputedClaim(ctx, work, defaultBlockNumber) + if done { + return result + } + + currEvent, shouldDrop, err := s.classifyClaimSubmittedEvents( + ctx, app, currEpoch, submittedEvents, "submitClaimsAndUpdateDatabase(ClaimSubmitted)") + if shouldDrop { + return claimDropped(err) + } + if err != nil { + return claimRetryLater(err) + } + if currEvent != nil { + return s.recordSubmittedEvent(ctx, app, currEpoch, currEvent) + } + + if s.submissionEnabled || app.ForecloseBlock != 0 { + // Before sending a transaction, read the claim state from the chain. + // The claim identity is (app, last processed block number, + // machineMerkleRoot). The chain may already know this claim as STAGED + // or ACCEPTED after a restart, or when another party acted first. + // + // This read also runs for foreclosed apps. A claim accepted before + // foreclosure must still be copied into the DB. Only the new submitClaim + // transaction is skipped for foreclosed apps. + if reconciled, err := s.reconcileBeforeSubmit(ctx, app, currEpoch, defaultBlockNumber); reconciled { + if err != nil { + return claimDropped(err) + } + return claimWorkCompleted(1) + } else if err != nil { + return claimDropped(err) + } + + // Foreclosed apps cannot submit new claims. The getClaim call above + // already showed that this claim is not STAGED or ACCEPTED, so it has + // no remaining on-chain path. + if app.ForecloseBlock != 0 { + if ferr := s.forecloseClaim(ctx, app, currEpoch, defaultBlockNumber.Uint64(), "submitClaimsAndUpdateDatabase"); ferr != nil { + return claimDropped(ferr) + } + return claimWorkCompleted(1) + } + } + + if s.submissionEnabled { + return s.planComputedClaim(ctx, app, currEpoch, defaultBlockNumber) + } + return claimNoProgress() +} + +func (s *Service) reconcileComputedAcceptedEvent( + ctx context.Context, + work claimWork, + defaultBlockNumber *big.Int, +) (claimStepResult, bool) { + app := work.app + currEpoch := work.epoch + + // Look for ClaimAccepted events for this epoch. + // + // If the event matches our machine root, we are just catching up from + // chain state and can mark the epoch ACCEPTED locally. + // + // If the event has a different machine root, another claim was accepted and + // our claim cannot win. Mark the app DIVERGED with the consensus-specific + // acceptance-divergence reason. Quorum can legitimately reach this path from + // CLAIM_COMPUTED when another validator's vote wins before ours is staged. + // Authority usually finds the mismatch earlier, during submission, but this + // path handles both consensus types. + acceptScanFrom := currEpoch.LastBlock + 1 + _, foreignAccepted, _, err := s.blockchain.findClaimAcceptedEventAndSucc( + ctx, app, currEpoch, acceptScanFrom, defaultBlockNumber.Uint64(), + ) + if err != nil { + return claimDropped(fmt.Errorf( + "scanning ClaimAccepted for computed epoch %d (%d): %w", + currEpoch.Index, currEpoch.VirtualIndex, err)), true + } + if foreignAccepted == nil || !claimAcceptedEventMatchesEpoch(app, currEpoch, foreignAccepted) { + return claimNoProgress(), false + } + matches, ok := claimAcceptedEventMatches(app, currEpoch, foreignAccepted) + if !ok { + return claimDropped(s.markMatcherPrecondFailure(ctx, app, currEpoch, "submitClaimsAndUpdateDatabase(ClaimAccepted)")), true + } + if !matches { + return claimDropped(s.markAcceptedDivergence(ctx, app, currEpoch, foreignAccepted, "submitClaimsAndUpdateDatabase")), true + } + acceptedTxHash := foreignAccepted.Raw.TxHash + if err := s.repository.UpdateEpochWithAcceptedClaim( + ctx, currEpoch.ApplicationID, currEpoch.Index, &acceptedTxHash); err != nil { + return claimDropped(fmt.Errorf( + "reconciling COMPUTED→ACCEPTED for epoch %d (%d): %w", + currEpoch.Index, currEpoch.VirtualIndex, err)), true + } + s.Logger.Info("ClaimAccepted observed for computed epoch (deep catch-up; reconciled)", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "tx", foreignAccepted.Raw.TxHash, + "last_block", currEpoch.LastBlock, + ) + return claimWorkCompleted(1), true +} + +func (s *Service) findSubmittedEventsForComputedClaim( + ctx context.Context, + work claimWork, + defaultBlockNumber *big.Int, +) (*iconsensus.IConsensus, []*iconsensus.IConsensusClaimSubmitted, claimStepResult, bool) { + app := work.app + currEpoch := work.epoch + ic, submittedEvents, err := s.blockchain.findClaimSubmittedEventAndSucc( + ctx, app, currEpoch, currEpoch.LastBlock+1, defaultBlockNumber.Uint64(), + ) + if err != nil { + return nil, nil, claimDropped(err), true + } + return ic, submittedEvents, claimNoProgress(), false +} + +func (s *Service) recordSubmittedEvent( + ctx context.Context, + app *model.Application, + currEpoch *model.Epoch, + currEvent *iconsensus.IConsensusClaimSubmitted, +) claimStepResult { + s.Logger.Debug("Updating claim status to submitted", + "app", app.IApplicationAddress, + "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), + "last_block", currEpoch.LastBlock, + ) + txHash := currEvent.Raw.TxHash + err := s.repository.UpdateEpochWithSubmittedClaim( + ctx, + currEpoch.ApplicationID, + currEpoch.Index, + txHash, + ) + if err != nil { + return claimDropped(err) + } + s.Logger.Info("Claim previously submitted", + "app", app.IApplicationAddress, + "event_block_number", currEvent.Raw.BlockNumber, + "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), + "last_block", currEpoch.LastBlock, + ) + return claimProgressed(1) +} + +func (s *Service) planComputedClaim( + ctx context.Context, + app *model.Application, + currEpoch *model.Epoch, + defaultBlockNumber *big.Int, +) claimStepResult { + // This diagnoses persisted proof integrity; publication has its own coherent + // epoch eligibility check. A later execution stop must not hide a DC finding. + status := app.Status + if !app.Enabled || app.ForecloseBlock != 0 || + (status != model.ApplicationStatus_OK && !status.IsExecutionTerminal() && status != model.ApplicationStatus_InvalidOutputsRoot) { + return claimNoProgress() + } + _, err := currEpoch.StateProof() + if err != nil { + stateErr := s.setApplicationCorrupted( + ctx, + app, + "cannot submit claim for epoch %d (%d), last_block %d: persisted machine state proof is incomplete: %v", + currEpoch.Index, + currEpoch.VirtualIndex, + currEpoch.LastBlock, + err, + ) + return claimDropped(stateErr) + } + return claimNoProgress() +} + +// reconcileBeforeSubmit calls getClaim before submitClaim. +// +// If the chain already has this claim as STAGED or ACCEPTED, update the DB and +// do not send another transaction. Returns (reconciled, err): +// - (true, nil): the DB was updated to STAGED or ACCEPTED. +// - (false, nil): the chain says UNSTAGED; caller may submit. +// - (_, err): an error occurred; caller should drop this work item. +// +// All chain reads in one tick use the same configured observation block. +func (s *Service) reconcileBeforeSubmit( + ctx context.Context, + app *model.Application, + currEpoch *model.Epoch, + defaultBlockNumber *big.Int, +) (bool, error) { + claim, err := s.blockchain.getClaimStatus(ctx, app, currEpoch, defaultBlockNumber) + if err != nil { + return false, fmt.Errorf("pre-submit getClaim (app=%v, epoch=%d): %w", + app.IApplicationAddress, currEpoch.Index, err) + } + switch claim.Status { + case claimStatusAccepted: + if err := s.updateEpochAcceptedFromClaimStatus(ctx, app, currEpoch, claim, "reconcileBeforeSubmit"); err != nil { + return false, fmt.Errorf("reconciling epoch %d (%d) to ACCEPTED: %w", + currEpoch.Index, currEpoch.VirtualIndex, err) + } + s.Logger.Info("Claim already accepted on chain (reconciled pre-submit)", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), + "last_block", currEpoch.LastBlock, + ) + return true, nil + case claimStatusStaged: + stagingBlock, err := s.updateEpochStagedFromClaimStatus(ctx, app, currEpoch, claim, "reconcileBeforeSubmit") + if err != nil { + return false, fmt.Errorf("reconciling epoch %d (%d) to STAGED: %w", + currEpoch.Index, currEpoch.VirtualIndex, err) + } + s.Logger.Info("Claim already staged on chain (reconciled pre-submit)", + "app", app.IApplicationAddress, + "epoch_index", currEpoch.Index, + "outputs_merkle_root", hashToHex(currEpoch.TxBufferDataBlock), + "last_block", currEpoch.LastBlock, + "staged_at_block", stagingBlock, + ) + return true, nil + case claimStatusUnstaged: + return false, nil + default: + return false, fmt.Errorf("unexpected ClaimStatus %d from getClaim for app=%v epoch=%d", + claim.Status, app.IApplicationAddress, currEpoch.Index) + } +} diff --git a/internal/claimer/submit_test.go b/internal/claimer/submit_test.go new file mode 100644 index 000000000..554057e35 --- /dev/null +++ b/internal/claimer/submit_test.go @@ -0,0 +1,757 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSubmitFirstClaim(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + require.Nil(t, m.publisher, "observation owns no publication queue") + assert.Zero(t, transitions, "planning is not durable progress") +} + +func TestSubmitClaimRejectsIncompletePersistedStateProofBeforeBroadcast(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + currEpoch.HtifTohostProof = currEpoch.HtifTohostProof[:model.StateProofSiblingCount-1] + expectPreSubmitPath(b, app, currEpoch, endBlock) + // Incomplete persisted proof must not become a publication candidate. + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "persisted machine state proof is incomplete") + })).Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + transitions, err := m.submitClaimsAndUpdateDatabase( + t.Context(), currEpochs, makeApplicationMap(app), endBlock) + + require.Zero(t, transitions) + require.ErrorContains(t, err, "persisted machine state proof is incomplete") + require.Empty(t, currEpochs) + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) +} + +func TestSubmitClaimForeclosesUnstagedForeclosedApp(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := withForeclosed(makeApplication(), 35) + currEpoch := makeComputedEpoch(app, 3) + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, currEpoch.Index). + Return(nil).Once() + + computedEpochs := makeEpochMap(currEpoch) + transitions, err := m.submitClaimsAndUpdateDatabase( + context.Background(), computedEpochs, makeApplicationMap(app), endBlock) + + assert.NoError(t, err, "foreclosing an impossible claim is not an error") + assert.Equal(t, 1, transitions, "CLAIM_FORECLOSED is a local status transition") + assert.Equal(t, model.EpochStatus_ClaimForeclosed, currEpoch.Status) +} + +func TestSubmitClaimForeclosesUnstagedForeclosedAppWhenSubmissionDisabled(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + m.submissionEnabled = false + endBlock := big.NewInt(40) + app := withForeclosed(makeApplication(), 35) + currEpoch := makeComputedEpoch(app, 3) + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, currEpoch.Index). + Return(nil).Once() + + transitions, err := m.submitClaimsAndUpdateDatabase( + context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.NoError(t, err) + assert.Equal(t, 1, transitions) + assert.Equal(t, model.EpochStatus_ClaimForeclosed, currEpoch.Status) +} + +// TestSubmitClaimForecloseMidFlight verifies the transition behaviour: a +// healthy app whose claim is broadcast on tick 1 must STOP broadcasting on +// tick 2 once the in-memory ForecloseBlock has been populated (by evmreader +// observing the on-chain Foreclosure event between ticks). The first +// claim's in-flight tracking is preserved — that broadcast already +// happened; it's the *next* epoch's broadcast that must be suppressed. +// +// Two-tick scenario: +// 1. Tick 1: app.ForecloseBlock == 0; epoch N broadcast fires. +// 2. Between ticks: evmreader observes Foreclosure; the in-memory app's +// ForecloseBlock is set to a value < epoch N+1's LastBlock. +// 3. Tick 2: epoch N+1 in the computedEpochs work-map. The pre-submit +// reconciliation reads still run (mirroring any pre-foreclosure +// ACCEPTED state into the local DB), but the broadcast must be +// SKIPPED so we don't burn gas on a guaranteed ApplicationForeclosed +// revert. +func TestSubmitClaimReconcilesAcceptedForForeclosedApp(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := withForeclosed(makeApplication(), 35) + currEpoch := makeComputedEpoch(app, 3) + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + // Chain returns ACCEPTED (status 2) — the reconcile-before-submit + // path mirrors this to the local DB and skips broadcast entirely. + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusAccepted, currEpoch, 0), nil).Once() + r.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, currEpoch.Index, mock.Anything). + Return(nil).Once() + + computedEpochs := makeEpochMap(currEpoch) + transitions, err := m.submitClaimsAndUpdateDatabase( + context.Background(), computedEpochs, makeApplicationMap(app), endBlock) + + assert.NoError(t, err) + assert.Equal(t, 1, transitions, "ACCEPTED reconciliation counts as a transition") +} + +func TestSubmitClaimReconcilesStagedBeforeBroadcast(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + var currEvent *iconsensus.IConsensusClaimSubmitted + stagedAt := currEpoch.LastBlock + 2 + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(makeClaimStatus(claimStatusStaged, currEpoch, stagedAt), nil).Once() + r.On("UpdateEpochReconciledStaged", mock.Anything, app.ID, currEpoch.Index, stagedAt). + Return(nil).Once() + + computedEpochs := makeEpochMap(currEpoch) + transitions, err := m.submitClaimsAndUpdateDatabase( + context.Background(), computedEpochs, makeApplicationMap(app), endBlock) + + assert.NoError(t, err) + assert.Equal(t, 1, transitions, "STAGED reconciliation counts as a transition") + assert.Empty(t, computedEpochs, "reconciled epoch must leave the computed work map") +} + +func TestReconcileBeforeSubmitAcceptedOutputsMismatchSetsDiverged(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + claim := makeClaimStatus(claimStatusAccepted, currEpoch, 0) + claim.StagedOutputsMerkleRoot = common.HexToHash("0xdeadbeef") + b.On("getClaimStatus", mock.Anything, app, currEpoch, endBlock). + Return(claim, nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + + transitions, err := m.submitClaimsAndUpdateDatabase( + context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.Error(t, err) + assert.Equal(t, 0, transitions) +} + +func TestSubmitClaimWithAntecessor(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + require.Nil(t, m.publisher, "observation owns no publication queue") + assert.Zero(t, transitions, "planning is not durable progress") +} + +func TestSubmitClaimWithAcceptedAntecessorWithoutClaimTransactionHash(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + prevEpoch.ClaimTransactionHash = nil + currEpoch := makeComputedEpoch(app, 3) + prevEvent := makeSubmittedEventWithTxHash(app, prevEpoch, common.HexToHash("0x20")) + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{prevEvent, currEvent}, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase( + context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + require.NoError(t, err) + require.Nil(t, m.publisher, "observation owns no publication queue") + assert.Zero(t, transitions, "planning is not durable progress") +} + +func TestPlanSubmitClaimWithStagedAntecessor(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeStagedEpoch(app, 1, 25) + currEpoch := makeComputedEpoch(app, 3) + + var currEvent *iconsensus.IConsensusClaimSubmitted + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase( + context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + + assert.NoError(t, err) + assert.Equal(t, 0, transitions, "a planned submission is not durable observer progress") +} + +func TestSkipSubmitFirstClaim(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + m.submissionEnabled = false + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 0, transitions, "no transition when submission is disabled") +} + +func TestSkipSubmitClaimWithAntecessor(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + m.submissionEnabled = false + endBlock := big.NewInt(40) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + + var currEvent *iconsensus.IConsensusClaimSubmitted = nil + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + + _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) +} + +func TestUpdateFirstClaim(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + var prevEvent *iconsensus.IConsensusClaimSubmitted = nil + currEvent := makeSubmittedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, prevEvent, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 1, transitions, "finding on-chain event counts as a transition") +} + +func TestUpdateClaimWithAntecessor(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + + currEvent := makeSubmittedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, currEvent, nil, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() + + _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) +} + +func TestQuorumSubmittedEventsIgnoresForeignDifferentOutputsAndUpdatesMatchingEvent(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xf001"), + common.HexToHash("0xf002"), + ) + foreignEvent.Raw.TxHash = common.HexToHash("0xf003") + currEvent := makeSubmittedEvent(app, currEpoch) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent, currEvent}, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, currEvent.Raw.TxHash). + Return(nil).Once() + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 1, transitions, "matching later event counts as a transition") +} + +func TestQuorumDifferentOutputSubmittedEventStillSubmitsLocalClaim(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xf001"), + common.HexToHash("0xf002"), + ) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Zero(t, transitions, "planning is not durable progress") +} + +func TestQuorumForeignMatchingSubmittedEventStillSubmitsLocalClaim(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEvent(app, currEpoch) + foreignEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000002") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Zero(t, transitions, "planning is not durable progress") +} + +func TestQuorumReaderModeRecordsForeignMatchingSubmittedEvent(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + m.submissionEnabled = false + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEvent(app, currEpoch) + foreignEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000002") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() + r.On("UpdateEpochWithSubmittedClaim", mock.Anything, app.ID, currEpoch.Index, foreignEvent.Raw.TxHash). + Return(nil).Once() + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 1, transitions, "reader mode must mirror a matching Quorum ClaimSubmitted from any validator") +} + +func TestQuorumSubmittedEventsIgnoresForeignAdversarialProofAndSubmitsLocalClaim(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xf001"), + common.HexToHash("0xf002"), + ) + foreignEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000002") + adversarialEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + *currEpoch.TxBufferDataBlock, + common.HexToHash("0xf003"), + ) + adversarialEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000003") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent, adversarialEvent}, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Zero(t, transitions, "planning is not durable progress") +} + +func TestQuorumSubmittedEventsOwnMismatchSetsDiverged(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + adversarialEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + *currEpoch.TxBufferDataBlock, + common.HexToHash("0xf003"), + ) + adversarialEvent.Submitter = b.submitterAddress + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{adversarialEvent}, nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + + currEpochs := makeEpochMap(currEpoch) + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), currEpochs, makeApplicationMap(app), endBlock) + assert.Error(t, err) + assert.Equal(t, 0, len(currEpochs)) + assert.Equal(t, 0, transitions) +} + +func TestQuorumReaderModeIgnoresNonMatchingSubmittedEvent(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + m.submissionEnabled = false + b.hasSubmitter = false + + endBlock := big.NewInt(40) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + currEpoch := makeComputedEpoch(app, 3) + foreignEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + *currEpoch.TxBufferDataBlock, + common.HexToHash("0xf003"), + ) + foreignEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000002") + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignEvent}, nil).Once() + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Equal(t, 0, transitions) +} + +func TestSubmitClaimWithEventMismatch(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(40) + app := makeApplication() + prevEpoch := makeAcceptedEpoch(app, 1) + currEpoch := makeComputedEpoch(app, 3) + prevEvent := makeSubmittedEvent(app, prevEpoch) + wrongEvent := makeSubmittedEventWithRoots( + app, + currEpoch, + common.HexToHash("0xbad1"), + common.HexToHash("0xbad2"), + ) + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{prevEvent, wrongEvent}, nil).Once() + r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + + _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) +} + +func TestQuorumPreviousSubmittedEventsIgnoresForeignMismatchAndSubmitsCurrentClaim(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + app.ConsensusType = model.Consensus_Quorum + prevEpoch := makeAcceptedEpoch(app, 2) + currEpoch := makeComputedEpoch(app, 3) + foreignPrevEvent := makeSubmittedEventWithRoots( + app, + prevEpoch, + *prevEpoch.TxBufferDataBlock, + common.HexToHash("0xf003"), + ) + foreignPrevEvent.Submitter = common.HexToAddress("0x0000000000000000000000000000000000000002") + matchingPrevEvent := makeSubmittedEvent(app, prevEpoch) + matchingPrevEvent.Submitter = b.submitterAddress + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(app.IConsensusAddress, nil).Once() + expectNoForeignClaimAccepted(b, app, currEpoch, prevEpoch.LastBlock+1, endBlock.Uint64()) + b.On("findClaimSubmittedEventAndSucc", mock.Anything, app, currEpoch, currEpoch.LastBlock+1, endBlock.Uint64()). + Return(&iconsensus.IConsensus{}, []*iconsensus.IConsensusClaimSubmitted{foreignPrevEvent, matchingPrevEvent}, nil).Once() + expectGetClaimStatusUnstaged(b, app, currEpoch, endBlock) + + transitions, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.NoError(t, err) + assert.Zero(t, transitions, "planning is not durable progress") +} + +func TestConsensusAddressChangedOnSubmittedClaims(t *testing.T) { + m, r, b := newServiceMock(t) + defer r.AssertExpectations(t) + defer b.AssertExpectations(t) + + endBlock := big.NewInt(100) + app := makeApplication() + currEpoch := makeComputedEpoch(app, 3) + wrongConsensusAddress := app.IConsensusAddress + wrongConsensusAddress[0]++ + + b.On("getConsensusAddress", mock.Anything, app, mock.Anything). + Return(wrongConsensusAddress, nil). + Once() + r.On("UpdateApplicationStatus", mock.Anything, int64(0), model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + + _, err := m.submitClaimsAndUpdateDatabase(context.Background(), makeEpochMap(currEpoch), makeApplicationMap(app), endBlock) + assert.Error(t, err) +} + +func TestCheckConsensusForAddressChangeUsesTickBlock(t *testing.T) { + m, _, b := newServiceMock(t) + defer b.AssertExpectations(t) + + app := makeApplication() + tickBlock := big.NewInt(123) + + b.On("getConsensusAddress", mock.Anything, app, mock.MatchedBy(func(blockNumber *big.Int) bool { + return blockNumber != nil && blockNumber.Cmp(tickBlock) == 0 + })). + Return(app.IConsensusAddress, nil). + Once() + + err := m.checkConsensusForAddressChange(context.Background(), app, tickBlock) + require.NoError(t, err) +} + +func TestCheckConsensusForAddressChangeCachesTickResult(t *testing.T) { + m, _, b := newServiceMock(t) + defer b.AssertExpectations(t) + + app := makeApplication() + tickBlock := big.NewInt(123) + m.consensusAddressChecks = map[consensusAddressCheckKey]error{} + + b.On("getConsensusAddress", mock.Anything, app, tickBlock). + Return(app.IConsensusAddress, nil). + Once() + + ctx := context.Background() + err := m.checkConsensusForAddressChange(ctx, app, tickBlock) + require.NoError(t, err) + err = m.checkConsensusForAddressChange(ctx, app, tickBlock) + require.NoError(t, err) +} + +func TestComputedClaimIncompleteProofEscalatesExecutionStop(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_GuestException, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_McycleOverflow, + model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + s, repo, blockchain := newServiceMock(t) + app := makeApplication() + app.Status, app.Reason = status, new("later epoch stopped") + epoch := makeComputedEpoch(app, 3) + epoch.HtifTohostProof = nil + block := big.NewInt(40) + expectPreSubmitPath(blockchain, app, epoch, block) + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "persisted machine state proof is incomplete") + })).Return(nil).Once() + + _, err := s.submitClaimsAndUpdateDatabase(t.Context(), makeEpochMap(epoch), makeApplicationMap(app), block) + + require.ErrorContains(t, err, "persisted machine state proof is incomplete") + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + repo.AssertExpectations(t) + blockchain.AssertExpectations(t) + }) + } +} diff --git a/internal/claimer/util.go b/internal/claimer/util.go new file mode 100644 index 000000000..65cc93ea4 --- /dev/null +++ b/internal/claimer/util.go @@ -0,0 +1,13 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import "github.com/ethereum/go-ethereum/common" + +func hashToHex(h *common.Hash) string { + if h == nil { + return "" + } + return h.Hex() +} diff --git a/internal/claimer/work.go b/internal/claimer/work.go new file mode 100644 index 000000000..d3c4cc5d3 --- /dev/null +++ b/internal/claimer/work.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package claimer + +import ( + "context" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "math/big" +) + +type claimWork struct { + app *model.Application + epoch *model.Epoch +} + +func (s *Service) observeClaims( + ctx context.Context, epochs map[int64]*model.Epoch, apps map[int64]*model.Application, block *big.Int, + process func(context.Context, claimWork, *big.Int) claimStepResult, +) (int, error) { + transitions := 0 + var err error + for id, epoch := range epochs { + if ctx.Err() != nil { + return transitions, errors.Join(err, ctx.Err()) + } + result := process(ctx, claimWork{app: apps[id], epoch: epoch}, block) + transitions += result.progress + err = errors.Join(err, result.err) + if result.drop { + delete(epochs, id) + } + } + return transitions, err +} diff --git a/internal/cli/cobra.go b/internal/cli/cobra.go new file mode 100644 index 000000000..d5dcbcbba --- /dev/null +++ b/internal/cli/cobra.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + + "github.com/cartesi/rollups-node/internal/config" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +func AddFlagBoolVar(flags *pflag.FlagSet, varRef *bool, flagName string, cfgName string, flagDesc string) { + var value bool + if declared := declaredFlagDefault(cfgName); declared != "" { + var err error + value, err = strconv.ParseBool(declared) + cobra.CheckErr(err) + } + flags.BoolVar(varRef, flagName, value, flagDesc) + cobra.CheckErr(viper.BindPFlag(cfgName, flags.Lookup(flagName))) +} + +func AddFlagUint64Var(flags *pflag.FlagSet, varRef *uint64, flagName string, cfgName string, flagDesc string) { + var value uint64 + if declared := declaredFlagDefault(cfgName); declared != "" { + var err error + value, err = strconv.ParseUint(declared, 10, 64) + cobra.CheckErr(err) + } + flags.Uint64Var(varRef, flagName, value, flagDesc) + cobra.CheckErr(viper.BindPFlag(cfgName, flags.Lookup(flagName))) +} + +func AddFlagStrVar(flags *pflag.FlagSet, varRef *string, flagName string, cfgName string, flagDesc string) { + flags.StringVar(varRef, flagName, declaredFlagDefault(cfgName), flagDesc) + cobra.CheckErr(viper.BindPFlag(cfgName, flags.Lookup(flagName))) +} + +func AddFlagStrVarP(flags *pflag.FlagSet, varRef *string, flagName string, flagShort string, cfgName string, flagDesc string) { + flags.StringVarP(varRef, flagName, flagShort, declaredFlagDefault(cfgName), flagDesc) + cobra.CheckErr(viper.BindPFlag(cfgName, flags.Lookup(flagName))) +} + +func declaredFlagDefault(cfgName string) string { + value, known := config.DeclaredDefault(cfgName) + if !known { + panic("unknown configuration key for flag: " + cfgName) + } + return value +} + +// LogErr logs an error without exiting, so callers can finish deferred cleanup. +func LogErr(logger *slog.Logger, err error, args ...any) { + if err == nil || errors.Is(err, context.Canceled) { + return + } + + msg := "Error" + if len(args) > 0 { + arg0 := args[0] + args = args[1:] + switch value := arg0.(type) { + case string: + msg = value + case []byte: + msg = string(value) + case fmt.Stringer: + msg = value.String() + default: + msg = fmt.Sprintf("%v", value) + } + } + + args = append([]any{"error", err}, args...) + logger.Error(msg, args...) +} diff --git a/internal/cli/cobra_test.go b/internal/cli/cobra_test.go new file mode 100644 index 000000000..3dd1fc58f --- /dev/null +++ b/internal/cli/cobra_test.go @@ -0,0 +1,120 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bytes" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func resetFlagConfig(t *testing.T) { + t.Helper() + viper.Reset() + viper.AutomaticEnv() + config.SetDefaults() + t.Cleanup(func() { + viper.Reset() + viper.AutomaticEnv() + config.SetDefaults() + }) +} + +func TestFlagHelpersKeepLiveValuesOutOfMetadata(t *testing.T) { + const marker = "SYNTHETIC_FLAG_SECRET_6420187953" + t.Setenv(config.DATABASE_CONNECTION, marker) + t.Setenv(config.BLOCKCHAIN_HTTP_ENDPOINT, "https://example.test/"+marker) + t.Setenv(config.BLOCKCHAIN_DEFAULT_BLOCK, marker) + t.Setenv(config.LOG_LEVEL, "error") + t.Setenv(config.LOG_COLOR, "false") + t.Setenv(config.BLOCKCHAIN_MAX_BLOCK_RANGE, "918273645") + for _, output := range []string{"help", "usage", "debug", "markdown"} { + t.Run(output, func(t *testing.T) { + resetFlagConfig(t) + cmd := &cobra.Command{Use: "test-command", Run: func(*cobra.Command, []string) {}} + var database, endpoint, block, level string + var color bool + var maxRange uint64 + AddFlagStrVar(cmd.Flags(), &database, "database", config.DATABASE_CONNECTION, "Database") + AddFlagStrVar(cmd.Flags(), &endpoint, "endpoint", config.BLOCKCHAIN_HTTP_ENDPOINT, "Endpoint") + AddFlagStrVarP(cmd.Flags(), &block, "block", "b", config.BLOCKCHAIN_DEFAULT_BLOCK, "Block") + AddFlagStrVar(cmd.Flags(), &level, "level", config.LOG_LEVEL, "Level") + AddFlagBoolVar(cmd.Flags(), &color, "color", config.LOG_COLOR, "Color") + AddFlagUint64Var(cmd.Flags(), &maxRange, "max-range", config.BLOCKCHAIN_MAX_BLOCK_RANGE, "Range") + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + switch output { + case "help": + cmd.SetArgs([]string{"--help"}) + require.NoError(t, cmd.Execute()) + case "usage": + cmd.SetArgs([]string{"--unknown-flag"}) + require.Error(t, cmd.Execute()) + case "debug": + cmd.DebugFlags() + case "markdown": + require.NoError(t, doc.GenMarkdown(cmd, &buf)) + } + require.Contains(t, buf.String(), "--database") + require.Contains(t, buf.String(), "--endpoint") + require.NotContains(t, buf.String(), marker) + require.NotContains(t, buf.String(), "918273645") + require.Contains(t, buf.String(), "finalized") + require.Contains(t, buf.String(), "info") + require.Empty(t, database) + require.Empty(t, endpoint) + require.Equal(t, "finalized", block) + require.Equal(t, "info", level) + require.True(t, color) + require.Equal(t, uint64(2000), maxRange) + // Application resolution still consumes the environment after registration. + require.Equal(t, marker, viper.GetString(config.DATABASE_CONNECTION)) + require.Equal(t, uint64(918273645), viper.GetUint64(config.BLOCKCHAIN_MAX_BLOCK_RANGE)) + }) + } +} + +func TestFlagConfigurationPrecedence(t *testing.T) { + const envLevel = "error" + const fileLevel = "debug" + for _, tc := range []struct { + name string + flag string + env string + configValue string + want slog.Level + }{ + {"flag wins", "warn", envLevel, fileLevel, slog.LevelWarn}, + {"env wins", "", envLevel, fileLevel, slog.LevelError}, + {"file wins", "", "", fileLevel, slog.LevelDebug}, + {"default", "", "", "", slog.LevelInfo}, + } { + t.Run(tc.name, func(t *testing.T) { + resetFlagConfig(t) + t.Setenv(config.LOG_LEVEL, tc.env) + if tc.configValue != "" { + viper.SetConfigType("toml") + require.NoError(t, viper.ReadConfig(strings.NewReader(fmt.Sprintf("%s = %q", config.LOG_LEVEL, tc.configValue)))) + } + cmd := &cobra.Command{Use: "test-command"} + var backing string + AddFlagStrVar(cmd.Flags(), &backing, "level", config.LOG_LEVEL, "Level") + if tc.flag != "" { + require.NoError(t, cmd.Flags().Set("level", tc.flag)) + } + got, err := config.GetLogLevel() + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/cli/connections.go b/internal/cli/connections.go new file mode 100644 index 000000000..2b1639f54 --- /dev/null +++ b/internal/cli/connections.go @@ -0,0 +1,54 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "fmt" + "log/slog" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/ethclient" +) + +// DialBlockchain opens the configured endpoint through the protected HTTP client. +// The caller owns the returned client and its cleanup. +func DialBlockchain(ctx context.Context, endpoint config.SafeURL) (*ethclient.Client, error) { + authorization, err := config.HTTPAuthorizationOption() + if err != nil { + return nil, err + } + maxRetries, err := config.GetBlockchainHttpMaxRetries() + if err != nil { + return nil, err + } + minWait, err := config.GetBlockchainHttpRetryMinWait() + if err != nil { + return nil, err + } + maxWait, err := config.GetBlockchainHttpRetryMaxWait() + if err != nil { + return nil, err + } + timeout, err := config.GetBlockchainHttpRequestTimeout() + if err != nil { + return nil, err + } + if timeout <= 0 { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT must be positive") + } + //nolint:forbidigo // Protected CLI connection gateway with configured authentication and retries. + return ethutil.NewEthClient(ctx, endpoint.Raw(), slog.Default(), ethutil.RetryConfig{ + MaxRetries: maxRetries, RetryMinWait: minWait, RetryMaxWait: maxWait, RequestTimeout: timeout, + }, authorization) +} + +// OpenRepository keeps raw DSNs in this gateway. The database constructor +// protects startup diagnostics. The caller owns the repository's cleanup. +func OpenRepository(ctx context.Context, endpoint config.SafeURL) (repository.Repository, error) { + return factory.NewRepositoryFromConnectionString(ctx, endpoint.Raw()) //nolint:forbidigo // CLI database connection gateway. +} diff --git a/internal/cli/connections_test.go b/internal/cli/connections_test.go new file mode 100644 index 000000000..e51996068 --- /dev/null +++ b/internal/cli/connections_test.go @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestDialBlockchainUsesConfiguredAuthorizationAndRetries(t *testing.T) { + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.BLOCKCHAIN_HTTP_AUTHORIZATION, "Authorization:Bearer private-provider-key") + viper.Set(config.BLOCKCHAIN_HTTP_MAX_RETRIES, 1) + viper.Set(config.BLOCKCHAIN_HTTP_RETRY_MIN_WAIT, "0") + viper.Set(config.BLOCKCHAIN_HTTP_RETRY_MAX_WAIT, "0") + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := calls.Add(1) + if r.Header.Get("Authorization") != "Bearer private-provider-key" { + http.Error(w, "authorization required", http.StatusUnauthorized) + return + } + if attempt == 1 { + http.Error(w, "try again", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x7a69"}`)) + require.NoError(t, err) + })) + t.Cleanup(server.Close) + endpoint, err := config.ToHTTPURLFromString(server.URL) + require.NoError(t, err) + client, err := DialBlockchain(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(client.Close) + chainID, err := client.ChainID(t.Context()) + require.NoError(t, err) + require.Equal(t, uint64(31337), chainID.Uint64()) + require.Equal(t, int64(2), calls.Load()) +} diff --git a/internal/cli/ethereum.go b/internal/cli/ethereum.go new file mode 100644 index 000000000..8776e46f3 --- /dev/null +++ b/internal/cli/ethereum.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "errors" + "math/big" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +func GetTransactOpts(ctx context.Context, chainID *big.Int) (*bind.TransactOpts, error) { + factory, err := auth.GetTransactOptsFactory(ctx, chainID) + if err != nil { + return nil, err + } + return GetTransactOptsFromFactory(ctx, factory) +} + +func GetTransactOptsFromFactory( + ctx context.Context, + factory ethutil.TransactOptsFactory, +) (*bind.TransactOpts, error) { + txOpts, err := factory.NewTransactOpts(ctx) + if err != nil { + return nil, err + } + + gasLimit, err := config.GetBlockchainGasLimit() + if err != nil && !errors.Is(err, config.ErrNotDefined) { + return nil, err + } + + if gasLimit > 0 { + txOpts.GasLimit = gasLimit + } + return txOpts, nil +} + +// prepareTransactionFees applies the CLI fee policy immediately before signing. +// Dependent transactions, such as approval followed by deposit, each obtain a +// fresh price. A false legacy setting preserves the binding's automatic choice. +func prepareTransactionFees( + ctx context.Context, + client ethereum.GasPricer, + opts *bind.TransactOpts, +) (*bind.TransactOpts, error) { + legacy, err := config.GetBlockchainLegacyEnabled() + if err != nil && !errors.Is(err, config.ErrNotDefined) { + return nil, err + } + factory := ethutil.NewStaticTransactOptsFactory(opts) + if legacy { + factory = ethutil.WithLegacyFees(factory, client) + } + return factory.NewTransactOpts(ctx) +} diff --git a/internal/cli/ethereum_test.go b/internal/cli/ethereum_test.go new file mode 100644 index 000000000..e3350382a --- /dev/null +++ b/internal/cli/ethereum_test.go @@ -0,0 +1,44 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +const testPrivateKey = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + +func TestGetTransactOptsBlockchainGasLimit(t *testing.T) { + setupPrivateKeyAuth := func(t *testing.T) { + t.Helper() + viper.Reset() + config.SetDefaults() + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, testPrivateKey) + } + + t.Run("default zero leaves gas limit unset", func(t *testing.T) { + setupPrivateKeyAuth(t) + + txOpts, err := GetTransactOpts(context.Background(), big.NewInt(31337)) + require.NoError(t, err) + require.Zero(t, txOpts.GasLimit) + }) + + t.Run("non-zero config sets gas limit", func(t *testing.T) { + setupPrivateKeyAuth(t) + viper.Set(config.BLOCKCHAIN_GAS_LIMIT, "123456") + + txOpts, err := GetTransactOpts(context.Background(), big.NewInt(31337)) + require.NoError(t, err) + require.Equal(t, uint64(123456), txOpts.GasLimit) + }) +} diff --git a/internal/cli/legacy_fees_test.go b/internal/cli/legacy_fees_test.go new file mode 100644 index 000000000..e856109ff --- /dev/null +++ b/internal/cli/legacy_fees_test.go @@ -0,0 +1,194 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "fmt" + "math/big" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" +) + +func TestTransactLegacyFeePolicy(t *testing.T) { + cases := []struct { + name string + legacy bool + manualGas bool + noWait bool + }{ + {name: "dynamic/estimate/wait"}, + {name: "dynamic/estimate/no_wait", noWait: true}, + {name: "dynamic/manual/wait", manualGas: true}, + {name: "dynamic/manual/no_wait", manualGas: true, noWait: true}, + {name: "legacy/estimate/wait", legacy: true}, + {name: "legacy/estimate/no_wait", legacy: true, noWait: true}, + {name: "legacy/manual/wait", legacy: true, manualGas: true}, + {name: "legacy/manual/no_wait", legacy: true, manualGas: true, noWait: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Configure the CLI signer, fee mode, gas limit, and receipt policy. + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.AUTH_KIND, "private_key") + viper.Set(config.AUTH_PRIVATE_KEY, testPrivateKey) + viper.Set(config.BLOCKCHAIN_LEGACY_ENABLED, tc.legacy) + if tc.manualGas { + viper.Set(config.BLOCKCHAIN_GAS_LIMIT, "123456") + } + chainID := big.NewInt(31337) + opts, err := GetTransactOpts(t.Context(), chainID) + require.NoError(t, err) + opts.Nonce, opts.Value = big.NewInt(9), big.NewInt(8) + cmd, _, _ := transactionCommand() + require.NoError(t, cmd.Flags().Set("no-wait", fmt.Sprint(tc.noWait))) + + // Keep the real client and generated binding; fake only the RPC server. + client, backend := newLegacyFeeRPCClient(t) + inputBox, err := iinputbox.NewIInputBox(common.HexToAddress("0x01"), client) + require.NoError(t, err) + + // Submit twice to verify that each legacy transaction gets a fresh fee. + for sequence := int64(1); sequence <= 2; sequence++ { + tx, receipt, err := Transact(t.Context(), cmd, client, opts, + func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.True(t, prepared.NoSend) + require.Equal(t, t.Context(), prepared.Context) + return inputBox.AddInput(prepared, common.HexToAddress("0x02"), []byte{3}) + }) + require.NoError(t, err) + require.Len(t, backend.sent, 1, "broadcast exactly once") + sent := <-backend.sent + require.Equal(t, tx.Hash(), sent.Hash()) + if tc.legacy { + require.EqualValues(t, types.LegacyTxType, sent.Type()) + require.Equal(t, big.NewInt(100+sequence), sent.GasPrice()) + } else { + require.EqualValues(t, types.DynamicFeeTxType, sent.Type()) + } + from, err := types.Sender(types.LatestSignerForChainID(chainID), sent) + require.NoError(t, err) + require.Equal(t, opts.From, from) + require.Equal(t, opts.Nonce.Uint64(), sent.Nonce()) + require.Equal(t, opts.Value, sent.Value()) + if tc.manualGas { + require.EqualValues(t, 123456, sent.Gas()) + } else { + require.EqualValues(t, 75000, sent.Gas()) + } + if tc.noWait { + require.Nil(t, receipt) + } else { + require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status) + } + } + + // Caller options must remain unchanged; RPC calls must match the policy. + require.False(t, opts.NoSend) + require.Nil(t, opts.GasPrice, "do not cache the fee in caller options") + if tc.legacy { + require.EqualValues(t, 2, backend.prices.Load()) + } else { + require.Zero(t, backend.prices.Load()) + } + if tc.manualGas { + require.Zero(t, backend.estimates.Load()) + } else { + require.EqualValues(t, 2, backend.estimates.Load()) + } + if tc.noWait { + require.Zero(t, backend.receipts.Load()) + } else { + require.EqualValues(t, 2, backend.receipts.Load()) + } + }) + } +} + +func newLegacyFeeRPCClient(t *testing.T) (*ethclient.Client, *cliLegacyFeeRPC) { + t.Helper() + backend := &cliLegacyFeeRPC{sent: make(chan *types.Transaction, 2)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + return client, backend +} + +type cliLegacyFeeRPC struct { + prices atomic.Int32 + estimates atomic.Int32 + receipts atomic.Int32 + sent chan *types.Transaction +} + +func (r *cliLegacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} + +func (*cliLegacyFeeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(0), BaseFee: big.NewInt(10)}, nil +} + +func (*cliLegacyFeeRPC) MaxPriorityFeePerGas(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(2)), nil +} + +func (*cliLegacyFeeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (r *cliLegacyFeeRPC) EstimateGas(context.Context, map[string]any) (hexutil.Uint64, error) { + r.estimates.Add(1) + return 75000, nil +} + +func (r *cliLegacyFeeRPC) SendRawTransaction(_ context.Context, data hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + r.sent <- tx + return tx.Hash(), nil +} + +func (r *cliLegacyFeeRPC) GetTransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts.Add(1) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(1), Logs: []*types.Log{}}, nil +} + +func TestTransactLegacyFeeFailureDoesNotSign(t *testing.T) { + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.BLOCKCHAIN_LEGACY_ENABLED, true) + client := &transactionRPC{} + cmd, _, stderr := transactionCommand() + tx, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { + t.Fatal("must not sign without a gas price") + return nil, nil + }) + require.ErrorContains(t, err, "prepare transaction fees: suggest legacy gas price") + require.Nil(t, tx) + require.Nil(t, receipt) + require.Empty(t, stderr.String()) + require.Zero(t, client.sends) + require.Zero(t, client.receipts) +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go new file mode 100644 index 000000000..d366e09f4 --- /dev/null +++ b/internal/cli/prompt.go @@ -0,0 +1,52 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" +) + +// ErrNonInteractive is returned when a confirmation prompt is attempted +// on a non-interactive (piped/redirected) stdin without using --yes. +var ErrNonInteractive = errors.New( + "cannot prompt for confirmation: stdin is not a terminal. Use --yes to skip prompts") + +// ConfirmPrompt prints the given message followed by " [y/N]: " and reads user input from stdin. +// Returns true if the user answers "y" or "yes" (case-insensitive), false otherwise. +// Returns ErrNonInteractive if stdin is not a terminal (e.g. piped in a script), +// or a wrapped I/O error if reading from stdin fails. +func ConfirmPrompt(message string) (bool, error) { + return ConfirmPromptTo(os.Stdout, message) +} + +// ConfirmPromptTo writes the human prompt separately from command results. +func ConfirmPromptTo(output io.Writer, message string) (bool, error) { + if !IsTerminal(os.Stdin) { + return false, ErrNonInteractive + } + if _, err := fmt.Fprintf(output, "%s [y/N]: ", message); err != nil { + return false, fmt.Errorf("write confirmation prompt: %w", err) + } + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("reading input: %w", err) + } + answer := strings.ToLower(strings.TrimSpace(scanner.Text())) + return answer == "y" || answer == "yes", nil +} + +// IsTerminal reports whether the given file is connected to a terminal. +func IsTerminal(f *os.File) bool { + fi, err := f.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/cli/revert.go b/internal/cli/revert.go new file mode 100644 index 000000000..312dae54f --- /dev/null +++ b/internal/cli/revert.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "fmt" + + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// DecorateRevert wraps a transaction error with a human-readable description +// of its custom-error revert data, decoded against the given contract ABIs. +// Without it the operator sees only the raw selector hex. Returns err +// unchanged when there is no revert data or no ABI declares the selector. +func DecorateRevert(err error, metadatas ...*bind.MetaData) error { + if err == nil { + return nil + } + if desc, ok := ethutil.DescribeRevert(err, metadatas...); ok { + return fmt.Errorf("%w\ndecoded revert: %s", err, desc) + } + return err +} diff --git a/internal/cli/single_service.go b/internal/cli/single_service.go new file mode 100644 index 000000000..caa83b6bd --- /dev/null +++ b/internal/cli/single_service.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "log/slog" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/spf13/cobra" +) + +// SingleServiceOptions supplies the configuration and constructor for a standalone service. +type SingleServiceOptions struct { + Command *cobra.Command + Name string + LogLevel slog.Level + LogColor bool + MaxStartupTime time.Duration + DatabaseConnection config.SafeURL + TelemetryAddress string + Create func(context.Context, *slog.Logger, repository.Repository) (service.SupervisedService, error) +} + +// RunSingleService owns the repository and supervisor lifecycle. Resources are +// cleaned up before an error is logged and returned to Cobra. +func RunSingleService(opts SingleServiceOptions) (runErr error) { + ctx, cancel := context.WithTimeout(context.Background(), opts.MaxStartupTime) + defer cancel() + logger := service.NewLogger(opts.Name, opts.LogLevel, opts.LogColor) + defer func() { LogErr(logger, runErr) }() + opts.Command.SilenceUsage = true + + repo, err := OpenRepository(ctx, opts.DatabaseConnection) + if err != nil { + return err + } + defer repo.Close() + + sup, err := service.NewSupervisor(ctx, &service.SupervisorConfigs{ + BaseConfigs: service.BaseConfigs{Name: opts.Name, Logger: logger}, + EnableSignalHandling: true, + TelemetryAddress: opts.TelemetryAddress, + Factories: []service.FactoryFunction{ + func(ctx context.Context, sup service.Supervisor) (service.SupervisedService, error) { + return opts.Create(ctx, sup.Logger(), repo) + }, + }, + }) + if err != nil { + return err + } + return sup.Serve() +} diff --git a/internal/cli/transaction.go b/internal/cli/transaction.go new file mode 100644 index 000000000..b784501a7 --- /dev/null +++ b/internal/cli/transaction.go @@ -0,0 +1,192 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" +) + +const ( + defaultReceiptTimeout = 2 * time.Minute + receiptPollInterval = 500 * time.Millisecond +) + +// AddTransactionFlags installs the receipt policy for one transaction command. +// Commands with dependent steps must also reject incompatible --no-wait use. +func AddTransactionFlags(cmd *cobra.Command) { + cmd.Flags().Bool("no-wait", false, "Return after broadcast without checking execution success") + cmd.Flags().Duration("wait-timeout", defaultReceiptTimeout, "Maximum time to wait for a mined receipt") + previousRun := cmd.PreRunE + cmd.PreRunE = func(command *cobra.Command, args []string) error { + _, _, err := transactionPolicy(command) + if err != nil { + return err + } + if previousRun != nil { + return previousRun(command, args) + } + return nil + } + previousHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(command *cobra.Command, args []string) { + if flag := command.Flags().Lookup("gas-limit"); flag != nil { + flag.Hidden = false + } + previousHelp(command, args) + }) +} + +func transactionPolicy(cmd *cobra.Command) (bool, time.Duration, error) { + noWait, err := cmd.Flags().GetBool("no-wait") + if err != nil { + return false, 0, err + } + timeout, err := cmd.Flags().GetDuration("wait-timeout") + if err != nil { + return false, 0, err + } + if timeout <= 0 { + return false, 0, fmt.Errorf("wait-timeout must be positive") + } + return noWait, timeout, nil +} + +// TransactionClient provides fee suggestions, broadcast, and receipt lookup. +type TransactionClient interface { + ethereum.GasPricer + SendTransaction(context.Context, *types.Transaction) error + TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) +} + +// Transact prepares and signs once, broadcasts once, and optionally checks mining. +// A nil receipt with no error means broadcast-only, not confirmed execution. +// After signing, the transaction is returned even if sending or waiting fails. +// GasLimit is preserved: zero estimates gas; a manual limit skips estimation. +func Transact( + ctx context.Context, + cmd *cobra.Command, + client TransactionClient, + opts *bind.TransactOpts, + build func(*bind.TransactOpts) (*types.Transaction, error), +) (*types.Transaction, *types.Receipt, error) { + noWait, timeout, err := transactionPolicy(cmd) + if err != nil { + return nil, nil, err + } + if err := ctx.Err(); err != nil { + return nil, nil, err + } + preparedOpts, err := prepareTransactionFees(ctx, client, opts) + if err != nil { + return nil, nil, fmt.Errorf("prepare transaction fees: %w", err) + } + preparedOpts.NoSend = true + tx, err := build(preparedOpts) + if err != nil { + return nil, nil, fmt.Errorf("prepare transaction: %w", err) + } + if tx == nil { + return nil, nil, fmt.Errorf("prepare transaction: no signed transaction") + } + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Transaction hash: %s\n", tx.Hash().Hex()); err != nil { + return tx, nil, fmt.Errorf("write transaction hash before broadcast: %w", err) + } + // Before broadcast, keep normal signal handling for prompts, stdin, and + // signers that capture their own context. No transaction has been sent. + // After this point, cancellation must report the known transaction hash. + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + if err := client.SendTransaction(ctx, tx); err != nil { + return tx, nil, fmt.Errorf("broadcast transaction %s: %w; check its receipt before retrying", tx.Hash().Hex(), err) + } + if noWait { + return tx, nil, nil + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + receipt, err := waitForReceipt(waitCtx, client, tx.Hash()) + return tx, receipt, err +} + +func waitForReceipt(ctx context.Context, client TransactionClient, hash common.Hash) (*types.Receipt, error) { + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), err) + } + receipt, err := client.TransactionReceipt(ctx, hash) + switch { + case err == nil && receipt != nil: + if receipt.TxHash != hash { + return nil, fmt.Errorf("transaction %s outcome unknown: provider returned a different receipt hash", hash.Hex()) + } + if receipt.Status != types.ReceiptStatusSuccessful { + return receipt, fmt.Errorf("transaction %s failed in block %v (receipt status %d)", + hash.Hex(), receipt.BlockNumber, receipt.Status) + } + return receipt, nil + case err != nil && !errors.Is(err, ethereum.NotFound): + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), err) + } + // A missing receipt is normal while the transaction is pending. Do not + // infer failure or resubmit from mempool visibility on another RPC node. + timer := time.NewTimer(receiptPollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("transaction %s outcome unknown: %w; check its receipt before retrying", hash.Hex(), ctx.Err()) + case <-timer.C: + } + } +} + +// TransactionResult distinguishes broadcast from successful mined execution. +// A mined result does not assert finality or application input processing. +type TransactionResult struct { + TransactionHash string `json:"transaction_hash"` + Status string `json:"status"` + BlockNumber string `json:"block_number,omitempty"` +} + +func NewTransactionResult(tx *types.Transaction, receipt *types.Receipt) TransactionResult { + result := TransactionResult{TransactionHash: tx.Hash().Hex(), Status: "broadcast"} + if receipt != nil { + result.Status = "mined" + if receipt.BlockNumber != nil { + result.BlockNumber = fmt.Sprintf("0x%x", receipt.BlockNumber) + } + } + return result +} + +func WriteTransactionResult(cmd *cobra.Command, tx *types.Transaction, receipt *types.Receipt) error { + result := NewTransactionResult(tx, receipt) + asJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + if asJSON { + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + err = encoder.Encode(result) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Transaction %s: %s\n", result.Status, result.TransactionHash) + } + if err != nil { + return fmt.Errorf("write transaction result: %w", err) + } + return nil +} diff --git a/internal/cli/transaction_review_test.go b/internal/cli/transaction_review_test.go new file mode 100644 index 000000000..c4f77cafc --- /dev/null +++ b/internal/cli/transaction_review_test.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "syscall" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +const transactionSignalTestChild = "CARTESI_CLI_TRANSACTION_SIGNAL_TEST_CHILD" + +func TestTransactHashWriteFailureStopsBroadcast(t *testing.T) { + t.Parallel() + cmd, stdout, _ := transactionCommand() + cmd.SetErr(failingTransactionWriter{}) + client := &transactionRPC{} + tx := transactionFixture() + actual, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorIs(t, err, io.ErrClosedPipe) + require.ErrorContains(t, err, "write transaction hash before broadcast") + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Zero(t, client.sends) + require.Zero(t, client.receipts) + require.Empty(t, stdout.String()) +} + +// Signals must reach only the subprocess. Other tests can have active +// transaction contexts in the parent process at the same time. +func TestTransactSignalCancellation(t *testing.T) { + if os.Getenv(transactionSignalTestChild) == "1" { + runTransactionSignalChild(t) + return + } + + executable, err := os.Executable() + require.NoError(t, err) + for _, signal := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + t.Run(signal.String(), func(t *testing.T) { + const childTimeout = 10 * time.Second + ctx, cancel := context.WithTimeout(t.Context(), childTimeout) + defer cancel() + child := exec.CommandContext(ctx, executable, "-test.run=^TestTransactSignalCancellation$") + child.Env = append(os.Environ(), transactionSignalTestChild+"=1") + var stderr bytes.Buffer + child.Stderr = &stderr + stdout, err := child.StdoutPipe() + require.NoError(t, err) + require.NoError(t, child.Start()) + t.Cleanup(func() { + cancel() + if child.ProcessState == nil { + _ = child.Wait() + } + }) + + // The child acknowledges the receipt request after Transact installs + // its signal handler. No sleep or process-start timing is required. + scanner := bufio.NewScanner(stdout) + require.True(t, scanner.Scan(), "child exited before requesting a receipt") + require.Equal(t, "waiting-for-receipt", scanner.Text()) + require.NoError(t, child.Process.Signal(signal)) + require.NoError(t, child.Wait(), stderr.String()) + require.Contains(t, stderr.String(), transactionFixture().Hash().Hex()) + require.Contains(t, stderr.String(), "outcome unknown") + require.Contains(t, stderr.String(), "context canceled") + }) + } +} + +func runTransactionSignalChild(t *testing.T) { + t.Helper() + cmd, _, _ := transactionCommand() + cmd.SetErr(os.Stderr) + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return nil }, + receipt: func(ctx context.Context, _ common.Hash) (*types.Receipt, error) { + _, err := fmt.Fprintln(os.Stdout, "waiting-for-receipt") + require.NoError(t, err) + <-ctx.Done() + return nil, ctx.Err() + }, + } + tx := transactionFixture() + actual, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorIs(t, err, context.Canceled) + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Equal(t, 1, client.sends) + require.Equal(t, 1, client.receipts) + _, writeErr := fmt.Fprintln(os.Stderr, err) + require.NoError(t, writeErr) +} diff --git a/internal/cli/transaction_test.go b/internal/cli/transaction_test.go new file mode 100644 index 000000000..e6ef18f3d --- /dev/null +++ b/internal/cli/transaction_test.go @@ -0,0 +1,262 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +type transactionRPC struct { + send func(context.Context, *types.Transaction) error + receipt func(context.Context, common.Hash) (*types.Receipt, error) + sends int + receipts int +} + +func (*transactionRPC) SuggestGasPrice(context.Context) (*big.Int, error) { + return nil, errors.New("unexpected gas price request") +} + +func (r *transactionRPC) SendTransaction(ctx context.Context, tx *types.Transaction) error { + r.sends++ + return r.send(ctx, tx) +} + +func (r *transactionRPC) TransactionReceipt(ctx context.Context, hash common.Hash) (*types.Receipt, error) { + r.receipts++ + return r.receipt(ctx, hash) +} + +func transactionCommand() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + cmd := &cobra.Command{Use: "test-transaction"} + AddTransactionFlags(cmd) + cmd.Flags().Bool("json", false, "JSON output") + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + return cmd, stdout, stderr +} + +func transactionFixture() *types.Transaction { + return types.NewTx(&types.LegacyTx{Gas: 21000, GasPrice: big.NewInt(1)}) +} + +func TestTransactPolicy(t *testing.T) { + t.Parallel() + for _, noWait := range []bool{false, true} { + name := "wait" + if noWait { + name = "no_wait" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + if noWait { + require.NoError(t, cmd.Flags().Set("no-wait", "true")) + } + tx := transactionFixture() + opts := &bind.TransactOpts{GasLimit: 123456} + var built bool + client := &transactionRPC{ + send: func(_ context.Context, sent *types.Transaction) error { + require.True(t, built) + require.Same(t, tx, sent) + require.Contains(t, stderr.String(), tx.Hash().Hex(), "hash must be available before broadcast") + return nil + }, + receipt: func(_ context.Context, hash common.Hash) (*types.Receipt, error) { + require.False(t, noWait, "no-wait must not query receipts") + require.Equal(t, tx.Hash(), hash) + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(9)}, nil + }, + } + actual, receipt, err := Transact(t.Context(), cmd, client, opts, + func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.NotSame(t, opts, prepared) + require.True(t, prepared.NoSend) + require.Equal(t, opts.GasLimit, prepared.GasLimit) + require.NoError(t, prepared.Context.Err()) + built = true + return tx, nil + }) + require.NoError(t, err) + require.Same(t, tx, actual) + require.False(t, opts.NoSend, "do not mutate caller options") + require.Equal(t, 1, client.sends) + require.Empty(t, stdout.String(), "the command owns its final result") + if noWait { + require.Nil(t, receipt) + require.Zero(t, client.receipts) + } else { + require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status) + require.Equal(t, 1, client.receipts) + } + }) + } +} + +func TestTransactErrorsKeepHash(t *testing.T) { + t.Parallel() + rpcError := errors.New("RPC response lost") + tx := transactionFixture() + for _, test := range []struct { + name string + sendError error + receipt *types.Receipt + readError error + want string + cause error + }{ + {name: "broadcast", sendError: rpcError, want: "check its receipt before retrying", cause: rpcError}, + {name: "read", readError: rpcError, want: "outcome unknown", cause: rpcError}, + {name: "manual_gas_mined_failure", receipt: &types.Receipt{ + TxHash: tx.Hash(), Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(9)}, want: "failed in block 9"}, + {name: "wrong_receipt", receipt: &types.Receipt{TxHash: common.HexToHash("0x99")}, want: "different receipt hash"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + cmd, _, stderr := transactionCommand() + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return test.sendError }, + receipt: func(context.Context, common.Hash) (*types.Receipt, error) { return test.receipt, test.readError }, + } + actual, _, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{GasLimit: tx.Gas()}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorContains(t, err, test.want) + require.ErrorContains(t, err, tx.Hash().Hex()) + if test.cause != nil { + require.ErrorIs(t, err, test.cause) + } + require.Same(t, tx, actual) + require.Contains(t, stderr.String(), tx.Hash().Hex()) + require.Equal(t, 1, client.sends, "never resubmit on send or receipt errors") + }) + } +} + +func TestTransactPendingTimeoutAndCancellation(t *testing.T) { + t.Parallel() + for _, cancelWait := range []bool{false, true} { + name := "timeout" + if cancelWait { + name = "cancel" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, _, _ := transactionCommand() + require.NoError(t, cmd.Flags().Set("wait-timeout", "10ms")) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := &transactionRPC{ + send: func(context.Context, *types.Transaction) error { return nil }, + receipt: func(context.Context, common.Hash) (*types.Receipt, error) { + if cancelWait { + cancel() + } + return nil, ethereum.NotFound + }, + } + tx := transactionFixture() + actual, receipt, err := Transact(ctx, cmd, client, &bind.TransactOpts{}, + func(*bind.TransactOpts) (*types.Transaction, error) { return tx, nil }) + require.ErrorContains(t, err, "outcome unknown") + require.ErrorContains(t, err, tx.Hash().Hex()) + if cancelWait { + require.ErrorIs(t, err, context.Canceled) + } else { + require.ErrorIs(t, err, context.DeadlineExceeded) + } + require.Same(t, tx, actual) + require.Nil(t, receipt) + require.Equal(t, 1, client.sends) + require.Equal(t, 1, client.receipts) + }) + } +} + +func TestTransactPreparationStopsBeforeBroadcast(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + estimateError := errors.New("execution reverted during estimation") + client := &transactionRPC{} + tx, receipt, err := Transact(t.Context(), cmd, client, &bind.TransactOpts{}, + func(opts *bind.TransactOpts) (*types.Transaction, error) { + require.Zero(t, opts.GasLimit, "preserve the default estimation policy") + return nil, estimateError + }) + require.ErrorIs(t, err, estimateError) + require.Nil(t, tx) + require.Nil(t, receipt) + require.Zero(t, client.sends) + require.Empty(t, stdout.String()) + require.Empty(t, stderr.String()) +} + +func TestTransactionFlagValidation(t *testing.T) { + t.Parallel() + for _, timeout := range []string{"0s", "-1s"} { + t.Run(timeout, func(t *testing.T) { + t.Parallel() + cmd, _, _ := transactionCommand() + cmd.RunE = func(*cobra.Command, []string) error { t.Fatal("must reject before command runs"); return nil } + cmd.SetArgs([]string{"--wait-timeout", timeout}) + require.ErrorContains(t, cmd.ExecuteContext(t.Context()), "wait-timeout must be positive") + }) + } + t.Run("preserve_command_validation", func(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{Use: "test", PreRunE: func(*cobra.Command, []string) error { return io.ErrUnexpectedEOF }} + AddTransactionFlags(cmd) + require.ErrorIs(t, cmd.PreRunE(cmd, nil), io.ErrUnexpectedEOF) + }) +} + +type failingTransactionWriter struct{} + +func (failingTransactionWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestWriteTransactionResult(t *testing.T) { + t.Parallel() + for _, asJSON := range []bool{false, true} { + name := "text" + if asJSON { + name = "json" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd, stdout, stderr := transactionCommand() + if asJSON { + require.NoError(t, cmd.Flags().Set("json", "true")) + } + tx := transactionFixture() + require.NoError(t, WriteTransactionResult(cmd, tx, nil)) + require.Contains(t, stdout.String(), tx.Hash().Hex()) + require.Contains(t, stdout.String(), "broadcast") + require.Empty(t, stderr.String()) + if asJSON { + var result map[string]string + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + require.NotContains(t, result, "block_number") + } + cmd.SetOut(failingTransactionWriter{}) + require.ErrorIs(t, WriteTransactionResult(cmd, tx, nil), io.ErrClosedPipe) + }) + } + result := NewTransactionResult(transactionFixture(), &types.Receipt{BlockNumber: big.NewInt(16)}) + require.Equal(t, "mined", result.Status) + require.Equal(t, "0x10", result.BlockNumber) +} diff --git a/internal/cli/types.go b/internal/cli/types.go new file mode 100644 index 000000000..a6b20b402 --- /dev/null +++ b/internal/cli/types.go @@ -0,0 +1,21 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package cli defines the JSON output types used by the CLI commands. +// Types here are CLI-specific (SendResult, TransactionResult, ValidateResult). +// Response envelope types (ListResponse, SingleResponse, Pagination) live +// in internal/jsonrpc/api. +package cli + +// SendResult is the JSON output of the "send" CLI command. +// With --no-wait, the input index and block number are not yet known. +type SendResult struct { + TransactionResult + ApplicationAddress string `json:"application_address"` + InputIndex string `json:"input_index,omitempty"` +} + +// ValidateResult is the JSON output of the "validate" CLI command. +type ValidateResult struct { + Valid bool `json:"valid"` +} diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go new file mode 100644 index 000000000..a616e695e --- /dev/null +++ b/internal/config/auth/auth.go @@ -0,0 +1,126 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package auth + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + aws "github.com/aws/aws-sdk-go-v2/aws" + aws_http "github.com/aws/aws-sdk-go-v2/aws/transport/http" + aws_cfg "github.com/aws/aws-sdk-go-v2/config" + aws_kms "github.com/aws/aws-sdk-go-v2/service/kms" + + . "github.com/cartesi/rollups-node/internal/config" + signtx "github.com/cartesi/rollups-node/internal/kms" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +type authGetters struct { + kind func() (AuthKind, error) + privateKey func() (RedactedString, error) + mnemonic func() (RedactedString, error) + mnemonicAccountIndex func() (RedactedUint, error) + awsKMSKeyID func() (RedactedString, error) +} + +func GetTransactOptsFactory(ctx context.Context, chainID *big.Int) (ethutil.TransactOptsFactory, error) { + return getTransactOptsFactory(ctx, chainID, authGetters{ + kind: GetAuthKind, + privateKey: GetAuthPrivateKey, + mnemonic: GetAuthMnemonic, + mnemonicAccountIndex: GetAuthMnemonicAccountIndex, + awsKMSKeyID: GetAuthAwsKmsKeyId, + }) +} + +func GetPrtTransactOptsFactory(ctx context.Context, chainID *big.Int) (ethutil.TransactOptsFactory, error) { + return getTransactOptsFactory(ctx, chainID, authGetters{ + kind: GetPrtAuthKind, + privateKey: GetPrtAuthPrivateKey, + mnemonic: GetPrtAuthMnemonic, + mnemonicAccountIndex: GetPrtAuthMnemonicAccountIndex, + awsKMSKeyID: GetPrtAuthAwsKmsKeyId, + }) +} + +func getTransactOptsFactory( + ctx context.Context, + chainID *big.Int, + getters authGetters, +) (ethutil.TransactOptsFactory, error) { + if chainID == nil || chainID.Sign() <= 0 { + return nil, bind.ErrNoChainID + } + + authKind, err := getters.kind() + if err != nil { + return nil, err + } + switch authKind { + case AuthKindMnemonicVar, AuthKindMnemonicFile: + mnemonic, err := getters.mnemonic() + if err != nil { + return nil, err + } + accountIndex, err := getters.mnemonicAccountIndex() + if err != nil { + return nil, err + } + privateKey, err := ethutil.MnemonicToPrivateKey(mnemonic.Value, accountIndex.Value) + if err != nil { + return nil, err + } + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID) + if err != nil { + return nil, err + } + return ethutil.NewStaticTransactOptsFactory(txOpts), nil + case AuthKindPrivateKeyVar, AuthKindPrivateKeyFile: + privateKey, err := getters.privateKey() + if err != nil { + return nil, err + } + key, err := crypto.HexToECDSA(ethutil.TrimHex(privateKey.Value)) + if err != nil { + return nil, err + } + txOpts, err := bind.NewKeyedTransactorWithChainID(key, chainID) + if err != nil { + return nil, err + } + return ethutil.NewStaticTransactOptsFactory(txOpts), nil + case AuthKindAWS: + keyID, err := getters.awsKMSKeyID() + if err != nil { + return nil, err + } + timeout, err := GetBlockchainHttpRequestTimeout() + if err != nil { + return nil, err + } + if timeout <= 0 { + return nil, fmt.Errorf("%s must be positive for AWS KMS", BLOCKCHAIN_HTTP_REQUEST_TIMEOUT) + } + awsCfg, err := aws_cfg.LoadDefaultConfig(ctx, + aws_cfg.WithHTTPClient(aws_http.NewBuildableClient().WithTimeout(timeout))) + if err != nil { + return nil, err + } + kmsClient := aws_kms.NewFromConfig(awsCfg) + return signtx.CreateAWSTransactOptsFactory( + ctx, + kmsClient, + aws.String(keyID.Value), + types.LatestSignerForChainID(chainID), + ) + default: + return nil, fmt.Errorf("no valid authentication method found") + } +} diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go new file mode 100644 index 000000000..1953dc475 --- /dev/null +++ b/internal/config/auth/auth_test.go @@ -0,0 +1,312 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package auth + +import ( + "crypto/ecdsa" + "crypto/rand" + "encoding/asn1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + . "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +func TestGetTransactOptsFactoryAcceptsVariableAndFileKinds(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + privateKeyText := hex.EncodeToString(crypto.FromECDSA(privateKey)) + privateKeyAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + + mnemonicKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 4) + require.NoError(t, err) + mnemonicAddress := crypto.PubkeyToAddress(mnemonicKey.PublicKey) + + tests := []struct { + name string + kind AuthKind + expectedAddress common.Address + }{ + {name: "private key variable", kind: AuthKindPrivateKeyVar, expectedAddress: privateKeyAddress}, + {name: "private key file", kind: AuthKindPrivateKeyFile, expectedAddress: privateKeyAddress}, + {name: "mnemonic variable", kind: AuthKindMnemonicVar, expectedAddress: mnemonicAddress}, + {name: "mnemonic file", kind: AuthKindMnemonicFile, expectedAddress: mnemonicAddress}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, err := getTransactOptsFactory(t.Context(), big.NewInt(31337), authGetters{ + kind: func() (AuthKind, error) { return test.kind, nil }, + privateKey: func() (RedactedString, error) { + return RedactedString{Value: privateKeyText}, nil + }, + mnemonic: func() (RedactedString, error) { + return RedactedString{Value: ethutil.FoundryMnemonic}, nil + }, + mnemonicAccountIndex: func() (RedactedUint, error) { + return RedactedUint{Value: 4}, nil + }, + awsKMSKeyID: func() (RedactedString, error) { + t.Fatal("AWS KMS getter must not be called") + return RedactedString{}, nil + }, + }) + require.NoError(t, err) + require.Equal(t, test.expectedAddress, factory.From()) + }) + } +} + +func TestGetPrtTransactOptsFactoryUsesOnlyPrtAuth(t *testing.T) { + for _, test := range []struct { + name string + index string + want uint32 + }{ + {name: "default", want: 6}, + {name: "explicit nonzero index", index: "4", want: 4}, + {name: "explicit zero index", index: "0"}, + } { + t.Run(test.name, func(t *testing.T) { + resetAuthConfig(t) + t.Setenv(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, test.index) + viper.Set(AUTH_KIND, "mnemonic") + viper.Set(AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(AUTH_MNEMONIC_ACCOUNT_INDEX, 0) + viper.Set(PRT_AUTH_KIND, "mnemonic") + viper.Set(PRT_AUTH_MNEMONIC, ethutil.FoundryMnemonic) + + claimerFactory, err := GetTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + prtFactory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + prtKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, test.want) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(prtKey.PublicKey), prtFactory.From()) + if test.want != 0 { + require.NotEqual(t, claimerFactory.From(), prtFactory.From()) + } else { + // Operators may configure the same address for both services. + require.Equal(t, claimerFactory.From(), prtFactory.From()) + } + }) + } +} + +func TestGetPrtTransactOptsFactoryDoesNotFallbackToGenericAuth(t *testing.T) { + resetAuthConfig(t) + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + viper.Set(AUTH_KIND, "private_key") + viper.Set(AUTH_PRIVATE_KEY, hex.EncodeToString(crypto.FromECDSA(privateKey))) + viper.Set(PRT_AUTH_KIND, "private_key") + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.Nil(t, factory) + require.ErrorContains(t, err, PRT_AUTH_PRIVATE_KEY) +} + +func TestGetPrtTransactOptsFactoryRequiresPrtMnemonic(t *testing.T) { + resetAuthConfig(t) + viper.Set(AUTH_KIND, "mnemonic") + viper.Set(AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(PRT_AUTH_KIND, "mnemonic") + viper.Set(PRT_AUTH_MNEMONIC, "") + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.Nil(t, factory) + require.ErrorContains(t, err, PRT_AUTH_MNEMONIC) +} + +func TestGetPrtTransactOptsFactoryReadsFileAuth(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + privateKeyText := hex.EncodeToString(crypto.FromECDSA(privateKey)) + privateKeyFile := filepath.Join(t.TempDir(), "prt-private-key") + require.NoError(t, os.WriteFile(privateKeyFile, []byte(privateKeyText), 0o600)) + + resetAuthConfig(t) + viper.Set(PRT_AUTH_KIND, "private_key_file") + viper.Set(PRT_AUTH_PRIVATE_KEY_FILE, privateKeyFile) + + factory, err := GetPrtTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), factory.From()) +} + +func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { + server := newFakeKMSServer(t) + t.Cleanup(server.Close) + setupAWSAuth(t, server.URL) + + chainID := big.NewInt(31337) + factory, err := GetTransactOptsFactory(t.Context(), chainID) + require.NoError(t, err) + opts, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + + to := common.Address{0x01} + tests := []struct { + name string + tx *types.Transaction + }{ + { + name: "dynamic fee", + tx: types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 21000, + To: &to, + Value: big.NewInt(3), + }), + }, + { + name: "legacy", + tx: types.NewTx(&types.LegacyTx{ + Nonce: 2, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &to, + Value: big.NewInt(3), + }), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + signed, err := opts.Signer(opts.From, test.tx) + require.NoError(t, err) + sender, err := types.Sender(types.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + require.Equal(t, opts.From, sender) + }) + } +} + +func TestGetTransactOptsFactoryRejectsInvalidChainID(t *testing.T) { + tests := []struct { + name string + chainID *big.Int + }{ + {name: "nil", chainID: nil}, + {name: "zero", chainID: big.NewInt(0)}, + {name: "negative", chainID: big.NewInt(-1)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, err := GetTransactOptsFactory(t.Context(), test.chainID) + require.Nil(t, factory) + require.ErrorIs(t, err, bind.ErrNoChainID) + }) + } +} + +func resetAuthConfig(t *testing.T) { + t.Helper() + viper.Reset() + viper.AutomaticEnv() + SetDefaults() + t.Cleanup(func() { + viper.Reset() + viper.AutomaticEnv() + SetDefaults() + }) +} + +func setupAWSAuth(t *testing.T, endpoint string) { + t.Helper() + resetAuthConfig(t) + viper.Set(AUTH_KIND, "aws") + viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") + + // Static dummy credentials keep the AWS SDK hermetic: it never consults + // shared config files, credential services, or EC2 instance metadata. + t.Setenv("AWS_ACCESS_KEY_ID", "test") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_REGION", "us-east-1") + t.Setenv("AWS_ENDPOINT_URL_KMS", endpoint) + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") +} + +func newFakeKMSServer(t *testing.T) *httptest.Server { + t.Helper() + + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + publicKey, err := asn1.Marshal(struct { + Algorithm struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + } + SubjectPublicKey asn1.BitString + }{ + Algorithm: struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + }{ + Algorithm: asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1}, + Parameters: asn1.ObjectIdentifier{1, 3, 132, 0, 10}, + }, + SubjectPublicKey: asn1.BitString{Bytes: crypto.FromECDSAPub(&privateKey.PublicKey)}, + }) + require.NoError(t, err) + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + switch r.Header.Get("X-Amz-Target") { + case "TrentService.GetPublicKey": + writeKMSJSON(t, w, map[string]any{ + "KeyId": "alias/test-key", + "KeySpec": "ECC_SECG_P256K1", + "KeyUsage": "SIGN_VERIFY", + "PublicKey": base64.StdEncoding.EncodeToString(publicKey), + }) + case "TrentService.Sign": + var input struct { + Message string `json:"Message"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&input)) + digest, err := base64.StdEncoding.DecodeString(input.Message) + require.NoError(t, err) + r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest) + require.NoError(t, err) + signature, err := asn1.Marshal(struct { + R *big.Int + S *big.Int + }{R: r, S: s}) + require.NoError(t, err) + writeKMSJSON(t, w, map[string]any{ + "KeyId": "alias/test-key", + "Signature": base64.StdEncoding.EncodeToString(signature), + "SigningAlgorithm": "ECDSA_SHA_256", + }) + default: + http.Error(w, "unexpected KMS operation", http.StatusBadRequest) + } + })) +} + +func writeKMSJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + require.NoError(t, json.NewEncoder(w).Encode(value)) +} diff --git a/internal/config/auth/node.go b/internal/config/auth/node.go new file mode 100644 index 000000000..637cdd56e --- /dev/null +++ b/internal/config/auth/node.go @@ -0,0 +1,32 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package auth + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +// GetNodeTransactOptsFactories resolves both standalone submitting roles before +// any service starts. Split binaries enforce the same boundary at registration. +// Observer-only startup must not call this function. +func GetNodeTransactOptsFactories( + ctx context.Context, chainID *big.Int, +) (claimer, prt ethutil.TransactOptsFactory, err error) { + claimer, err = GetTransactOptsFactory(ctx, chainID) + if err != nil { + return nil, nil, fmt.Errorf("resolve claimer signer: %w", err) + } + prt, err = GetPrtTransactOptsFactory(ctx, chainID) + if err != nil { + return nil, nil, fmt.Errorf("resolve prt signer: %w", err) + } + if claimer.From() == prt.From() { + return nil, nil, fmt.Errorf("claimer and prt require distinct submitting addresses; both resolve to %s", claimer.From()) + } + return claimer, prt, nil +} diff --git a/internal/config/auth/node_test.go b/internal/config/auth/node_test.go new file mode 100644 index 000000000..d3b6bb6c7 --- /dev/null +++ b/internal/config/auth/node_test.go @@ -0,0 +1,33 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package auth + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestNodeSubmittersResolveDistinctAddressesBeforeStartup(t *testing.T) { + viper.Reset() + config.SetDefaults() + t.Cleanup(func() { viper.Reset(); config.SetDefaults() }) + viper.Set(config.AUTH_KIND, "mnemonic") + viper.Set(config.AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(config.AUTH_MNEMONIC_ACCOUNT_INDEX, 0) + viper.Set(config.PRT_AUTH_KIND, "mnemonic") + viper.Set(config.PRT_AUTH_MNEMONIC, ethutil.FoundryMnemonic) + viper.Set(config.PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, 6) + claimer, prt, err := GetNodeTransactOptsFactories(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + require.NotEqual(t, claimer.From(), prt.From()) + viper.Set(config.PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, 0) + _, _, err = GetNodeTransactOptsFactories(t.Context(), big.NewInt(31337)) + require.ErrorContains(t, err, "claimer and prt require distinct submitting addresses") + require.ErrorContains(t, err, claimer.From().Hex()) +} diff --git a/internal/config/auth/timeout_test.go b/internal/config/auth/timeout_test.go new file mode 100644 index 000000000..64f478651 --- /dev/null +++ b/internal/config/auth/timeout_test.go @@ -0,0 +1,91 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package auth + +import ( + "context" + "encoding/pem" + "io" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestKMSSigningHTTPTimeoutAllowsNextRequest(t *testing.T) { + normal := newFakeKMSServer(t) + t.Cleanup(normal.Close) + var stall atomic.Bool + stall.Store(true) + started := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Amz-Target") == "TrentService.Sign" && stall.Swap(false) { + if _, err := io.Copy(io.Discard, r.Body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + started <- struct{}{} + <-r.Context().Done() + return + } + normal.Config.Handler.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + setupAWSAuth(t, server.URL) + t.Setenv("AWS_MAX_ATTEMPTS", "1") + viper.Set(config.BLOCKCHAIN_HTTP_REQUEST_TIMEOUT, "0.1") + factory, err := GetTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + opts, err := factory.NewTransactOpts(ctx) + require.NoError(t, err) + to := common.Address{1} + tx := types.NewTx(&types.LegacyTx{To: &to, Gas: 21000, GasPrice: big.NewInt(1)}) + _, err = opts.Signer(opts.From, tx) + require.Error(t, err) + require.NoError(t, ctx.Err(), "the HTTP timeout must bound signing before its caller expires") + require.Len(t, started, 1, "the server must receive the request before stalling") + signed, err := opts.Signer(opts.From, tx) + require.NoError(t, err) + require.NotNil(t, signed) +} + +func TestKMSRequiresPositiveRequestTimeout(t *testing.T) { + for _, timeout := range []string{"0", "-1"} { + t.Run(timeout, func(t *testing.T) { + setupAWSAuth(t, "http://127.0.0.1:1") + viper.Set(config.BLOCKCHAIN_HTTP_REQUEST_TIMEOUT, timeout) + _, err := GetTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.ErrorContains(t, err, "must be positive for AWS KMS") + }) + } +} + +func TestKMSRequestTimeoutPreservesCustomCA(t *testing.T) { + normal := newFakeKMSServer(t) + t.Cleanup(normal.Close) + server := httptest.NewTLSServer(normal.Config.Handler) + t.Cleanup(server.Close) + setupAWSAuth(t, server.URL) + t.Setenv("AWS_MAX_ATTEMPTS", "1") + caFile := filepath.Join(t.TempDir(), "kms-ca.pem") + require.NoError(t, os.WriteFile(caFile, + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600)) + t.Setenv("AWS_CA_BUNDLE", caFile) + viper.Set(config.BLOCKCHAIN_HTTP_REQUEST_TIMEOUT, "1") + factory, err := GetTransactOptsFactory(t.Context(), big.NewInt(31337)) + require.NoError(t, err, "the AWS SDK must retain custom TLS roots when setting the request timeout") + require.NotNil(t, factory) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 000000000..b40aebe80 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,440 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// The config package manages the node configuration, which comes from environment variables. +// The sub-package generate specifies these environment variables. +package config + +import ( + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cartesi/rollups-node/internal/httpclient" + "github.com/cartesi/rollups-node/internal/model" + + "github.com/ethereum/go-ethereum/common" +) + +// Redacted is a wrapper that redacts a given field from the logs. +type Redacted[T any] struct { + Value T +} + +func (r Redacted[T]) String() string { + return "[REDACTED]" +} + +// Format redacts verbs dispatched to fmt.Formatter, including numeric verbs and +// %#v. Pointer verbs and reflection through unexported enclosing fields can +// bypass this method; the wrapper does not make its exported Value inaccessible. +func (r Redacted[T]) Format(state fmt.State, _ rune) { + _, _ = fmt.Fprint(state, r.String()) +} + +// MarshalJSON prevents exported values from leaking through JSON diagnostics. +func (r Redacted[T]) MarshalJSON() ([]byte, error) { + return []byte(`"[REDACTED]"`), nil +} + +// LogValue protects both text and structured slog handlers. +func (r Redacted[T]) LogValue() slog.Value { + return slog.StringValue(r.String()) +} + +// SafeURL wraps *url.URL with a safe String() that only shows scheme://host. +// Use Raw() to get the full URL string for dialing or connecting. +type SafeURL struct { + url *url.URL +} + +// NewSafeURL creates a SafeURL from a *url.URL. A nil input produces a zero-value SafeURL. +func NewSafeURL(u *url.URL) SafeURL { + return SafeURL{url: u} +} + +// String returns the redacted form "scheme://host", hiding paths, query parameters, +// and userinfo that may contain API keys or passwords. +func (u SafeURL) String() string { + if u.url == nil || u.url.Scheme == "" || u.url.Host == "" { + return "[REDACTED]" + } + return fmt.Sprintf("%s://%s", u.url.Scheme, u.url.Host) +} + +// LogValue implements slog.LogValuer so structured logging automatically redacts. +func (u SafeURL) LogValue() slog.Value { + return slog.StringValue(u.String()) +} + +// Raw returns the full, unredacted URL string for dialing or connecting. +// Returns an empty string for a zero-value SafeURL. +func (u SafeURL) Raw() string { + if u.url == nil { + return "" + } + return u.url.String() +} + +// Scheme returns the URL scheme (e.g. "https", "postgres"). +func (u SafeURL) Scheme() string { + if u.url == nil { + return "" + } + return u.url.Scheme +} + +// Host returns the URL host (including port if present). +func (u SafeURL) Host() string { + if u.url == nil { + return "" + } + return u.url.Host +} + +type ( + URL = SafeURL + Duration = time.Duration + LogLevel = slog.Level + DefaultBlock = model.DefaultBlock + RedactedString = Redacted[string] + RedactedUint = Redacted[uint32] + Address = common.Address +) + +// ------------------------------------------------------------------------------------------------ +// Auth Kind +// ------------------------------------------------------------------------------------------------ + +type AuthKind uint8 + +const ( + AuthKindPrivateKeyVar AuthKind = iota + AuthKindPrivateKeyFile + AuthKindMnemonicVar + AuthKindMnemonicFile + AuthKindAWS +) + +// ------------------------------------------------------------------------------------------------ +// JSON-RPC Machine log level +// ------------------------------------------------------------------------------------------------ + +// MachineLogLevel represents the verbosity level for machine logs +type MachineLogLevel string + +const ( + MachineLogLevelTrace MachineLogLevel = "trace" + MachineLogLevelDebug MachineLogLevel = "debug" + MachineLogLevelInfo MachineLogLevel = "info" + MachineLogLevelWarn MachineLogLevel = "warn" + MachineLogLevelError MachineLogLevel = "error" + MachineLogLevelFatal MachineLogLevel = "fatal" +) + +// ------------------------------------------------------------------------------------------------ +// Parsing functions +// ------------------------------------------------------------------------------------------------ + +func ToUint64FromString(s string) (uint64, error) { + value, err := strconv.ParseUint(s, 10, 64) + return value, err +} + +func ToUint64FromDecimalOrHexString(s string) (uint64, error) { + if len(s) >= 2 && (strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) { + return strconv.ParseUint(s[2:], 16, 64) + } + return ToUint64FromString(s) +} + +func AsHexString(s string) (string, error) { + val, err := ToUint64FromDecimalOrHexString(s) + if err != nil { + return "", fmt.Errorf("invalid index '%s': expected decimal or hex value: %w", s, err) + } + return fmt.Sprintf("0x%x", val), nil +} + +func ToIndexFromString(indexString string) (uint64, error) { + if len(indexString) < 3 || (!strings.HasPrefix(indexString, "0x") && !strings.HasPrefix(indexString, "0X")) { + return 0, errors.New("expected hex encoded value") + } + str := indexString[2:] + index, err := strconv.ParseUint(str, 16, 64) + if err != nil { + return 0, err + } + return index, nil +} + +func ToStringFromString(s string) (string, error) { + return s, nil +} + +func ToDurationFromSeconds(s string) (time.Duration, error) { + return time.ParseDuration(s + "s") +} + +func ToLogLevelFromString(s string) (LogLevel, error) { + var m = map[string]LogLevel{ + "debug": slog.LevelDebug, + "info": slog.LevelInfo, //nolint:goconst // Stable config keyword also appears in generated defaults. + "warn": slog.LevelWarn, + "error": slog.LevelError, + } + if v, ok := m[s]; ok { + return v, nil + } else { + var zeroValue LogLevel + return zeroValue, fmt.Errorf("invalid log level '%s'", s) + } +} + +func ToMachineLogLevelFromString(s string) (MachineLogLevel, error) { + var m = map[string]MachineLogLevel{ + string(MachineLogLevelTrace): MachineLogLevelTrace, + string(MachineLogLevelDebug): MachineLogLevelDebug, + string(MachineLogLevelInfo): MachineLogLevelInfo, + string(MachineLogLevelWarn): MachineLogLevelWarn, + string(MachineLogLevelError): MachineLogLevelError, + string(MachineLogLevelFatal): MachineLogLevelFatal, + } + if v, ok := m[s]; ok { + return v, nil + } + return "", fmt.Errorf("invalid remote machine log level") +} + +func ToAddressFromString(s string) (Address, error) { + if len(s) < 3 || (!strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X")) { + return Address{}, fmt.Errorf("invalid address '%s'", s) + } + s = s[2:] + b, err := hex.DecodeString(s) + if err != nil { + return Address{}, err + } + return common.BytesToAddress(b), nil +} + +func ToHashFromString(s string) (common.Hash, error) { + // Require exactly 32 bytes: common.BytesToHash would silently left-pad + // short input and keep only the last 32 bytes of long input, turning a + // typo'd hash parameter into an empty query result instead of an error. + if len(s) < 3 || (!strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X")) { + return common.Hash{}, fmt.Errorf("invalid hash '%s'", s) + } + s = s[2:] + b, err := hex.DecodeString(s) + if err != nil { + return common.Hash{}, err + } + if len(b) != common.HashLength { + return common.Hash{}, fmt.Errorf("invalid hash '0x%s': must be %d bytes, got %d", s, common.HashLength, len(b)) + } + return common.BytesToHash(b), nil +} + +func ToApplicationNameFromString(s string) (string, error) { + if s == "" { + return "", fmt.Errorf("application name cannot be empty") + } + validNamePattern := regexp.MustCompile(`^[a-z0-9_-]+$`) + if !validNamePattern.MatchString(s) { + return "", fmt.Errorf("invalid application name '%s': must contain only lowercase letters, numbers, underscores, and hyphens", s) + } + return s, nil +} + +func ToApplicationNameOrAddressFromString(s string) (string, error) { + if s == "" { + return "", fmt.Errorf("application name or address cannot be empty") + } + if len(s) >= 3 && (strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) { + _, err := ToAddressFromString(s) + if err != nil { + return "", fmt.Errorf("invalid Ethereum address '%s'", s) + } + return s, nil + } + return ToApplicationNameFromString(s) +} + +func ToDefaultBlockFromString(s string) (DefaultBlock, error) { + var m = map[string]DefaultBlock{ + "latest": model.DefaultBlock_Latest, + "pending": model.DefaultBlock_Pending, + "safe": model.DefaultBlock_Safe, + "finalized": model.DefaultBlock_Finalized, + } + if v, ok := m[s]; ok { + return v, nil + } else { + var zeroValue DefaultBlock + return zeroValue, fmt.Errorf("invalid default block '%s'", s) + } +} + +func ToAuthKindFromString(s string) (AuthKind, error) { + var m = map[string]AuthKind{ + "private_key": AuthKindPrivateKeyVar, + "private_key_file": AuthKindPrivateKeyFile, + "mnemonic": AuthKindMnemonicVar, //nolint:goconst // Stable config keyword also appears in generated defaults. + "mnemonic_file": AuthKindMnemonicFile, + "aws": AuthKindAWS, + } + if v, ok := m[s]; ok { + return v, nil + } else { + var zeroValue AuthKind + return zeroValue, fmt.Errorf("invalid auth kind '%s'", s) + } +} + +func ToRedactedStringFromString(s string) (RedactedString, error) { + return RedactedString{s}, nil +} + +func ToRedactedUint32FromString(s string) (RedactedUint, error) { + value, err := strconv.ParseUint(s, 10, 32) + if err != nil { + cause := strconv.ErrSyntax + if errors.Is(err, strconv.ErrRange) { + cause = strconv.ErrRange + } + return RedactedUint{}, fmt.Errorf("invalid redacted number: %w", cause) + } + return RedactedUint{uint32(value)}, nil +} + +// readConfigFile retains filesystem classification without keeping a filename +// in the returned error: operators may accidentally supply a secret as the path. +func readConfigFile(filename string) ([]byte, error) { + contents, err := os.ReadFile(filename) + if err == nil { + return contents, nil + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return nil, pathErr.Err + } + return nil, errors.New("cannot read configuration file") +} + +// ToHTTPURLFromString applies the HTTP-specific policy without changing DSNs. +func ToHTTPURLFromString(s string) (SafeURL, error) { + u, err := httpclient.Validate(s) + if err != nil { + return SafeURL{}, err + } + return NewSafeURL(u), nil +} + +func ToURLFromString(s string) (SafeURL, error) { + result, err := url.Parse(s) + if err != nil { + return SafeURL{}, fmt.Errorf("invalid URL [Redacted]") + } + if result.Scheme == "postgres" || result.Scheme == "postgresql" { + if err := checkDatabaseURL(s, result); err != nil { + return SafeURL{}, err + } + } + return NewSafeURL(result), nil +} + +// checkDatabaseURL rejects the database URLs that the node's database libraries +// read differently. From v5.11, pgx reads a URL like libpq: the last of repeated +// parameters wins, and '#' is part of the text. net/url, and the lib/pq driver of +// the migrations, take the first parameter and cut the URL at '#'. +func checkDatabaseURL(raw string, u *url.URL) error { + if strings.Contains(raw, "#") { + return errors.New("invalid database URL [Redacted]: encode '#' as %23") + } + for _, values := range u.Query() { + if len(values) > 1 { + return errors.New("invalid database URL [Redacted]: repeated parameter") + } + } + return nil +} + +// Service name constants for per-service configuration lookup. +const ( + ServiceNode = "node" + ServiceAdvancer = "advancer" + ServiceClaimer = "claimer" + ServiceEvmReader = "evm-reader" + ServiceJsonrpc = "jsonrpc-api" + ServicePrt = "prt" + ServiceValidator = "validator" +) + +// serviceLogLevelGetters maps service names to their per-service log level getter functions. +var serviceLogLevelGetters = map[string]func() (LogLevel, error){ + ServiceAdvancer: GetLogLevelAdvancer, + ServiceClaimer: GetLogLevelClaimer, + ServiceEvmReader: GetLogLevelEvmReader, + ServiceJsonrpc: GetLogLevelJsonrpcApi, + ServicePrt: GetLogLevelPrt, + ServiceValidator: GetLogLevelValidator, +} + +// ResolveServiceLogLevel returns the per-service log level override if set, +// otherwise falls back to the global log level. Invalid override values +// produce a warning log and fall back to the global level. +func ResolveServiceLogLevel(name string, globalLevel LogLevel) LogLevel { + getter, ok := serviceLogLevelGetters[name] + if !ok { + return globalLevel + } + level, err := getter() + if err != nil { + if !errors.Is(err, ErrNotDefined) { + slog.Warn("invalid per-service log level override, using global", + "service", name, "error", err) + } + return globalLevel + } + return level +} + +// Aliases to be used by the generated functions. +var ( + toBool = strconv.ParseBool + toUint64 = ToUint64FromString + toString = ToStringFromString + toDuration = ToDurationFromSeconds + toLogLevel = ToLogLevelFromString + toAuthKind = ToAuthKindFromString + toDefaultBlock = ToDefaultBlockFromString + toRedactedString = ToRedactedStringFromString + toRedactedUint = ToRedactedUint32FromString + toURL = ToURLFromString + toHTTPURL = ToHTTPURLFromString + toMachineLogLevel = ToMachineLogLevelFromString + toAddress = ToAddressFromString +) + +var ( + notDefinedbool = func() bool { return false } + notDefineduint64 = func() uint64 { return 0 } + notDefinedstring = func() string { return "" } + notDefinedDuration = func() time.Duration { return 0 } + notDefinedLogLevel = func() slog.Level { return slog.LevelInfo } + notDefinedAuthKind = func() AuthKind { return AuthKindMnemonicVar } + notDefinedDefaultBlock = func() model.DefaultBlock { return model.DefaultBlock_Finalized } + notDefinedRedactedString = func() RedactedString { return RedactedString{""} } + notDefinedRedactedUint = func() RedactedUint { return RedactedUint{0} } + notDefinedURL = func() URL { return SafeURL{} } + notDefinedMachineLogLevel = func() MachineLogLevel { return MachineLogLevelInfo } + notDefinedAddress = func() Address { return common.Address{} } +) diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 000000000..499555c36 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,177 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "log/slog" + "net/url" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestResolveServiceLogLevel(t *testing.T) { + t.Run("returns global level when no override is set", func(t *testing.T) { + viper.Reset() + level := ResolveServiceLogLevel(ServiceAdvancer, slog.LevelInfo) + require.Equal(t, slog.LevelInfo, level) + }) + + t.Run("returns per-service level when override is set", func(t *testing.T) { + viper.Reset() + viper.Set(LOG_LEVEL_ADVANCER, "debug") + level := ResolveServiceLogLevel(ServiceAdvancer, slog.LevelInfo) + require.Equal(t, slog.LevelDebug, level) + }) + + t.Run("returns global level when override has invalid value", func(t *testing.T) { + viper.Reset() + viper.Set(LOG_LEVEL_VALIDATOR, "invalid") + level := ResolveServiceLogLevel(ServiceValidator, slog.LevelWarn) + require.Equal(t, slog.LevelWarn, level) + }) + + t.Run("returns global level for unknown service name", func(t *testing.T) { + viper.Reset() + level := ResolveServiceLogLevel("unknown-service", slog.LevelError) + require.Equal(t, slog.LevelError, level) + }) + + t.Run("each service resolves independently", func(t *testing.T) { + viper.Reset() + viper.Set(LOG_LEVEL_ADVANCER, "debug") + viper.Set(LOG_LEVEL_CLAIMER, "error") + + require.Equal(t, slog.LevelDebug, ResolveServiceLogLevel(ServiceAdvancer, slog.LevelInfo)) + require.Equal(t, slog.LevelError, ResolveServiceLogLevel(ServiceClaimer, slog.LevelInfo)) + require.Equal(t, slog.LevelInfo, ResolveServiceLogLevel(ServiceValidator, slog.LevelInfo)) + require.Equal(t, slog.LevelInfo, ResolveServiceLogLevel(ServiceEvmReader, slog.LevelInfo)) + require.Equal(t, slog.LevelInfo, ResolveServiceLogLevel(ServiceJsonrpc, slog.LevelInfo)) + require.Equal(t, slog.LevelInfo, ResolveServiceLogLevel(ServicePrt, slog.LevelInfo)) + }) + + t.Run("all service constants are mapped", func(t *testing.T) { + viper.Reset() + services := []string{ + ServiceAdvancer, ServiceClaimer, ServiceEvmReader, + ServiceJsonrpc, ServicePrt, ServiceValidator, + } + for _, name := range services { + _, ok := serviceLogLevelGetters[name] + require.True(t, ok, "service %q should have a log level getter", name) + } + }) +} + +func TestSafeURL(t *testing.T) { + t.Run("String redacts full URL", func(t *testing.T) { + u, _ := url.Parse("https://eth-mainnet.alchemyapi.io/v2/my-secret-key") + safe := NewSafeURL(u) + require.Equal(t, "https://eth-mainnet.alchemyapi.io", safe.String()) + }) + + t.Run("Raw returns full URL", func(t *testing.T) { + raw := "https://eth-mainnet.alchemyapi.io/v2/my-secret-key" + u, _ := url.Parse(raw) + safe := NewSafeURL(u) + require.Equal(t, raw, safe.Raw()) + }) + + t.Run("String redacts userinfo passwords", func(t *testing.T) { + u, _ := url.Parse("postgres://user:secret@localhost:5432/mydb") + safe := NewSafeURL(u) + require.Equal(t, "postgres://localhost:5432", safe.String()) + }) + + t.Run("zero value is safe", func(t *testing.T) { + var safe SafeURL + require.Equal(t, "[REDACTED]", safe.String()) + require.Equal(t, "", safe.Raw()) + require.Equal(t, "", safe.Scheme()) + require.Equal(t, "", safe.Host()) + }) + + t.Run("nil url is safe", func(t *testing.T) { + safe := NewSafeURL(nil) + require.Equal(t, "[REDACTED]", safe.String()) + require.Equal(t, "", safe.Raw()) + }) + + t.Run("LogValue returns redacted string", func(t *testing.T) { + u, _ := url.Parse("https://infura.io/v3/my-api-key") + safe := NewSafeURL(u) + logVal := safe.LogValue() + require.Equal(t, slog.KindString, logVal.Kind()) + require.Equal(t, "https://infura.io", logVal.String()) + }) + + t.Run("Scheme and Host delegate correctly", func(t *testing.T) { + u, _ := url.Parse("wss://example.com:8546/ws") + safe := NewSafeURL(u) + require.Equal(t, "wss", safe.Scheme()) + require.Equal(t, "example.com:8546", safe.Host()) + }) + + t.Run("ToURLFromString returns SafeURL", func(t *testing.T) { + safe, err := ToURLFromString("https://host.example/path?key=secret") + require.NoError(t, err) + require.Equal(t, "https://host.example", safe.String()) + require.Equal(t, "https://host.example/path?key=secret", safe.Raw()) + }) + + t.Run("ToURLFromString error returns zero SafeURL", func(t *testing.T) { + safe, err := ToURLFromString("://invalid") + require.Error(t, err) + require.Equal(t, "[REDACTED]", safe.String()) + }) + + t.Run("ToURLFromString rejects ambiguous database URLs", func(t *testing.T) { + for _, raw := range []string{ + "postgres://user:s3cr#t@db:5432/rollups", + "postgres://user:s3cret@db:5432/rollups#", + "postgresql://user:s3cret@db:5432/rollups?sslmode=require&sslmode=disable", + } { + safe, err := ToURLFromString(raw) + require.Error(t, err, raw) + require.NotContains(t, err.Error(), "s3cr") + require.Equal(t, "[REDACTED]", safe.String()) + } + }) + + t.Run("ToURLFromString accepts encoded database URLs", func(t *testing.T) { + safe, err := ToURLFromString("postgres://user:s3cr%23t@db:5432/rollups?sslmode=require&connect_timeout=5") + require.NoError(t, err) + require.Equal(t, "postgres://db:5432", safe.String()) + }) + + t.Run("ToURLFromString keeps other schemes unchanged", func(t *testing.T) { + safe, err := ToURLFromString("https://host.example/path?key=a&key=b#fragment") + require.NoError(t, err) + require.Equal(t, "https://host.example", safe.String()) + }) +} + +func TestEvmReaderReadyMaxStaleness(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + SetDefaults() + budget, err := GetEvmReaderReadyMaxStaleness() + require.NoError(t, err) + require.Zero(t, budget) + + viper.AutomaticEnv() + t.Setenv(EVM_READER_READY_MAX_STALENESS, "90") + budget, err = GetEvmReaderReadyMaxStaleness() + require.NoError(t, err) + require.Equal(t, 90*time.Second, budget) + + cfg := &NodeConfig{EvmReaderReadyMaxStaleness: budget} + require.Equal(t, budget, cfg.ToEvmreaderConfig().EvmReaderReadyMaxStaleness) + + t.Setenv(EVM_READER_READY_MAX_STALENESS, "invalid") + _, err = GetEvmReaderReadyMaxStaleness() + require.Error(t, err) +} diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml new file mode 100644 index 000000000..4442abe02 --- /dev/null +++ b/internal/config/generate/Config.toml @@ -0,0 +1,620 @@ +# (c) Cartesi and individual authors (see AUTHORS) +# SPDX-License-Identifier: Apache-2.0 (see LICENSE) +# +# Logging +# +[logging.CARTESI_LOG_LEVEL] +default = "info" +go-type = "LogLevel" +description = """ +One of "debug", "info", "warn", "error".""" +used-by = ["advancer", "claimer", "evmreader", "validator", "jsonrpc", "node", "prt"] + +[logging.CARTESI_LOG_COLOR] +default = "true" +go-type = "bool" +description = """ +If set to true, the node will add colors to its log output.""" +used-by = ["advancer", "claimer", "evmreader", "validator", "jsonrpc", "node", "prt"] + +[logging.CARTESI_LOG_LEVEL_ADVANCER] +go-type = "LogLevel" +omit = true +description = """ +Override log level for the advancer service. Falls back to CARTESI_LOG_LEVEL if unset. +One of "debug", "info", "warn", "error".""" +used-by = ["advancer", "node"] + +[logging.CARTESI_LOG_LEVEL_CLAIMER] +go-type = "LogLevel" +omit = true +description = """ +Override log level for the claimer service. Falls back to CARTESI_LOG_LEVEL if unset. +One of "debug", "info", "warn", "error".""" +used-by = ["claimer", "node"] + +[logging.CARTESI_LOG_LEVEL_EVM_READER] +go-type = "LogLevel" +omit = true +description = """ +Override log level for the evm-reader service. Falls back to CARTESI_LOG_LEVEL if unset. +One of "debug", "info", "warn", "error".""" +used-by = ["evmreader", "node"] + +[logging.CARTESI_LOG_LEVEL_JSONRPC_API] +go-type = "LogLevel" +omit = true +description = """ +Override log level for the jsonrpc-api service. Falls back to CARTESI_LOG_LEVEL if unset. +One of "debug", "info", "warn", "error".""" +used-by = ["jsonrpc", "node"] + +[logging.CARTESI_LOG_LEVEL_PRT] +go-type = "LogLevel" +omit = true +description = """ +Override log level for the prt service. Falls back to CARTESI_LOG_LEVEL if unset. +One of "debug", "info", "warn", "error".""" +used-by = ["prt", "node"] + +[logging.CARTESI_LOG_LEVEL_VALIDATOR] +go-type = "LogLevel" +omit = true +description = """ +Override log level for the validator service. Falls back to CARTESI_LOG_LEVEL if unset. +One of "debug", "info", "warn", "error".""" +used-by = ["validator", "node"] + +# +# Features +# + +[features.CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED] +default = "true" +go-type = "bool" +description = """ +If set to false, the node will not submit claims (reader mode). +The mode is saved when each submitter first starts. A later configuration mismatch +stops startup. Mode changes on an existing database are not supported.""" +used-by = ["claimer", "node", "prt"] + +[features.CARTESI_FEATURE_INSPECT_ENABLED] +default = "true" +go-type = "bool" +description = """ +If set to false, the node will not start the inspect service.""" +used-by = ["advancer", "node"] + +[features.CARTESI_FEATURE_JSONRPC_API_ENABLED] +default = "true" +go-type = "bool" +description = """ +If set to false, the node will not start the jsonrpc api service.""" +used-by = ["node"] + +[features.CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED] +default = "true" +go-type = "bool" +description = """ +If set to false, the node will *not* check whether the Cartesi machine hash from +the snapshot matches the hash in the Application contract.""" +used-by = ["advancer", "node", "cli"] + +# +# Rollups +# + +[rollups.CARTESI_ADVANCER_INPUT_BATCH_SIZE] +default = "500" +go-type = "uint64" +description = """ +Maximum number of inputs fetched per database query during advance processing and machine +synchronization. Bounds memory usage for applications with large backlogs of unprocessed inputs.""" +used-by = ["advancer", "node"] + +[rollups.CARTESI_EVM_READER_POLLING_INTERVAL] +default = "12" +go-type = "Duration" +description = """ +Time in seconds to wait before checking for a new block header. The default (12s) is tuned for mainnet. Reduce for faster chains or devnets.""" +used-by = ["evmreader", "node"] + +[rollups.CARTESI_EVM_READER_READY_MAX_STALENESS] +default = "0" +go-type = "Duration" +description = """ +Maximum time in seconds since the EVM reader completed a poll and its block scan before it reports +not ready. Zero automatically uses the maximum of three polling intervals, the HTTP request timeout +times (1 + maximum HTTP retries), and one second. Positive values override this automatic budget; +negative values are invalid. A scan refreshes readiness when it finishes, but can become stale while +still running. Readiness also fails after three consecutive scan cycles with errors and recovers after a clean cycle.""" +used-by = ["evmreader", "node"] + +[rollups.CARTESI_ADVANCER_POLLING_INTERVAL] +default = "3" +go-type = "Duration" +description = """ +How many seconds the node will wait before querying the database for new inputs.""" +used-by = ["advancer", "node"] + +[rollups.CARTESI_VALIDATOR_POLLING_INTERVAL] +default = "3" +go-type = "Duration" +description = """ +How many seconds the node will wait before trying to finish epochs for all applications.""" +used-by = ["validator", "node"] + +[rollups.CARTESI_CLAIMER_POLLING_INTERVAL] +default = "3" +go-type = "Duration" +description = """ +How many seconds the node will wait before querying the database for new claims.""" +used-by = ["claimer", "node"] + +[rollups.CARTESI_PRT_POLLING_INTERVAL] +default = "3" +go-type = "Duration" +description = """ +How many seconds the node will wait before trying to finish epochs for all applications.""" +used-by = ["prt", "node"] + +[rollups.CARTESI_MAX_STARTUP_TIME] +default = "15" +go-type = "Duration" +description = """ +How many seconds the node expects services take initializing before aborting.""" +used-by = ["advancer", "claimer", "evmreader", "validator", "jsonrpc", "node", "prt"] + +# +# Blockchain +# + +[blockchain.CARTESI_BLOCKCHAIN_ID] +go-type = "uint64" +description = """ +An unique identifier representing a blockchain network.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[blockchain.CARTESI_BLOCKCHAIN_HTTP_ENDPOINT] +file = true +go-type = "URL" +http = true +description = """ +HTTP(S) endpoint with a DNS name or IP address for the blockchain RPC provider. +Redirects are not followed; use the final endpoint URL.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[blockchain.CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION] +file = true +go-type = "RedactedString" +description = """ +Additional security when interacting with providers. +Specify a Key ':' Value pair to include in the HTTP header with authorization keys. +Examples: + - Alchemy: "Authorization:Bearer ${API_Key}" + - Infura: "Authorization:Basic base64(':' + ${})" + - Google: "X-goog-api-key:${API_Key}" +""" +omit = true +used-by = ["evmreader", "claimer", "node", "prt", "cli"] + +[blockchain.CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS] +default = "" +go-type = "string" +description = """ +Optional maximum transaction fee per gas, as a positive decimal integer in wei. +Caps dynamic maxFeePerGas and legacy gasPrice. Unset means no operator price ceiling. +The sender uses fresh fee quotes. After a price refusal, it retries once within +this ceiling. The ceiling does not guarantee replacement, cancellation or inclusion.""" +used-by = ["claimer", "prt", "node"] + +[blockchain.CARTESI_BLOCKCHAIN_LEGACY_ENABLED] +default = "false" +go-type = "bool" +description = """ +If true, force the legacy transaction format with a fresh suggested gas price +for each transaction. If false, select the fee format automatically: EIP-1559 +when the network provides a base fee, or legacy otherwise. +This setting does not change gas estimation. After a price refusal, service delivery +retries once using a fresh-quote fallback and the optional fee cap.""" +used-by = ["claimer", "node", "cli", "prt"] + +[blockchain.CARTESI_BLOCKCHAIN_DEFAULT_BLOCK] +default = "finalized" +go-type = "DefaultBlock" +description = """ +One of 'latest', 'safe', 'finalized'. Defaults to 'finalized'. +The block used by EVM Reader, Claimer, and PRT for chain-state observations. +Nonce selection and transaction readiness checks use 'latest'. +The sender releases its in-memory transaction when the latest mined nonce advances. +All contributing services must use the same policy. +'latest' and 'safe' observations can change after a reorg. The node does not roll back +stored observations; rebuilding may be required. +The policy is saved when each service first starts. A later configuration mismatch stops startup. +'pending' is unsupported because it does not identify a mined block.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[rollups.CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES] +default = "4" +go-type = "uint64" +description = """ +Maximum number of retry attempts for HTTP blockchain requests after encountering an error.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[rollups.CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT] +default = "1" +go-type = "Duration" +description = """ +Minimum wait time in seconds for the exponential backoff retry policy. This is the initial delay before the first retry for HTTP blockchain requests.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[rollups.CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT] +default = "60" +go-type = "Duration" +description = """ +Maximum wait time in seconds for the exponential backoff retry policy. The delay between retries for HTTP blockchain requests will never exceed this value, regardless of the backoff calculation.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[rollups.CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT] +default = "120" +go-type = "Duration" +description = """ +Maximum time in seconds for each HTTP request attempt to the blockchain provider or AWS KMS, +and for each transaction signing operation. This does not bound a whole publication pass.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[rollups.CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE] +default = "2000" +go-type = "uint64" +description = """ +Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited.""" +used-by = ["evmreader", "claimer", "node", "prt"] + +[rollups.CARTESI_BLOCKCHAIN_GAS_LIMIT] +default = "0" +go-type = "uint64" +description = """ +Gas limit to be used on chain transactions. Zero for the estimated limit provided by the chain. Default is zero.""" +used-by = ["cli"] + +# +# Contracts +# + +[contracts.CARTESI_CONTRACTS_INPUT_BOX_ADDRESS] +go-type = "Address" +description = """ +Address of the InputBox contract. Not required, used only by the CLI and tests""" +omit = true +used-by = ["cli"] + +[contracts.CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS] +go-type = "Address" +description = """ +Address of the AuthorityFactory contract. Not required, used only by the CLI and tests""" +omit = true +used-by = ["cli"] + +[contracts.CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS] +go-type = "Address" +description = """ +Address of the QuorumFactory contract. Not required, used only by the CLI and tests""" +omit = true +used-by = ["cli"] + +[contracts.CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS] +go-type = "Address" +description = """ +Address of the ApplicationFactory contract. Not required, used only by the CLI and tests""" +omit = true +used-by = ["cli"] + +[contracts.CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS] +go-type = "Address" +description = """ +Address of the SelfHostedApplicationFactory contract. Not required, used only by the CLI and tests""" +omit = true +used-by = ["cli"] + +[contracts.CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS] +go-type = "Address" +description = """ +Address of the Dave Application factory contract. Not required, used only by the CLI and tests""" +omit = true +used-by = ["cli"] + +# +# Snapshot +# + +[snapshot.CARTESI_SNAPSHOTS_DIR] +default = "/var/lib/cartesi-rollups-node/snapshots" +go-type = "string" +description = """ +Path to the directory where the snapshots will be written.""" +used-by = ["advancer", "node", "prt"] + +# +# Auth +# + +[auth.CARTESI_AUTH_KIND] +default = "mnemonic" +go-type = "AuthKind" +description = """ +One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". + +The auth variable for the kind defined here is required. Eg.: CARTESI_AUTH_MNEMONIC + +Use one submitting process per signer. Do not send from an active node's signer +with the CLI or another wallet. Use one node deployment per database and one +active process per service role. Stop the current process before starting its replacement.""" +omit = true +used-by = ["claimer", "node", "cli"] + +[auth.CARTESI_AUTH_PRIVATE_KEY] +file = true +go-type = "RedactedString" +description = """ +The node will use this private key to sign transactions.""" +omit = true +used-by = ["claimer", "node", "cli"] + +[auth.CARTESI_AUTH_MNEMONIC] +file = true +go-type = "RedactedString" +description = """ +The node will use the private key generated from this mnemonic to sign transactions.""" +omit = true +used-by = ["claimer", "node", "cli"] + +[auth.CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX] +default = "0" +go-type = "RedactedUint" +description = """ +When using mnemonics to sign transactions, +the node will use this account index to generate the private key.""" +omit = true +used-by = ["claimer", "node", "cli"] + +[auth.CARTESI_AUTH_AWS_KMS_KEY_ID] +go-type = "RedactedString" +description = """ +An AWS KMS key ID, alias, or ARN. + +If set, the node will use the AWS KMS service with this key to sign transactions. + +Everything else about the AWS connection — region, endpoint, and credentials — is +resolved by the AWS SDK's standard chain, not by CARTESI_ variables. See the +"Externally-provided configuration" section for the variables involved. + +Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, so +the same alias in a different region names a different key and therefore a +different signing address.""" +omit = true +used-by = ["claimer", "node", "cli"] + +# +# PRT Auth +# + +[prt-auth.CARTESI_PRT_AUTH_KIND] +default = "mnemonic" +go-type = "AuthKind" +description = """ +One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws". + +When claim submission is enabled, configure the PRT auth variable for the selected +kind, for example CARTESI_PRT_AUTH_MNEMONIC. The standalone node always starts PRT, +even when it serves only Authority or Quorum applications. CARTESI_AUTH_* does not +replace CARTESI_PRT_AUTH_*. PRT signer credentials are not required when claim +submission is disabled. + +Use distinct addresses for claimer and PRT. Use one submitting process per signer. +Do not send from an active node's signer with the CLI or another wallet. Use one +node deployment per database and one active process per service role. Stop the +current process before starting its replacement.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_PRIVATE_KEY] +file = true +go-type = "RedactedString" +description = """ +The PRT service will use this private key to sign transactions.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_MNEMONIC] +file = true +go-type = "RedactedString" +description = """ +The PRT service will use the private key generated from this mnemonic to sign transactions.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX] +default = "6" +go-type = "RedactedUint" +description = """ +When using a mnemonic to sign PRT transactions, +the PRT service will use this account index to generate the private key. +The resulting PRT and Claimer signer addresses must differ.""" +omit = true +used-by = ["node", "prt"] + +[prt-auth.CARTESI_PRT_AUTH_AWS_KMS_KEY_ID] +go-type = "RedactedString" +description = """ +An AWS KMS key ID, alias, or ARN for the PRT service. + +If set, the PRT service will use the AWS KMS service with this key to sign transactions. + +Everything else about the AWS connection — region, endpoint, and credentials — is +resolved by the AWS SDK's standard chain, not by CARTESI_ variables. See the +"Externally-provided configuration" section for the variables involved. + +Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, so +the same alias in a different region names a different key and therefore a +different signing address.""" +omit = true +used-by = ["node", "prt"] + +# +# Database +# + +[database.CARTESI_DATABASE_CONNECTION] +default = "" +file = true +go-type = "URL" +description = """ +Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + +If not set, or set to empty string, will defer the behaviour to the PG driver. +See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + +It is also possible to set the endpoint without a password and load it from Postgres' passfile. +See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) +for more information.""" +used-by = ["advancer", "claimer", "evmreader", "validator", "jsonrpc", "node", "cli", "prt"] + +# +# Telemetry http address +# + +[http.CARTESI_ADVANCER_TELEMETRY_ADDRESS] +default = ":10002" +go-type = "string" +description = """ +HTTP address for Advancer's telemetry service.""" +used-by = ["advancer"] + +[http.CARTESI_CLAIMER_TELEMETRY_ADDRESS] +default = ":10004" +go-type = "string" +description = """ +HTTP address for Claimer's telemetry service.""" +used-by = ["claimer"] + +[http.CARTESI_EVM_READER_TELEMETRY_ADDRESS] +default = ":10001" +go-type = "string" +description = """ +HTTP address for EVM Reader's telemetry service.""" +used-by = ["evmreader"] + +[http.CARTESI_VALIDATOR_TELEMETRY_ADDRESS] +default = ":10003" +go-type = "string" +description = """ +HTTP address for Validator's telemetry service.""" +used-by = ["validator"] + +[http.CARTESI_JSONRPC_TELEMETRY_ADDRESS] +default = ":10005" +go-type = "string" +description = """ +HTTP address for JSON-RPC's telemetry service.""" +used-by = ["jsonrpc"] + +[http.CARTESI_NODE_TELEMETRY_ADDRESS] +default = ":10000" +go-type = "string" +description = """ +HTTP address for Node's telemetry service.""" +used-by = ["node"] + +[http.CARTESI_PRT_TELEMETRY_ADDRESS] +default = ":10006" +go-type = "string" +description = """ +HTTP address for PRT's telemetry service.""" +used-by = ["prt"] + +# +# HTTP +# + +[http.CARTESI_JSONRPC_API_ADDRESS] +default = ":10011" +go-type = "string" +description = """ +HTTP address for the JSON-RPC API.""" +used-by = ["jsonrpc", "node"] + +[http.CARTESI_JSONRPC_MAX_INFLIGHT] +default = "64" +go-type = "uint64" +description = """ +Maximum number of concurrent in-flight JSON-RPC requests. +Requests beyond this limit receive HTTP 503 Service Unavailable +with Retry-After: 1. Set to 0 to disable HTTP-level admission +control.""" +used-by = ["jsonrpc", "node"] + +[http.CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS] +default = "" +go-type = "string" +description = """ +Comma-separated list of allowed browser origins for the JSON-RPC API. +If empty, CORS is disabled. Origins are lowercased and validated at +startup. Example: "http://localhost:3000,https://app.example.com".""" +used-by = ["jsonrpc", "node"] + +[http.CARTESI_INSPECT_CORS_ALLOWED_ORIGINS] +default = "" +go-type = "string" +description = """ +Comma-separated list of allowed browser origins for inspect. +If empty, CORS is disabled. Origins are lowercased and validated at +startup. Example: "http://localhost:3000,https://app.example.com".""" +used-by = ["advancer", "node"] + +[http.CARTESI_INSPECT_ADDRESS] +default = ":10012" +go-type = "string" +description = """ +HTTP address for inspect.""" +used-by = ["advancer", "node"] + +[http.CARTESI_INSPECT_MAX_INFLIGHT] +default = "64" +go-type = "uint64" +description = """ +Maximum number of concurrent in-flight HTTP inspect requests. +Requests beyond this limit receive HTTP 503 Service Unavailable +with Retry-After: 1. Set to 0 to disable HTTP-level admission +control (backpressure then falls back to the per-application +machine semaphore).""" +used-by = ["advancer", "node"] + +[http.CARTESI_JSONRPC_API_URL] +default = "http://localhost:10011/rpc" +go-type = "URL" +http = true +description = """ +HTTP(S) URL with a DNS name or IP address for the JSON-RPC API. +Redirects are not followed; use the final endpoint URL.""" +used-by = ["cli"] + +[http.CARTESI_INSPECT_URL] +default = "http://localhost:10012" +go-type = "URL" +http = true +description = """ +HTTP(S) URL with a DNS name or IP address for the inspect service. +Redirects are not followed; use the final endpoint URL.""" +used-by = ["cli"] + +# +# Remote Cartesi Machine +# + +[machine.CARTESI_JSONRPC_MACHINE_LOG_LEVEL] +default = "info" +go-type = "string" +description = """ +Remote Cartesi Machine server log level. +One of "trace", "debug", "info", "warning", "error", "fatal".""" +used-by = ["advancer", "node"] diff --git a/internal/config/generate/code.go b/internal/config/generate/code.go new file mode 100644 index 000000000..e16b3ec34 --- /dev/null +++ b/internal/config/generate/code.go @@ -0,0 +1,260 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "bytes" + "go/format" + "os" + "sort" + "strings" + "text/template" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + "slices" +) + +// Template function map +var funcMap = template.FuncMap{ + // toFieldName converts an env var name like "CARTESI_BLOCKCHAIN_DEFAULT_BLOCK" + // into a Go-friendly field name like "BlockchainDefaultBlock". + "toFieldName": func(env string) string { + caser := cases.Title(language.English) + words := strings.FieldsFunc(env, func(r rune) bool { + return r == '_' + }) + // Optionally remove a common prefix (e.g. "CARTESI") + if len(words) > 1 && words[0] == "CARTESI" { + words = words[1:] + } + for i, word := range words { + words[i] = caser.String(strings.ToLower(word)) + } + return strings.Join(words, "") + }, + "toConstName": func(s string) string { + return strings.TrimPrefix(s, "CARTESI_") + }, + "hasPrefix": func(prefix, s string) bool { + return strings.HasPrefix(s, prefix) + }, + // defaultVal returns the dereferenced default value or an empty string. + "defaultVal": func(s *string) string { + if s == nil { + return "" + } + return *s + }, + // toGoFunc returns the conversion function name. + "toGoFunc": func(env Env) string { + if env.HTTP { + return "toHTTPURL" + } + // For example, "int" becomes "toInt", "Duration" becomes "toDuration". + return "to" + strings.ToUpper(env.GoType[:1]) + env.GoType[1:] + }, + // splitLines splits a string into lines (by "\n"). + "splitLines": func(s string) []string { + return strings.Split(s, "\n") + }, + "mapstructure": func(s string) string { + return "`mapstructure:\"" + s + "\"`" + }, + // hasEmptyStringDefault checks if a variable has default="" and is a string type. + // These vars need viper.IsSet instead of s!="" because empty string is a valid value. + "hasEmptyStringDefault": func(env Env) bool { + return env.Default != nil && *env.Default == "" && env.GoType == "string" + }, + // isUsedBy checks if a variable is used by a specific service + "isUsedBy": func(env Env, service string) bool { + return slices.Contains(env.UsedBy, service) + }, + // getServices extracts all unique service names from the environment variables + "getServices": func(envs []Env) []string { + serviceMap := make(map[string]bool) + for _, env := range envs { + for _, service := range env.UsedBy { + if service != "cli" { + serviceMap[service] = true + } + } + } + + services := make([]string, 0, len(serviceMap)) + for service := range serviceMap { + services = append(services, service) + } + sort.Strings(services) + return services + }, + // capitalize the first letter of a string + "capitalize": func(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + s[1:] + }, +} + +// generateCodeFile renders the template with the given Env slice and writes it to the specified file. +func generateCodeFile(path string, envs []Env) { + tmpl := template.Must(template.New("code").Funcs(funcMap).Parse(codeTemplate)) + var buff bytes.Buffer + if err := tmpl.Execute(&buff, envs); err != nil { + panic(err) + } + code, err := format.Source(buff.Bytes()) + if err != nil { + panic(err) + } + if err := os.WriteFile(path, code, 0644); err != nil { // nolint: mnd + panic(err) + } +} + +// The template for the generated code +const codeTemplate = `// Code generated by internal/config/generate. +// DO NOT EDIT. +// +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +var ErrNotDefined = fmt.Errorf("variable not defined") + +func init() { + // Automatically bind environment variables. + viper.AutomaticEnv() +} + +const ( +{{ range . -}} + {{ toConstName .Name }} = "{{ .Name }}" +{{ end }} + // File variants +{{ range . -}} + {{ if .File -}}{{ toConstName .Name }}_FILE = "{{ .Name }}_FILE"{{ end }} +{{ end -}} +) + +// DeclaredDefault returns only the binary's declared configuration default. +// A known setting without a default returns an empty string and true. +func DeclaredDefault(name string) (string, bool) { + switch name { +{{ range . -}} + case {{ toConstName .Name }}: + return {{ printf "%q" (defaultVal .Default) }}, true +{{ end -}} + default: + return "", false + } +} + +func SetDefaults() { + // Set defaults based on the TOML definitions. +{{ range . }} +{{ if .Default }} viper.SetDefault({{ toConstName .Name }}, {{ printf "%q" (defaultVal .Default) }}) +{{ else }} // no default for {{ .Name }} +{{ end }} +{{ end }} +} + +{{ $services := getServices . -}} +{{ range $service := $services }} +// {{ capitalize $service }}Config holds configuration values for the {{ $service }} service. +type {{ capitalize $service }}Config struct { +{{ range $ }} +{{ if and (isUsedBy . $service) (not .Omit) -}} +{{ if .Description -}} +{{ range $line := splitLines .Description -}} + // {{ $line }} +{{ end -}} +{{ end -}} + {{ toFieldName .Name }} {{ .GoType }} {{ mapstructure .Name }} +{{ end -}} +{{ end }} +} + +// Load{{ capitalize $service }}Config reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func Load{{ capitalize $service }}Config() (*{{ capitalize $service }}Config, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg {{ capitalize $service }}Config + var err error + +{{ range $ }} +{{ if and (isUsedBy . $service) (not .Omit) -}} + cfg.{{ toFieldName .Name }}, err = Get{{ toFieldName .Name }}() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get {{ .Name }}: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("{{ .Name }} is required for the {{ $service }} service: %w", err) + } +{{ end -}} +{{ end }} + + return &cfg, nil +} +{{ end }} + +{{ range $service := $services }} +{{ if ne $service "node" }} +// To{{ capitalize $service }}Config converts a NodeConfig to a {{ capitalize $service }}Config. +func (c *NodeConfig) To{{ capitalize $service }}Config() *{{ capitalize $service }}Config { + return &{{ capitalize $service }}Config{ +{{ range $ -}} +{{ if and (isUsedBy . $service) (isUsedBy . "node") (not .Omit) -}} + {{ toFieldName .Name }}: c.{{ toFieldName .Name }}, +{{ end -}} +{{ end }} + } +} +{{ end }} +{{ end }} + +{{ range . }} +// Get{{ toFieldName .Name }} returns the value for the environment variable {{ .Name }}. +func Get{{ toFieldName .Name }}() ({{ .GoType }}, error) { + s := viper.GetString({{ toConstName .Name }}) + {{- if .File }} + if s == "" { + filename := viper.GetString({{toConstName .Name}}_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefined{{ .GoType }}(), fmt.Errorf("failed to parse %s: %w", {{ toConstName .Name }}_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + {{- end }} + {{- if hasEmptyStringDefault . }} + if viper.IsSet({{ toConstName .Name }}) { + {{- else }} + if s != "" { + {{- end }} + v, err := {{ toGoFunc . }}(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", {{ toConstName .Name }}, err) + } + return v, nil + } + return notDefined{{ .GoType }}(), fmt.Errorf("%s: %w", {{ toConstName .Name }}, ErrNotDefined) +} +{{ end }} +` diff --git a/internal/config/generate/code_test.go b/internal/config/generate/code_test.go new file mode 100644 index 000000000..4c70765e7 --- /dev/null +++ b/internal/config/generate/code_test.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/stretchr/testify/require" +) + +const testDescription = "test" + +func TestDeclaredDefaultsMatchConfiguration(t *testing.T) { + for _, env := range sortConfig(decodeTOML(readTOML("Config.toml"))) { + env.validate() + got, known := config.DeclaredDefault(env.Name) + require.True(t, known, env.Name) + want := "" + if env.Default != nil { + want = *env.Default + } + require.Equal(t, want, got, env.Name) + } + _, known := config.DeclaredDefault("CARTESI_NOT_A_SETTING") + require.False(t, known) +} + +func TestGenerateCodeQuotesDefaults(t *testing.T) { + value := "a quoted \"value\" and a newline\nwith \\slashes" + env := Env{Name: "CARTESI_TEST_STRING", GoType: "string", Default: &value, Description: testDescription, UsedBy: []string{"cli"}} + path := filepath.Join(t.TempDir(), "generated.go") + generateCodeFile(path, []Env{env}) + code, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(code), fmt.Sprintf("return %q, true", value)) + require.Contains(t, string(code), fmt.Sprintf("viper.SetDefault(TEST_STRING, %q)", value)) +} + +func TestDeclaredDefaultsValidateFlagConversions(t *testing.T) { + for _, tc := range []struct { + kind string + value string + }{ + {"bool", "invalid"}, + {"uint64", "-1"}, + {"uint64", "18446744073709551616"}, + } { + env := Env{Name: "CARTESI_TEST", GoType: tc.kind, Default: &tc.value, Description: testDescription} + require.Panics(t, env.validate) + } + env := Env{Name: "CARTESI_TEST", GoType: "string", HTTP: true, Description: testDescription} + require.Panics(t, env.validate) +} diff --git a/internal/config/generate/docs.go b/internal/config/generate/docs.go new file mode 100644 index 000000000..8820686b2 --- /dev/null +++ b/internal/config/generate/docs.go @@ -0,0 +1,81 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "os" + "text/template" +) + +// generateDocsFile generates a Markdown file with the documentation of the config variables. +func generateDocsFile(path string, env []Env) { + // Open output file + file, err := os.Create(path) + if err != nil { + panic(err) + } + defer file.Close() + + // Load template + funcMap := template.FuncMap{ + "backtick": func(s string) string { + return "`" + s + "`" + }, + "quote": func(s string) string { + return `"` + s + `"` + }, + } + tmpl := template.Must(template.New("docs").Funcs(funcMap).Parse(docsTemplate)) + + // Execute template + err = tmpl.Execute(file, env) + if err != nil { + panic(err) + } +} + +const docsTemplate string = ` + + +# Node Configuration + +The node is configurable through environment variables. +Variables prefixed CARTESI_ are listed below. A few subsystems additionally read +standard variables defined by third-party SDKs; those are listed at the end. + +This file documents the configuration options. + + +{{- range .}} + +## {{backtick .Name}} + +{{.Description}} + +* **Type:** {{backtick .GoType}} +{{- if .Default}} +* **Default:** {{.Default | quote | backtick}} +{{- end}} +{{- if .UsedBy}} +* **Used by:** {{range $i, $e := .UsedBy}}{{if $i}}, {{end}}{{$e}}{{end}} +{{- end}} +{{- end}} + +## Externally-provided configuration + +These are read by the AWS SDK, not by the node's own configuration layer, and +apply only when CARTESI_AUTH_KIND=aws. + +* AWS_REGION / AWS_DEFAULT_REGION — region used to resolve the KMS key. +* AWS_ENDPOINT_URL_KMS / AWS_ENDPOINT_URL — override the KMS endpoint + (VPC endpoint, PrivateLink, FIPS, or a local emulator such as LocalStack). +* AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN — static + credentials. They may instead come from the shared config file, an EC2 instance + profile, or IRSA; the node does not require any particular source. + +Full resolution order is documented by AWS; the node applies no overrides. +` diff --git a/internal/config/generate/env.go b/internal/config/generate/env.go new file mode 100644 index 000000000..401c67f2e --- /dev/null +++ b/internal/config/generate/env.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import "strconv" + +// An entry in the toml's top level table representing an environment variable. +type Env struct { + // Name of the environment variable. + Name string + + // The default value for the variable. + // This field is optional. + Default *string `toml:"default"` + + // The Go type for the environment variable. + // This field is required. + GoType string `toml:"go-type"` + + // A brief description of the environment variable. + // This field is required. + Description string `toml:"description"` + + // If defined omit the field from the generated Config struct + Omit bool `toml:"omit"` + + // Configuration also has a "_FILE" variant that should be searched + File bool `toml:"file"` + + // HTTP restricts a URL to an HTTP(S) endpoint with a hostname. + HTTP bool `toml:"http"` + + // List of services that use this environment variable. + // Possible values: "advancer", "claimer", "cli", "evm-reader", "jsonrpc-api", "node", "validator" + UsedBy []string `toml:"used-by"` +} + +// Validates whether the fields of the environment variables were initialized correctly +// and sets defaults for optional fields. +func (e *Env) validate() { + if e.GoType == "" { + panic("missing go-type for " + e.Name) + } + if e.Description == "" { + panic("missing description for " + e.Name) + } + if e.HTTP && e.GoType != "URL" { + panic("HTTP validation requires URL type for " + e.Name) + } + if e.Default != nil { + var err error + switch e.GoType { + case "bool": + _, err = strconv.ParseBool(*e.Default) + case "uint64": + _, err = strconv.ParseUint(*e.Default, 10, 64) + } + if err != nil { + panic("invalid declared default for " + e.Name) + } + } +} diff --git a/internal/node/config/generate/helpers.go b/internal/config/generate/helpers.go similarity index 100% rename from internal/node/config/generate/helpers.go rename to internal/config/generate/helpers.go diff --git a/internal/config/generate/main.go b/internal/config/generate/main.go new file mode 100644 index 000000000..b537886e9 --- /dev/null +++ b/internal/config/generate/main.go @@ -0,0 +1,48 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:generate go run . -mode=code + +// This script will read the Config.toml file and create: +// - a formatted get.go file, with get functions for each environment variable; +// - a config.md file with documentation for the environment variables. +// +// Usage: +// +// go run . -mode=code # generate only the Go code (generated.go) +// go run . -mode=docs # generate only the documentation (docs/config.md) +// go run . -mode=all # generate both (default) +// +// Each table entry in the toml file translates into an environment variable. +// In Go, this becomes a map[string](map[string]Env), with the keys of the outer map being topic +// names, and the keys of the inner map being variable names. +package main + +import ( + "flag" + "log" +) + +func main() { + mode := flag.String("mode", "all", "generation mode: code, docs, or all") + flag.Parse() + + data := readTOML("Config.toml") + config := decodeTOML(data) + envs := sortConfig(config) + for _, env := range envs { + env.validate() + } + + switch *mode { + case "code": + generateCodeFile("../generated.go", envs) + case "docs": + generateDocsFile("../../../docs/config.md", envs) + case "all": + generateCodeFile("../generated.go", envs) + generateDocsFile("../../../docs/config.md", envs) + default: + log.Fatalf("invalid mode: %s (expected code, docs, or all)", *mode) + } +} diff --git a/internal/config/generated.go b/internal/config/generated.go new file mode 100644 index 000000000..80b60d17d --- /dev/null +++ b/internal/config/generated.go @@ -0,0 +1,2824 @@ +// Code generated by internal/config/generate. +// DO NOT EDIT. +// +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +var ErrNotDefined = fmt.Errorf("variable not defined") + +func init() { + // Automatically bind environment variables. + viper.AutomaticEnv() +} + +const ( + AUTH_AWS_KMS_KEY_ID = "CARTESI_AUTH_AWS_KMS_KEY_ID" + AUTH_KIND = "CARTESI_AUTH_KIND" + AUTH_MNEMONIC = "CARTESI_AUTH_MNEMONIC" + AUTH_MNEMONIC_ACCOUNT_INDEX = "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX" + AUTH_PRIVATE_KEY = "CARTESI_AUTH_PRIVATE_KEY" + BLOCKCHAIN_DEFAULT_BLOCK = "CARTESI_BLOCKCHAIN_DEFAULT_BLOCK" + BLOCKCHAIN_HTTP_AUTHORIZATION = "CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION" + BLOCKCHAIN_HTTP_ENDPOINT = "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT" + BLOCKCHAIN_ID = "CARTESI_BLOCKCHAIN_ID" + BLOCKCHAIN_LEGACY_ENABLED = "CARTESI_BLOCKCHAIN_LEGACY_ENABLED" + BLOCKCHAIN_TX_MAX_FEE_PER_GAS = "CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS" + CONTRACTS_APPLICATION_FACTORY_ADDRESS = "CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS" + CONTRACTS_AUTHORITY_FACTORY_ADDRESS = "CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS" + CONTRACTS_DAVE_APP_FACTORY_ADDRESS = "CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS" + CONTRACTS_INPUT_BOX_ADDRESS = "CARTESI_CONTRACTS_INPUT_BOX_ADDRESS" + CONTRACTS_QUORUM_FACTORY_ADDRESS = "CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS" + CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS = "CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS" + DATABASE_CONNECTION = "CARTESI_DATABASE_CONNECTION" + FEATURE_CLAIM_SUBMISSION_ENABLED = "CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED" + FEATURE_INSPECT_ENABLED = "CARTESI_FEATURE_INSPECT_ENABLED" + FEATURE_JSONRPC_API_ENABLED = "CARTESI_FEATURE_JSONRPC_API_ENABLED" + FEATURE_MACHINE_HASH_CHECK_ENABLED = "CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED" + ADVANCER_TELEMETRY_ADDRESS = "CARTESI_ADVANCER_TELEMETRY_ADDRESS" + CLAIMER_TELEMETRY_ADDRESS = "CARTESI_CLAIMER_TELEMETRY_ADDRESS" + EVM_READER_TELEMETRY_ADDRESS = "CARTESI_EVM_READER_TELEMETRY_ADDRESS" + INSPECT_ADDRESS = "CARTESI_INSPECT_ADDRESS" + INSPECT_CORS_ALLOWED_ORIGINS = "CARTESI_INSPECT_CORS_ALLOWED_ORIGINS" + INSPECT_MAX_INFLIGHT = "CARTESI_INSPECT_MAX_INFLIGHT" + INSPECT_URL = "CARTESI_INSPECT_URL" + JSONRPC_API_ADDRESS = "CARTESI_JSONRPC_API_ADDRESS" + JSONRPC_API_URL = "CARTESI_JSONRPC_API_URL" + JSONRPC_CORS_ALLOWED_ORIGINS = "CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS" + JSONRPC_MAX_INFLIGHT = "CARTESI_JSONRPC_MAX_INFLIGHT" + JSONRPC_TELEMETRY_ADDRESS = "CARTESI_JSONRPC_TELEMETRY_ADDRESS" + NODE_TELEMETRY_ADDRESS = "CARTESI_NODE_TELEMETRY_ADDRESS" + PRT_TELEMETRY_ADDRESS = "CARTESI_PRT_TELEMETRY_ADDRESS" + VALIDATOR_TELEMETRY_ADDRESS = "CARTESI_VALIDATOR_TELEMETRY_ADDRESS" + LOG_COLOR = "CARTESI_LOG_COLOR" + LOG_LEVEL = "CARTESI_LOG_LEVEL" + LOG_LEVEL_ADVANCER = "CARTESI_LOG_LEVEL_ADVANCER" + LOG_LEVEL_CLAIMER = "CARTESI_LOG_LEVEL_CLAIMER" + LOG_LEVEL_EVM_READER = "CARTESI_LOG_LEVEL_EVM_READER" + LOG_LEVEL_JSONRPC_API = "CARTESI_LOG_LEVEL_JSONRPC_API" + LOG_LEVEL_PRT = "CARTESI_LOG_LEVEL_PRT" + LOG_LEVEL_VALIDATOR = "CARTESI_LOG_LEVEL_VALIDATOR" + JSONRPC_MACHINE_LOG_LEVEL = "CARTESI_JSONRPC_MACHINE_LOG_LEVEL" + PRT_AUTH_AWS_KMS_KEY_ID = "CARTESI_PRT_AUTH_AWS_KMS_KEY_ID" + PRT_AUTH_KIND = "CARTESI_PRT_AUTH_KIND" + PRT_AUTH_MNEMONIC = "CARTESI_PRT_AUTH_MNEMONIC" + PRT_AUTH_MNEMONIC_ACCOUNT_INDEX = "CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX" + PRT_AUTH_PRIVATE_KEY = "CARTESI_PRT_AUTH_PRIVATE_KEY" + ADVANCER_INPUT_BATCH_SIZE = "CARTESI_ADVANCER_INPUT_BATCH_SIZE" + ADVANCER_POLLING_INTERVAL = "CARTESI_ADVANCER_POLLING_INTERVAL" + BLOCKCHAIN_GAS_LIMIT = "CARTESI_BLOCKCHAIN_GAS_LIMIT" + BLOCKCHAIN_HTTP_MAX_RETRIES = "CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES" + BLOCKCHAIN_HTTP_REQUEST_TIMEOUT = "CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT" + BLOCKCHAIN_HTTP_RETRY_MAX_WAIT = "CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT" + BLOCKCHAIN_HTTP_RETRY_MIN_WAIT = "CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT" + BLOCKCHAIN_MAX_BLOCK_RANGE = "CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE" + CLAIMER_POLLING_INTERVAL = "CARTESI_CLAIMER_POLLING_INTERVAL" + EVM_READER_POLLING_INTERVAL = "CARTESI_EVM_READER_POLLING_INTERVAL" + EVM_READER_READY_MAX_STALENESS = "CARTESI_EVM_READER_READY_MAX_STALENESS" + MAX_STARTUP_TIME = "CARTESI_MAX_STARTUP_TIME" + PRT_POLLING_INTERVAL = "CARTESI_PRT_POLLING_INTERVAL" + VALIDATOR_POLLING_INTERVAL = "CARTESI_VALIDATOR_POLLING_INTERVAL" + SNAPSHOTS_DIR = "CARTESI_SNAPSHOTS_DIR" + + // File variants + + AUTH_MNEMONIC_FILE = "CARTESI_AUTH_MNEMONIC_FILE" + + AUTH_PRIVATE_KEY_FILE = "CARTESI_AUTH_PRIVATE_KEY_FILE" + + BLOCKCHAIN_HTTP_AUTHORIZATION_FILE = "CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION_FILE" + BLOCKCHAIN_HTTP_ENDPOINT_FILE = "CARTESI_BLOCKCHAIN_HTTP_ENDPOINT_FILE" + + DATABASE_CONNECTION_FILE = "CARTESI_DATABASE_CONNECTION_FILE" + + PRT_AUTH_MNEMONIC_FILE = "CARTESI_PRT_AUTH_MNEMONIC_FILE" + + PRT_AUTH_PRIVATE_KEY_FILE = "CARTESI_PRT_AUTH_PRIVATE_KEY_FILE" +) + +// DeclaredDefault returns only the binary's declared configuration default. +// A known setting without a default returns an empty string and true. +func DeclaredDefault(name string) (string, bool) { + switch name { + case AUTH_AWS_KMS_KEY_ID: + return "", true + case AUTH_KIND: + return "mnemonic", true + case AUTH_MNEMONIC: + return "", true + case AUTH_MNEMONIC_ACCOUNT_INDEX: + return "0", true + case AUTH_PRIVATE_KEY: + return "", true + case BLOCKCHAIN_DEFAULT_BLOCK: + return "finalized", true + case BLOCKCHAIN_HTTP_AUTHORIZATION: + return "", true + case BLOCKCHAIN_HTTP_ENDPOINT: + return "", true + case BLOCKCHAIN_ID: + return "", true + case BLOCKCHAIN_LEGACY_ENABLED: + return "false", true + case BLOCKCHAIN_TX_MAX_FEE_PER_GAS: + return "", true + case CONTRACTS_APPLICATION_FACTORY_ADDRESS: + return "", true + case CONTRACTS_AUTHORITY_FACTORY_ADDRESS: + return "", true + case CONTRACTS_DAVE_APP_FACTORY_ADDRESS: + return "", true + case CONTRACTS_INPUT_BOX_ADDRESS: + return "", true + case CONTRACTS_QUORUM_FACTORY_ADDRESS: + return "", true + case CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS: + return "", true + case DATABASE_CONNECTION: + return "", true + case FEATURE_CLAIM_SUBMISSION_ENABLED: + return "true", true + case FEATURE_INSPECT_ENABLED: + return "true", true + case FEATURE_JSONRPC_API_ENABLED: + return "true", true + case FEATURE_MACHINE_HASH_CHECK_ENABLED: + return "true", true + case ADVANCER_TELEMETRY_ADDRESS: + return ":10002", true + case CLAIMER_TELEMETRY_ADDRESS: + return ":10004", true + case EVM_READER_TELEMETRY_ADDRESS: + return ":10001", true + case INSPECT_ADDRESS: + return ":10012", true + case INSPECT_CORS_ALLOWED_ORIGINS: + return "", true + case INSPECT_MAX_INFLIGHT: + return "64", true + case INSPECT_URL: + return "http://localhost:10012", true + case JSONRPC_API_ADDRESS: + return ":10011", true + case JSONRPC_API_URL: + return "http://localhost:10011/rpc", true + case JSONRPC_CORS_ALLOWED_ORIGINS: + return "", true + case JSONRPC_MAX_INFLIGHT: + return "64", true + case JSONRPC_TELEMETRY_ADDRESS: + return ":10005", true + case NODE_TELEMETRY_ADDRESS: + return ":10000", true + case PRT_TELEMETRY_ADDRESS: + return ":10006", true + case VALIDATOR_TELEMETRY_ADDRESS: + return ":10003", true + case LOG_COLOR: + return "true", true + case LOG_LEVEL: + return "info", true + case LOG_LEVEL_ADVANCER: + return "", true + case LOG_LEVEL_CLAIMER: + return "", true + case LOG_LEVEL_EVM_READER: + return "", true + case LOG_LEVEL_JSONRPC_API: + return "", true + case LOG_LEVEL_PRT: + return "", true + case LOG_LEVEL_VALIDATOR: + return "", true + case JSONRPC_MACHINE_LOG_LEVEL: + return "info", true + case PRT_AUTH_AWS_KMS_KEY_ID: + return "", true + case PRT_AUTH_KIND: + return "mnemonic", true + case PRT_AUTH_MNEMONIC: + return "", true + case PRT_AUTH_MNEMONIC_ACCOUNT_INDEX: + return "6", true + case PRT_AUTH_PRIVATE_KEY: + return "", true + case ADVANCER_INPUT_BATCH_SIZE: + return "500", true + case ADVANCER_POLLING_INTERVAL: + return "3", true + case BLOCKCHAIN_GAS_LIMIT: + return "0", true + case BLOCKCHAIN_HTTP_MAX_RETRIES: + return "4", true + case BLOCKCHAIN_HTTP_REQUEST_TIMEOUT: + return "120", true + case BLOCKCHAIN_HTTP_RETRY_MAX_WAIT: + return "60", true + case BLOCKCHAIN_HTTP_RETRY_MIN_WAIT: + return "1", true + case BLOCKCHAIN_MAX_BLOCK_RANGE: + return "2000", true + case CLAIMER_POLLING_INTERVAL: + return "3", true + case EVM_READER_POLLING_INTERVAL: + return "12", true + case EVM_READER_READY_MAX_STALENESS: + return "0", true + case MAX_STARTUP_TIME: + return "15", true + case PRT_POLLING_INTERVAL: + return "3", true + case VALIDATOR_POLLING_INTERVAL: + return "3", true + case SNAPSHOTS_DIR: + return "/var/lib/cartesi-rollups-node/snapshots", true + default: + return "", false + } +} + +func SetDefaults() { + // Set defaults based on the TOML definitions. + + // no default for CARTESI_AUTH_AWS_KMS_KEY_ID + + viper.SetDefault(AUTH_KIND, "mnemonic") + + // no default for CARTESI_AUTH_MNEMONIC + + viper.SetDefault(AUTH_MNEMONIC_ACCOUNT_INDEX, "0") + + // no default for CARTESI_AUTH_PRIVATE_KEY + + viper.SetDefault(BLOCKCHAIN_DEFAULT_BLOCK, "finalized") + + // no default for CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION + + // no default for CARTESI_BLOCKCHAIN_HTTP_ENDPOINT + + // no default for CARTESI_BLOCKCHAIN_ID + + viper.SetDefault(BLOCKCHAIN_LEGACY_ENABLED, "false") + + viper.SetDefault(BLOCKCHAIN_TX_MAX_FEE_PER_GAS, "") + + // no default for CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS + + // no default for CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS + + // no default for CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS + + // no default for CARTESI_CONTRACTS_INPUT_BOX_ADDRESS + + // no default for CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS + + // no default for CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS + + viper.SetDefault(DATABASE_CONNECTION, "") + + viper.SetDefault(FEATURE_CLAIM_SUBMISSION_ENABLED, "true") + + viper.SetDefault(FEATURE_INSPECT_ENABLED, "true") + + viper.SetDefault(FEATURE_JSONRPC_API_ENABLED, "true") + + viper.SetDefault(FEATURE_MACHINE_HASH_CHECK_ENABLED, "true") + + viper.SetDefault(ADVANCER_TELEMETRY_ADDRESS, ":10002") + + viper.SetDefault(CLAIMER_TELEMETRY_ADDRESS, ":10004") + + viper.SetDefault(EVM_READER_TELEMETRY_ADDRESS, ":10001") + + viper.SetDefault(INSPECT_ADDRESS, ":10012") + + viper.SetDefault(INSPECT_CORS_ALLOWED_ORIGINS, "") + + viper.SetDefault(INSPECT_MAX_INFLIGHT, "64") + + viper.SetDefault(INSPECT_URL, "http://localhost:10012") + + viper.SetDefault(JSONRPC_API_ADDRESS, ":10011") + + viper.SetDefault(JSONRPC_API_URL, "http://localhost:10011/rpc") + + viper.SetDefault(JSONRPC_CORS_ALLOWED_ORIGINS, "") + + viper.SetDefault(JSONRPC_MAX_INFLIGHT, "64") + + viper.SetDefault(JSONRPC_TELEMETRY_ADDRESS, ":10005") + + viper.SetDefault(NODE_TELEMETRY_ADDRESS, ":10000") + + viper.SetDefault(PRT_TELEMETRY_ADDRESS, ":10006") + + viper.SetDefault(VALIDATOR_TELEMETRY_ADDRESS, ":10003") + + viper.SetDefault(LOG_COLOR, "true") + + viper.SetDefault(LOG_LEVEL, "info") + + // no default for CARTESI_LOG_LEVEL_ADVANCER + + // no default for CARTESI_LOG_LEVEL_CLAIMER + + // no default for CARTESI_LOG_LEVEL_EVM_READER + + // no default for CARTESI_LOG_LEVEL_JSONRPC_API + + // no default for CARTESI_LOG_LEVEL_PRT + + // no default for CARTESI_LOG_LEVEL_VALIDATOR + + viper.SetDefault(JSONRPC_MACHINE_LOG_LEVEL, "info") + + // no default for CARTESI_PRT_AUTH_AWS_KMS_KEY_ID + + viper.SetDefault(PRT_AUTH_KIND, "mnemonic") + + // no default for CARTESI_PRT_AUTH_MNEMONIC + + viper.SetDefault(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, "6") + + // no default for CARTESI_PRT_AUTH_PRIVATE_KEY + + viper.SetDefault(ADVANCER_INPUT_BATCH_SIZE, "500") + + viper.SetDefault(ADVANCER_POLLING_INTERVAL, "3") + + viper.SetDefault(BLOCKCHAIN_GAS_LIMIT, "0") + + viper.SetDefault(BLOCKCHAIN_HTTP_MAX_RETRIES, "4") + + viper.SetDefault(BLOCKCHAIN_HTTP_REQUEST_TIMEOUT, "120") + + viper.SetDefault(BLOCKCHAIN_HTTP_RETRY_MAX_WAIT, "60") + + viper.SetDefault(BLOCKCHAIN_HTTP_RETRY_MIN_WAIT, "1") + + viper.SetDefault(BLOCKCHAIN_MAX_BLOCK_RANGE, "2000") + + viper.SetDefault(CLAIMER_POLLING_INTERVAL, "3") + + viper.SetDefault(EVM_READER_POLLING_INTERVAL, "12") + + viper.SetDefault(EVM_READER_READY_MAX_STALENESS, "0") + + viper.SetDefault(MAX_STARTUP_TIME, "15") + + viper.SetDefault(PRT_POLLING_INTERVAL, "3") + + viper.SetDefault(VALIDATOR_POLLING_INTERVAL, "3") + + viper.SetDefault(SNAPSHOTS_DIR, "/var/lib/cartesi-rollups-node/snapshots") + +} + +// AdvancerConfig holds configuration values for the advancer service. +type AdvancerConfig struct { + + // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + // + // If not set, or set to empty string, will defer the behaviour to the PG driver. + // See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + // + // It is also possible to set the endpoint without a password and load it from Postgres' passfile. + // See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) + // for more information. + DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` + + // If set to false, the node will not start the inspect service. + FeatureInspectEnabled bool `mapstructure:"CARTESI_FEATURE_INSPECT_ENABLED"` + + // If set to false, the node will *not* check whether the Cartesi machine hash from + // the snapshot matches the hash in the Application contract. + FeatureMachineHashCheckEnabled bool `mapstructure:"CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED"` + + // HTTP address for Advancer's telemetry service. + AdvancerTelemetryAddress string `mapstructure:"CARTESI_ADVANCER_TELEMETRY_ADDRESS"` + + // HTTP address for inspect. + InspectAddress string `mapstructure:"CARTESI_INSPECT_ADDRESS"` + + // Comma-separated list of allowed browser origins for inspect. + // If empty, CORS is disabled. Origins are lowercased and validated at + // startup. Example: "http://localhost:3000,https://app.example.com". + InspectCorsAllowedOrigins string `mapstructure:"CARTESI_INSPECT_CORS_ALLOWED_ORIGINS"` + + // Maximum number of concurrent in-flight HTTP inspect requests. + // Requests beyond this limit receive HTTP 503 Service Unavailable + // with Retry-After: 1. Set to 0 to disable HTTP-level admission + // control (backpressure then falls back to the per-application + // machine semaphore). + InspectMaxInflight uint64 `mapstructure:"CARTESI_INSPECT_MAX_INFLIGHT"` + + // If set to true, the node will add colors to its log output. + LogColor bool `mapstructure:"CARTESI_LOG_COLOR"` + + // One of "debug", "info", "warn", "error". + LogLevel LogLevel `mapstructure:"CARTESI_LOG_LEVEL"` + + // Remote Cartesi Machine server log level. + // One of "trace", "debug", "info", "warning", "error", "fatal". + JsonrpcMachineLogLevel string `mapstructure:"CARTESI_JSONRPC_MACHINE_LOG_LEVEL"` + + // Maximum number of inputs fetched per database query during advance processing and machine + // synchronization. Bounds memory usage for applications with large backlogs of unprocessed inputs. + AdvancerInputBatchSize uint64 `mapstructure:"CARTESI_ADVANCER_INPUT_BATCH_SIZE"` + + // How many seconds the node will wait before querying the database for new inputs. + AdvancerPollingInterval Duration `mapstructure:"CARTESI_ADVANCER_POLLING_INTERVAL"` + + // How many seconds the node expects services take initializing before aborting. + MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` + + // Path to the directory where the snapshots will be written. + SnapshotsDir string `mapstructure:"CARTESI_SNAPSHOTS_DIR"` +} + +// LoadAdvancerConfig reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func LoadAdvancerConfig() (*AdvancerConfig, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg AdvancerConfig + var err error + + cfg.DatabaseConnection, err = GetDatabaseConnection() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_DATABASE_CONNECTION: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the advancer service: %w", err) + } + + cfg.FeatureInspectEnabled, err = GetFeatureInspectEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INSPECT_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_INSPECT_ENABLED is required for the advancer service: %w", err) + } + + cfg.FeatureMachineHashCheckEnabled, err = GetFeatureMachineHashCheckEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED is required for the advancer service: %w", err) + } + + cfg.AdvancerTelemetryAddress, err = GetAdvancerTelemetryAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_ADVANCER_TELEMETRY_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_ADVANCER_TELEMETRY_ADDRESS is required for the advancer service: %w", err) + } + + cfg.InspectAddress, err = GetInspectAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_INSPECT_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_INSPECT_ADDRESS is required for the advancer service: %w", err) + } + + cfg.InspectCorsAllowedOrigins, err = GetInspectCorsAllowedOrigins() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_INSPECT_CORS_ALLOWED_ORIGINS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_INSPECT_CORS_ALLOWED_ORIGINS is required for the advancer service: %w", err) + } + + cfg.InspectMaxInflight, err = GetInspectMaxInflight() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_INSPECT_MAX_INFLIGHT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_INSPECT_MAX_INFLIGHT is required for the advancer service: %w", err) + } + + cfg.LogColor, err = GetLogColor() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_COLOR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_COLOR is required for the advancer service: %w", err) + } + + cfg.LogLevel, err = GetLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_LEVEL is required for the advancer service: %w", err) + } + + cfg.JsonrpcMachineLogLevel, err = GetJsonrpcMachineLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_MACHINE_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_MACHINE_LOG_LEVEL is required for the advancer service: %w", err) + } + + cfg.AdvancerInputBatchSize, err = GetAdvancerInputBatchSize() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_ADVANCER_INPUT_BATCH_SIZE: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_ADVANCER_INPUT_BATCH_SIZE is required for the advancer service: %w", err) + } + + cfg.AdvancerPollingInterval, err = GetAdvancerPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_ADVANCER_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_ADVANCER_POLLING_INTERVAL is required for the advancer service: %w", err) + } + + cfg.MaxStartupTime, err = GetMaxStartupTime() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_MAX_STARTUP_TIME is required for the advancer service: %w", err) + } + + cfg.SnapshotsDir, err = GetSnapshotsDir() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_SNAPSHOTS_DIR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_SNAPSHOTS_DIR is required for the advancer service: %w", err) + } + + return &cfg, nil +} + +// ClaimerConfig holds configuration values for the claimer service. +type ClaimerConfig struct { + + // One of 'latest', 'safe', 'finalized'. Defaults to 'finalized'. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // Nonce selection and transaction readiness checks use 'latest'. + // The sender releases its in-memory transaction when the latest mined nonce advances. + // All contributing services must use the same policy. + // 'latest' and 'safe' observations can change after a reorg. The node does not roll back + // stored observations; rebuilding may be required. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. + // 'pending' is unsupported because it does not identify a mined block. + BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` + + // HTTP(S) endpoint with a DNS name or IP address for the blockchain RPC provider. + // Redirects are not followed; use the final endpoint URL. + BlockchainHttpEndpoint URL `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_ENDPOINT"` + + // An unique identifier representing a blockchain network. + BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` + + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation. After a price refusal, service delivery + // retries once using a fresh-quote fallback and the optional fee cap. + BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` + + // Optional maximum transaction fee per gas, as a positive decimal integer in wei. + // Caps dynamic maxFeePerGas and legacy gasPrice. Unset means no operator price ceiling. + // The sender uses fresh fee quotes. After a price refusal, it retries once within + // this ceiling. The ceiling does not guarantee replacement, cancellation or inclusion. + BlockchainTxMaxFeePerGas string `mapstructure:"CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS"` + + // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + // + // If not set, or set to empty string, will defer the behaviour to the PG driver. + // See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + // + // It is also possible to set the endpoint without a password and load it from Postgres' passfile. + // See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) + // for more information. + DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` + + // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. + FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` + + // HTTP address for Claimer's telemetry service. + ClaimerTelemetryAddress string `mapstructure:"CARTESI_CLAIMER_TELEMETRY_ADDRESS"` + + // If set to true, the node will add colors to its log output. + LogColor bool `mapstructure:"CARTESI_LOG_COLOR"` + + // One of "debug", "info", "warn", "error". + LogLevel LogLevel `mapstructure:"CARTESI_LOG_LEVEL"` + + // Maximum number of retry attempts for HTTP blockchain requests after encountering an error. + BlockchainHttpMaxRetries uint64 `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES"` + + // Maximum time in seconds for each HTTP request attempt to the blockchain provider or AWS KMS, + // and for each transaction signing operation. This does not bound a whole publication pass. + BlockchainHttpRequestTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT"` + + // Maximum wait time in seconds for the exponential backoff retry policy. The delay between retries for HTTP blockchain requests will never exceed this value, regardless of the backoff calculation. + BlockchainHttpRetryMaxWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT"` + + // Minimum wait time in seconds for the exponential backoff retry policy. This is the initial delay before the first retry for HTTP blockchain requests. + BlockchainHttpRetryMinWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT"` + + // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. + BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` + + // How many seconds the node will wait before querying the database for new claims. + ClaimerPollingInterval Duration `mapstructure:"CARTESI_CLAIMER_POLLING_INTERVAL"` + + // How many seconds the node expects services take initializing before aborting. + MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` +} + +// LoadClaimerConfig reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func LoadClaimerConfig() (*ClaimerConfig, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg ClaimerConfig + var err error + + cfg.BlockchainDefaultBlock, err = GetBlockchainDefaultBlock() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_DEFAULT_BLOCK is required for the claimer service: %w", err) + } + + cfg.BlockchainHttpEndpoint, err = GetBlockchainHttpEndpoint() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT is required for the claimer service: %w", err) + } + + cfg.BlockchainId, err = GetBlockchainId() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_ID: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_ID is required for the claimer service: %w", err) + } + + cfg.BlockchainLegacyEnabled, err = GetBlockchainLegacyEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_LEGACY_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_LEGACY_ENABLED is required for the claimer service: %w", err) + } + + cfg.BlockchainTxMaxFeePerGas, err = GetBlockchainTxMaxFeePerGas() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS is required for the claimer service: %w", err) + } + + cfg.DatabaseConnection, err = GetDatabaseConnection() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_DATABASE_CONNECTION: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the claimer service: %w", err) + } + + cfg.FeatureClaimSubmissionEnabled, err = GetFeatureClaimSubmissionEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED is required for the claimer service: %w", err) + } + + cfg.ClaimerTelemetryAddress, err = GetClaimerTelemetryAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_TELEMETRY_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_CLAIMER_TELEMETRY_ADDRESS is required for the claimer service: %w", err) + } + + cfg.LogColor, err = GetLogColor() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_COLOR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_COLOR is required for the claimer service: %w", err) + } + + cfg.LogLevel, err = GetLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_LEVEL is required for the claimer service: %w", err) + } + + cfg.BlockchainHttpMaxRetries, err = GetBlockchainHttpMaxRetries() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES is required for the claimer service: %w", err) + } + + cfg.BlockchainHttpRequestTimeout, err = GetBlockchainHttpRequestTimeout() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT is required for the claimer service: %w", err) + } + + cfg.BlockchainHttpRetryMaxWait, err = GetBlockchainHttpRetryMaxWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT is required for the claimer service: %w", err) + } + + cfg.BlockchainHttpRetryMinWait, err = GetBlockchainHttpRetryMinWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT is required for the claimer service: %w", err) + } + + cfg.BlockchainMaxBlockRange, err = GetBlockchainMaxBlockRange() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE is required for the claimer service: %w", err) + } + + cfg.ClaimerPollingInterval, err = GetClaimerPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_CLAIMER_POLLING_INTERVAL is required for the claimer service: %w", err) + } + + cfg.MaxStartupTime, err = GetMaxStartupTime() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_MAX_STARTUP_TIME is required for the claimer service: %w", err) + } + + return &cfg, nil +} + +// EvmreaderConfig holds configuration values for the evmreader service. +type EvmreaderConfig struct { + + // One of 'latest', 'safe', 'finalized'. Defaults to 'finalized'. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // Nonce selection and transaction readiness checks use 'latest'. + // The sender releases its in-memory transaction when the latest mined nonce advances. + // All contributing services must use the same policy. + // 'latest' and 'safe' observations can change after a reorg. The node does not roll back + // stored observations; rebuilding may be required. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. + // 'pending' is unsupported because it does not identify a mined block. + BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` + + // HTTP(S) endpoint with a DNS name or IP address for the blockchain RPC provider. + // Redirects are not followed; use the final endpoint URL. + BlockchainHttpEndpoint URL `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_ENDPOINT"` + + // An unique identifier representing a blockchain network. + BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` + + // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + // + // If not set, or set to empty string, will defer the behaviour to the PG driver. + // See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + // + // It is also possible to set the endpoint without a password and load it from Postgres' passfile. + // See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) + // for more information. + DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` + + // HTTP address for EVM Reader's telemetry service. + EvmReaderTelemetryAddress string `mapstructure:"CARTESI_EVM_READER_TELEMETRY_ADDRESS"` + + // If set to true, the node will add colors to its log output. + LogColor bool `mapstructure:"CARTESI_LOG_COLOR"` + + // One of "debug", "info", "warn", "error". + LogLevel LogLevel `mapstructure:"CARTESI_LOG_LEVEL"` + + // Maximum number of retry attempts for HTTP blockchain requests after encountering an error. + BlockchainHttpMaxRetries uint64 `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES"` + + // Maximum time in seconds for each HTTP request attempt to the blockchain provider or AWS KMS, + // and for each transaction signing operation. This does not bound a whole publication pass. + BlockchainHttpRequestTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT"` + + // Maximum wait time in seconds for the exponential backoff retry policy. The delay between retries for HTTP blockchain requests will never exceed this value, regardless of the backoff calculation. + BlockchainHttpRetryMaxWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT"` + + // Minimum wait time in seconds for the exponential backoff retry policy. This is the initial delay before the first retry for HTTP blockchain requests. + BlockchainHttpRetryMinWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT"` + + // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. + BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` + + // Time in seconds to wait before checking for a new block header. The default (12s) is tuned for mainnet. Reduce for faster chains or devnets. + EvmReaderPollingInterval Duration `mapstructure:"CARTESI_EVM_READER_POLLING_INTERVAL"` + + // Maximum time in seconds since the EVM reader completed a poll and its block scan before it reports + // not ready. Zero automatically uses the maximum of three polling intervals, the HTTP request timeout + // times (1 + maximum HTTP retries), and one second. Positive values override this automatic budget; + // negative values are invalid. A scan refreshes readiness when it finishes, but can become stale while + // still running. Readiness also fails after three consecutive scan cycles with errors and recovers after a clean cycle. + EvmReaderReadyMaxStaleness Duration `mapstructure:"CARTESI_EVM_READER_READY_MAX_STALENESS"` + + // How many seconds the node expects services take initializing before aborting. + MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` +} + +// LoadEvmreaderConfig reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func LoadEvmreaderConfig() (*EvmreaderConfig, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg EvmreaderConfig + var err error + + cfg.BlockchainDefaultBlock, err = GetBlockchainDefaultBlock() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_DEFAULT_BLOCK is required for the evmreader service: %w", err) + } + + cfg.BlockchainHttpEndpoint, err = GetBlockchainHttpEndpoint() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT is required for the evmreader service: %w", err) + } + + cfg.BlockchainId, err = GetBlockchainId() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_ID: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_ID is required for the evmreader service: %w", err) + } + + cfg.DatabaseConnection, err = GetDatabaseConnection() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_DATABASE_CONNECTION: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the evmreader service: %w", err) + } + + cfg.EvmReaderTelemetryAddress, err = GetEvmReaderTelemetryAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_EVM_READER_TELEMETRY_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_EVM_READER_TELEMETRY_ADDRESS is required for the evmreader service: %w", err) + } + + cfg.LogColor, err = GetLogColor() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_COLOR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_COLOR is required for the evmreader service: %w", err) + } + + cfg.LogLevel, err = GetLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_LEVEL is required for the evmreader service: %w", err) + } + + cfg.BlockchainHttpMaxRetries, err = GetBlockchainHttpMaxRetries() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES is required for the evmreader service: %w", err) + } + + cfg.BlockchainHttpRequestTimeout, err = GetBlockchainHttpRequestTimeout() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT is required for the evmreader service: %w", err) + } + + cfg.BlockchainHttpRetryMaxWait, err = GetBlockchainHttpRetryMaxWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT is required for the evmreader service: %w", err) + } + + cfg.BlockchainHttpRetryMinWait, err = GetBlockchainHttpRetryMinWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT is required for the evmreader service: %w", err) + } + + cfg.BlockchainMaxBlockRange, err = GetBlockchainMaxBlockRange() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE is required for the evmreader service: %w", err) + } + + cfg.EvmReaderPollingInterval, err = GetEvmReaderPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_EVM_READER_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_EVM_READER_POLLING_INTERVAL is required for the evmreader service: %w", err) + } + + cfg.EvmReaderReadyMaxStaleness, err = GetEvmReaderReadyMaxStaleness() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_EVM_READER_READY_MAX_STALENESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_EVM_READER_READY_MAX_STALENESS is required for the evmreader service: %w", err) + } + + cfg.MaxStartupTime, err = GetMaxStartupTime() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_MAX_STARTUP_TIME is required for the evmreader service: %w", err) + } + + return &cfg, nil +} + +// JsonrpcConfig holds configuration values for the jsonrpc service. +type JsonrpcConfig struct { + + // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + // + // If not set, or set to empty string, will defer the behaviour to the PG driver. + // See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + // + // It is also possible to set the endpoint without a password and load it from Postgres' passfile. + // See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) + // for more information. + DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` + + // HTTP address for the JSON-RPC API. + JsonrpcApiAddress string `mapstructure:"CARTESI_JSONRPC_API_ADDRESS"` + + // Comma-separated list of allowed browser origins for the JSON-RPC API. + // If empty, CORS is disabled. Origins are lowercased and validated at + // startup. Example: "http://localhost:3000,https://app.example.com". + JsonrpcCorsAllowedOrigins string `mapstructure:"CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS"` + + // Maximum number of concurrent in-flight JSON-RPC requests. + // Requests beyond this limit receive HTTP 503 Service Unavailable + // with Retry-After: 1. Set to 0 to disable HTTP-level admission + // control. + JsonrpcMaxInflight uint64 `mapstructure:"CARTESI_JSONRPC_MAX_INFLIGHT"` + + // HTTP address for JSON-RPC's telemetry service. + JsonrpcTelemetryAddress string `mapstructure:"CARTESI_JSONRPC_TELEMETRY_ADDRESS"` + + // If set to true, the node will add colors to its log output. + LogColor bool `mapstructure:"CARTESI_LOG_COLOR"` + + // One of "debug", "info", "warn", "error". + LogLevel LogLevel `mapstructure:"CARTESI_LOG_LEVEL"` + + // How many seconds the node expects services take initializing before aborting. + MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` +} + +// LoadJsonrpcConfig reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func LoadJsonrpcConfig() (*JsonrpcConfig, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg JsonrpcConfig + var err error + + cfg.DatabaseConnection, err = GetDatabaseConnection() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_DATABASE_CONNECTION: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the jsonrpc service: %w", err) + } + + cfg.JsonrpcApiAddress, err = GetJsonrpcApiAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_API_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_API_ADDRESS is required for the jsonrpc service: %w", err) + } + + cfg.JsonrpcCorsAllowedOrigins, err = GetJsonrpcCorsAllowedOrigins() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS is required for the jsonrpc service: %w", err) + } + + cfg.JsonrpcMaxInflight, err = GetJsonrpcMaxInflight() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_MAX_INFLIGHT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_MAX_INFLIGHT is required for the jsonrpc service: %w", err) + } + + cfg.JsonrpcTelemetryAddress, err = GetJsonrpcTelemetryAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_TELEMETRY_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_TELEMETRY_ADDRESS is required for the jsonrpc service: %w", err) + } + + cfg.LogColor, err = GetLogColor() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_COLOR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_COLOR is required for the jsonrpc service: %w", err) + } + + cfg.LogLevel, err = GetLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_LEVEL is required for the jsonrpc service: %w", err) + } + + cfg.MaxStartupTime, err = GetMaxStartupTime() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_MAX_STARTUP_TIME is required for the jsonrpc service: %w", err) + } + + return &cfg, nil +} + +// NodeConfig holds configuration values for the node service. +type NodeConfig struct { + + // One of 'latest', 'safe', 'finalized'. Defaults to 'finalized'. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // Nonce selection and transaction readiness checks use 'latest'. + // The sender releases its in-memory transaction when the latest mined nonce advances. + // All contributing services must use the same policy. + // 'latest' and 'safe' observations can change after a reorg. The node does not roll back + // stored observations; rebuilding may be required. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. + // 'pending' is unsupported because it does not identify a mined block. + BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` + + // HTTP(S) endpoint with a DNS name or IP address for the blockchain RPC provider. + // Redirects are not followed; use the final endpoint URL. + BlockchainHttpEndpoint URL `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_ENDPOINT"` + + // An unique identifier representing a blockchain network. + BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` + + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation. After a price refusal, service delivery + // retries once using a fresh-quote fallback and the optional fee cap. + BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` + + // Optional maximum transaction fee per gas, as a positive decimal integer in wei. + // Caps dynamic maxFeePerGas and legacy gasPrice. Unset means no operator price ceiling. + // The sender uses fresh fee quotes. After a price refusal, it retries once within + // this ceiling. The ceiling does not guarantee replacement, cancellation or inclusion. + BlockchainTxMaxFeePerGas string `mapstructure:"CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS"` + + // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + // + // If not set, or set to empty string, will defer the behaviour to the PG driver. + // See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + // + // It is also possible to set the endpoint without a password and load it from Postgres' passfile. + // See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) + // for more information. + DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` + + // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. + FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` + + // If set to false, the node will not start the inspect service. + FeatureInspectEnabled bool `mapstructure:"CARTESI_FEATURE_INSPECT_ENABLED"` + + // If set to false, the node will not start the jsonrpc api service. + FeatureJsonrpcApiEnabled bool `mapstructure:"CARTESI_FEATURE_JSONRPC_API_ENABLED"` + + // If set to false, the node will *not* check whether the Cartesi machine hash from + // the snapshot matches the hash in the Application contract. + FeatureMachineHashCheckEnabled bool `mapstructure:"CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED"` + + // HTTP address for inspect. + InspectAddress string `mapstructure:"CARTESI_INSPECT_ADDRESS"` + + // Comma-separated list of allowed browser origins for inspect. + // If empty, CORS is disabled. Origins are lowercased and validated at + // startup. Example: "http://localhost:3000,https://app.example.com". + InspectCorsAllowedOrigins string `mapstructure:"CARTESI_INSPECT_CORS_ALLOWED_ORIGINS"` + + // Maximum number of concurrent in-flight HTTP inspect requests. + // Requests beyond this limit receive HTTP 503 Service Unavailable + // with Retry-After: 1. Set to 0 to disable HTTP-level admission + // control (backpressure then falls back to the per-application + // machine semaphore). + InspectMaxInflight uint64 `mapstructure:"CARTESI_INSPECT_MAX_INFLIGHT"` + + // HTTP address for the JSON-RPC API. + JsonrpcApiAddress string `mapstructure:"CARTESI_JSONRPC_API_ADDRESS"` + + // Comma-separated list of allowed browser origins for the JSON-RPC API. + // If empty, CORS is disabled. Origins are lowercased and validated at + // startup. Example: "http://localhost:3000,https://app.example.com". + JsonrpcCorsAllowedOrigins string `mapstructure:"CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS"` + + // Maximum number of concurrent in-flight JSON-RPC requests. + // Requests beyond this limit receive HTTP 503 Service Unavailable + // with Retry-After: 1. Set to 0 to disable HTTP-level admission + // control. + JsonrpcMaxInflight uint64 `mapstructure:"CARTESI_JSONRPC_MAX_INFLIGHT"` + + // HTTP address for Node's telemetry service. + NodeTelemetryAddress string `mapstructure:"CARTESI_NODE_TELEMETRY_ADDRESS"` + + // If set to true, the node will add colors to its log output. + LogColor bool `mapstructure:"CARTESI_LOG_COLOR"` + + // One of "debug", "info", "warn", "error". + LogLevel LogLevel `mapstructure:"CARTESI_LOG_LEVEL"` + + // Remote Cartesi Machine server log level. + // One of "trace", "debug", "info", "warning", "error", "fatal". + JsonrpcMachineLogLevel string `mapstructure:"CARTESI_JSONRPC_MACHINE_LOG_LEVEL"` + + // Maximum number of inputs fetched per database query during advance processing and machine + // synchronization. Bounds memory usage for applications with large backlogs of unprocessed inputs. + AdvancerInputBatchSize uint64 `mapstructure:"CARTESI_ADVANCER_INPUT_BATCH_SIZE"` + + // How many seconds the node will wait before querying the database for new inputs. + AdvancerPollingInterval Duration `mapstructure:"CARTESI_ADVANCER_POLLING_INTERVAL"` + + // Maximum number of retry attempts for HTTP blockchain requests after encountering an error. + BlockchainHttpMaxRetries uint64 `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES"` + + // Maximum time in seconds for each HTTP request attempt to the blockchain provider or AWS KMS, + // and for each transaction signing operation. This does not bound a whole publication pass. + BlockchainHttpRequestTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT"` + + // Maximum wait time in seconds for the exponential backoff retry policy. The delay between retries for HTTP blockchain requests will never exceed this value, regardless of the backoff calculation. + BlockchainHttpRetryMaxWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT"` + + // Minimum wait time in seconds for the exponential backoff retry policy. This is the initial delay before the first retry for HTTP blockchain requests. + BlockchainHttpRetryMinWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT"` + + // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. + BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` + + // How many seconds the node will wait before querying the database for new claims. + ClaimerPollingInterval Duration `mapstructure:"CARTESI_CLAIMER_POLLING_INTERVAL"` + + // Time in seconds to wait before checking for a new block header. The default (12s) is tuned for mainnet. Reduce for faster chains or devnets. + EvmReaderPollingInterval Duration `mapstructure:"CARTESI_EVM_READER_POLLING_INTERVAL"` + + // Maximum time in seconds since the EVM reader completed a poll and its block scan before it reports + // not ready. Zero automatically uses the maximum of three polling intervals, the HTTP request timeout + // times (1 + maximum HTTP retries), and one second. Positive values override this automatic budget; + // negative values are invalid. A scan refreshes readiness when it finishes, but can become stale while + // still running. Readiness also fails after three consecutive scan cycles with errors and recovers after a clean cycle. + EvmReaderReadyMaxStaleness Duration `mapstructure:"CARTESI_EVM_READER_READY_MAX_STALENESS"` + + // How many seconds the node expects services take initializing before aborting. + MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` + + // How many seconds the node will wait before trying to finish epochs for all applications. + PrtPollingInterval Duration `mapstructure:"CARTESI_PRT_POLLING_INTERVAL"` + + // How many seconds the node will wait before trying to finish epochs for all applications. + ValidatorPollingInterval Duration `mapstructure:"CARTESI_VALIDATOR_POLLING_INTERVAL"` + + // Path to the directory where the snapshots will be written. + SnapshotsDir string `mapstructure:"CARTESI_SNAPSHOTS_DIR"` +} + +// LoadNodeConfig reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func LoadNodeConfig() (*NodeConfig, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg NodeConfig + var err error + + cfg.BlockchainDefaultBlock, err = GetBlockchainDefaultBlock() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_DEFAULT_BLOCK is required for the node service: %w", err) + } + + cfg.BlockchainHttpEndpoint, err = GetBlockchainHttpEndpoint() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT is required for the node service: %w", err) + } + + cfg.BlockchainId, err = GetBlockchainId() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_ID: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_ID is required for the node service: %w", err) + } + + cfg.BlockchainLegacyEnabled, err = GetBlockchainLegacyEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_LEGACY_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_LEGACY_ENABLED is required for the node service: %w", err) + } + + cfg.BlockchainTxMaxFeePerGas, err = GetBlockchainTxMaxFeePerGas() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS is required for the node service: %w", err) + } + + cfg.DatabaseConnection, err = GetDatabaseConnection() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_DATABASE_CONNECTION: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the node service: %w", err) + } + + cfg.FeatureClaimSubmissionEnabled, err = GetFeatureClaimSubmissionEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED is required for the node service: %w", err) + } + + cfg.FeatureInspectEnabled, err = GetFeatureInspectEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_INSPECT_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_INSPECT_ENABLED is required for the node service: %w", err) + } + + cfg.FeatureJsonrpcApiEnabled, err = GetFeatureJsonrpcApiEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_JSONRPC_API_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_JSONRPC_API_ENABLED is required for the node service: %w", err) + } + + cfg.FeatureMachineHashCheckEnabled, err = GetFeatureMachineHashCheckEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED is required for the node service: %w", err) + } + + cfg.InspectAddress, err = GetInspectAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_INSPECT_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_INSPECT_ADDRESS is required for the node service: %w", err) + } + + cfg.InspectCorsAllowedOrigins, err = GetInspectCorsAllowedOrigins() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_INSPECT_CORS_ALLOWED_ORIGINS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_INSPECT_CORS_ALLOWED_ORIGINS is required for the node service: %w", err) + } + + cfg.InspectMaxInflight, err = GetInspectMaxInflight() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_INSPECT_MAX_INFLIGHT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_INSPECT_MAX_INFLIGHT is required for the node service: %w", err) + } + + cfg.JsonrpcApiAddress, err = GetJsonrpcApiAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_API_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_API_ADDRESS is required for the node service: %w", err) + } + + cfg.JsonrpcCorsAllowedOrigins, err = GetJsonrpcCorsAllowedOrigins() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS is required for the node service: %w", err) + } + + cfg.JsonrpcMaxInflight, err = GetJsonrpcMaxInflight() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_MAX_INFLIGHT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_MAX_INFLIGHT is required for the node service: %w", err) + } + + cfg.NodeTelemetryAddress, err = GetNodeTelemetryAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_NODE_TELEMETRY_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_NODE_TELEMETRY_ADDRESS is required for the node service: %w", err) + } + + cfg.LogColor, err = GetLogColor() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_COLOR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_COLOR is required for the node service: %w", err) + } + + cfg.LogLevel, err = GetLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_LEVEL is required for the node service: %w", err) + } + + cfg.JsonrpcMachineLogLevel, err = GetJsonrpcMachineLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_JSONRPC_MACHINE_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_JSONRPC_MACHINE_LOG_LEVEL is required for the node service: %w", err) + } + + cfg.AdvancerInputBatchSize, err = GetAdvancerInputBatchSize() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_ADVANCER_INPUT_BATCH_SIZE: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_ADVANCER_INPUT_BATCH_SIZE is required for the node service: %w", err) + } + + cfg.AdvancerPollingInterval, err = GetAdvancerPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_ADVANCER_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_ADVANCER_POLLING_INTERVAL is required for the node service: %w", err) + } + + cfg.BlockchainHttpMaxRetries, err = GetBlockchainHttpMaxRetries() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES is required for the node service: %w", err) + } + + cfg.BlockchainHttpRequestTimeout, err = GetBlockchainHttpRequestTimeout() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT is required for the node service: %w", err) + } + + cfg.BlockchainHttpRetryMaxWait, err = GetBlockchainHttpRetryMaxWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT is required for the node service: %w", err) + } + + cfg.BlockchainHttpRetryMinWait, err = GetBlockchainHttpRetryMinWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT is required for the node service: %w", err) + } + + cfg.BlockchainMaxBlockRange, err = GetBlockchainMaxBlockRange() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE is required for the node service: %w", err) + } + + cfg.ClaimerPollingInterval, err = GetClaimerPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_CLAIMER_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_CLAIMER_POLLING_INTERVAL is required for the node service: %w", err) + } + + cfg.EvmReaderPollingInterval, err = GetEvmReaderPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_EVM_READER_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_EVM_READER_POLLING_INTERVAL is required for the node service: %w", err) + } + + cfg.EvmReaderReadyMaxStaleness, err = GetEvmReaderReadyMaxStaleness() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_EVM_READER_READY_MAX_STALENESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_EVM_READER_READY_MAX_STALENESS is required for the node service: %w", err) + } + + cfg.MaxStartupTime, err = GetMaxStartupTime() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_MAX_STARTUP_TIME is required for the node service: %w", err) + } + + cfg.PrtPollingInterval, err = GetPrtPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_PRT_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_PRT_POLLING_INTERVAL is required for the node service: %w", err) + } + + cfg.ValidatorPollingInterval, err = GetValidatorPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_VALIDATOR_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_VALIDATOR_POLLING_INTERVAL is required for the node service: %w", err) + } + + cfg.SnapshotsDir, err = GetSnapshotsDir() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_SNAPSHOTS_DIR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_SNAPSHOTS_DIR is required for the node service: %w", err) + } + + return &cfg, nil +} + +// PrtConfig holds configuration values for the prt service. +type PrtConfig struct { + + // One of 'latest', 'safe', 'finalized'. Defaults to 'finalized'. + // The block used by EVM Reader, Claimer, and PRT for chain-state observations. + // Nonce selection and transaction readiness checks use 'latest'. + // The sender releases its in-memory transaction when the latest mined nonce advances. + // All contributing services must use the same policy. + // 'latest' and 'safe' observations can change after a reorg. The node does not roll back + // stored observations; rebuilding may be required. + // The policy is saved when each service first starts. A later configuration mismatch stops startup. + // 'pending' is unsupported because it does not identify a mined block. + BlockchainDefaultBlock DefaultBlock `mapstructure:"CARTESI_BLOCKCHAIN_DEFAULT_BLOCK"` + + // HTTP(S) endpoint with a DNS name or IP address for the blockchain RPC provider. + // Redirects are not followed; use the final endpoint URL. + BlockchainHttpEndpoint URL `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_ENDPOINT"` + + // An unique identifier representing a blockchain network. + BlockchainId uint64 `mapstructure:"CARTESI_BLOCKCHAIN_ID"` + + // If true, force the legacy transaction format with a fresh suggested gas price + // for each transaction. If false, select the fee format automatically: EIP-1559 + // when the network provides a base fee, or legacy otherwise. + // This setting does not change gas estimation. After a price refusal, service delivery + // retries once using a fresh-quote fallback and the optional fee cap. + BlockchainLegacyEnabled bool `mapstructure:"CARTESI_BLOCKCHAIN_LEGACY_ENABLED"` + + // Optional maximum transaction fee per gas, as a positive decimal integer in wei. + // Caps dynamic maxFeePerGas and legacy gasPrice. Unset means no operator price ceiling. + // The sender uses fresh fee quotes. After a price refusal, it retries once within + // this ceiling. The ceiling does not guarantee replacement, cancellation or inclusion. + BlockchainTxMaxFeePerGas string `mapstructure:"CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS"` + + // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + // + // If not set, or set to empty string, will defer the behaviour to the PG driver. + // See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + // + // It is also possible to set the endpoint without a password and load it from Postgres' passfile. + // See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) + // for more information. + DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` + + // If set to false, the node will not submit claims (reader mode). + // The mode is saved when each submitter first starts. A later configuration mismatch + // stops startup. Mode changes on an existing database are not supported. + FeatureClaimSubmissionEnabled bool `mapstructure:"CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED"` + + // HTTP address for PRT's telemetry service. + PrtTelemetryAddress string `mapstructure:"CARTESI_PRT_TELEMETRY_ADDRESS"` + + // If set to true, the node will add colors to its log output. + LogColor bool `mapstructure:"CARTESI_LOG_COLOR"` + + // One of "debug", "info", "warn", "error". + LogLevel LogLevel `mapstructure:"CARTESI_LOG_LEVEL"` + + // Maximum number of retry attempts for HTTP blockchain requests after encountering an error. + BlockchainHttpMaxRetries uint64 `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES"` + + // Maximum time in seconds for each HTTP request attempt to the blockchain provider or AWS KMS, + // and for each transaction signing operation. This does not bound a whole publication pass. + BlockchainHttpRequestTimeout Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT"` + + // Maximum wait time in seconds for the exponential backoff retry policy. The delay between retries for HTTP blockchain requests will never exceed this value, regardless of the backoff calculation. + BlockchainHttpRetryMaxWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT"` + + // Minimum wait time in seconds for the exponential backoff retry policy. This is the initial delay before the first retry for HTTP blockchain requests. + BlockchainHttpRetryMinWait Duration `mapstructure:"CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT"` + + // Maximum number of blocks in a single query to the provider. Queries with larger ranges will be broken into multiple smaller queries. Zero for unlimited. + BlockchainMaxBlockRange uint64 `mapstructure:"CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE"` + + // How many seconds the node expects services take initializing before aborting. + MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` + + // How many seconds the node will wait before trying to finish epochs for all applications. + PrtPollingInterval Duration `mapstructure:"CARTESI_PRT_POLLING_INTERVAL"` + + // Path to the directory where the snapshots will be written. + SnapshotsDir string `mapstructure:"CARTESI_SNAPSHOTS_DIR"` +} + +// LoadPrtConfig reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func LoadPrtConfig() (*PrtConfig, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg PrtConfig + var err error + + cfg.BlockchainDefaultBlock, err = GetBlockchainDefaultBlock() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_DEFAULT_BLOCK is required for the prt service: %w", err) + } + + cfg.BlockchainHttpEndpoint, err = GetBlockchainHttpEndpoint() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT is required for the prt service: %w", err) + } + + cfg.BlockchainId, err = GetBlockchainId() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_ID: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_ID is required for the prt service: %w", err) + } + + cfg.BlockchainLegacyEnabled, err = GetBlockchainLegacyEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_LEGACY_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_LEGACY_ENABLED is required for the prt service: %w", err) + } + + cfg.BlockchainTxMaxFeePerGas, err = GetBlockchainTxMaxFeePerGas() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS is required for the prt service: %w", err) + } + + cfg.DatabaseConnection, err = GetDatabaseConnection() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_DATABASE_CONNECTION: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the prt service: %w", err) + } + + cfg.FeatureClaimSubmissionEnabled, err = GetFeatureClaimSubmissionEnabled() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED is required for the prt service: %w", err) + } + + cfg.PrtTelemetryAddress, err = GetPrtTelemetryAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_PRT_TELEMETRY_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_PRT_TELEMETRY_ADDRESS is required for the prt service: %w", err) + } + + cfg.LogColor, err = GetLogColor() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_COLOR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_COLOR is required for the prt service: %w", err) + } + + cfg.LogLevel, err = GetLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_LEVEL is required for the prt service: %w", err) + } + + cfg.BlockchainHttpMaxRetries, err = GetBlockchainHttpMaxRetries() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES is required for the prt service: %w", err) + } + + cfg.BlockchainHttpRequestTimeout, err = GetBlockchainHttpRequestTimeout() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT is required for the prt service: %w", err) + } + + cfg.BlockchainHttpRetryMaxWait, err = GetBlockchainHttpRetryMaxWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT is required for the prt service: %w", err) + } + + cfg.BlockchainHttpRetryMinWait, err = GetBlockchainHttpRetryMinWait() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT is required for the prt service: %w", err) + } + + cfg.BlockchainMaxBlockRange, err = GetBlockchainMaxBlockRange() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE is required for the prt service: %w", err) + } + + cfg.MaxStartupTime, err = GetMaxStartupTime() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_MAX_STARTUP_TIME is required for the prt service: %w", err) + } + + cfg.PrtPollingInterval, err = GetPrtPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_PRT_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_PRT_POLLING_INTERVAL is required for the prt service: %w", err) + } + + cfg.SnapshotsDir, err = GetSnapshotsDir() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_SNAPSHOTS_DIR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_SNAPSHOTS_DIR is required for the prt service: %w", err) + } + + return &cfg, nil +} + +// ValidatorConfig holds configuration values for the validator service. +type ValidatorConfig struct { + + // Postgres endpoint in the 'postgres://user:password@hostname:port/database' format (URL). + // + // If not set, or set to empty string, will defer the behaviour to the PG driver. + // See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. + // + // It is also possible to set the endpoint without a password and load it from Postgres' passfile. + // See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) + // for more information. + DatabaseConnection URL `mapstructure:"CARTESI_DATABASE_CONNECTION"` + + // HTTP address for Validator's telemetry service. + ValidatorTelemetryAddress string `mapstructure:"CARTESI_VALIDATOR_TELEMETRY_ADDRESS"` + + // If set to true, the node will add colors to its log output. + LogColor bool `mapstructure:"CARTESI_LOG_COLOR"` + + // One of "debug", "info", "warn", "error". + LogLevel LogLevel `mapstructure:"CARTESI_LOG_LEVEL"` + + // How many seconds the node expects services take initializing before aborting. + MaxStartupTime Duration `mapstructure:"CARTESI_MAX_STARTUP_TIME"` + + // How many seconds the node will wait before trying to finish epochs for all applications. + ValidatorPollingInterval Duration `mapstructure:"CARTESI_VALIDATOR_POLLING_INTERVAL"` +} + +// LoadValidatorConfig reads configuration from environment variables, a config file, and defaults. +// Priority: command line flags > environment variables > config file > defaults. +func LoadValidatorConfig() (*ValidatorConfig, error) { + // Load config file if specified via --config flag. + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + } + + var cfg ValidatorConfig + var err error + + cfg.DatabaseConnection, err = GetDatabaseConnection() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_DATABASE_CONNECTION: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_DATABASE_CONNECTION is required for the validator service: %w", err) + } + + cfg.ValidatorTelemetryAddress, err = GetValidatorTelemetryAddress() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_VALIDATOR_TELEMETRY_ADDRESS: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_VALIDATOR_TELEMETRY_ADDRESS is required for the validator service: %w", err) + } + + cfg.LogColor, err = GetLogColor() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_COLOR: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_COLOR is required for the validator service: %w", err) + } + + cfg.LogLevel, err = GetLogLevel() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_LOG_LEVEL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_LOG_LEVEL is required for the validator service: %w", err) + } + + cfg.MaxStartupTime, err = GetMaxStartupTime() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_MAX_STARTUP_TIME: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_MAX_STARTUP_TIME is required for the validator service: %w", err) + } + + cfg.ValidatorPollingInterval, err = GetValidatorPollingInterval() + if err != nil && err != ErrNotDefined { + return nil, fmt.Errorf("failed to get CARTESI_VALIDATOR_POLLING_INTERVAL: %w", err) + } else if err == ErrNotDefined { + return nil, fmt.Errorf("CARTESI_VALIDATOR_POLLING_INTERVAL is required for the validator service: %w", err) + } + + return &cfg, nil +} + +// ToAdvancerConfig converts a NodeConfig to a AdvancerConfig. +func (c *NodeConfig) ToAdvancerConfig() *AdvancerConfig { + return &AdvancerConfig{ + DatabaseConnection: c.DatabaseConnection, + FeatureInspectEnabled: c.FeatureInspectEnabled, + FeatureMachineHashCheckEnabled: c.FeatureMachineHashCheckEnabled, + InspectAddress: c.InspectAddress, + InspectCorsAllowedOrigins: c.InspectCorsAllowedOrigins, + InspectMaxInflight: c.InspectMaxInflight, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + JsonrpcMachineLogLevel: c.JsonrpcMachineLogLevel, + AdvancerInputBatchSize: c.AdvancerInputBatchSize, + AdvancerPollingInterval: c.AdvancerPollingInterval, + MaxStartupTime: c.MaxStartupTime, + SnapshotsDir: c.SnapshotsDir, + } +} + +// ToClaimerConfig converts a NodeConfig to a ClaimerConfig. +func (c *NodeConfig) ToClaimerConfig() *ClaimerConfig { + return &ClaimerConfig{ + BlockchainDefaultBlock: c.BlockchainDefaultBlock, + BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, + BlockchainId: c.BlockchainId, + BlockchainLegacyEnabled: c.BlockchainLegacyEnabled, + BlockchainTxMaxFeePerGas: c.BlockchainTxMaxFeePerGas, + DatabaseConnection: c.DatabaseConnection, + FeatureClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, + BlockchainHttpRequestTimeout: c.BlockchainHttpRequestTimeout, + BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, + BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, + BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, + ClaimerPollingInterval: c.ClaimerPollingInterval, + MaxStartupTime: c.MaxStartupTime, + } +} + +// ToEvmreaderConfig converts a NodeConfig to a EvmreaderConfig. +func (c *NodeConfig) ToEvmreaderConfig() *EvmreaderConfig { + return &EvmreaderConfig{ + BlockchainDefaultBlock: c.BlockchainDefaultBlock, + BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, + BlockchainId: c.BlockchainId, + DatabaseConnection: c.DatabaseConnection, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, + BlockchainHttpRequestTimeout: c.BlockchainHttpRequestTimeout, + BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, + BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, + BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, + EvmReaderPollingInterval: c.EvmReaderPollingInterval, + EvmReaderReadyMaxStaleness: c.EvmReaderReadyMaxStaleness, + MaxStartupTime: c.MaxStartupTime, + } +} + +// ToJsonrpcConfig converts a NodeConfig to a JsonrpcConfig. +func (c *NodeConfig) ToJsonrpcConfig() *JsonrpcConfig { + return &JsonrpcConfig{ + DatabaseConnection: c.DatabaseConnection, + JsonrpcApiAddress: c.JsonrpcApiAddress, + JsonrpcCorsAllowedOrigins: c.JsonrpcCorsAllowedOrigins, + JsonrpcMaxInflight: c.JsonrpcMaxInflight, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + MaxStartupTime: c.MaxStartupTime, + } +} + +// ToPrtConfig converts a NodeConfig to a PrtConfig. +func (c *NodeConfig) ToPrtConfig() *PrtConfig { + return &PrtConfig{ + BlockchainDefaultBlock: c.BlockchainDefaultBlock, + BlockchainHttpEndpoint: c.BlockchainHttpEndpoint, + BlockchainId: c.BlockchainId, + BlockchainLegacyEnabled: c.BlockchainLegacyEnabled, + BlockchainTxMaxFeePerGas: c.BlockchainTxMaxFeePerGas, + DatabaseConnection: c.DatabaseConnection, + FeatureClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + BlockchainHttpMaxRetries: c.BlockchainHttpMaxRetries, + BlockchainHttpRequestTimeout: c.BlockchainHttpRequestTimeout, + BlockchainHttpRetryMaxWait: c.BlockchainHttpRetryMaxWait, + BlockchainHttpRetryMinWait: c.BlockchainHttpRetryMinWait, + BlockchainMaxBlockRange: c.BlockchainMaxBlockRange, + MaxStartupTime: c.MaxStartupTime, + PrtPollingInterval: c.PrtPollingInterval, + SnapshotsDir: c.SnapshotsDir, + } +} + +// ToValidatorConfig converts a NodeConfig to a ValidatorConfig. +func (c *NodeConfig) ToValidatorConfig() *ValidatorConfig { + return &ValidatorConfig{ + DatabaseConnection: c.DatabaseConnection, + LogColor: c.LogColor, + LogLevel: c.LogLevel, + MaxStartupTime: c.MaxStartupTime, + ValidatorPollingInterval: c.ValidatorPollingInterval, + } +} + +// GetAuthAwsKmsKeyId returns the value for the environment variable CARTESI_AUTH_AWS_KMS_KEY_ID. +func GetAuthAwsKmsKeyId() (RedactedString, error) { + s := viper.GetString(AUTH_AWS_KMS_KEY_ID) + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", AUTH_AWS_KMS_KEY_ID, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_KEY_ID, ErrNotDefined) +} + +// GetAuthKind returns the value for the environment variable CARTESI_AUTH_KIND. +func GetAuthKind() (AuthKind, error) { + s := viper.GetString(AUTH_KIND) + if s != "" { + v, err := toAuthKind(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", AUTH_KIND, err) + } + return v, nil + } + return notDefinedAuthKind(), fmt.Errorf("%s: %w", AUTH_KIND, ErrNotDefined) +} + +// GetAuthMnemonic returns the value for the environment variable CARTESI_AUTH_MNEMONIC. +func GetAuthMnemonic() (RedactedString, error) { + s := viper.GetString(AUTH_MNEMONIC) + if s == "" { + filename := viper.GetString(AUTH_MNEMONIC_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", AUTH_MNEMONIC_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", AUTH_MNEMONIC, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_MNEMONIC, ErrNotDefined) +} + +// GetAuthMnemonicAccountIndex returns the value for the environment variable CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX. +func GetAuthMnemonicAccountIndex() (RedactedUint, error) { + s := viper.GetString(AUTH_MNEMONIC_ACCOUNT_INDEX) + if s != "" { + v, err := toRedactedUint(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", AUTH_MNEMONIC_ACCOUNT_INDEX, err) + } + return v, nil + } + return notDefinedRedactedUint(), fmt.Errorf("%s: %w", AUTH_MNEMONIC_ACCOUNT_INDEX, ErrNotDefined) +} + +// GetAuthPrivateKey returns the value for the environment variable CARTESI_AUTH_PRIVATE_KEY. +func GetAuthPrivateKey() (RedactedString, error) { + s := viper.GetString(AUTH_PRIVATE_KEY) + if s == "" { + filename := viper.GetString(AUTH_PRIVATE_KEY_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", AUTH_PRIVATE_KEY_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", AUTH_PRIVATE_KEY, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_PRIVATE_KEY, ErrNotDefined) +} + +// GetBlockchainDefaultBlock returns the value for the environment variable CARTESI_BLOCKCHAIN_DEFAULT_BLOCK. +func GetBlockchainDefaultBlock() (DefaultBlock, error) { + s := viper.GetString(BLOCKCHAIN_DEFAULT_BLOCK) + if s != "" { + v, err := toDefaultBlock(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_DEFAULT_BLOCK, err) + } + return v, nil + } + return notDefinedDefaultBlock(), fmt.Errorf("%s: %w", BLOCKCHAIN_DEFAULT_BLOCK, ErrNotDefined) +} + +// GetBlockchainHttpAuthorization returns the value for the environment variable CARTESI_BLOCKCHAIN_HTTP_AUTHORIZATION. +func GetBlockchainHttpAuthorization() (RedactedString, error) { + s := viper.GetString(BLOCKCHAIN_HTTP_AUTHORIZATION) + if s == "" { + filename := viper.GetString(BLOCKCHAIN_HTTP_AUTHORIZATION_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_AUTHORIZATION_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_AUTHORIZATION, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", BLOCKCHAIN_HTTP_AUTHORIZATION, ErrNotDefined) +} + +// GetBlockchainHttpEndpoint returns the value for the environment variable CARTESI_BLOCKCHAIN_HTTP_ENDPOINT. +func GetBlockchainHttpEndpoint() (URL, error) { + s := viper.GetString(BLOCKCHAIN_HTTP_ENDPOINT) + if s == "" { + filename := viper.GetString(BLOCKCHAIN_HTTP_ENDPOINT_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefinedURL(), fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_ENDPOINT_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toHTTPURL(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_ENDPOINT, err) + } + return v, nil + } + return notDefinedURL(), fmt.Errorf("%s: %w", BLOCKCHAIN_HTTP_ENDPOINT, ErrNotDefined) +} + +// GetBlockchainId returns the value for the environment variable CARTESI_BLOCKCHAIN_ID. +func GetBlockchainId() (uint64, error) { + s := viper.GetString(BLOCKCHAIN_ID) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_ID, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", BLOCKCHAIN_ID, ErrNotDefined) +} + +// GetBlockchainLegacyEnabled returns the value for the environment variable CARTESI_BLOCKCHAIN_LEGACY_ENABLED. +func GetBlockchainLegacyEnabled() (bool, error) { + s := viper.GetString(BLOCKCHAIN_LEGACY_ENABLED) + if s != "" { + v, err := toBool(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_LEGACY_ENABLED, err) + } + return v, nil + } + return notDefinedbool(), fmt.Errorf("%s: %w", BLOCKCHAIN_LEGACY_ENABLED, ErrNotDefined) +} + +// GetBlockchainTxMaxFeePerGas returns the value for the environment variable CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS. +func GetBlockchainTxMaxFeePerGas() (string, error) { + s := viper.GetString(BLOCKCHAIN_TX_MAX_FEE_PER_GAS) + if viper.IsSet(BLOCKCHAIN_TX_MAX_FEE_PER_GAS) { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_TX_MAX_FEE_PER_GAS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", BLOCKCHAIN_TX_MAX_FEE_PER_GAS, ErrNotDefined) +} + +// GetContractsApplicationFactoryAddress returns the value for the environment variable CARTESI_CONTRACTS_APPLICATION_FACTORY_ADDRESS. +func GetContractsApplicationFactoryAddress() (Address, error) { + s := viper.GetString(CONTRACTS_APPLICATION_FACTORY_ADDRESS) + if s != "" { + v, err := toAddress(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CONTRACTS_APPLICATION_FACTORY_ADDRESS, err) + } + return v, nil + } + return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_APPLICATION_FACTORY_ADDRESS, ErrNotDefined) +} + +// GetContractsAuthorityFactoryAddress returns the value for the environment variable CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS. +func GetContractsAuthorityFactoryAddress() (Address, error) { + s := viper.GetString(CONTRACTS_AUTHORITY_FACTORY_ADDRESS) + if s != "" { + v, err := toAddress(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CONTRACTS_AUTHORITY_FACTORY_ADDRESS, err) + } + return v, nil + } + return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_AUTHORITY_FACTORY_ADDRESS, ErrNotDefined) +} + +// GetContractsDaveAppFactoryAddress returns the value for the environment variable CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS. +func GetContractsDaveAppFactoryAddress() (Address, error) { + s := viper.GetString(CONTRACTS_DAVE_APP_FACTORY_ADDRESS) + if s != "" { + v, err := toAddress(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CONTRACTS_DAVE_APP_FACTORY_ADDRESS, err) + } + return v, nil + } + return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_DAVE_APP_FACTORY_ADDRESS, ErrNotDefined) +} + +// GetContractsInputBoxAddress returns the value for the environment variable CARTESI_CONTRACTS_INPUT_BOX_ADDRESS. +func GetContractsInputBoxAddress() (Address, error) { + s := viper.GetString(CONTRACTS_INPUT_BOX_ADDRESS) + if s != "" { + v, err := toAddress(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CONTRACTS_INPUT_BOX_ADDRESS, err) + } + return v, nil + } + return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_INPUT_BOX_ADDRESS, ErrNotDefined) +} + +// GetContractsQuorumFactoryAddress returns the value for the environment variable CARTESI_CONTRACTS_QUORUM_FACTORY_ADDRESS. +func GetContractsQuorumFactoryAddress() (Address, error) { + s := viper.GetString(CONTRACTS_QUORUM_FACTORY_ADDRESS) + if s != "" { + v, err := toAddress(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CONTRACTS_QUORUM_FACTORY_ADDRESS, err) + } + return v, nil + } + return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_QUORUM_FACTORY_ADDRESS, ErrNotDefined) +} + +// GetContractsSelfHostedApplicationFactoryAddress returns the value for the environment variable CARTESI_CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS. +func GetContractsSelfHostedApplicationFactoryAddress() (Address, error) { + s := viper.GetString(CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS) + if s != "" { + v, err := toAddress(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS, err) + } + return v, nil + } + return notDefinedAddress(), fmt.Errorf("%s: %w", CONTRACTS_SELF_HOSTED_APPLICATION_FACTORY_ADDRESS, ErrNotDefined) +} + +// GetDatabaseConnection returns the value for the environment variable CARTESI_DATABASE_CONNECTION. +func GetDatabaseConnection() (URL, error) { + s := viper.GetString(DATABASE_CONNECTION) + if s == "" { + filename := viper.GetString(DATABASE_CONNECTION_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefinedURL(), fmt.Errorf("failed to parse %s: %w", DATABASE_CONNECTION_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toURL(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", DATABASE_CONNECTION, err) + } + return v, nil + } + return notDefinedURL(), fmt.Errorf("%s: %w", DATABASE_CONNECTION, ErrNotDefined) +} + +// GetFeatureClaimSubmissionEnabled returns the value for the environment variable CARTESI_FEATURE_CLAIM_SUBMISSION_ENABLED. +func GetFeatureClaimSubmissionEnabled() (bool, error) { + s := viper.GetString(FEATURE_CLAIM_SUBMISSION_ENABLED) + if s != "" { + v, err := toBool(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", FEATURE_CLAIM_SUBMISSION_ENABLED, err) + } + return v, nil + } + return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_CLAIM_SUBMISSION_ENABLED, ErrNotDefined) +} + +// GetFeatureInspectEnabled returns the value for the environment variable CARTESI_FEATURE_INSPECT_ENABLED. +func GetFeatureInspectEnabled() (bool, error) { + s := viper.GetString(FEATURE_INSPECT_ENABLED) + if s != "" { + v, err := toBool(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", FEATURE_INSPECT_ENABLED, err) + } + return v, nil + } + return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_INSPECT_ENABLED, ErrNotDefined) +} + +// GetFeatureJsonrpcApiEnabled returns the value for the environment variable CARTESI_FEATURE_JSONRPC_API_ENABLED. +func GetFeatureJsonrpcApiEnabled() (bool, error) { + s := viper.GetString(FEATURE_JSONRPC_API_ENABLED) + if s != "" { + v, err := toBool(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", FEATURE_JSONRPC_API_ENABLED, err) + } + return v, nil + } + return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_JSONRPC_API_ENABLED, ErrNotDefined) +} + +// GetFeatureMachineHashCheckEnabled returns the value for the environment variable CARTESI_FEATURE_MACHINE_HASH_CHECK_ENABLED. +func GetFeatureMachineHashCheckEnabled() (bool, error) { + s := viper.GetString(FEATURE_MACHINE_HASH_CHECK_ENABLED) + if s != "" { + v, err := toBool(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", FEATURE_MACHINE_HASH_CHECK_ENABLED, err) + } + return v, nil + } + return notDefinedbool(), fmt.Errorf("%s: %w", FEATURE_MACHINE_HASH_CHECK_ENABLED, ErrNotDefined) +} + +// GetAdvancerTelemetryAddress returns the value for the environment variable CARTESI_ADVANCER_TELEMETRY_ADDRESS. +func GetAdvancerTelemetryAddress() (string, error) { + s := viper.GetString(ADVANCER_TELEMETRY_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", ADVANCER_TELEMETRY_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", ADVANCER_TELEMETRY_ADDRESS, ErrNotDefined) +} + +// GetClaimerTelemetryAddress returns the value for the environment variable CARTESI_CLAIMER_TELEMETRY_ADDRESS. +func GetClaimerTelemetryAddress() (string, error) { + s := viper.GetString(CLAIMER_TELEMETRY_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CLAIMER_TELEMETRY_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", CLAIMER_TELEMETRY_ADDRESS, ErrNotDefined) +} + +// GetEvmReaderTelemetryAddress returns the value for the environment variable CARTESI_EVM_READER_TELEMETRY_ADDRESS. +func GetEvmReaderTelemetryAddress() (string, error) { + s := viper.GetString(EVM_READER_TELEMETRY_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", EVM_READER_TELEMETRY_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", EVM_READER_TELEMETRY_ADDRESS, ErrNotDefined) +} + +// GetInspectAddress returns the value for the environment variable CARTESI_INSPECT_ADDRESS. +func GetInspectAddress() (string, error) { + s := viper.GetString(INSPECT_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", INSPECT_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", INSPECT_ADDRESS, ErrNotDefined) +} + +// GetInspectCorsAllowedOrigins returns the value for the environment variable CARTESI_INSPECT_CORS_ALLOWED_ORIGINS. +func GetInspectCorsAllowedOrigins() (string, error) { + s := viper.GetString(INSPECT_CORS_ALLOWED_ORIGINS) + if viper.IsSet(INSPECT_CORS_ALLOWED_ORIGINS) { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", INSPECT_CORS_ALLOWED_ORIGINS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", INSPECT_CORS_ALLOWED_ORIGINS, ErrNotDefined) +} + +// GetInspectMaxInflight returns the value for the environment variable CARTESI_INSPECT_MAX_INFLIGHT. +func GetInspectMaxInflight() (uint64, error) { + s := viper.GetString(INSPECT_MAX_INFLIGHT) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", INSPECT_MAX_INFLIGHT, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", INSPECT_MAX_INFLIGHT, ErrNotDefined) +} + +// GetInspectUrl returns the value for the environment variable CARTESI_INSPECT_URL. +func GetInspectUrl() (URL, error) { + s := viper.GetString(INSPECT_URL) + if s != "" { + v, err := toHTTPURL(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", INSPECT_URL, err) + } + return v, nil + } + return notDefinedURL(), fmt.Errorf("%s: %w", INSPECT_URL, ErrNotDefined) +} + +// GetJsonrpcApiAddress returns the value for the environment variable CARTESI_JSONRPC_API_ADDRESS. +func GetJsonrpcApiAddress() (string, error) { + s := viper.GetString(JSONRPC_API_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", JSONRPC_API_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", JSONRPC_API_ADDRESS, ErrNotDefined) +} + +// GetJsonrpcApiUrl returns the value for the environment variable CARTESI_JSONRPC_API_URL. +func GetJsonrpcApiUrl() (URL, error) { + s := viper.GetString(JSONRPC_API_URL) + if s != "" { + v, err := toHTTPURL(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", JSONRPC_API_URL, err) + } + return v, nil + } + return notDefinedURL(), fmt.Errorf("%s: %w", JSONRPC_API_URL, ErrNotDefined) +} + +// GetJsonrpcCorsAllowedOrigins returns the value for the environment variable CARTESI_JSONRPC_CORS_ALLOWED_ORIGINS. +func GetJsonrpcCorsAllowedOrigins() (string, error) { + s := viper.GetString(JSONRPC_CORS_ALLOWED_ORIGINS) + if viper.IsSet(JSONRPC_CORS_ALLOWED_ORIGINS) { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", JSONRPC_CORS_ALLOWED_ORIGINS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", JSONRPC_CORS_ALLOWED_ORIGINS, ErrNotDefined) +} + +// GetJsonrpcMaxInflight returns the value for the environment variable CARTESI_JSONRPC_MAX_INFLIGHT. +func GetJsonrpcMaxInflight() (uint64, error) { + s := viper.GetString(JSONRPC_MAX_INFLIGHT) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", JSONRPC_MAX_INFLIGHT, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", JSONRPC_MAX_INFLIGHT, ErrNotDefined) +} + +// GetJsonrpcTelemetryAddress returns the value for the environment variable CARTESI_JSONRPC_TELEMETRY_ADDRESS. +func GetJsonrpcTelemetryAddress() (string, error) { + s := viper.GetString(JSONRPC_TELEMETRY_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", JSONRPC_TELEMETRY_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", JSONRPC_TELEMETRY_ADDRESS, ErrNotDefined) +} + +// GetNodeTelemetryAddress returns the value for the environment variable CARTESI_NODE_TELEMETRY_ADDRESS. +func GetNodeTelemetryAddress() (string, error) { + s := viper.GetString(NODE_TELEMETRY_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", NODE_TELEMETRY_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", NODE_TELEMETRY_ADDRESS, ErrNotDefined) +} + +// GetPrtTelemetryAddress returns the value for the environment variable CARTESI_PRT_TELEMETRY_ADDRESS. +func GetPrtTelemetryAddress() (string, error) { + s := viper.GetString(PRT_TELEMETRY_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_TELEMETRY_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", PRT_TELEMETRY_ADDRESS, ErrNotDefined) +} + +// GetValidatorTelemetryAddress returns the value for the environment variable CARTESI_VALIDATOR_TELEMETRY_ADDRESS. +func GetValidatorTelemetryAddress() (string, error) { + s := viper.GetString(VALIDATOR_TELEMETRY_ADDRESS) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", VALIDATOR_TELEMETRY_ADDRESS, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", VALIDATOR_TELEMETRY_ADDRESS, ErrNotDefined) +} + +// GetLogColor returns the value for the environment variable CARTESI_LOG_COLOR. +func GetLogColor() (bool, error) { + s := viper.GetString(LOG_COLOR) + if s != "" { + v, err := toBool(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_COLOR, err) + } + return v, nil + } + return notDefinedbool(), fmt.Errorf("%s: %w", LOG_COLOR, ErrNotDefined) +} + +// GetLogLevel returns the value for the environment variable CARTESI_LOG_LEVEL. +func GetLogLevel() (LogLevel, error) { + s := viper.GetString(LOG_LEVEL) + if s != "" { + v, err := toLogLevel(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_LEVEL, err) + } + return v, nil + } + return notDefinedLogLevel(), fmt.Errorf("%s: %w", LOG_LEVEL, ErrNotDefined) +} + +// GetLogLevelAdvancer returns the value for the environment variable CARTESI_LOG_LEVEL_ADVANCER. +func GetLogLevelAdvancer() (LogLevel, error) { + s := viper.GetString(LOG_LEVEL_ADVANCER) + if s != "" { + v, err := toLogLevel(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_LEVEL_ADVANCER, err) + } + return v, nil + } + return notDefinedLogLevel(), fmt.Errorf("%s: %w", LOG_LEVEL_ADVANCER, ErrNotDefined) +} + +// GetLogLevelClaimer returns the value for the environment variable CARTESI_LOG_LEVEL_CLAIMER. +func GetLogLevelClaimer() (LogLevel, error) { + s := viper.GetString(LOG_LEVEL_CLAIMER) + if s != "" { + v, err := toLogLevel(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_LEVEL_CLAIMER, err) + } + return v, nil + } + return notDefinedLogLevel(), fmt.Errorf("%s: %w", LOG_LEVEL_CLAIMER, ErrNotDefined) +} + +// GetLogLevelEvmReader returns the value for the environment variable CARTESI_LOG_LEVEL_EVM_READER. +func GetLogLevelEvmReader() (LogLevel, error) { + s := viper.GetString(LOG_LEVEL_EVM_READER) + if s != "" { + v, err := toLogLevel(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_LEVEL_EVM_READER, err) + } + return v, nil + } + return notDefinedLogLevel(), fmt.Errorf("%s: %w", LOG_LEVEL_EVM_READER, ErrNotDefined) +} + +// GetLogLevelJsonrpcApi returns the value for the environment variable CARTESI_LOG_LEVEL_JSONRPC_API. +func GetLogLevelJsonrpcApi() (LogLevel, error) { + s := viper.GetString(LOG_LEVEL_JSONRPC_API) + if s != "" { + v, err := toLogLevel(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_LEVEL_JSONRPC_API, err) + } + return v, nil + } + return notDefinedLogLevel(), fmt.Errorf("%s: %w", LOG_LEVEL_JSONRPC_API, ErrNotDefined) +} + +// GetLogLevelPrt returns the value for the environment variable CARTESI_LOG_LEVEL_PRT. +func GetLogLevelPrt() (LogLevel, error) { + s := viper.GetString(LOG_LEVEL_PRT) + if s != "" { + v, err := toLogLevel(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_LEVEL_PRT, err) + } + return v, nil + } + return notDefinedLogLevel(), fmt.Errorf("%s: %w", LOG_LEVEL_PRT, ErrNotDefined) +} + +// GetLogLevelValidator returns the value for the environment variable CARTESI_LOG_LEVEL_VALIDATOR. +func GetLogLevelValidator() (LogLevel, error) { + s := viper.GetString(LOG_LEVEL_VALIDATOR) + if s != "" { + v, err := toLogLevel(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", LOG_LEVEL_VALIDATOR, err) + } + return v, nil + } + return notDefinedLogLevel(), fmt.Errorf("%s: %w", LOG_LEVEL_VALIDATOR, ErrNotDefined) +} + +// GetJsonrpcMachineLogLevel returns the value for the environment variable CARTESI_JSONRPC_MACHINE_LOG_LEVEL. +func GetJsonrpcMachineLogLevel() (string, error) { + s := viper.GetString(JSONRPC_MACHINE_LOG_LEVEL) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", JSONRPC_MACHINE_LOG_LEVEL, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", JSONRPC_MACHINE_LOG_LEVEL, ErrNotDefined) +} + +// GetPrtAuthAwsKmsKeyId returns the value for the environment variable CARTESI_PRT_AUTH_AWS_KMS_KEY_ID. +func GetPrtAuthAwsKmsKeyId() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_AWS_KMS_KEY_ID) + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_AWS_KMS_KEY_ID, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_AWS_KMS_KEY_ID, ErrNotDefined) +} + +// GetPrtAuthKind returns the value for the environment variable CARTESI_PRT_AUTH_KIND. +func GetPrtAuthKind() (AuthKind, error) { + s := viper.GetString(PRT_AUTH_KIND) + if s != "" { + v, err := toAuthKind(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_KIND, err) + } + return v, nil + } + return notDefinedAuthKind(), fmt.Errorf("%s: %w", PRT_AUTH_KIND, ErrNotDefined) +} + +// GetPrtAuthMnemonic returns the value for the environment variable CARTESI_PRT_AUTH_MNEMONIC. +func GetPrtAuthMnemonic() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_MNEMONIC) + if s == "" { + filename := viper.GetString(PRT_AUTH_MNEMONIC_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_MNEMONIC, ErrNotDefined) +} + +// GetPrtAuthMnemonicAccountIndex returns the value for the environment variable CARTESI_PRT_AUTH_MNEMONIC_ACCOUNT_INDEX. +func GetPrtAuthMnemonicAccountIndex() (RedactedUint, error) { + s := viper.GetString(PRT_AUTH_MNEMONIC_ACCOUNT_INDEX) + if s != "" { + v, err := toRedactedUint(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, err) + } + return v, nil + } + return notDefinedRedactedUint(), fmt.Errorf("%s: %w", PRT_AUTH_MNEMONIC_ACCOUNT_INDEX, ErrNotDefined) +} + +// GetPrtAuthPrivateKey returns the value for the environment variable CARTESI_PRT_AUTH_PRIVATE_KEY. +func GetPrtAuthPrivateKey() (RedactedString, error) { + s := viper.GetString(PRT_AUTH_PRIVATE_KEY) + if s == "" { + filename := viper.GetString(PRT_AUTH_PRIVATE_KEY_FILE) + contents, err := readConfigFile(filename) + if err != nil { + return notDefinedRedactedString(), fmt.Errorf("failed to parse %s: %w", PRT_AUTH_PRIVATE_KEY_FILE, err) + } + s = strings.TrimSpace(string(contents)) + } + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_AUTH_PRIVATE_KEY, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", PRT_AUTH_PRIVATE_KEY, ErrNotDefined) +} + +// GetAdvancerInputBatchSize returns the value for the environment variable CARTESI_ADVANCER_INPUT_BATCH_SIZE. +func GetAdvancerInputBatchSize() (uint64, error) { + s := viper.GetString(ADVANCER_INPUT_BATCH_SIZE) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", ADVANCER_INPUT_BATCH_SIZE, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", ADVANCER_INPUT_BATCH_SIZE, ErrNotDefined) +} + +// GetAdvancerPollingInterval returns the value for the environment variable CARTESI_ADVANCER_POLLING_INTERVAL. +func GetAdvancerPollingInterval() (Duration, error) { + s := viper.GetString(ADVANCER_POLLING_INTERVAL) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", ADVANCER_POLLING_INTERVAL, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", ADVANCER_POLLING_INTERVAL, ErrNotDefined) +} + +// GetBlockchainGasLimit returns the value for the environment variable CARTESI_BLOCKCHAIN_GAS_LIMIT. +func GetBlockchainGasLimit() (uint64, error) { + s := viper.GetString(BLOCKCHAIN_GAS_LIMIT) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_GAS_LIMIT, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", BLOCKCHAIN_GAS_LIMIT, ErrNotDefined) +} + +// GetBlockchainHttpMaxRetries returns the value for the environment variable CARTESI_BLOCKCHAIN_HTTP_MAX_RETRIES. +func GetBlockchainHttpMaxRetries() (uint64, error) { + s := viper.GetString(BLOCKCHAIN_HTTP_MAX_RETRIES) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_MAX_RETRIES, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", BLOCKCHAIN_HTTP_MAX_RETRIES, ErrNotDefined) +} + +// GetBlockchainHttpRequestTimeout returns the value for the environment variable CARTESI_BLOCKCHAIN_HTTP_REQUEST_TIMEOUT. +func GetBlockchainHttpRequestTimeout() (Duration, error) { + s := viper.GetString(BLOCKCHAIN_HTTP_REQUEST_TIMEOUT) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_REQUEST_TIMEOUT, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", BLOCKCHAIN_HTTP_REQUEST_TIMEOUT, ErrNotDefined) +} + +// GetBlockchainHttpRetryMaxWait returns the value for the environment variable CARTESI_BLOCKCHAIN_HTTP_RETRY_MAX_WAIT. +func GetBlockchainHttpRetryMaxWait() (Duration, error) { + s := viper.GetString(BLOCKCHAIN_HTTP_RETRY_MAX_WAIT) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_RETRY_MAX_WAIT, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", BLOCKCHAIN_HTTP_RETRY_MAX_WAIT, ErrNotDefined) +} + +// GetBlockchainHttpRetryMinWait returns the value for the environment variable CARTESI_BLOCKCHAIN_HTTP_RETRY_MIN_WAIT. +func GetBlockchainHttpRetryMinWait() (Duration, error) { + s := viper.GetString(BLOCKCHAIN_HTTP_RETRY_MIN_WAIT) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_HTTP_RETRY_MIN_WAIT, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", BLOCKCHAIN_HTTP_RETRY_MIN_WAIT, ErrNotDefined) +} + +// GetBlockchainMaxBlockRange returns the value for the environment variable CARTESI_BLOCKCHAIN_MAX_BLOCK_RANGE. +func GetBlockchainMaxBlockRange() (uint64, error) { + s := viper.GetString(BLOCKCHAIN_MAX_BLOCK_RANGE) + if s != "" { + v, err := toUint64(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", BLOCKCHAIN_MAX_BLOCK_RANGE, err) + } + return v, nil + } + return notDefineduint64(), fmt.Errorf("%s: %w", BLOCKCHAIN_MAX_BLOCK_RANGE, ErrNotDefined) +} + +// GetClaimerPollingInterval returns the value for the environment variable CARTESI_CLAIMER_POLLING_INTERVAL. +func GetClaimerPollingInterval() (Duration, error) { + s := viper.GetString(CLAIMER_POLLING_INTERVAL) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", CLAIMER_POLLING_INTERVAL, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", CLAIMER_POLLING_INTERVAL, ErrNotDefined) +} + +// GetEvmReaderPollingInterval returns the value for the environment variable CARTESI_EVM_READER_POLLING_INTERVAL. +func GetEvmReaderPollingInterval() (Duration, error) { + s := viper.GetString(EVM_READER_POLLING_INTERVAL) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", EVM_READER_POLLING_INTERVAL, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", EVM_READER_POLLING_INTERVAL, ErrNotDefined) +} + +// GetEvmReaderReadyMaxStaleness returns the value for the environment variable CARTESI_EVM_READER_READY_MAX_STALENESS. +func GetEvmReaderReadyMaxStaleness() (Duration, error) { + s := viper.GetString(EVM_READER_READY_MAX_STALENESS) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", EVM_READER_READY_MAX_STALENESS, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", EVM_READER_READY_MAX_STALENESS, ErrNotDefined) +} + +// GetMaxStartupTime returns the value for the environment variable CARTESI_MAX_STARTUP_TIME. +func GetMaxStartupTime() (Duration, error) { + s := viper.GetString(MAX_STARTUP_TIME) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", MAX_STARTUP_TIME, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", MAX_STARTUP_TIME, ErrNotDefined) +} + +// GetPrtPollingInterval returns the value for the environment variable CARTESI_PRT_POLLING_INTERVAL. +func GetPrtPollingInterval() (Duration, error) { + s := viper.GetString(PRT_POLLING_INTERVAL) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", PRT_POLLING_INTERVAL, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", PRT_POLLING_INTERVAL, ErrNotDefined) +} + +// GetValidatorPollingInterval returns the value for the environment variable CARTESI_VALIDATOR_POLLING_INTERVAL. +func GetValidatorPollingInterval() (Duration, error) { + s := viper.GetString(VALIDATOR_POLLING_INTERVAL) + if s != "" { + v, err := toDuration(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", VALIDATOR_POLLING_INTERVAL, err) + } + return v, nil + } + return notDefinedDuration(), fmt.Errorf("%s: %w", VALIDATOR_POLLING_INTERVAL, ErrNotDefined) +} + +// GetSnapshotsDir returns the value for the environment variable CARTESI_SNAPSHOTS_DIR. +func GetSnapshotsDir() (string, error) { + s := viper.GetString(SNAPSHOTS_DIR) + if s != "" { + v, err := toString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", SNAPSHOTS_DIR, err) + } + return v, nil + } + return notDefinedstring(), fmt.Errorf("%s: %w", SNAPSHOTS_DIR, ErrNotDefined) +} diff --git a/internal/config/http_authorization.go b/internal/config/http_authorization.go new file mode 100644 index 000000000..36c20c159 --- /dev/null +++ b/internal/config/http_authorization.go @@ -0,0 +1,34 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "fmt" + "net/http" + "strings" + + "github.com/ethereum/go-ethereum/rpc" +) + +// Unwrap the http authorization config `key:val` into a ClientOption that rpc accepts +func HTTPAuthorizationOption() (rpc.ClientOption, error) { + + // no authorization is allowed. + auth, err := GetBlockchainHttpAuthorization() + if err != nil { + return nil, nil + } + + kv := strings.SplitN(auth.Value, ":", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("malformed BlockchainHttpAuthorization, expected :") + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + + return rpc.WithHTTPAuth(func(h http.Header) error { + h.Set(key, val) + return nil + }), nil +} diff --git a/internal/config/http_endpoints_test.go b/internal/config/http_endpoints_test.go new file mode 100644 index 000000000..78eca34bf --- /dev/null +++ b/internal/config/http_endpoints_test.go @@ -0,0 +1,74 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHTTPSettingsRequireSchemeAndHostname(t *testing.T) { + getters := []struct { + key string + get func() (SafeURL, error) + }{ + {BLOCKCHAIN_HTTP_ENDPOINT, GetBlockchainHttpEndpoint}, + {JSONRPC_API_URL, GetJsonrpcApiUrl}, + {INSPECT_URL, GetInspectUrl}, + } + const marker = "SYNTHETIC_HTTP_ENDPOINT_SECRET_1426709538" + for _, getter := range getters { + t.Run(getter.key, func(t *testing.T) { + for _, raw := range []string{ + "wss://provider.example/" + marker, + "ws://provider.example/" + marker, + "file:///tmp/" + marker, + "/tmp/" + marker, + "http:///" + marker, + "https://:8545/" + marker, + "http://user:" + marker + "@/", + "https://provider.example/%" + marker, + } { + resetSecretConfig(t) + t.Setenv(getter.key, raw) + value, err := getter.get() + require.Error(t, err, "expected an HTTP endpoint rejection") + require.Contains(t, err.Error(), getter.key) + require.NotContains(t, fmt.Sprintf("%+v", err), marker) + require.Empty(t, value.Raw()) + } + for _, scheme := range []string{"http", "https"} { + resetSecretConfig(t) + raw := scheme + "://user:" + marker + "@provider.example:8545/a%2Fb?key=" + marker + "&key=second" + t.Setenv(getter.key, raw) + value, err := getter.get() + require.NoError(t, err) + require.Equal(t, raw, value.Raw()) + require.Equal(t, scheme+"://provider.example:8545", value.String()) + } + }) + } +} + +func TestHTTPPolicyDoesNotChangeGenericURLs(t *testing.T) { + for _, raw := range []string{ + "postgres://user:secret@db.example:5432/rollups?sslmode=require", + "postgresql://user:secret@db.example:5432/rollups?sslmode=require", + "wss://provider.example/ws", + } { + value, err := ToURLFromString(raw) + require.NoError(t, err) + require.Equal(t, raw, value.Raw()) + } +} + +func TestDatabaseURLValidationDoesNotEchoQueryKeys(t *testing.T) { + const marker = "SYNTHETIC_DATABASE_QUERY_SECRET_7962140835" + _, err := ToURLFromString("postgres://user:password@db.example/rollups?" + marker + "=first&" + marker + "=second") + require.Error(t, err) + require.Contains(t, err.Error(), "repeated") + require.NotContains(t, err.Error(), marker) +} diff --git a/internal/config/persistent.go b/internal/config/persistent.go new file mode 100644 index 000000000..a18ad8245 --- /dev/null +++ b/internal/config/persistent.go @@ -0,0 +1,153 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "encoding/json" + "fmt" + "math/big" + "slices" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +// PersistentChainConfig identifies the chain and the observation policy used to +// write service state. A restart must not silently change either value. +type PersistentChainConfig struct { + DefaultBlock model.DefaultBlock + ChainID uint64 +} + +func (c PersistentChainConfig) Validate() error { + if c.ChainID == 0 { + return fmt.Errorf("ChainID must be greater than zero") + } + if !slices.Contains(model.DefaultBlockAllValues, c.DefaultBlock) { + return fmt.Errorf("invalid DefaultBlock %q", c.DefaultBlock) + } + return nil +} + +// UnmarshalJSON checks required fields. Plain struct decoding would accept a +// missing field or null as a valid zero value, including a disabled submitter. +func (c *PersistentChainConfig) UnmarshalJSON(data []byte) error { + var raw struct { + DefaultBlock *model.DefaultBlock + ChainID *uint64 + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.DefaultBlock == nil || raw.ChainID == nil { + return fmt.Errorf("persistent config requires non-null DefaultBlock and ChainID") + } + next := PersistentChainConfig{DefaultBlock: *raw.DefaultBlock, ChainID: *raw.ChainID} + if err := next.Validate(); err != nil { + return err + } + *c = next + return nil +} + +// CheckRequested rejects changes to a database's chain or observation policy. +// A stronger block policy does not repair data already read at a weaker policy. +func (c PersistentChainConfig) CheckRequested(requested PersistentChainConfig) error { + if err := requested.Validate(); err != nil { + return fmt.Errorf("invalid requested config: %w", err) + } + if err := c.Validate(); err != nil { + return fmt.Errorf("invalid saved config: %w", err) + } + if c.ChainID != requested.ChainID { + return fmt.Errorf("chain ID mismatch: database=%d, configured=%d; saved config was not changed", + c.ChainID, requested.ChainID) + } + if c.DefaultBlock != requested.DefaultBlock { + return fmt.Errorf("observation policy mismatch: database=%s, configured=%s; saved config was not changed", + c.DefaultBlock, requested.DefaultBlock) + } + return nil +} + +// PersistentSubmitterConfig adds a submission mode fixed when the service first +// starts. Mode transitions require claim and tournament reconciliation that is +// not supported yet. In particular, observing a Quorum claim is not a local vote. +type PersistentSubmitterConfig struct { + DefaultBlock model.DefaultBlock + ClaimSubmissionEnabled bool + ChainID uint64 + ResolvedSignerAddress common.Address `json:",omitempty"` +} + +func (c PersistentSubmitterConfig) chainConfig() PersistentChainConfig { + return PersistentChainConfig{DefaultBlock: c.DefaultBlock, ChainID: c.ChainID} +} + +func (c PersistentSubmitterConfig) Validate() error { + if err := c.chainConfig().Validate(); err != nil { + return err + } + if c.ClaimSubmissionEnabled && c.ResolvedSignerAddress == (common.Address{}) { + return fmt.Errorf("submitting role requires a resolved signer address") + } + if !c.ClaimSubmissionEnabled && c.ResolvedSignerAddress != (common.Address{}) { + return fmt.Errorf("observer-only role cannot register a signer address") + } + return nil +} + +func (c *PersistentSubmitterConfig) UnmarshalJSON(data []byte) error { + var chain PersistentChainConfig + if err := json.Unmarshal(data, &chain); err != nil { + return err + } + var raw struct { + ClaimSubmissionEnabled *bool + ResolvedSignerAddress common.Address + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.ClaimSubmissionEnabled == nil { + return fmt.Errorf("persistent config requires non-null ClaimSubmissionEnabled") + } + next := PersistentSubmitterConfig{ + DefaultBlock: chain.DefaultBlock, ChainID: chain.ChainID, + ClaimSubmissionEnabled: *raw.ClaimSubmissionEnabled, + ResolvedSignerAddress: raw.ResolvedSignerAddress, + } + if err := next.Validate(); err != nil { + return err + } + *c = next + return nil +} + +func (c PersistentSubmitterConfig) CheckRequested(requested PersistentSubmitterConfig) error { + if err := c.chainConfig().CheckRequested(requested.chainConfig()); err != nil { + return err + } + if c.ClaimSubmissionEnabled != requested.ClaimSubmissionEnabled { + return fmt.Errorf("claim submission mode mismatch: database=%t, configured=%t; "+ + "mode changes on an existing database are not supported; saved config was not changed", + c.ClaimSubmissionEnabled, requested.ClaimSubmissionEnabled) + } + if c.ResolvedSignerAddress != requested.ResolvedSignerAddress { + return fmt.Errorf("resolved signer address mismatch: saved=%s, configured=%s", + c.ResolvedSignerAddress, requested.ResolvedSignerAddress) + } + return nil +} + +// CheckNetworkChainID avoids truncating an invalid or oversized RPC chain ID. +func CheckNetworkChainID(network *big.Int, configured uint64) error { + if network == nil || network.Sign() <= 0 || !network.IsUint64() { + return fmt.Errorf("invalid network chain ID %v", network) + } + if network.Uint64() != configured { + return fmt.Errorf("chain ID mismatch: network=%s, configured=%d", network, configured) + } + return nil +} diff --git a/internal/config/persistent_test.go b/internal/config/persistent_test.go new file mode 100644 index 000000000..d75cf228a --- /dev/null +++ b/internal/config/persistent_test.go @@ -0,0 +1,164 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "encoding/json" + "fmt" + "math" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestPersistentConfigRejectsInvalidJSONFields(t *testing.T) { + for _, raw := range []string{ + `null`, `{}`, `[]`, ``, `{`, + `{"DefaultBlock":"FINALIZED"}`, + `{"ChainID":1}`, + `{"DefaultBlock":null,"ChainID":1}`, + `{"DefaultBlock":"FINALIZED","ChainID":null}`, + `{"DefaultBlock":"FINALIZED","ChainID":0}`, + `{"DefaultBlock":"FINALIZED","ChainID":-1}`, + `{"DefaultBlock":"FINALIZED","ChainID":18446744073709551616}`, + `{"DefaultBlock":"FINALIZED","ChainID":"1"}`, + `{"DefaultBlock":1,"ChainID":1}`, + `{"DefaultBlock":"finalized","ChainID":1}`, + `{"DefaultBlock":"INVALID","ChainID":1}`, + } { + t.Run(raw, func(t *testing.T) { + var chain PersistentChainConfig + require.Error(t, json.Unmarshal([]byte(raw), &chain)) + var submitter PersistentSubmitterConfig + require.Error(t, json.Unmarshal([]byte(raw), &submitter)) + }) + } + for _, mode := range []string{"", `,"ClaimSubmissionEnabled":null`, `,"ClaimSubmissionEnabled":"false"`} { + t.Run("mode"+mode, func(t *testing.T) { + raw := []byte(`{"DefaultBlock":"FINALIZED","ChainID":1` + mode + `}`) + var submitter PersistentSubmitterConfig + require.Error(t, json.Unmarshal(raw, &submitter)) + }) + } +} + +func TestPersistentConfigRoundTrip(t *testing.T) { + for _, policy := range model.DefaultBlockAllValues { + for _, enabled := range []bool{false, true} { + want := PersistentSubmitterConfig{ + DefaultBlock: policy, ClaimSubmissionEnabled: enabled, ChainID: math.MaxUint64, + } + if enabled { + want.ResolvedSignerAddress = common.HexToAddress("0x1") + } + raw, err := json.Marshal(want) + require.NoError(t, err) + var got PersistentSubmitterConfig + require.NoError(t, json.Unmarshal(raw, &got)) + require.Equal(t, want, got) + require.NoError(t, got.CheckRequested(want)) + var chain PersistentChainConfig + require.NoError(t, json.Unmarshal(raw, &chain)) + require.NoError(t, chain.CheckRequested(want.chainConfig())) + } + } +} + +func TestPersistentConfigAcceptsUnknownFields(t *testing.T) { + t.Parallel() + raw := []byte(`{"DefaultBlock":"FINALIZED","ChainID":42,"ClaimSubmissionEnabled":true, + "ResolvedSignerAddress":"0x0000000000000000000000000000000000000001","ExtraSetting":{"enabled":false}}`) + var chain PersistentChainConfig + require.NoError(t, json.Unmarshal(raw, &chain)) + require.Equal(t, PersistentChainConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42}, chain) + var submitter PersistentSubmitterConfig + require.NoError(t, json.Unmarshal(raw, &submitter)) + require.NoError(t, submitter.CheckRequested(PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: true, + ResolvedSignerAddress: common.HexToAddress("0x1"), + })) + roundTrip, err := json.Marshal(submitter) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(roundTrip, &fields)) + require.NotContains(t, fields, "ExtraSetting") +} + +func TestPersistentConfigRejectsRequestedChanges(t *testing.T) { + saved := PersistentSubmitterConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1} + for _, test := range []struct { + name string + change func(*PersistentSubmitterConfig) + want string + }{ + {"chain", func(c *PersistentSubmitterConfig) { c.ChainID = 2 }, "database=1, configured=2"}, + {"policy", func(c *PersistentSubmitterConfig) { c.DefaultBlock = model.DefaultBlock_Latest }, + "database=FINALIZED, configured=LATEST"}, + {"mode", func(c *PersistentSubmitterConfig) { c.ClaimSubmissionEnabled = true }, "database=false, configured=true"}, + {"invalid chain", func(c *PersistentSubmitterConfig) { c.ChainID = 0 }, "invalid requested config"}, + {"invalid policy", func(c *PersistentSubmitterConfig) { c.DefaultBlock = "" }, "invalid requested config"}, + } { + t.Run(test.name, func(t *testing.T) { + requested := saved + test.change(&requested) + require.ErrorContains(t, saved.CheckRequested(requested), test.want) + }) + } + requested := saved + saved.ClaimSubmissionEnabled = true + require.ErrorContains(t, saved.CheckRequested(requested), "database=true, configured=false") + saved.ChainID = 0 + require.ErrorContains(t, saved.CheckRequested(requested), "invalid saved config") +} + +func TestCheckNetworkChainID(t *testing.T) { + for _, invalid := range []*big.Int{nil, big.NewInt(0), big.NewInt(-1), new(big.Int).Lsh(big.NewInt(1), 64)} { + require.ErrorContains(t, CheckNetworkChainID(invalid, 1), "invalid network chain ID") + } + require.ErrorContains(t, CheckNetworkChainID(big.NewInt(2), 1), "network=2, configured=1") + require.NoError(t, CheckNetworkChainID(new(big.Int).SetUint64(math.MaxUint64), math.MaxUint64)) +} + +func TestPersistentSubmitterIdentityMatchesMode(t *testing.T) { + for _, test := range []struct { + name string + enabled bool + identity string + valid bool + }{ + {"observer without key", false, "", true}, + {"observer zero key", false, `,"ResolvedSignerAddress":"0x0000000000000000000000000000000000000000"`, true}, + {"observer with key", false, `,"ResolvedSignerAddress":"0x0000000000000000000000000000000000000001"`, false}, + {"submitter without key", true, "", false}, + {"submitter null key", true, `,"ResolvedSignerAddress":null`, false}, + {"submitter zero key", true, `,"ResolvedSignerAddress":"0x0000000000000000000000000000000000000000"`, false}, + {"submitter malformed key", true, `,"ResolvedSignerAddress":"0x01"`, false}, + {"submitter fixed key", true, `,"ResolvedSignerAddress":"0x0000000000000000000000000000000000000001"`, true}, + } { + t.Run(test.name, func(t *testing.T) { + raw := fmt.Sprintf(`{"DefaultBlock":"FINALIZED","ChainID":1,"ClaimSubmissionEnabled":%t%s}`, + test.enabled, test.identity) + var value PersistentSubmitterConfig + err := json.Unmarshal([]byte(raw), &value) + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestPersistentSubmitterRejectsSignerChange(t *testing.T) { + saved := PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, ClaimSubmissionEnabled: true, + ResolvedSignerAddress: common.HexToAddress("0x1"), + } + requested := saved + requested.ResolvedSignerAddress = common.HexToAddress("0x2") + require.ErrorContains(t, saved.CheckRequested(requested), "resolved signer address mismatch") +} diff --git a/internal/config/secret_output_test.go b/internal/config/secret_output_test.go new file mode 100644 index 000000000..c33fd5f69 --- /dev/null +++ b/internal/config/secret_output_test.go @@ -0,0 +1,160 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strconv" + "syscall" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func resetSecretConfig(t *testing.T) { + t.Helper() + viper.Reset() + viper.AutomaticEnv() + SetDefaults() + t.Cleanup(func() { + viper.Reset() + viper.AutomaticEnv() + SetDefaults() + }) +} + +func TestSensitiveFileErrorsDoNotExposeInput(t *testing.T) { + for _, tc := range []struct { + name string + get func() error + }{ + {AUTH_MNEMONIC, func() error { _, err := GetAuthMnemonic(); return err }}, + {AUTH_PRIVATE_KEY, func() error { _, err := GetAuthPrivateKey(); return err }}, + {PRT_AUTH_MNEMONIC, func() error { _, err := GetPrtAuthMnemonic(); return err }}, + {PRT_AUTH_PRIVATE_KEY, func() error { _, err := GetPrtAuthPrivateKey(); return err }}, + {DATABASE_CONNECTION, func() error { _, err := GetDatabaseConnection(); return err }}, + {BLOCKCHAIN_HTTP_ENDPOINT, func() error { _, err := GetBlockchainHttpEndpoint(); return err }}, + {BLOCKCHAIN_HTTP_AUTHORIZATION, func() error { _, err := GetBlockchainHttpAuthorization(); return err }}, + } { + t.Run(tc.name, func(t *testing.T) { + resetSecretConfig(t) + const marker = "SYNTHETIC_SECRET_INSTEAD_OF_FILENAME_76289041" + t.Setenv(tc.name, "") + t.Setenv(tc.name+"_FILE", filepath.Join(t.TempDir(), marker)) + err := tc.get() + require.Error(t, err) + require.ErrorIs(t, err, fs.ErrNotExist) + require.Contains(t, err.Error(), tc.name+"_FILE") + require.NotContains(t, fmt.Sprintf("%+v", err), marker) + var pathErr *fs.PathError + require.False(t, errors.As(err, &pathErr), "a reachable PathError retains the mistaken secret") + }) + } +} + +func TestRedactedFormattingDoesNotExposeValue(t *testing.T) { + const marker = "SYNTHETIC_REDACTED_VALUE_5098724316" + secret := RedactedString{Value: marker} + for _, value := range []any{secret, &secret} { + for _, format := range []string{"%v", "%+v", "%#v", "%s", "%q"} { + output := fmt.Sprintf(format, value) + require.NotContains(t, output, marker) + require.Contains(t, output, "[REDACTED]") + } + data, err := json.Marshal(struct{ Secret any }{Secret: value}) + require.NoError(t, err) + require.NotContains(t, string(data), marker) + require.Contains(t, string(data), "[REDACTED]") + for _, handler := range []func(*bytes.Buffer) slog.Handler{ + func(b *bytes.Buffer) slog.Handler { return slog.NewTextHandler(b, nil) }, + func(b *bytes.Buffer) slog.Handler { return slog.NewJSONHandler(b, nil) }, + } { + var output bytes.Buffer + slog.New(handler(&output)).Info("config", "secret", value) + require.NotContains(t, output.String(), marker) + require.Contains(t, output.String(), "[REDACTED]") + } + } +} + +func TestRedactedUintErrorsDoNotExposeInput(t *testing.T) { + for _, tc := range []struct { + input string + cause error + }{ + {"SYNTHETIC_INVALID_NUMBER_6302907431", strconv.ErrSyntax}, + {"18446744073709551616", strconv.ErrRange}, + } { + value, err := ToRedactedUint32FromString(tc.input) + require.Error(t, err) + require.ErrorIs(t, err, tc.cause) + require.NotContains(t, err.Error(), tc.input) + var numberErr *strconv.NumError + require.False(t, errors.As(err, &numberErr)) + require.Zero(t, value.Value) + } + value, err := ToRedactedUint32FromString("4294967295") + require.NoError(t, err) + require.Equal(t, uint32(4294967295), value.Value) +} + +func TestSensitiveFilesStillLoad(t *testing.T) { + resetSecretConfig(t) + const marker = "SYNTHETIC_FILE_SECRET_2431098547" + path := filepath.Join(t.TempDir(), "credential") + require.NoError(t, os.WriteFile(path, []byte(" "+marker+"\n"), 0600)) + t.Setenv(AUTH_MNEMONIC, "") + t.Setenv(AUTH_MNEMONIC_FILE, path) + value, err := GetAuthMnemonic() + require.NoError(t, err) + require.Equal(t, marker, value.Value) + t.Setenv(AUTH_MNEMONIC, "SYNTHETIC_DIRECT_VALUE") + value, err = GetAuthMnemonic() + require.NoError(t, err) + require.Equal(t, "SYNTHETIC_DIRECT_VALUE", value.Value) +} + +func TestSensitiveFileErrorsKeepSafeFilesystemCause(t *testing.T) { + resetSecretConfig(t) + const marker = "SYNTHETIC_SECRET_FILE_PATH_1743852690" + directory := filepath.Join(t.TempDir(), marker) + require.NoError(t, os.Mkdir(directory, 0700)) + t.Setenv(AUTH_PRIVATE_KEY, "") + t.Setenv(AUTH_PRIVATE_KEY_FILE, directory) + _, err := GetAuthPrivateKey() + require.ErrorIs(t, err, syscall.EISDIR) + require.NotContains(t, err.Error(), marker) + + path := filepath.Join(t.TempDir(), marker) + require.NoError(t, os.WriteFile(path, []byte("secret"), 0000)) + t.Cleanup(func() { _ = os.Chmod(path, 0600) }) + if _, probeErr := os.ReadFile(path); probeErr == nil { + t.Skip("current user can bypass filesystem permissions") + } + t.Setenv(AUTH_PRIVATE_KEY_FILE, path) + _, err = GetAuthPrivateKey() + require.ErrorIs(t, err, fs.ErrPermission) + require.NotContains(t, err.Error(), marker) +} + +func TestRedactedNumericFormattingAndDeserialization(t *testing.T) { + secret := RedactedUint{Value: 1234567890} + for _, value := range []any{secret, &secret} { + for _, format := range []string{"%d", "%x", "%#v"} { + require.Equal(t, "[REDACTED]", fmt.Sprintf(format, value)) + } + } + // JSON input and direct value access used by signer configuration remain valid. + var value RedactedString + require.NoError(t, json.Unmarshal([]byte(`{"Value":"SYNTHETIC_SIGNER_VALUE"}`), &value)) + require.Equal(t, "SYNTHETIC_SIGNER_VALUE", value.Value) +} diff --git a/internal/deps/deps.go b/internal/deps/deps.go deleted file mode 100644 index 7742fb830..000000000 --- a/internal/deps/deps.go +++ /dev/null @@ -1,264 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// Package deps provides mechanisms to run Node dependencies using docker -package deps - -import ( - "context" - "fmt" - "io" - "log/slog" - "strings" - "sync" - "time" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -const ( - DefaultPostgresDatabase = "postgres" - DefaultPostgresDockerImage = "postgres:16-alpine" - DefaultPostgresPort = "5432" - DefaultPostgresUser = "postgres" - DefaultPostgresPassword = "password" - DefaultDevnetDockerImage = "cartesi/rollups-node-devnet:devel" - DefaultDevnetPort = "8545" - DefaultDevnetBlockTime = "1" - DefaultDevnetBlockToWaitForOnStartup = "21" - DefaultDevnetNoMining = false - - numPostgresCheckReadyAttempts = 2 - pollInterval = 5 * time.Second -) - -const ( - postgresKey = iota - devnetKey -) - -// Struct to hold Node dependencies containers configurations -type DepsConfig struct { - Postgres *PostgresConfig - Devnet *DevnetConfig -} - -type PostgresConfig struct { - DockerImage string - Port string - Password string -} - -type DevnetConfig struct { - DockerImage string - Port string - BlockTime string - BlockToWaitForOnStartup string - NoMining bool -} - -// Builds a DepsConfig struct with default values -func NewDefaultDepsConfig() *DepsConfig { - return &DepsConfig{ - &PostgresConfig{ - DefaultPostgresDockerImage, - DefaultPostgresPort, - DefaultPostgresPassword, - }, - &DevnetConfig{ - DefaultDevnetDockerImage, - DefaultDevnetPort, - DefaultDevnetBlockTime, - DefaultDevnetBlockToWaitForOnStartup, - DefaultDevnetNoMining, - }, - } -} - -// Struct to represent the Node dependencies containers -type DepsContainers struct { - containers map[int]testcontainers.Container - //Literal copies lock value from waitGroup as sync.WaitGroup contains sync.noCopy - waitGroup *sync.WaitGroup -} - -func (depContainers *DepsContainers) DevnetLogs(ctx context.Context) (io.ReadCloser, error) { - container, ok := depContainers.containers[devnetKey] - if !ok { - return nil, fmt.Errorf("Container Devnet is not present") - } - reader, err := container.Logs(ctx) - if err != nil { - return nil, fmt.Errorf("Error retrieving logs from Devnet Container : %w", err) - } - return reader, nil -} - -func (depContainers *DepsContainers) PostgresLogs(ctx context.Context) (io.ReadCloser, error) { - container, ok := depContainers.containers[postgresKey] - if !ok { - return nil, fmt.Errorf("Container Postgres is not present") - } - reader, err := container.Logs(ctx) - if err != nil { - return nil, fmt.Errorf("Error retrieving logs from Postgres Container : %w", err) - } - return reader, nil -} - -func (depContainers *DepsContainers) DevnetEndpoint( - ctx context.Context, - protocol string, -) (string, error) { - container, ok := depContainers.containers[devnetKey] - if !ok { - return "", fmt.Errorf("Container Devnet is not present") - } - endpoint, err := container.Endpoint(ctx, protocol) - if err != nil { - return "", fmt.Errorf("Error retrieving endpoint from Devnet Container : %w", err) - } - return endpoint, nil -} - -func (depContainers *DepsContainers) PostgresEndpoint( - ctx context.Context, - protocol string, -) (string, error) { - - container, ok := depContainers.containers[postgresKey] - if !ok { - return "", fmt.Errorf("Container Postgres is not present") - } - endpoint, err := container.Endpoint(ctx, protocol) - if err != nil { - return "", fmt.Errorf("Error retrieving endpoint from Postgres Container : %w", err) - } - return endpoint, nil -} - -// debugLogging implements the testcontainers.Logging interface by printing the log to slog.Debug. -type debugLogging struct{} - -func (d debugLogging) Printf(format string, v ...interface{}) { - slog.Debug(fmt.Sprintf(format, v...)) -} - -func createHook(finishedWaitGroup *sync.WaitGroup) []testcontainers.ContainerLifecycleHooks { - return []testcontainers.ContainerLifecycleHooks{ - { - - PostTerminates: []testcontainers.ContainerHook{ - func(ctx context.Context, container testcontainers.Container) error { - finishedWaitGroup.Done() - return nil - }, - }, - }, - } -} - -// Run starts the Node dependencies containers. -// The returned DepContainers struct can be used to gracefully -// terminate the containers using the Terminate method -func Run(ctx context.Context, depsConfig DepsConfig) (*DepsContainers, error) { - - debugLogger := debugLogging{} - var finishedWaitGroup sync.WaitGroup - containers := make(map[int]testcontainers.Container) - if depsConfig.Postgres != nil { - // wait strategy copied from testcontainers docs - postgresWaitStrategy := wait.ForLog("database system is ready to accept connections"). - WithOccurrence(numPostgresCheckReadyAttempts). - WithPollInterval(pollInterval) - - postgresExposedPorts := "5432/tcp" - if depsConfig.Postgres.Port != "" { - postgresExposedPorts = strings.Join([]string{ - depsConfig.Postgres.Port, ":", postgresExposedPorts}, "") - } - postgresReq := testcontainers.ContainerRequest{ - Image: depsConfig.Postgres.DockerImage, - ExposedPorts: []string{postgresExposedPorts}, - WaitingFor: postgresWaitStrategy, - Env: map[string]string{ - "POSTGRES_PASSWORD": depsConfig.Postgres.Password, - }, - LifecycleHooks: createHook(&finishedWaitGroup), - } - postgres, err := testcontainers.GenericContainer( - ctx, - testcontainers.GenericContainerRequest{ - ContainerRequest: postgresReq, - Started: true, - Logger: debugLogger, - }, - ) - if err != nil { - return nil, err - } - finishedWaitGroup.Add(1) - containers[postgresKey] = postgres - } - - if depsConfig.Devnet != nil { - - devnetExposedPort := "8545/tcp" - if depsConfig.Devnet.Port != "" { - devnetExposedPort = strings.Join([]string{ - depsConfig.Devnet.Port, ":", devnetExposedPort}, "") - } - cmd := []string{ - "anvil", - "--load-state", - "/usr/share/devnet/anvil_state.json", - } - var waitStrategy *wait.LogStrategy - if depsConfig.Devnet.NoMining { - cmd = append(cmd, "--no-mining") - waitStrategy = wait.ForLog("net_listening") - } else { - cmd = append(cmd, "--block-time", - depsConfig.Devnet.BlockTime) - waitStrategy = wait.ForLog("Block Number: " + depsConfig.Devnet.BlockToWaitForOnStartup) - } - devNetReq := testcontainers.ContainerRequest{ - Image: depsConfig.Devnet.DockerImage, - ExposedPorts: []string{devnetExposedPort}, - WaitingFor: waitStrategy, - Cmd: cmd, - LifecycleHooks: createHook(&finishedWaitGroup), - } - devnet, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: devNetReq, - Started: true, - Logger: debugLogger, - }) - if err != nil { - return nil, err - } - finishedWaitGroup.Add(1) - containers[devnetKey] = devnet - } - - if len(containers) < 1 { - return nil, fmt.Errorf("configuration is empty") - } - - return &DepsContainers{containers: containers, - waitGroup: &finishedWaitGroup, - }, nil -} - -// Terminate terminates all dependencies containers. This method waits for all the containers -// to terminate or gives an error if it fails to terminate one of the containers -func Terminate(ctx context.Context, depContainers *DepsContainers) error { - for _, depContainer := range depContainers.containers { - terr := depContainer.Terminate(ctx) - if terr != nil { - return terr - } - } - return nil -} diff --git a/internal/errutil/cancellation.go b/internal/errutil/cancellation.go new file mode 100644 index 000000000..f1e90416b --- /dev/null +++ b/internal/errutil/cancellation.go @@ -0,0 +1,35 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package errutil provides error classification shared by node components. +package errutil + +import ( + "context" + "errors" +) + +// IsOnlyCancellation reports whether every cause of err is context.Canceled. +// Unlike errors.Is, it does not hide other causes in a joined error. Nil is not +// a cancellation. Errors without children are checked with errors.Is. Callers +// must separately establish that the service is shutting down. +func IsOnlyCancellation(err error) bool { + switch cause := err.(type) { + case interface{ Unwrap() []error }: + children := cause.Unwrap() + if len(children) == 0 { + return errors.Is(err, context.Canceled) + } + for _, child := range children { + if !IsOnlyCancellation(child) { + return false + } + } + return true + case interface{ Unwrap() error }: + if child := cause.Unwrap(); child != nil { + return IsOnlyCancellation(child) + } + } + return errors.Is(err, context.Canceled) +} diff --git a/internal/errutil/cancellation_test.go b/internal/errutil/cancellation_test.go new file mode 100644 index 000000000..93be1aa10 --- /dev/null +++ b/internal/errutil/cancellation_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package errutil + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsOnlyCancellation(t *testing.T) { + other := errors.New("database unavailable") + for _, test := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"canceled", context.Canceled, true}, + {"wrapped", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined", errors.Join(context.Canceled, fmt.Errorf("write: %w", context.Canceled)), true}, + {"multiple wrapped", fmt.Errorf("queries: %w; %w", context.Canceled, context.Canceled), true}, + {"nested", fmt.Errorf("query: %w", errors.Join(context.Canceled, context.Canceled)), true}, + {"deadline", context.DeadlineExceeded, false}, + {"other", other, false}, + {"mixed", errors.Join(context.Canceled, other), false}, + {"mixed reversed", errors.Join(other, context.Canceled), false}, + {"mixed deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"nested mixed", fmt.Errorf("query: %w", errors.Join(context.Canceled, other)), false}, + {"empty causes", &cancellationTestError{}, false}, + {"nil cause", &cancellationTestError{causes: []error{nil}}, false}, + {"nil wrapped cause", fmt.Errorf("query: %w", nil), false}, + {"custom cancellation leaf", &cancellationTestError{canceled: true}, true}, + {"custom cancellation with failure", &cancellationTestError{canceled: true, causes: []error{other}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, IsOnlyCancellation(test.err)) + }) + } +} + +type cancellationTestError struct { + causes []error + canceled bool +} + +func (*cancellationTestError) Error() string { return "test dependency error" } +func (e *cancellationTestError) Unwrap() []error { return e.causes } +func (e *cancellationTestError) Is(target error) bool { + return e.canceled && target == context.Canceled +} diff --git a/internal/evmreader/accounts_drive_proved.go b/internal/evmreader/accounts_drive_proved.go new file mode 100644 index 000000000..a436a42f8 --- /dev/null +++ b/internal/evmreader/accounts_drive_proved.go @@ -0,0 +1,192 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "math/big" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// checkForDriveProved runs once per evmreader tick for each post-foreclosure +// app whose `accounts_drive_proved_block` is still zero. It first checks the +// one-way getAccountsDriveMerkleRoot().wasProved flag at mostRecent. If the +// flag is false, the scan cursor advances because no prove event exists up to +// that block. If the flag is true, it does a FilterLogs over +// `[max(foreclose_block, last_accounts_drive_proved_check_block+1), mostRecent]` +// for `AccountsDriveMerkleRootProved` events on the IApplication contract. +// +// The contract reverts on a second `proveAccountsDriveMerkleRoot` call +// (`AccountsDriveMerkleRootAlreadyProved`), so at most one event can fire per +// app over its lifetime. On the (at most one) event in the window: +// +// 1. Persist via +// UpdateAccountsDriveProved with the +// event's (block, txHash, root) and the scanner cursor. Idempotent on +// first observation. +// 2. Mirror the values onto app.application so this tick's downstream +// dispatcher (checkPostForeclosure) sees the drive-proved marker and +// routes the next tick to the withdrawal scan. +// +// If the on-chain flag says proved but the log scan returns no event, the +// cursor is left unchanged so a transient eth_call/eth_getLogs disagreement +// cannot skip the only prove event. +func (r *Service) checkForDriveProved( + ctx context.Context, + app appContracts, + mostRecentBlockNumber uint64, +) bool { + startBlock := app.application.LastAccountsDriveProvedCheckBlock + 1 + if floor := app.application.ForecloseBlock; startBlock < floor { + startBlock = floor + } + if startBlock > mostRecentBlockNumber { + // Cursor already past head (rare; e.g. defaultBlock policy drift). + // Nothing to scan; do not regress the cursor. + return true + } + + proved, _, err := app.applicationContract.GetAccountsDriveMerkleRoot(&bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + }) + if err != nil { + if abortPostForeclosureLoop(r, err, "getAccountsDriveMerkleRoot") { + return false + } + r.Logger.Error("Failed to query accounts drive proved state", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "block", mostRecentBlockNumber, + "error", err) + return false + } + if !proved { + if !r.advanceLastAccountsDriveProvedCheckBlock(ctx, app.application.ID, mostRecentBlockNumber) { + return false + } + app.application.LastAccountsDriveProvedCheckBlock = mostRecentBlockNumber + return true + } + + events, err := app.applicationContract.RetrieveAccountsDriveProvedEvents(&bind.FilterOpts{ + Context: ctx, + Start: startBlock, + End: &mostRecentBlockNumber, + }) + if err != nil { + if abortPostForeclosureLoop(r, err, "retrieveAccountsDriveProvedEvents") { + return false + } + r.Logger.Error("Failed to scan accounts-drive-proved events", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber, + "error", err) + return false + } + if len(events) == 0 { + // proveAccountsDriveMerkleRoot is onlyForeclosed (Application.sol), so a + // prove event is always emitted at a block >= foreclose_block and thus + // always lands inside this scan window ([>= foreclose_block, mostRecent]). + // An honest, self-consistent RPC therefore always returns it here. The + // flag reading proved at head while no event is found in the window is a + // transient eth_call/eth_getLogs view skew (a persistent miss would mean + // a broken RPC, handled outside the node): leave the cursor unchanged and + // retry next tick, which cannot skip the only prove event. + r.Logger.Warn( + "getAccountsDriveMerkleRoot() is proved but no AccountsDriveMerkleRootProved event found; will retry same window", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber) + return false + } + + // The contract caps lifetime emissions at one; defensively take the first + // if more than one slipped through. + ev := events[0] + if err := r.persistDriveProved(ctx, app, ev, mostRecentBlockNumber); err != nil { + return false + } + app.application.LastAccountsDriveProvedCheckBlock = mostRecentBlockNumber + return true +} + +// persistDriveProved writes the (block, txHash, root) tuple from the on-chain +// event and the scan cursor to the application row in one repository +// transaction, then mirrors the marker onto the in-memory model. Returning an +// error tells the caller to leave the in-memory cursor unchanged so the event +// can be retried on the next tick. +func (r *Service) persistDriveProved( + ctx context.Context, + app appContracts, + ev *iapplication.IApplicationAccountsDriveMerkleRootProved, + mostRecentBlockNumber uint64, +) error { + block := ev.Raw.BlockNumber + txHash := ev.Raw.TxHash + root := common.Hash(ev.AccountsDriveMerkleRoot) + + err := r.repository.UpdateAccountsDriveProved( + ctx, app.application.ID, block, txHash, root, mostRecentBlockNumber, + ) + if errors.Is(err, repository.ErrNotFound) { + // Row was deleted between the ListApplications scan and now. + // Skip the in-memory marker write — diverging from a row that no + // longer exists is worse than missing a marker. + r.Logger.Warn( + "Drive-prove observed but application row is missing; skipping", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "accounts_drive_proved_block", block, + ) + return nil + } + if err != nil { + r.Logger.Error("Failed to record accounts drive proved", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "accounts_drive_proved_block", block, + "error", err) + return err + } + + app.application.AccountsDriveProvedBlock = block + txHashCopy := txHash + rootCopy := root + app.application.AccountsDriveProvedTransaction = &txHashCopy + app.application.AccountsDriveMerkleRoot = &rootCopy + + r.Logger.Info("Accounts drive proved observed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "accounts_drive_proved_block", block, + "accounts_drive_proved_transaction", txHash, + "accounts_drive_merkle_root", root, + ) + return nil +} + +// advanceLastAccountsDriveProvedCheckBlock persists the new cursor value +// and reports whether it succeeded. A failed write is non-fatal: +// the next tick will re-scan the same window, paying the cost but producing +// correct behavior. +func (r *Service) advanceLastAccountsDriveProvedCheckBlock(ctx context.Context, appID int64, head uint64) bool { + success := true + if err := r.repository.UpdateApplicationLastAccountsDriveProvedCheckBlock(ctx, appID, head); err != nil { + success = false + r.Logger.Warn("Failed to advance last_accounts_drive_proved_check_block", + "application_id", appID, + "head", head, + "error", err) + } + return success +} diff --git a/internal/evmreader/accounts_drive_proved_test.go b/internal/evmreader/accounts_drive_proved_test.go new file mode 100644 index 000000000..76d4448eb --- /dev/null +++ b/internal/evmreader/accounts_drive_proved_test.go @@ -0,0 +1,344 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "log/slog" + "math/big" + "os" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newPostForeclosureFixture builds the minimal Service surface needed by +// the post-foreclosure scans (drive-prove + withdrawal). +func newPostForeclosureFixture(t *testing.T) ( + *Service, *MockApplicationContract, *MockRepository, +) { + t.Helper() + repo := newMockRepository() + appContract := newMockApplicationContract() + s := &Service{ + repository: repo, + } + require.NoError(t, service.InitTickServiceTemplate( + &s.TickServiceTemplate, + &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: "evm-reader", + Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), + }, + }, + s, + )) + return s, appContract, repo +} + +// driveProvedTestApp builds a foreclosed Application with foreclose_block +// set to the given foreclose block; accounts_drive_proved_block is zero so +// the dispatcher routes here. +func driveProvedTestApp(id int64, forecloseBlock uint64) *Application { + return &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + Status: ApplicationStatus_OK, + ForecloseBlock: forecloseBlock, + } +} + +// makeDriveProvedEvent builds a synthetic IApplicationAccountsDriveMerkleRootProved +// event with the given block / tx / root for stubbing +// RetrieveAccountsDriveProvedEvents. +func makeDriveProvedEvent( + block uint64, txHash common.Hash, root common.Hash, +) *iapplication.IApplicationAccountsDriveMerkleRootProved { + return &iapplication.IApplicationAccountsDriveMerkleRootProved{ + AccountsDriveMerkleRoot: [32]byte(root), + Raw: types.Log{ + BlockNumber: block, + TxHash: txHash, + }, + } +} + +// --------------------------------------------------------------------------- +// checkForDriveProved +// --------------------------------------------------------------------------- + +// TestCheckForDriveProved_NoEvent verifies the steady-state path: the on-chain +// proved flag is false, so no event scan or UpdateAccountsDriveProved call is +// made. The cursor still advances to mostRecent so the next tick scans only the +// new slice. +func TestCheckForDriveProved_NoEvent(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(false, common.Hash{}, nil).Once() + repo.On("UpdateApplicationLastAccountsDriveProvedCheckBlock", + mock.Anything, app.ID, head).Return(nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastAccountsDriveProvedCheckBlock, + "in-memory cursor mirrors the DB advance") + assert.Zero(t, app.AccountsDriveProvedBlock, + "AccountsDriveProvedBlock must remain zero when no event was observed") +} + +// TestCheckForDriveProved_PersistsAndMirrors walks the happy path: one +// AccountsDriveMerkleRootProved event in the window; the persist call +// receives the event's (block, txHash, root); the in-memory marker is +// mirrored so the next tick's dispatcher routes to the withdrawal scan. +func TestCheckForDriveProved_PersistsAndMirrors(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + const eventBlock = uint64(110) + txHash := common.HexToHash("0xcafe") + root := common.HexToHash("0xfeed") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, root, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + // startBlock = max(foreclose_block=100, last_cursor+1=1) = 100. + return opts.Start == 100 && opts.End != nil && *opts.End == head + })).Return([]*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(eventBlock, txHash, root), + }, nil).Once() + + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, eventBlock, txHash, root, head, + ).Return(nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, eventBlock, app.AccountsDriveProvedBlock, + "in-memory AccountsDriveProvedBlock must mirror the DB write") + if assert.NotNil(t, app.AccountsDriveProvedTransaction) { + assert.Equal(t, txHash, *app.AccountsDriveProvedTransaction) + } + if assert.NotNil(t, app.AccountsDriveMerkleRoot) { + assert.Equal(t, root, *app.AccountsDriveMerkleRoot) + } + assert.Equal(t, head, app.LastAccountsDriveProvedCheckBlock) +} + +// TestCheckForDriveProved_TakesFirstWhenMultiple is defensive: the contract +// caps emissions at one (AccountsDriveMerkleRootAlreadyProved on a second +// call), but if FilterLogs ever returns more than one we must persist the +// first and ignore the rest rather than overwriting with the later event. +func TestCheckForDriveProved_TakesFirstWhenMultiple(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + firstTx := common.HexToHash("0xaaaa") + firstRoot := common.HexToHash("0x1111") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, firstRoot, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything).Return( + []*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(110, firstTx, firstRoot), + makeDriveProvedEvent(115, common.HexToHash("0xbbbb"), common.HexToHash("0x2222")), + }, nil, + ).Once() + + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, uint64(110), firstTx, firstRoot, head, + ).Return(nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + if assert.NotNil(t, app.AccountsDriveMerkleRoot) { + assert.Equal(t, firstRoot, *app.AccountsDriveMerkleRoot, + "in-memory marker must hold the FIRST event's data") + } +} + +// TestCheckForDriveProved_CursorRespectsForecloseBlockAsFloor pins the +// search-window lower bound. When LastAccountsDriveProvedCheckBlock is 0 +// and the foreclose block is mid-range, the scan must start at +// ForecloseBlock (not 1, not 0) — drive-prove cannot land before the +// foreclosure that gates it. If the proved state is true but the event is +// missing, the cursor remains unchanged so the window is retried. +func TestCheckForDriveProved_CursorRespectsForecloseBlockAsFloor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 500) + const head = uint64(600) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, common.Hash{}, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 500 + })).Return([]*iapplication.IApplicationAccountsDriveMerkleRootProved{}, nil).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock) +} + +// TestCheckForDriveProved_SkipsWhenCursorPastHead verifies the +// short-circuit: a previous tick already advanced the cursor past the +// current head (defaultBlock policy drift, reorg recovery, etc.). The +// function must not issue any RetrieveAccountsDriveProvedEvents call and +// must not regress the cursor. +func TestCheckForDriveProved_SkipsWhenCursorPastHead(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + app.LastAccountsDriveProvedCheckBlock = 200 + const head = uint64(150) + + // No mock expectations — assertion is by negation. + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, uint64(200), app.LastAccountsDriveProvedCheckBlock, + "in-memory cursor must not regress when head < last cursor") +} + +// TestCheckForDriveProved_DoesNotAdvanceCursorOnQueryError verifies that when +// the FilterLogs call errors, the cursor remains unchanged so the next tick +// retries the same range instead of permanently skipping the event. +func TestCheckForDriveProved_DoesNotAdvanceCursorOnQueryError(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, common.Hash{}, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything). + Return([]*iapplication.IApplicationAccountsDriveMerkleRootProved(nil), + errors.New("eth_getLogs failed")).Once() + + // No atomic drive-proved marker write — the scan errored before any persist + // could fire. + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock, + "scan failure keeps cursor unchanged for retry") +} + +// TestCheckForDriveProved_AbortsOnDeadlineExceeded verifies the +// context-error semantics: a DeadlineExceeded mid-scan must abort the +// loop with one ERROR log; the cursor must NOT advance (otherwise we'd +// silently mask a stuck tick by claiming progress). +func TestCheckForDriveProved_AbortsOnDeadlineExceeded(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(false, common.Hash{}, context.DeadlineExceeded).Once() + + // No cursor advance expected — abort path. + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock, + "DeadlineExceeded aborts before cursor advance") +} + +// TestCheckForDriveProved_ErrNotFoundSkipsInMemoryMarker verifies the +// row-deleted-between-scan-and-write path. The repository returns +// ErrNotFound for the atomic drive-proved marker write; the in-memory marker must +// NOT be written (writing it would diverge from a row that no longer +// exists). Subsequent ticks have nothing to repair because the row is +// gone. +func TestCheckForDriveProved_ErrNotFoundSkipsInMemoryMarker(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + const eventBlock = uint64(110) + txHash := common.HexToHash("0xcafe") + root := common.HexToHash("0xfeed") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, root, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything).Return( + []*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(eventBlock, txHash, root), + }, nil).Once() + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, eventBlock, txHash, root, head, + ).Return(repository.ErrNotFound).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.AccountsDriveProvedBlock, + "ErrNotFound must not set the in-memory marker — row is gone") + assert.Nil(t, app.AccountsDriveProvedTransaction) + assert.Nil(t, app.AccountsDriveMerkleRoot) +} + +// TestCheckForDriveProved_DoesNotAdvanceCursorOnPersistError verifies that a +// failed write of the observed event leaves the scan cursor unchanged. This +// prevents the node from moving past the only window where the event was seen. +func TestCheckForDriveProved_DoesNotAdvanceCursorOnPersistError(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + const eventBlock = uint64(110) + txHash := common.HexToHash("0xcafe") + root := common.HexToHash("0xfeed") + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(true, root, nil).Once() + c.On("RetrieveAccountsDriveProvedEvents", mock.Anything).Return( + []*iapplication.IApplicationAccountsDriveMerkleRootProved{ + makeDriveProvedEvent(eventBlock, txHash, root), + }, nil).Once() + repo.On("UpdateAccountsDriveProved", + mock.Anything, app.ID, eventBlock, txHash, root, head, + ).Return(errors.New("db unavailable")).Once() + + s.checkForDriveProved(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.AccountsDriveProvedBlock) + assert.Zero(t, app.LastAccountsDriveProvedCheckBlock, + "persist failure keeps cursor unchanged for retry") +} diff --git a/internal/evmreader/adapter_resolver.go b/internal/evmreader/adapter_resolver.go new file mode 100644 index 000000000..b70f8d57c --- /dev/null +++ b/internal/evmreader/adapter_resolver.go @@ -0,0 +1,112 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "log/slog" + + "github.com/ethereum/go-ethereum/common" + + . "github.com/cartesi/rollups-node/internal/model" +) + +// cachedAdapters stores contract adapters along with the configuration fields +// used to create them, enabling staleness detection when app config changes. +type cachedAdapters struct { + applicationContract ApplicationContractAdapter + inputSource InputSourceAdapter + daveConsensus DaveConsensusAdapter + consensusAddr common.Address + inputBoxAddr common.Address + isDaveConsensus bool +} + +type applicationAdapterResolver struct { + logger *slog.Logger + factory AdapterFactory + cache map[common.Address]cachedAdapters +} + +func newApplicationAdapterResolver( + logger *slog.Logger, + factory AdapterFactory, +) *applicationAdapterResolver { + return &applicationAdapterResolver{ + logger: logger, + factory: factory, + cache: map[common.Address]cachedAdapters{}, + } +} + +func (r *applicationAdapterResolver) buildAppContracts(apps []*Application) []appContracts { + r.evictRemovedApplications(apps) + + contracts := make([]appContracts, 0, len(apps)) + for _, app := range apps { + cached, ok := r.getOrCreateAdapters(app) + if !ok { + continue + } + contracts = append(contracts, appContracts{ + application: app, + applicationContract: cached.applicationContract, + inputSource: cached.inputSource, + daveConsensus: cached.daveConsensus, + }) + } + return contracts +} + +func (r *applicationAdapterResolver) evictRemovedApplications(apps []*Application) { + activeAddrs := make(map[common.Address]struct{}, len(apps)) + for _, app := range apps { + activeAddrs[app.IApplicationAddress] = struct{}{} + } + for addr := range r.cache { + if _, active := activeAddrs[addr]; active { + continue + } + r.logger.Debug("Evicting cached adapters for removed application", "address", addr) + delete(r.cache, addr) + } +} + +func (r *applicationAdapterResolver) getOrCreateAdapters(app *Application) (cachedAdapters, bool) { + addr := app.IApplicationAddress + cached, cacheHit := r.cache[addr] + if cacheHit && adaptersAreStale(cached, app) { + r.logger.Info( + "Application contract configuration changed, recreating adapters", + "application", app.Name, + "address", addr, + ) + delete(r.cache, addr) + cacheHit = false + } + if cacheHit { + return cached, true + } + + appContract, inputSource, daveConsensus, err := r.factory.CreateAdapters(app) + if err != nil { + r.logger.Error("Error retrieving application contracts", "app", app, "error", err) + return cachedAdapters{}, false + } + cached = cachedAdapters{ + applicationContract: appContract, + inputSource: inputSource, + daveConsensus: daveConsensus, + consensusAddr: app.IConsensusAddress, + inputBoxAddr: app.IInputBoxAddress, + isDaveConsensus: app.IsDaveConsensus(), + } + r.cache[addr] = cached + return cached, true +} + +func adaptersAreStale(cached cachedAdapters, app *Application) bool { + return cached.consensusAddr != app.IConsensusAddress || + cached.inputBoxAddr != app.IInputBoxAddress || + cached.isDaveConsensus != app.IsDaveConsensus() +} diff --git a/internal/evmreader/adapter_resolver_test.go b/internal/evmreader/adapter_resolver_test.go new file mode 100644 index 000000000..47ac2f55d --- /dev/null +++ b/internal/evmreader/adapter_resolver_test.go @@ -0,0 +1,161 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "io" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestApplicationAdapterResolver_ReusesAdaptersWithLatestApplicationPointer(t *testing.T) { + factory := &countingAdapterFactory{} + resolver := newApplicationAdapterResolver(testLogger(t), factory) + + app := resolverApp(1) + contracts := resolver.buildAppContracts([]*Application{app}) + require.Len(t, contracts, 1) + require.Same(t, app, contracts[0].application) + require.Len(t, factory.calls, 1) + + refreshedApp := resolverApp(1) + refreshedApp.LastInputCheckBlock = 100 + contracts = resolver.buildAppContracts([]*Application{refreshedApp}) + require.Len(t, contracts, 1) + require.Same(t, refreshedApp, contracts[0].application) + require.Len(t, factory.calls, 1) +} + +func TestApplicationAdapterResolver_InvalidatesStaleAdapters(t *testing.T) { + tests := []struct { + name string + change func(app *Application) + }{ + { + name: "consensus address changed", + change: func(app *Application) { + app.IConsensusAddress = common.HexToAddress("0x00000000000000000000000000000000000000c2") + }, + }, + { + name: "input box address changed", + change: func(app *Application) { + app.IInputBoxAddress = common.HexToAddress("0x00000000000000000000000000000000000000b2") + }, + }, + { + name: "consensus type changed", + change: func(app *Application) { + app.ConsensusType = Consensus_PRT + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory := &countingAdapterFactory{} + resolver := newApplicationAdapterResolver(testLogger(t), factory) + + contracts := resolver.buildAppContracts([]*Application{resolverApp(1)}) + require.Len(t, contracts, 1) + + changed := resolverApp(1) + tt.change(changed) + contracts = resolver.buildAppContracts([]*Application{changed}) + require.Len(t, contracts, 1) + require.Len(t, factory.calls, 2) + }) + } +} + +func TestApplicationAdapterResolver_EvictsRemovedApplications(t *testing.T) { + factory := &countingAdapterFactory{} + resolver := newApplicationAdapterResolver(testLogger(t), factory) + + contracts := resolver.buildAppContracts([]*Application{resolverApp(1), resolverApp(2)}) + require.Len(t, contracts, 2) + require.Len(t, factory.calls, 2) + + contracts = resolver.buildAppContracts([]*Application{resolverApp(2)}) + require.Len(t, contracts, 1) + require.Len(t, factory.calls, 2) + + contracts = resolver.buildAppContracts([]*Application{resolverApp(1), resolverApp(2)}) + require.Len(t, contracts, 2) + require.Len(t, factory.calls, 3) +} + +func TestApplicationAdapterResolver_DoesNotCacheCreationErrors(t *testing.T) { + factory := &countingAdapterFactory{ + results: []adapterFactoryResult{ + {err: errors.New("boom")}, + {}, + }, + } + resolver := newApplicationAdapterResolver(testLogger(t), factory) + + contracts := resolver.buildAppContracts([]*Application{resolverApp(1)}) + require.Empty(t, contracts) + require.Len(t, factory.calls, 1) + + contracts = resolver.buildAppContracts([]*Application{resolverApp(1)}) + require.Len(t, contracts, 1) + require.Len(t, factory.calls, 2) +} + +type adapterFactoryResult struct { + applicationContract ApplicationContractAdapter + inputSource InputSourceAdapter + daveConsensus DaveConsensusAdapter + err error +} + +type countingAdapterFactory struct { + calls []*Application + results []adapterFactoryResult +} + +func (f *countingAdapterFactory) CreateAdapters( + app *Application, +) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { + f.calls = append(f.calls, app) + result := adapterFactoryResult{} + if len(f.results) >= len(f.calls) { + result = f.results[len(f.calls)-1] + } + if result.err != nil { + return nil, nil, nil, result.err + } + if result.applicationContract == nil { + result.applicationContract = newMockApplicationContract() + } + if result.inputSource == nil { + result.inputSource = newMockInputBox() + } + return result.applicationContract, result.inputSource, result.daveConsensus, nil +} + +func resolverApp(id int64) *Application { + return &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + IConsensusAddress: common.HexToAddress("0x00000000000000000000000000000000000000c1"), + IInputBoxAddress: common.HexToAddress("0x00000000000000000000000000000000000000b1"), + ConsensusType: Consensus_Authority, + Enabled: true, + Status: ApplicationStatus_OK, + } +} + +func testLogger(t *testing.T) *slog.Logger { + t.Helper() + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/internal/evmreader/application_adapter.go b/internal/evmreader/application_adapter.go new file mode 100644 index 000000000..27567c01b --- /dev/null +++ b/internal/evmreader/application_adapter.go @@ -0,0 +1,205 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "fmt" + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type ApplicationContractAdapter interface { + RetrieveOutputExecutionEvents( + opts *bind.FilterOpts, + ) ([]*iapplication.IApplicationOutputExecuted, error) + RetrieveForeclosureEvents( + opts *bind.FilterOpts, + ) ([]*iapplication.IApplicationForeclosure, error) + RetrieveWithdrawalEvents( + opts *bind.FilterOpts, + ) ([]*iapplication.IApplicationWithdrawal, error) + RetrieveAccountsDriveProvedEvents( + opts *bind.FilterOpts, + ) ([]*iapplication.IApplicationAccountsDriveMerkleRootProved, error) + GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) + GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (bool, common.Hash, error) + GetNumberOfExecutedOutputs(opts *bind.CallOpts) (*big.Int, error) + GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) + IsForeclosed(opts *bind.CallOpts) (bool, error) +} + +// IApplication Wrapper +type ApplicationContractAdapterImpl struct { + application *iapplication.IApplication + client *ethclient.Client + applicationAddress common.Address + filter ethutil.Filter +} + +func NewApplicationContractAdapter( + appAddress common.Address, + client *ethclient.Client, + filter ethutil.Filter, +) (ApplicationContractAdapter, error) { + applicationContract, err := iapplication.NewIApplication(appAddress, client) + if err != nil { + return nil, err + } + return &ApplicationContractAdapterImpl{ + application: applicationContract, + applicationAddress: appAddress, + client: client, + filter: filter, + }, nil +} + +func buildApplicationEventFilterQuery( + opts *bind.FilterOpts, + applicationAddress common.Address, + eventName MonitoredEvent, +) (q ethereum.FilterQuery, err error) { + c, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return q, err + } + + event, ok := c.Events[eventName.String()] + if !ok { + return q, fmt.Errorf("event %q not found in IApplication ABI", eventName) + } + topics, err := abi.MakeTopics( + []any{event.ID}, + ) + if err != nil { + return q, err + } + + q = ethereum.FilterQuery{ + Addresses: []common.Address{applicationAddress}, + FromBlock: new(big.Int).SetUint64(opts.Start), + Topics: topics, + } + if opts.End != nil { + q.ToBlock = new(big.Int).SetUint64(*opts.End) + } + return q, err +} + +func retrieveApplicationEvents[T any]( + opts *bind.FilterOpts, + client *ethclient.Client, + filter *ethutil.Filter, + applicationAddress common.Address, + eventName MonitoredEvent, + parse func(types.Log) (*T, error), +) ([]*T, error) { + q, err := buildApplicationEventFilterQuery(opts, applicationAddress, eventName) + if err != nil { + return nil, err + } + + itr, err := filter.ChunkedFilterLogs(opts.Context, client, q) + if err != nil { + return nil, err + } + + var events []*T + for log, err := range itr { + if err != nil { + return nil, err + } + ev, err := parse(*log) + if err != nil { + return nil, err + } + events = append(events, ev) + } + return events, nil +} + +func (a *ApplicationContractAdapterImpl) RetrieveOutputExecutionEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationOutputExecuted, error) { + return retrieveApplicationEvents( + opts, + a.client, + &a.filter, + a.applicationAddress, + MonitoredEvent_OutputExecuted, + a.application.ParseOutputExecuted, + ) +} + +func (a *ApplicationContractAdapterImpl) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) { + return a.application.GetDeploymentBlockNumber(opts) +} + +func (a *ApplicationContractAdapterImpl) GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (bool, common.Hash, error) { + result, err := a.application.GetAccountsDriveMerkleRoot(opts) + if err != nil { + return false, common.Hash{}, err + } + return result.WasAccountsDriveMerkleRootProved, common.Hash(result.AccountsDriveMerkleRoot), nil +} + +func (a *ApplicationContractAdapterImpl) GetNumberOfExecutedOutputs(opts *bind.CallOpts) (*big.Int, error) { + return a.application.GetNumberOfExecutedOutputs(opts) +} + +func (a *ApplicationContractAdapterImpl) IsForeclosed(opts *bind.CallOpts) (bool, error) { + return a.application.IsForeclosed(opts) +} + +func (a *ApplicationContractAdapterImpl) GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) { + return a.application.GetNumberOfWithdrawals(opts) +} + +func (a *ApplicationContractAdapterImpl) RetrieveForeclosureEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationForeclosure, error) { + return retrieveApplicationEvents( + opts, + a.client, + &a.filter, + a.applicationAddress, + MonitoredEvent_Foreclosure, + a.application.ParseForeclosure, + ) +} + +func (a *ApplicationContractAdapterImpl) RetrieveWithdrawalEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationWithdrawal, error) { + return retrieveApplicationEvents( + opts, + a.client, + &a.filter, + a.applicationAddress, + MonitoredEvent_Withdrawal, + a.application.ParseWithdrawal, + ) +} + +func (a *ApplicationContractAdapterImpl) RetrieveAccountsDriveProvedEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationAccountsDriveMerkleRootProved, error) { + return retrieveApplicationEvents( + opts, + a.client, + &a.filter, + a.applicationAddress, + MonitoredEvent_AccountsDriveMerkleRootProved, + a.application.ParseAccountsDriveMerkleRootProved, + ) +} diff --git a/internal/evmreader/block_scan_plan.go b/internal/evmreader/block_scan_plan.go new file mode 100644 index 000000000..8afec5390 --- /dev/null +++ b/internal/evmreader/block_scan_plan.go @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +type blockScanPlan struct { + iConsensusInputTargets []appContracts + daveEpochTargets []appContracts + outputTargets []appContracts + postForeclosureTargets []appContracts +} + +func buildBlockScanPlan(apps []appContracts) blockScanPlan { + var plan blockScanPlan + for _, app := range apps { + application := app.application + if application == nil || !application.NeedsL1Observation() { + continue + } + + if application.IsForeclosed() { + plan.outputTargets = append(plan.outputTargets, app) + plan.postForeclosureTargets = append(plan.postForeclosureTargets, app) + + if application.IsDaveConsensus() { + if application.LastEpochCheckBlock < application.ForecloseBlock || + application.LastInputCheckBlock < application.ForecloseBlock { + plan.daveEpochTargets = append(plan.daveEpochTargets, app) + } + continue + } + + if application.LastInputCheckBlock < application.ForecloseBlock { + plan.iConsensusInputTargets = append(plan.iConsensusInputTargets, app) + } + continue + } + + // Execution health controls machine work, not L1 observation. Keep + // indexing inputs/epochs and output executions while an external + // watchdog observes the durable failure and eventually forecloses. + plan.outputTargets = append(plan.outputTargets, app) + if application.IsDaveConsensus() { + plan.daveEpochTargets = append(plan.daveEpochTargets, app) + } else { + plan.iConsensusInputTargets = append(plan.iConsensusInputTargets, app) + } + } + return plan +} diff --git a/internal/evmreader/block_scan_plan_test.go b/internal/evmreader/block_scan_plan_test.go new file mode 100644 index 000000000..4726ee6d0 --- /dev/null +++ b/internal/evmreader/block_scan_plan_test.go @@ -0,0 +1,200 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "fmt" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestBuildBlockScanPlan_RoutesScannerTargets(t *testing.T) { + tests := []struct { + name string + apps []appContracts + wantIConsensusInput []int64 + wantDaveEpoch []int64 + wantOutput []int64 + wantPostForeclosure []int64 + }{ + { + name: "OK IConsensus app is executable", + apps: []appContracts{planApp(1, planAppConfig{})}, + wantIConsensusInput: []int64{1}, + wantOutput: []int64{1}, + }, + { + name: "OK DaveConsensus app is executable", + apps: []appContracts{planApp(3, planAppConfig{ + consensus: Consensus_PRT, + })}, + wantDaveEpoch: []int64{3}, + wantOutput: []int64{3}, + }, + { + name: "non-executing apps remain observable before foreclosure", + apps: []appContracts{ + planApp(40, planAppConfig{status: ApplicationStatus_Failed}), + planApp(41, planAppConfig{status: ApplicationStatus_Diverged}), + planApp(42, planAppConfig{status: ApplicationStatus_Corrupted}), + planApp(43, planAppConfig{status: ApplicationStatus_GuestException}), + planApp(44, planAppConfig{status: ApplicationStatus_MachineHalted}), + planApp(45, planAppConfig{status: ApplicationStatus_McycleOverflow}), + planApp(46, planAppConfig{status: ApplicationStatus_UnexpectedYield}), + planApp(47, planAppConfig{status: ApplicationStatus_InvalidOutputsRoot}), + }, + wantIConsensusInput: []int64{40, 41, 42, 43, 44, 45, 46, 47}, + wantOutput: []int64{40, 41, 42, 43, 44, 45, 46, 47}, + }, + { + name: "foreclosed IConsensus app with input cursor behind gets final input catch-up", + apps: []appContracts{planApp(5, planAppConfig{ + forecloseBlock: 100, + lastInputCheckBlock: 99, + })}, + wantIConsensusInput: []int64{5}, + wantOutput: []int64{5}, + wantPostForeclosure: []int64{5}, + }, + { + name: "foreclosed DaveConsensus app with epoch cursor behind gets sealed-epoch catch-up", + apps: []appContracts{planApp(7, planAppConfig{ + consensus: Consensus_PRT, + forecloseBlock: 100, + lastEpochCheckBlock: 99, + })}, + wantDaveEpoch: []int64{7}, + wantOutput: []int64{7}, + wantPostForeclosure: []int64{7}, + }, + { + name: "foreclosed DaveConsensus app with open-input cursor behind gets input catch-up", + apps: []appContracts{planApp(71, planAppConfig{ + consensus: Consensus_PRT, + forecloseBlock: 100, + lastEpochCheckBlock: 100, + lastInputCheckBlock: 99, + })}, + wantDaveEpoch: []int64{71}, + wantOutput: []int64{71}, + wantPostForeclosure: []int64{71}, + }, + { + name: "foreclosed diverged app still catches up pre-foreclosure work", + apps: []appContracts{planApp(8, planAppConfig{ + status: ApplicationStatus_Diverged, + forecloseBlock: 100, + lastInputCheckBlock: 99, + })}, + wantIConsensusInput: []int64{8}, + wantOutput: []int64{8}, + wantPostForeclosure: []int64{8}, + }, + { + name: "foreclosed invalid-root Dave app remains observable", + apps: []appContracts{planApp(81, planAppConfig{ + status: ApplicationStatus_InvalidOutputsRoot, + consensus: Consensus_PRT, + forecloseBlock: 100, + lastEpochCheckBlock: 99, + lastInputCheckBlock: 99, + })}, + wantDaveEpoch: []int64{81}, + wantOutput: []int64{81}, + wantPostForeclosure: []int64{81}, + }, + { + name: "foreclosed app with cursor at foreclose block only keeps post-foreclosure observation", + apps: []appContracts{planApp(9, planAppConfig{ + forecloseBlock: 100, + lastInputCheckBlock: 100, + })}, + wantOutput: []int64{9}, + wantPostForeclosure: []int64{9}, + }, + { + name: "foreclosed OK app is routed once through the foreclosed path", + apps: []appContracts{planApp(10, planAppConfig{ + status: ApplicationStatus_OK, + forecloseBlock: 100, + lastInputCheckBlock: 99, + })}, + wantIConsensusInput: []int64{10}, + wantOutput: []int64{10}, + wantPostForeclosure: []int64{10}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan := buildBlockScanPlan(tt.apps) + + require.ElementsMatch(t, tt.wantIConsensusInput, planTargetIDs(plan.iConsensusInputTargets)) + require.ElementsMatch(t, tt.wantDaveEpoch, planTargetIDs(plan.daveEpochTargets)) + require.ElementsMatch(t, tt.wantOutput, planTargetIDs(plan.outputTargets)) + require.ElementsMatch(t, tt.wantPostForeclosure, planTargetIDs(plan.postForeclosureTargets)) + require.NoError(t, requireNoDuplicatePlanTargets(plan)) + }) + } +} + +func requireNoDuplicatePlanTargets(plan blockScanPlan) error { + targets := [][]appContracts{ + plan.iConsensusInputTargets, + plan.daveEpochTargets, + plan.outputTargets, + plan.postForeclosureTargets, + } + for _, apps := range targets { + seen := map[int64]struct{}{} + for _, app := range apps { + if _, ok := seen[app.application.ID]; ok { + return fmt.Errorf("duplicate target for application %d", app.application.ID) + } + seen[app.application.ID] = struct{}{} + } + } + return nil +} + +type planAppConfig struct { + status ApplicationStatus + consensus Consensus + forecloseBlock uint64 + lastInputCheckBlock uint64 + lastEpochCheckBlock uint64 +} + +func planApp(id int64, cfg planAppConfig) appContracts { + status := cfg.status + if status == "" { + status = ApplicationStatus_OK + } + consensus := cfg.consensus + if consensus == "" { + consensus = Consensus_Authority + } + return appContracts{application: &Application{ + ID: id, + IInputBoxAddress: common.BigToAddress(big.NewInt(id)), + Enabled: true, + Status: status, + ConsensusType: consensus, + ForecloseBlock: cfg.forecloseBlock, + LastInputCheckBlock: cfg.lastInputCheckBlock, + LastEpochCheckBlock: cfg.lastEpochCheckBlock, + }} +} + +func planTargetIDs(apps []appContracts) []int64 { + ids := make([]int64, 0, len(apps)) + for _, app := range apps { + ids = append(ids, app.application.ID) + } + return ids +} diff --git a/internal/evmreader/dave_consensus_test.go b/internal/evmreader/dave_consensus_test.go new file mode 100644 index 000000000..91b05b93d --- /dev/null +++ b/internal/evmreader/dave_consensus_test.go @@ -0,0 +1,520 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +const daveTestApplicationName = "test-app" + +// Keep scan boundaries, InputBox identity, and application status in each test. +func newDaveAppContracts(inputSource InputSourceAdapter, daveConsensus DaveConsensusAdapter) appContracts { + return appContracts{ + application: &Application{ + ID: 1, Name: daveTestApplicationName, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + }, + inputSource: inputSource, daveConsensus: daveConsensus, + } +} + +// makeSealedEpochResult constructs the node-owned GetCurrentSealedEpoch result. +func makeSealedEpochResult( + epochNum int64, + lowerBound, upperBound uint64, + tournament common.Address, +) DaveCurrentSealedEpoch { + return DaveCurrentSealedEpoch{ + EpochNumber: big.NewInt(epochNum), + InputIndexLowerBound: new(big.Int).SetUint64(lowerBound), + InputIndexUpperBound: new(big.Int).SetUint64(upperBound), + Tournament: tournament, + } +} + +// makeSealedEpochEvent constructs an IDaveConsensusEpochSealed event for testing. +func makeSealedEpochEvent( + epochNum int64, + lowerBound, upperBound, blockNum uint64, + tournament common.Address, +) *idaveconsensus.IDaveConsensusEpochSealed { + return &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(epochNum), + InputIndexLowerBound: new(big.Int).SetUint64(lowerBound), + InputIndexUpperBound: new(big.Int).SetUint64(upperBound), + Tournament: tournament, + Raw: types.Log{ + BlockNumber: blockNum, + TxHash: common.BigToHash(new(big.Int).SetUint64(blockNum)), + }, + } +} + +// --- Test 1: Full sealed pipeline with 2 epoch transitions (no inputs) --- +// Exercises: processApplicationSealedEpochs → FindTransitions → processEpochTransition +// → processSealedEpochEvent, verifying the complete DaveConsensus sealed epoch pipeline. +func (s *SealedEpochsSuite) TestSealedEpochsEndToEndTwoTransitions() { + const ( + inputBoxBlock uint64 = 10 + searchStart uint64 = 50 + sealBlock0 uint64 = 100 + sealBlock1 uint64 = 150 + mostRecentBlock uint64 = 200 + ) + + tournamentAddr0 := common.HexToAddress("0xA000") + tournamentAddr1 := common.HexToAddress("0xA001") + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = inputBoxBlock + app.application.LastEpochCheckBlock = searchStart + + // Oracle: GetCurrentSealedEpoch returns epoch number based on block + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Int64() < int64(sealBlock0) + }), + ).Return(makeSealedEpochResult(-1, 0, 0, common.Address{}), nil) + + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + b := opts.BlockNumber.Uint64() + return b >= sealBlock0 && b < sealBlock1 + }), + ).Return(makeSealedEpochResult(0, 0, 0, tournamentAddr0), nil) + + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock1 + }), + ).Return(makeSealedEpochResult(1, 0, 0, tournamentAddr1), nil) + + // RetrieveSealedEpochs at each transition block + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock0 }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, sealBlock0, tournamentAddr0), + }, nil) + + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock1 }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(1, 0, 0, sealBlock1, tournamentAddr1), + }, nil) + + // Epoch 0: first lookup → nil (new epoch) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(nil, nil).Once() + // Epoch 1: lookup of prev epoch (0) → returns epoch 0 data + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: inputBoxBlock, LastBlock: sealBlock0, + InputIndexLowerBound: 0, InputIndexUpperBound: 0, + }, nil) + // Epoch 1: self-lookup → nil (new epoch) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything, + ).Return(nil) + + var storedEpochs []*Epoch + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch := range epochInputMap { + storedEpochs = append(storedEpochs, epoch) + } + }).Return(nil) + + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, mock.Anything, MonitoredEvent_EpochSealed, mostRecentBlock, + ).Return(nil) + + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + // Two epochs stored + s.Require().Len(storedEpochs, 2) + // Sort by index for deterministic assertions + if storedEpochs[0].Index > storedEpochs[1].Index { + storedEpochs[0], storedEpochs[1] = storedEpochs[1], storedEpochs[0] + } + + // Epoch 0 + s.Require().Equal(uint64(0), storedEpochs[0].Index) + s.Require().Equal(inputBoxBlock, storedEpochs[0].FirstBlock) + s.Require().Equal(sealBlock0, storedEpochs[0].LastBlock) + s.Require().Equal(EpochStatus_Closed, storedEpochs[0].Status) + s.Require().Equal(&tournamentAddr0, storedEpochs[0].TournamentAddress) + + // Epoch 1 (PRT overlap: FirstBlock = prevEpoch.LastBlock) + s.Require().Equal(uint64(1), storedEpochs[1].Index) + s.Require().Equal(sealBlock0, storedEpochs[1].FirstBlock) + s.Require().Equal(sealBlock1, storedEpochs[1].LastBlock) + s.Require().Equal(EpochStatus_Closed, storedEpochs[1].Status) + s.Require().Equal(&tournamentAddr1, storedEpochs[1].TournamentAddress) + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 2) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 1) +} + +// --- Test 2: Open epoch happy path — new epoch with 1 input --- +// Exercises processApplicationOpenEpoch creating a new open epoch and fetching inputs. +func (s *SealedEpochsSuite) TestOpenEpochHappyPathCreatesNewEpoch() { + const ( + sealBlock uint64 = 100 + mostRecentBlock uint64 = 200 + ) + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + + // Last sealed epoch + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: sealBlock, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + + // Open epoch (index 1) doesn't exist yet + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + // Last input check block + s.repository.On("GetEventLastCheckBlock", + mock.Anything, int64(1), MonitoredEvent_InputAdded, + ).Return(sealBlock, nil) + + // DB has 3 inputs + s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(3), nil) + + // On-chain: 3 inputs before block 150, 4 from block 150 + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 150 + }), + mock.Anything, + ).Return(big.NewInt(3), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 150 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + + // Input 3 at block 150 + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 150 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 3, 150)}, nil) + + var storedEpoch *Epoch + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch, inputs := range epochInputMap { + storedEpoch = epoch + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processApplicationOpenEpoch(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + s.Require().NotNil(storedEpoch) + s.Require().Equal(uint64(1), storedEpoch.Index) + s.Require().Equal(sealBlock, storedEpoch.FirstBlock) // PRT overlap + s.Require().Equal(mostRecentBlock, storedEpoch.LastBlock) + s.Require().Equal(uint64(3), storedEpoch.InputIndexLowerBound) + s.Require().Equal(uint64(4), storedEpoch.InputIndexUpperBound) // 3 + 1 new + s.Require().Equal(EpochStatus_Open, storedEpoch.Status) + + s.Require().Len(storedInputs, 1) + s.Require().Equal(uint64(3), storedInputs[0].Index) + s.Require().Equal(uint64(150), storedInputs[0].BlockNumber) +} + +// --- Test 3: Open epoch with existing epoch accumulates inputs --- +// When processApplicationOpenEpoch finds an existing open epoch in the DB, +// it must reuse it and add new inputs (incrementing InputIndexUpperBound). +func (s *SealedEpochsSuite) TestOpenEpochExistingEpochAccumulatesInputs() { + const mostRecentBlock uint64 = 300 + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + + // Last sealed epoch + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + + // Open epoch already exists from a previous tick + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(&Epoch{ + Index: 1, FirstBlock: 100, LastBlock: 200, + InputIndexLowerBound: 3, InputIndexUpperBound: 4, + Status: EpochStatus_Open, + }, nil) + + // Last input check at block 200 + s.repository.On("GetEventLastCheckBlock", + mock.Anything, int64(1), MonitoredEvent_InputAdded, + ).Return(uint64(200), nil) + + // DB has 4 inputs + s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(4), nil) + + // On-chain: 4 inputs before block 250, 5 from block 250 + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 250 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 250 + }), + mock.Anything, + ).Return(big.NewInt(5), nil) + + // Input 4 at block 250 + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 250 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 4, 250)}, nil) + + var storedEpoch *Epoch + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch, inputs := range epochInputMap { + storedEpoch = epoch + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processApplicationOpenEpoch(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + // Existing epoch reused with accumulated inputs + s.Require().Equal(uint64(1), storedEpoch.Index) + s.Require().Equal(uint64(100), storedEpoch.FirstBlock) // Preserved + s.Require().Equal(mostRecentBlock, storedEpoch.LastBlock) // Updated + s.Require().Equal(uint64(3), storedEpoch.InputIndexLowerBound) // Preserved + s.Require().Equal(uint64(5), storedEpoch.InputIndexUpperBound) // 4 + 1 new + s.Require().Equal(EpochStatus_Open, storedEpoch.Status) + + s.Require().Len(storedInputs, 1) + s.Require().Equal(uint64(4), storedInputs[0].Index) +} + +func (s *SealedEpochsSuite) TestOpenEpochScansBoundaryBlockAfterIntraTickCursorAdvance() { + const ( + previousInputCursor uint64 = 100 + mostRecentBlock uint64 = 200 + ) + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastInputCheckBlock = previousInputCursor + + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(&Epoch{ + Index: 0, + FirstBlock: 10, + LastBlock: mostRecentBlock, + InputIndexLowerBound: 0, + InputIndexUpperBound: 4, + }, nil) + + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + s.repository.On("GetEventLastCheckBlock", + mock.Anything, int64(1), MonitoredEvent_InputAdded, + ).Return(mostRecentBlock-1, nil) + + s.repository.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(4), nil) + + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() == mostRecentBlock + }), + mock.Anything, + ).Return(big.NewInt(5), nil) + + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == mostRecentBlock }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 4, mostRecentBlock)}, nil) + + var storedEpoch *Epoch + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch, inputs := range epochInputMap { + storedEpoch = epoch + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processApplicationOpenEpoch(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + s.Require().NotNil(storedEpoch) + s.Require().Equal(uint64(1), storedEpoch.Index) + s.Require().Equal(mostRecentBlock, storedEpoch.FirstBlock) + s.Require().Equal(mostRecentBlock, storedEpoch.LastBlock) + s.Require().Equal(uint64(5), storedEpoch.InputIndexUpperBound) + s.Require().Len(storedInputs, 1) + s.Require().Equal(uint64(4), storedInputs[0].Index) + s.Require().Equal(mostRecentBlock, storedInputs[0].BlockNumber) +} + +// --- Test 4: Multiple EpochSealed events in one block --- +// When two epochs are sealed in the same block, FindTransitions sees a single +// transition, but RetrieveSealedEpochs returns both events and each is processed. +func (s *SealedEpochsSuite) TestMultipleSealedEpochsInOneBlock() { + const ( + sealBlock uint64 = 100 + mostRecentBlock uint64 = 200 + ) + + tournamentAddr0 := common.HexToAddress("0xA000") + tournamentAddr1 := common.HexToAddress("0xA001") + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastEpochCheckBlock = 50 + + // Oracle: before sealBlock → -1, from sealBlock → 1 (both sealed at same block) + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Int64() < int64(sealBlock) + }), + ).Return(makeSealedEpochResult(-1, 0, 0, common.Address{}), nil) + + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock + }), + ).Return(makeSealedEpochResult(1, 0, 0, tournamentAddr1), nil) + + // Both sealed events at the same block + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, sealBlock, tournamentAddr0), + makeSealedEpochEvent(1, 0, 0, sealBlock, tournamentAddr1), + }, nil) + + // Epoch 0: self-lookup → nil (new) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(nil, nil).Once() + // Epoch 1: prev epoch lookup → epoch 0 data + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: sealBlock, + InputIndexLowerBound: 0, InputIndexUpperBound: 0, + }, nil) + // Epoch 1: self-lookup → nil (new) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything, + ).Return(nil) + + var storedEpochs []*Epoch + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch := range epochInputMap { + storedEpochs = append(storedEpochs, epoch) + } + }).Return(nil) + + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, mock.Anything, MonitoredEvent_EpochSealed, mostRecentBlock, + ).Return(nil) + + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, mostRecentBlock) + s.Require().NoError(err) + + s.Require().Len(storedEpochs, 2) + if storedEpochs[0].Index > storedEpochs[1].Index { + storedEpochs[0], storedEpochs[1] = storedEpochs[1], storedEpochs[0] + } + + s.Require().Equal(uint64(0), storedEpochs[0].Index) + s.Require().Equal(uint64(10), storedEpochs[0].FirstBlock) + s.Require().Equal(sealBlock, storedEpochs[0].LastBlock) + + s.Require().Equal(uint64(1), storedEpochs[1].Index) + s.Require().Equal(sealBlock, storedEpochs[1].FirstBlock) // PRT overlap + s.Require().Equal(sealBlock, storedEpochs[1].LastBlock) // Same block + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 2) +} + +// The deployment floor does not certify a successful scan or change its cursor. +func (s *SealedEpochsSuite) TestInitialSealedEpochSearchBlockDoesNotSetCheckpoint() { + app := newDaveAppContracts(nil, s.dave) + + // DaveConsensus deployed at block 50 + s.dave.On("GetDeploymentBlockNumber", mock.Anything). + Return(big.NewInt(50), nil) + + block, err := s.evmReader.initialSealedEpochSearchBlock(s.ctx, app, 200) + s.Require().NoError(err) + s.Equal(uint64(50), block) + s.Zero(app.application.LastEpochCheckBlock) + s.Empty(s.repository.Calls) +} + +func (s *SealedEpochsSuite) TestInitializeSealedEpochSyncSkipsBeforeConsensusDeployment() { + app := newDaveAppContracts(nil, s.dave) + + s.dave.On("GetDeploymentBlockNumber", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber != nil && opts.BlockNumber.Uint64() == 90 + }), + ).Return(new(big.Int), bind.ErrNoCode).Once() + + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, 90) + s.Require().ErrorIs(err, errContractNotDeployedAtBlock) + s.Require().ErrorIs(err, bind.ErrNoCode) + s.Require().Zero(app.application.LastEpochCheckBlock) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} diff --git a/internal/evmreader/dave_readiness_test.go b/internal/evmreader/dave_readiness_test.go new file mode 100644 index 000000000..08aae4a85 --- /dev/null +++ b/internal/evmreader/dave_readiness_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveReadinessDistinguishesDeploymentWaitFromScanFailure(t *testing.T) { + const head uint64 = 100 + for _, test := range []struct { + name string + deploymentWait bool + initialStatus model.ApplicationStatus + statusWriteErr error + }{ + {name: "deployment not yet visible", deploymentWait: true}, + {name: "missing sealed epoch"}, + {name: "existing integrity failure", initialStatus: model.ApplicationStatus_Diverged}, + {name: "corruption status write fails", statusWriteErr: errors.New("database unavailable")}, + {name: "execution terminal escalation fails", initialStatus: model.ApplicationStatus_MachineHalted, + statusWriteErr: errors.New("database unavailable")}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + client := newMockEthClient() + app := &model.Application{ + ID: 1, Name: "dave-readiness", Enabled: true, Status: model.ApplicationStatus_OK, + ConsensusType: model.Consensus_PRT, IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, + LastEpochCheckBlock: head, LastInputCheckBlock: head - 1, + LastOutputCheckBlock: head, LastForecloseCheckBlock: head, + } + if test.initialStatus != "" { + app.Status = test.initialStatus + } + client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil) + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + adapters := newMockAdapterFactory() + adapters.On("CreateAdapters", app). + Return(newMockApplicationContract(), newMockInputBox(), dave, nil).Once() + if test.deploymentWait { + app.LastEpochCheckBlock = 0 + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int), bind.ErrNoCode) + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil) + if app.Status != model.ApplicationStatus_Diverged { + statusWrite := repo.On("UpdateApplicationStatus", t.Context(), app.ID, + model.ApplicationStatus_Corrupted, mock.Anything).Return(test.statusWriteErr) + if test.statusWriteErr == nil { + statusWrite.Once() + } + } + } + reader := &Service{client: client, repository: repo, + defaultBlock: model.DefaultBlock_Finalized, readyMaxStaleness: time.Hour} + reader.Logger = testLogger(t) + reader.resolver = newApplicationAdapterResolver(reader.Logger, adapters) + + for cycle := 1; cycle <= maxConsecutiveScanFailures+1; cycle++ { + reschedule, err := reader.Tick(t.Context()) + require.NoError(t, err) + require.False(t, reschedule) + require.Equal(t, test.statusWriteErr == nil || cycle < maxConsecutiveScanFailures, reader.Ready()) + } + if test.deploymentWait { + require.Zero(t, reader.consecutiveScanFailures.Load()) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "GetLastNonOpenEpoch", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + if test.statusWriteErr == nil { + require.Zero(t, reader.consecutiveScanFailures.Load()) + if test.initialStatus == model.ApplicationStatus_Diverged { + require.Equal(t, test.initialStatus, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + } + } else { + require.EqualValues(t, maxConsecutiveScanFailures, reader.consecutiveScanFailures.Load()) + if test.initialStatus != "" { + require.Equal(t, test.initialStatus, app.Status) + } else { + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + } + } + repo.AssertExpectations(t) + dave.AssertExpectations(t) + client.AssertExpectations(t) + adapters.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/daveconsensus_adapter.go b/internal/evmreader/daveconsensus_adapter.go new file mode 100644 index 000000000..22628a0f0 --- /dev/null +++ b/internal/evmreader/daveconsensus_adapter.go @@ -0,0 +1,144 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Interface for DaveConsensus reading +type DaveConsensusAdapter interface { + GetInputBox(opts *bind.CallOpts) (common.Address, error) + GetCurrentSealedEpoch(opts *bind.CallOpts) (DaveCurrentSealedEpoch, error) + GetApplicationContract(opts *bind.CallOpts) (common.Address, error) + GetTournamentFactory(opts *bind.CallOpts) (common.Address, error) + GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) + RetrieveSealedEpochs(opts *bind.FilterOpts) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) +} + +// DaveCurrentSealedEpoch contains the fields used to observe sealed epochs. +type DaveCurrentSealedEpoch struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address +} + +// DaveConsensus Wrapper +type DaveConsensusAdapterImpl struct { + daveConsensus *idaveconsensus.IDaveConsensus + client *ethclient.Client + daveConsensusAddress common.Address + filter ethutil.Filter +} + +func NewDaveConsensusAdapter( + daveConsensusAddress common.Address, + client *ethclient.Client, + filter ethutil.Filter, +) (DaveConsensusAdapter, error) { + daveConsensusContract, err := idaveconsensus.NewIDaveConsensus(daveConsensusAddress, client) + if err != nil { + return nil, err + } + return &DaveConsensusAdapterImpl{ + daveConsensus: daveConsensusContract, + daveConsensusAddress: daveConsensusAddress, + client: client, + filter: filter, + }, nil +} + +func buildEpochSealedFilterQuery( + opts *bind.FilterOpts, + daveConsensusAddress common.Address, +) (q ethereum.FilterQuery, err error) { + c, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + if err != nil { + return q, err + } + + topics, err := abi.MakeTopics( + []any{c.Events[MonitoredEvent_EpochSealed.String()].ID}, + ) + if err != nil { + return q, err + } + + q = ethereum.FilterQuery{ + Addresses: []common.Address{daveConsensusAddress}, + FromBlock: new(big.Int).SetUint64(opts.Start), + Topics: topics, + } + if opts.End != nil { + q.ToBlock = new(big.Int).SetUint64(*opts.End) + } + return q, err +} + +func (d *DaveConsensusAdapterImpl) GetInputBox(opts *bind.CallOpts) (common.Address, error) { + return d.daveConsensus.GetInputBox(opts) +} + +func (d *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (DaveCurrentSealedEpoch, error) { + result, err := d.daveConsensus.GetCurrentSealedEpoch(opts) + if err != nil { + return DaveCurrentSealedEpoch{}, err + } + return DaveCurrentSealedEpoch{ + EpochNumber: result.EpochNumber, + InputIndexLowerBound: result.InputIndexLowerBound, + InputIndexUpperBound: result.InputIndexUpperBound, + Tournament: result.Tournament, + }, nil +} + +func (d *DaveConsensusAdapterImpl) GetApplicationContract(opts *bind.CallOpts) (common.Address, error) { + return d.daveConsensus.GetApplicationContract(opts) +} + +func (d *DaveConsensusAdapterImpl) GetTournamentFactory(opts *bind.CallOpts) (common.Address, error) { + return d.daveConsensus.GetTournamentFactory(opts) +} + +func (d *DaveConsensusAdapterImpl) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) { + return d.daveConsensus.GetDeploymentBlockNumber(opts) +} + +func (d *DaveConsensusAdapterImpl) RetrieveSealedEpochs( + opts *bind.FilterOpts, +) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) { + q, err := buildEpochSealedFilterQuery(opts, d.daveConsensusAddress) + if err != nil { + return nil, err + } + + itr, err := d.filter.ChunkedFilterLogs(opts.Context, d.client, q) + if err != nil { + return nil, err + } + + var events []*idaveconsensus.IDaveConsensusEpochSealed + for log, err := range itr { + if err != nil { + return nil, err + } + ev, err := d.daveConsensus.ParseEpochSealed(*log) + if err != nil { + return nil, err + } + events = append(events, ev) + } + return events, nil +} diff --git a/internal/evmreader/edge_cases_test.go b/internal/evmreader/edge_cases_test.go new file mode 100644 index 000000000..b8dbfdaa2 --- /dev/null +++ b/internal/evmreader/edge_cases_test.go @@ -0,0 +1,533 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "fmt" + "math" + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +// --- #19: Duplicate/out-of-order input events within a block --- +// When RetrieveInputs returns duplicate events (same index) at a single block, +// insertSorted must deduplicate them. Out-of-order events must be sorted by index. +func (s *EvmReaderSuite) TestFetchApplicationInputsDuplicateAndOutOfOrderEvents() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + s.evmReader.repository = repo + + // On-chain: 0 inputs before block 100, 3 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(3), nil) + + // RetrieveInputs at block 100: 4 events including duplicate index 1, + // delivered out-of-order: [2, 0, 1, 1] + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 2, 100), + makeInputEvent(addr, 0, 100), + makeInputEvent(addr, 1, 100), + makeInputEvent(addr, 1, 100), // duplicate + }, nil) + + prevValue := new(big.Int).SetUint64(0) // repo returns 0 inputs + inputs, _, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 0, math.MaxUint64) + s.Require().NoError(err) + + // 3 unique inputs, sorted by index + s.Require().Len(inputs, 3) + s.Require().Equal(uint64(0), inputs[0].Index) + s.Require().Equal(uint64(1), inputs[1].Index) + s.Require().Equal(uint64(2), inputs[2].Index) +} + +func (s *EvmReaderSuite) TestFetchInputsSameTransactionHashPreservesLogIndex() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + txHash := common.HexToHash("0xfeed") + input0 := makeInputEvent(addr, 0, 100) + input0.Raw.TxHash = txHash + input0.Raw.Index = 7 + input1 := makeInputEvent(addr, 1, 100) + input1.Raw.TxHash = txHash + input1.Raw.Index = 8 + + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{input0, input1}, nil) + + inputs, _, err := s.evmReader.fetchInputs( + s.ctx, app, 10, 200, new(big.Int).SetUint64(0), 0, math.MaxUint64) + s.Require().NoError(err) + s.Require().Len(inputs, 2) + s.Equal(txHash, inputs[0].TransactionHash) + s.Equal(uint64(7), inputs[0].LogIndex) + s.Equal(txHash, inputs[1].TransactionHash) + s.Equal(uint64(8), inputs[1].LogIndex) +} + +// sameTransactionScanApp builds the app/mocks for a scan of block 100 that +// finds two InputAdded events sharing one transaction hash (log indexes 7, 8). +func (s *EvmReaderSuite) sameTransactionScanApp() (appContracts, common.Hash) { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + Enabled: true, + Status: ApplicationStatus_OK, + IInputBoxBlock: 10, + EpochLength: 10, + LastInputCheckBlock: 99, + }, + inputSource: inputSrc, + } + + txHash := common.HexToHash("0xfeed") + event0 := makeInputEvent(addr, 0, 100) + event0.Raw.TxHash = txHash + event0.Raw.Index = 7 + event1 := makeInputEvent(addr, 1, 100) + event1.Raw.TxHash = txHash + event1.Raw.Index = 8 + + inputSrc.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() == 100 + }), + addr, + ).Return(new(big.Int).SetUint64(2), nil) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 100 && opts.End != nil && *opts.End == 100 + }), + []common.Address{addr}, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{event0, event1}, nil).Once() + + return app, txHash +} + +// The same-transaction pair must survive the full scan path — fetch, sort, +// epoch indexing — and reach CreateEpochsAndInputs with the shared +// transaction hash and distinct log indexes. +func (s *EvmReaderSuite) TestSameTransactionInputsReachRepository() { + app, txHash := s.sameTransactionScanApp() + addr := app.application.IApplicationAddress + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, addr.String()).Return(uint64(0), nil) + repo.On("GetEpoch", mock.Anything, addr.String(), uint64(9)).Return(nil, nil) + repo.On("CreateEpochsAndInputs", + mock.Anything, addr.String(), mock.Anything, uint64(100), + ).Run(func(arguments mock.Arguments) { + epochInputMap, ok := arguments.Get(2).(map[*Epoch][]*Input) + s.Require().True(ok) + s.Require().Len(epochInputMap, 1) + for _, inputs := range epochInputMap { + s.Require().Len(inputs, 2) + s.Require().Equal(uint64(0), inputs[0].Index) + s.Require().Equal(txHash, inputs[0].TransactionHash) + s.Require().Equal(uint64(7), inputs[0].LogIndex) + s.Require().Equal(uint64(1), inputs[1].Index) + s.Require().Equal(txHash, inputs[1].TransactionHash) + s.Require().Equal(uint64(8), inputs[1].LogIndex) + } + }).Return(nil).Once() + s.evmReader.repository = repo + + s.evmReader.scanIConsensusInputs(s.ctx, []appContracts{app}, 100) + + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) +} + +// A CreateEpochsAndInputs failure tagged ErrInputLogIdentityConflict means the +// stored inputs diverged from rescanned chain data; the scan must escalate the +// application to CORRUPTED instead of retrying the same insert forever. +func (s *EvmReaderSuite) TestInputLogIdentityConflictMarksApplicationCorrupted() { + app, _ := s.sameTransactionScanApp() + addr := app.application.IApplicationAddress + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, addr.String()).Return(uint64(0), nil) + repo.On("GetEpoch", mock.Anything, addr.String(), uint64(9)).Return(nil, nil) + repo.On("CreateEpochsAndInputs", + mock.Anything, addr.String(), mock.Anything, uint64(100), + ).Return(fmt.Errorf("insert failed: %w", repository.ErrInputLogIdentityConflict)).Once() + repo.On("UpdateApplicationStatus", + mock.Anything, app.application.ID, ApplicationStatus_Corrupted, mock.Anything, + ).Return(nil).Once() + s.evmReader.repository = repo + + s.evmReader.scanIConsensusInputs(s.ctx, []appContracts{app}, 100) + + repo.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 1) + s.Equal(ApplicationStatus_Corrupted, app.application.Status) +} + +// --- Bounds filtering: inputs outside [lowerBound, upperBound) are excluded --- +// When RetrieveInputs returns events with indices outside the requested bounds, +// fetchInputs must exclude them. This exercises the bounds filtering added in the +// unified fetchInputs (C2 consolidation). +func (s *EvmReaderSuite) TestFetchInputsBoundsFiltering() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + // On-chain: 0 inputs before block 100, 5 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(5), nil) + + // RetrieveInputs returns indices 0..4, but we only want [2, 4) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 100), // below lowerBound → excluded + makeInputEvent(addr, 1, 100), // below lowerBound → excluded + makeInputEvent(addr, 2, 100), // in bounds → included + makeInputEvent(addr, 3, 100), // in bounds → included + makeInputEvent(addr, 4, 100), // >= upperBound → excluded + }, nil) + + prevValue := new(big.Int).SetUint64(0) + inputs, _, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 2, 4) + s.Require().NoError(err) + + // Only indices 2 and 3 should be included + s.Require().Len(inputs, 2) + s.Require().Equal(uint64(2), inputs[0].Index) + s.Require().Equal(uint64(3), inputs[1].Index) +} + +// --- Bounds filtering: upperBound == lowerBound yields zero inputs --- +// When lowerBound == upperBound, the half-open range [lb, ub) is empty, +// so no inputs should be returned even if the chain has matching events. +func (s *EvmReaderSuite) TestFetchInputsEmptyBoundsRange() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + // On-chain: 0 inputs before block 100, 2 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 100), + makeInputEvent(addr, 1, 100), + }, nil) + + // lowerBound == upperBound == 5 → empty range + prevValue := new(big.Int).SetUint64(0) + inputs, _, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 5, 5) + s.Require().NoError(err) + s.Require().Empty(inputs) +} + +// --- Bounds filtering: boundary-exact inclusion/exclusion --- +// Verifies the half-open [lowerBound, upperBound) semantics precisely: +// lowerBound is inclusive, upperBound is exclusive. +func (s *EvmReaderSuite) TestFetchInputsBoundaryExactness() { + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + inputSrc := &MockInputBox{} + app := appContracts{ + application: &Application{ + Name: "test-app", + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: inputSrc, + } + + // On-chain: 0 inputs before block 100, 3 from block 100 + inputSrc.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(3), nil) + + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 100), + makeInputEvent(addr, 1, 100), + makeInputEvent(addr, 2, 100), + }, nil) + + // Range [1, 2): only index 1 should be included + prevValue := new(big.Int).SetUint64(0) + inputs, _, err := s.evmReader.fetchInputs(s.ctx, app, 10, 200, prevValue, 1, 2) + s.Require().NoError(err) + s.Require().Len(inputs, 1) + s.Require().Equal(uint64(1), inputs[0].Index) +} + +// --- #18: Adversarial EpochSealed data (LowerBound > UpperBound) --- +// When a sealed event has InputIndexLowerBound > InputIndexUpperBound, +// the code skips input fetching and stores the epoch without crashing. +func (s *SealedEpochsSuite) TestSealedEpochLowerBoundGreaterThanUpperBound() { + tournamentAddr := common.HexToAddress("0xDDDD") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + // Adversarial sealed event: LowerBound=5, UpperBound=3 + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(5), + InputIndexUpperBound: big.NewInt(3), // < LowerBound — adversarial + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: 200}, + } + + // Previous epoch (index 0) with UpperBound=5 matching the adversarial LowerBound + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 5, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything, + ).Return(nil) + + // Epoch 1 doesn't exist yet + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + var storedEpoch *Epoch + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for epoch, inputs := range epochInputMap { + storedEpoch = epoch + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().NoError(err) + + // Epoch stored with inverted bounds, no inputs fetched + s.Require().NotNil(storedEpoch) + s.Require().Equal(uint64(1), storedEpoch.Index) + s.Require().Equal(uint64(5), storedEpoch.InputIndexLowerBound) + s.Require().Equal(uint64(3), storedEpoch.InputIndexUpperBound) + s.Require().Equal(EpochStatus_Closed, storedEpoch.Status) + s.Require().Empty(storedInputs) + + // No input fetching occurred + s.inputBox.AssertNotCalled(s.T(), "GetNumberOfInputs") + s.inputBox.AssertNotCalled(s.T(), "RetrieveInputs") +} + +// --- #10: RetrieveInputs failure at a specific block --- +// When RetrieveInputs fails during fetchInputs, the error must +// propagate through FindTransitions back to the caller. +func (s *SealedEpochsSuite) TestFetchSealedEpochInputsRetrieveFailure() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 10, + }, + inputSource: s.inputBox, + } + + epoch := &Epoch{ + Index: 0, + FirstBlock: 10, + LastBlock: 200, + InputIndexLowerBound: 0, + InputIndexUpperBound: 2, + } + + // On-chain: 0 inputs before block 100, 2 from block 100 + s.inputBox.On("GetNumberOfInputs", blockRange(0, 100), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + s.inputBox.On("GetNumberOfInputs", blockFrom(100), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + + // RetrieveInputs fails at the transition block + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 100 }), + mock.Anything, mock.Anything, + ).Return(([]iinputbox.IInputBoxInputAdded)(nil), errors.New("RPC timeout")) + + prevValue := new(big.Int).SetUint64(epoch.InputIndexLowerBound) + _, _, err := s.evmReader.fetchInputs(s.ctx, app, + epoch.FirstBlock, epoch.LastBlock, + prevValue, + epoch.InputIndexLowerBound, epoch.InputIndexUpperBound) + s.Require().Error(err) + s.Require().ErrorContains(err, "RPC timeout") + s.Require().ErrorContains(err, "failed to walk input transitions") +} + +// --- Adapter cache invalidation on config change --- +// When an application's consensus address changes between block headers, +// the adapter cache must be invalidated and adapters recreated. +func (s *EvmReaderSuite) TestAdapterCacheInvalidationOnConfigChange() { + // Fire 3 headers (block numbers below 999 so output check skips) + s.client.EnqueueNewHead(100).Once() + s.client.EnqueueNewHead(101).Once() + called := newCallNotification(s.client.EnqueueNewHead(102)) + + s.evmReader.defaultBlock = DefaultBlock_Latest + + addr := common.HexToAddress("0x4444444444444444444444444444444444444444") + consensusAddr1 := common.HexToAddress("0xAAA1") + consensusAddr2 := common.HexToAddress("0xAAA2") + + repo := newMockRepository() + // Header 1: app with consensus=addr1 + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{{ + ID: 1, Name: "app", + IApplicationAddress: addr, + IConsensusAddress: consensusAddr1, + IInputBoxAddress: inputBoxAddr, + LastOutputCheckBlock: 999, // > header block → skip output check + LastInputCheckBlock: 999, // input scan is already current for this adapter test + LastForecloseCheckBlock: 999, + }}, uint64(1), nil).Once() + // Header 2: consensus address changed → cache invalidation + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{{ + ID: 1, Name: "app", + IApplicationAddress: addr, + IConsensusAddress: consensusAddr2, + IInputBoxAddress: inputBoxAddr, + LastOutputCheckBlock: 999, + LastInputCheckBlock: 999, + LastForecloseCheckBlock: 999, + }}, uint64(1), nil).Once() + // Header 3: same config as header 2 → cache hit + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{{ + ID: 1, Name: "app", + IApplicationAddress: addr, + IConsensusAddress: consensusAddr2, + IInputBoxAddress: inputBoxAddr, + LastOutputCheckBlock: 999, + LastInputCheckBlock: 999, + LastForecloseCheckBlock: 999, + }}, uint64(1), nil).Once() + // Catch-all for sentinel header + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + repo.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, int64(1), mock.Anything, + ).Return(nil).Maybe() + s.evmReader.repository = repo + + factory := newMockAdapterFactory() + factory.On("CreateAdapters", mock.Anything). + Return(newMockApplicationContract(), newMockInputBox(), nil, nil) + s.evmReader.adapterFactory = factory + s.evmReader.resolver = newApplicationAdapterResolver(s.evmReader.Logger, factory) + + done := make(chan struct{}) + go func() { + err := s.supervisor.Serve() + s.Require().NoError(err) + close(done) + }() + + s.Require().True(waitNotification(called), "evmreader did not read headers") + s.supervisor.Stop() + s.Require().True(waitNotification(done), "evmreader did not stop after supervisor was stopped") + + // CreateAdapters called twice: + // Header 1: cache miss → create + // Header 2: consensus changed → invalidate + recreate + // Header 3: cache hit → skip + factory.AssertNumberOfCalls(s.T(), "CreateAdapters", 2) +} diff --git a/internal/evmreader/error_paths_test.go b/internal/evmreader/error_paths_test.go new file mode 100644 index 000000000..6051ecd3c --- /dev/null +++ b/internal/evmreader/error_paths_test.go @@ -0,0 +1,616 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- Priority 1: CreateEpochsAndInputs error → checkpoint not advanced --- +// When CreateEpochsAndInputs fails for an app with inputs, the checkpoint must +// NOT be advanced. Apps with inputs are excluded from the no-input checkpoint +// update path, so no UpdateEventLastCheckBlock call should occur. +func (s *EvmReaderSuite) TestCreateEpochsAndInputsErrorDoesNotAdvanceCheckpoint() { + require := require.New(s.T()) + + addr := common.HexToAddress("0x1111111111111111111111111111111111111111") + + app := &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + + // Input source: 0 inputs before block 105, 1 from block 105 + inputSrc := &MockInputBox{} + inputSrc.On("GetNumberOfInputs", blockRange(0, 105), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(105), mock.Anything). + Return(new(big.Int).SetUint64(1), nil) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 105 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(addr, 0, 105)}, nil) + + apps := []appContracts{ + {application: app, inputSource: inputSrc}, + } + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + repo.On("GetEpoch", mock.Anything, mock.Anything, uint64(10)). + Return(nil, nil) + repo.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Return(errors.New("database connection lost")) + + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) + require.ErrorContains(err, "store inputs and epochs: database connection lost") + + // CreateEpochsAndInputs was attempted + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + // Checkpoint must NOT be advanced + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + +// --- Priority 2: Sealed epoch input count mismatch → error, no epoch stored --- +// When fetchInputs returns fewer inputs than the sealed event +// declares, processSealedEpochEvent must return an error and NOT store the epoch. +func (s *SealedEpochsSuite) TestSealedEpochInputCountMismatchReturnsError() { + const sealBlock uint64 = 200 + tournamentAddr := common.HexToAddress("0xAAAA") + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + + // Sealed event expects 2 inputs (indices 3 to 5), but only 1 exists on-chain. + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(3), + InputIndexUpperBound: big.NewInt(5), + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: sealBlock}, + } + + // Previous epoch (index 0) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything).Return(nil) + // Epoch 1 doesn't exist yet + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + // On-chain: only 4 inputs total (1 new instead of expected 2) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 150 + }), + mock.Anything, + ).Return(big.NewInt(3), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 150 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 150 + }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(app1Addr, 3, 150), + }, nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().Error(err) + s.Require().ErrorIs(err, errInconclusiveEpochView) + s.Require().ErrorContains(err, "input count mismatch") + s.Require().ErrorContains(err, "expected 2") + s.Require().ErrorContains(err, "got 1") + + // Epoch must NOT be stored + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + + // A complete response to the same event can be committed on retry. No + // cursor was advanced past either missing input by the first attempt. + s.inputBox.Unset("GetNumberOfInputs") + s.inputBox.Unset("RetrieveInputs") + s.inputBox.On("GetNumberOfInputs", blockRange(0, 150), mock.Anything).Return(big.NewInt(3), nil) + s.inputBox.On("GetNumberOfInputs", blockFrom(150), mock.Anything).Return(big.NewInt(5), nil) + s.inputBox.On("RetrieveInputs", inputLogBlock(150), mock.Anything, mock.Anything). + Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(app1Addr, 3, 150), makeInputEvent(app1Addr, 4, 150), + }, nil).Once() + s.repository.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), mock.Anything, sealBlock-1). + Run(func(args mock.Arguments) { + epochs := args.Get(2).(map[*Epoch][]*Input) + s.Require().Len(epochs, 1) + for epoch, inputs := range epochs { + s.Require().Equal(uint64(1), epoch.Index) + s.Require().Equal(EpochStatus_Closed, epoch.Status) + s.Require().Len(inputs, 2) + } + }).Return(nil).Once() + s.Require().NoError(s.evmReader.processSealedEpochEvent(s.ctx, app, event)) + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + +// --- Priority 3: Existing epoch FirstBlock mismatch → error --- +// When a sealed epoch already exists in the DB with a different FirstBlock +// than what the previous epoch's LastBlock implies, processSealedEpochEvent +// must return a data mismatch error. +func (s *SealedEpochsSuite) TestSealedEpochFirstBlockMismatchReturnsError() { + tournamentAddr := common.HexToAddress("0xBBBB") + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + + // Epoch 1 sealed. Previous epoch (0) has LastBlock=100 → firstBlock should be 100. + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(3), + InputIndexUpperBound: big.NewInt(3), // no inputs + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: 200}, + } + + // Previous epoch + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, FirstBlock: 10, LastBlock: 100, + InputIndexLowerBound: 0, InputIndexUpperBound: 3, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", + mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Existing epoch 1 has WRONG FirstBlock (50 instead of 100) + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(&Epoch{ + Index: 1, + FirstBlock: 50, // should be 100 (prevEpoch.LastBlock) + InputIndexLowerBound: 3, + }, nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().Error(err) + s.Require().ErrorContains(err, "data mismatch") + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +// --- Priority 4: GetLastNonOpenEpoch nil → app set corrupted --- +// When processApplicationOpenEpoch finds no non-open epoch, the application +// must be set corrupted. This is an invariant of the DaveConsensus protocol. +func (s *SealedEpochsSuite) TestOpenEpochWithNoNonOpenEpochSetsCorrupted() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: app1Addr, + }, + inputSource: s.inputBox, + } + + s.repository.On("GetLastNonOpenEpoch", mock.Anything, mock.Anything). + Return(nil, nil) + s.repository.On("UpdateApplicationStatus", + mock.Anything, int64(1), ApplicationStatus_Corrupted, mock.Anything, + ).Return(nil).Once() + + err := s.evmReader.processApplicationOpenEpoch(s.ctx, app, 200) + s.Require().Error(err) + s.Require().ErrorContains(err, "no non open epochs found") + + s.repository.AssertExpectations(s.T()) + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +// --- Priority 5: Epoch 0 with IInputBoxBlock=0 → error --- +// When processing sealed epoch 0 for an app without a configured InputBox +// block number, processSealedEpochEvent must return an error immediately. +func (s *SealedEpochsSuite) TestSealedEpoch0WithNoInputBoxBlockReturnsError() { + tournamentAddr := common.HexToAddress("0xCCCC") + + app := appContracts{ + application: &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 0, // misconfigured + }, + inputSource: s.inputBox, + daveConsensus: s.dave, + } + + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(0), + InputIndexLowerBound: big.NewInt(0), + InputIndexUpperBound: big.NewInt(0), + Tournament: tournamentAddr, + Raw: types.Log{BlockNumber: 100}, + } + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, event) + s.Require().Error(err) + s.Require().ErrorContains(err, "no InputBox block number defined") + + // No DB operations should occur + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + s.repository.AssertNumberOfCalls(s.T(), "GetEpoch", 0) +} + +// --- Priority 6: UpdateOutputsExecution error → checkpoint not advanced --- +// When UpdateOutputsExecution fails, no UpdateEventLastCheckBlock for +// OutputExecuted should be called. The checkpoint update is embedded in +// UpdateOutputsExecution, so failure means no checkpoint advance. +func (s *EvmReaderSuite) TestUpdateOutputsExecutionErrorDoesNotAdvanceCheckpoint() { + appContract := &MockApplicationContract{} + // On-chain: 0 executed outputs before block 50, 1 from block 50 + appContract.On("GetNumberOfExecutedOutputs", blockRange(0, 50)). + Return(new(big.Int).SetUint64(0), nil) + appContract.On("GetNumberOfExecutedOutputs", blockFrom(50)). + Return(new(big.Int).SetUint64(1), nil) + appContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 50 + }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) + + app := appContracts{ + application: &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: app1Addr, + }, + applicationContract: appContract, + } + + repo := newMockRepository() + repo.On("GetNumberOfExecutedOutputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + repo.On("GetOutput", mock.Anything, mock.Anything, uint64(0)). + Return(output0, nil) + repo.On("UpdateOutputsExecution", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Return(errors.New("database connection lost")) + + s.evmReader.repository = repo + + s.evmReader.readAndUpdateOutputs(s.ctx, app, 40, 60) + + // UpdateOutputsExecution was attempted once (and failed) + repo.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 1) + // Checkpoint must NOT be advanced + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + +// --- Priority 7: Block regression → no DB writes, warn logged --- +// When mostRecentBlockNumber < lastProcessedBlock (chain reorg or node +// misconfiguration), scanIConsensusInputs must not write to the database. +func (s *EvmReaderSuite) TestBlockRegressionDoesNotWriteToDb() { + app := &Application{ + Name: daveTestApplicationName, + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + + apps := []appContracts{ + {application: app}, + } + + repo := newMockRepository() + s.evmReader.repository = repo + + // mostRecentBlockNumber (90) < lastProcessedBlock (100) → block regression + s.evmReader.scanIConsensusInputs(s.ctx, apps, 90) + + // No DB writes should happen + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(s.T(), "GetEpoch", 0) + repo.AssertNumberOfCalls(s.T(), "GetNumberOfInputs", 0) +} + +// --- Block regression in sealed epoch path → no DB writes --- +// When mostRecentBlockNumber < LastEpochCheckBlock, processApplicationSealedEpochs +// must skip processing and not write to the database. +func (s *SealedEpochsSuite) TestSealedEpochBlockRegressionDoesNotWriteToDb() { + app := newDaveAppContracts(nil, s.dave) + app.application.LastEpochCheckBlock = 200 + + // mostRecentBlockNumber (150) < LastEpochCheckBlock (200) → regression + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, 150) + s.Require().NoError(err) + + // No DB writes should occur + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + s.repository.AssertNumberOfCalls(s.T(), "GetLastNonOpenEpoch", 0) +} + +// --- Block regression in output execution path → no DB writes --- +// When mostRecentBlockNumber < LastOutputCheckBlock, checkForOutputExecution +// must skip processing and not write to the database. +func (s *EvmReaderSuite) TestOutputBlockRegressionDoesNotWriteToDb() { + app := appContracts{ + application: &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: app1Addr, + LastOutputCheckBlock: 100, + }, + applicationContract: &MockApplicationContract{}, + } + + repo := newMockRepository() + s.evmReader.repository = repo + + // mostRecentBlockNumber (80) < LastOutputCheckBlock (100) + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{app}, 80) + + // No DB writes should occur + repo.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(s.T(), "GetNumberOfExecutedOutputs", 0) +} + +func (s *EvmReaderSuite) TestOutputExecutionSyncSkipsBeforeApplicationDeployment() { + appContract := &MockApplicationContract{} + appContract.On("GetDeploymentBlockNumber", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber != nil && opts.BlockNumber.Uint64() == 90 + }), + ).Return(new(big.Int), bind.ErrNoCode).Once() + + app := appContracts{ + application: &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: app1Addr, + }, + applicationContract: appContract, + } + + repo := newMockRepository() + s.evmReader.repository = repo + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{app}, 90) + + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(s.T(), "GetNumberOfPendingExecutableOutputs", 0) + appContract.AssertExpectations(s.T()) +} + +// --- Input count mismatch in IConsensus path → app skipped, no checkpoint advance --- +// When the on-chain counter delta at endBlock disagrees with the number of inputs +// returned by RetrieveInputs (e.g., missing event), the app must be skipped +// (not stored) and its checkpoint must NOT advance. +func (s *EvmReaderSuite) TestIConsensusInputCountMismatchSkipsApp() { + addr := common.HexToAddress("0x6666666666666666666666666666666666666666") + + inputSrc := &MockInputBox{} + app := &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + + // On-chain counter says 2 new inputs, but RetrieveInputs only returns 1 + inputSrc.On("GetNumberOfInputs", blockRange(0, 105), mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", blockFrom(105), mock.Anything). + Return(new(big.Int).SetUint64(2), nil) + + // RetrieveInputs at 105 only returns 1 event (missing second input) + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 105 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 105), + }, nil) + + apps := []appContracts{ + {application: app, inputSource: inputSrc}, + } + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(uint64(0), nil) + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) + s.Require().ErrorContains(err, "input count mismatch") + + // App was skipped: counter says 2 new, but only 1 fetched → no DB writes + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + +// The IConsensus count check must validate against the end-block counter +// observed by the same transition walk that fetched the logs. Under normal +// RPC semantics, a second eth_call pinned to the same block number should +// return the same value; using the already-observed value avoids a redundant +// call and keeps validation tied to the fetch walk's chain view. +func (s *EvmReaderSuite) TestIConsensusInputCountValidationUsesObservedEndCount() { + addr := common.HexToAddress("0x7777777777777777777777777777777777777777") + + inputSrc := &MockInputBox{} + app := &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + + inputSrc.On("GetNumberOfInputs", blockRange(0, 105), addr). + Return(new(big.Int).SetUint64(0), nil) + inputSrc.On("GetNumberOfInputs", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() == 105 + }), addr).Return(new(big.Int).SetUint64(1), nil).Once() + + inputSrc.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 105 }), + mock.Anything, mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{ + makeInputEvent(addr, 0, 105), + }, nil).Once() + + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, addr.String()). + Return(uint64(0), nil).Once() + repo.On("GetEpoch", mock.Anything, addr.String(), uint64(10)). + Return(nil, nil).Once() + repo.On("CreateEpochsAndInputs", + mock.Anything, + addr.String(), + mock.Anything, + uint64(105), + ).Run(func(arguments mock.Arguments) { + epochInputMap, ok := arguments.Get(2).(map[*Epoch][]*Input) + s.Require().True(ok) + s.Require().Len(epochInputMap, 1) + for _, inputs := range epochInputMap { + s.Require().Len(inputs, 1) + s.Require().Equal(uint64(0), inputs[0].Index) + } + }).Return(nil).Once() + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 105, appContracts{ + application: app, + inputSource: inputSrc, + }) + s.Require().NoError(err) + + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + repo.AssertExpectations(s.T()) + inputSrc.AssertExpectations(s.T()) +} + +// --- EpochLength=0 sets app corrupted --- +// When an application with EpochLength=0 reaches the epoch indexing logic, +// it must be set corrupted to prevent division-by-zero in calculateEpochIndex. +func (s *EvmReaderSuite) TestEpochLengthZeroSetsAppCorrupted() { + addr := common.HexToAddress("0x5555555555555555555555555555555555555555") + + inputSrc := &MockInputBox{} + // On-chain: constant 0 inputs (no transitions, fetchInputs succeeds) + inputSrc.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + + apps := []appContracts{{ + application: &Application{ + ID: 1, + Name: daveTestApplicationName, + IApplicationAddress: addr, + IInputBoxAddress: inputBoxAddr, + EpochLength: 0, // will trigger corrupted + LastInputCheckBlock: 100, + }, + inputSource: inputSrc, + }} + + repo := newMockRepository() + repo.On("UpdateApplicationStatus", + mock.Anything, int64(1), ApplicationStatus_Corrupted, mock.Anything, + ).Return(nil) + s.evmReader.repository = repo + + err := s.evmReader.readAndStoreApplicationInputs(s.ctx, 100, 110, apps[0]) + s.Require().NoError(err) + + // App must be set inoperable + repo.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 1) + // No epochs or inputs should be stored + repo.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} + +// --- Sealed epoch CreateEpochsAndInputs failure prevents checkpoint advance --- +// When CreateEpochsAndInputs fails inside processSealedEpochEvent, the error +// must propagate through processEpochTransition and FindTransitions, causing +// processApplicationSealedEpochs to return before calling UpdateEventLastCheckBlock. +func (s *SealedEpochsSuite) TestSealedEpochDBFailurePreventsCheckpointAdvance() { + const ( + sealBlock uint64 = 100 + mostRecentBlock uint64 = 200 + ) + tournamentAddr := common.HexToAddress("0xEEEE") + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + app.application.LastEpochCheckBlock = 50 + + // Oracle: before sealBlock → -1, from sealBlock → 0 + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Int64() < int64(sealBlock) + }), + ).Return(makeSealedEpochResult(-1, 0, 0, common.Address{}), nil) + s.dave.On("GetCurrentSealedEpoch", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock + }), + ).Return(makeSealedEpochResult(0, 0, 0, tournamentAddr), nil) + + // RetrieveSealedEpochs at transition block + s.dave.On("RetrieveSealedEpochs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == sealBlock }), + ).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, sealBlock, tournamentAddr), + }, nil) + + // Epoch 0 doesn't exist + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(nil, nil) + + // CreateEpochsAndInputs FAILS + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Return(errors.New("database connection lost")) + + err := s.evmReader.processApplicationSealedEpochs(s.ctx, app, mostRecentBlock) + s.Require().Error(err) + s.Require().ErrorContains(err, "database connection lost") + + // CreateEpochsAndInputs was attempted + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + // Checkpoint must NOT be advanced (error prevented reaching UpdateEventLastCheckBlock) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) +} diff --git a/internal/evmreader/errors.go b/internal/evmreader/errors.go new file mode 100644 index 000000000..ec80b6a43 --- /dev/null +++ b/internal/evmreader/errors.go @@ -0,0 +1,8 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import "errors" + +var errContractNotDeployedAtBlock = errors.New("contract not deployed at block") diff --git a/internal/evmreader/evmreader.go b/internal/evmreader/evmreader.go new file mode 100644 index 000000000..07046f017 --- /dev/null +++ b/internal/evmreader/evmreader.go @@ -0,0 +1,308 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/errutil" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +// Interface for the node repository +type EvmReaderRepository interface { + UpdateApplicationForeclosure( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + blockNumber uint64, + ) error + UpdateApplicationLastForecloseCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error + UpdateAccountsDriveProved( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + root common.Hash, + blockNumber uint64, + ) error + UpdateApplicationLastAccountsDriveProvedCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error + StoreWithdrawalEvents( + ctx context.Context, + appID int64, + withdrawals []*Withdrawal, + blockNumber uint64, + ) error + GetNumberOfWithdrawals(ctx context.Context, appID int64) (uint64, error) + ListApplications( + ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool, + ) ([]*Application, uint64, error) + UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error + UpdateEventLastCheckBlock(ctx context.Context, appIDs []int64, event MonitoredEvent, blockNumber uint64) error + GetEventLastCheckBlock(ctx context.Context, appID int64, event MonitoredEvent) (uint64, error) + + RegisterObserver( + ctx context.Context, key string, requested config.PersistentChainConfig, + ) (config.PersistentChainConfig, error) + LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) + + // Input monitor + CreateEpochsAndInputs( + ctx context.Context, nameOrAddress string, + epochInputMap map[*Epoch][]*Input, blockNumber uint64, + ) error + GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) + ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, + p repository.Pagination, descending bool) ([]*Epoch, uint64, error) + UpdateEpochClaimTransactionHash(ctx context.Context, nameOrAddress string, e *Epoch) error + GetLastNonOpenEpoch(ctx context.Context, nameOrAddress string) (*Epoch, error) + + GetNumberOfInputs(ctx context.Context, nameOrAddress string) (uint64, error) + + // Output execution monitor + GetOutput(ctx context.Context, nameOrAddress string, indexKey uint64) (*Output, error) + UpdateOutputsExecution(ctx context.Context, nameOrAddress string, executedOutputs []*Output, blockNumber uint64) error + GetNumberOfExecutedOutputs(ctx context.Context, nameOrAddress string) (uint64, error) + GetNumberOfPendingExecutableOutputs(ctx context.Context, nameOrAddress string) (uint64, error) +} + +// EthClientInterface defines the methods we need from ethclient.Client +type EthClientInterface interface { + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) + ChainID(ctx context.Context) (*big.Int, error) +} + +// Internal struct to hold application and it's contracts together +type appContracts struct { + application *Application + applicationContract ApplicationContractAdapter + inputSource InputSourceAdapter + daveConsensus DaveConsensusAdapter +} + +func listEnabledApplications(ctx context.Context, er EvmReaderRepository) ([]*Application, uint64, error) { + return er.ListApplications(ctx, repository.ApplicationFilter{Enabled: new(true)}, repository.Pagination{}, false) +} + +func (r *Service) setApplicationDiverged(ctx context.Context, app *Application, reasonFmt string, args ...any) error { + return appstatus.SetDivergedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) +} + +// The status helper logs its reason and any failed status write. Outer scan +// loops can consume this marker without repeating those logs or continuing work. +var errApplicationStatusReported = errors.New("application status already reported") + +func (r *Service) setApplicationCorrupted(ctx context.Context, app *Application, reasonFmt string, args ...any) error { + err := appstatus.SetCorruptedf(ctx, r.Logger, r.repository, app, reasonFmt, args...) + return fmt.Errorf("%w: %w", errApplicationStatusReported, err) +} + +func (r *Service) Tick(ctx context.Context) (bool, error) { + blockNumber, err := r.fetchMostRecentHeader(ctx, r.defaultBlock) + if err != nil { + if errors.Is(err, context.Canceled) { + return false, nil + } + return false, err + } + + if blockNumber != r.lastBlockNumber.Load() { + r.lastBlockNumber.Store(blockNumber) + r.Logger.Info("Got new block header", "block", blockNumber, "policy", r.defaultBlock) + } + + // Scans run under the service context: cancellable on shutdown, free to take + // as long as catch-up needs. Per-request bounds live on the HTTP transport. + scanOK := r.processBlockHead(ctx, blockNumber, r.resolver) + if ctx.Err() != nil { + return false, nil + } + if scanOK { + r.consecutiveScanFailures.Store(0) + } else if r.consecutiveScanFailures.Load() < maxConsecutiveScanFailures { + r.consecutiveScanFailures.Add(1) + } + + now := time.Now() + r.lastSuccessfulPoll.Store(&now) + + return false, nil +} + +func (r *Service) Ready() bool { + lastPoll := r.lastSuccessfulPoll.Load() + return r.consecutiveScanFailures.Load() < maxConsecutiveScanFailures && + lastPoll != nil && !lastPoll.IsZero() && time.Since(*lastPoll) < r.readyMaxStaleness +} + +// A few failed cycles tolerate transient errors without hiding persistent stalls. +const maxConsecutiveScanFailures = 3 + +func (r *Service) processBlockHead( + ctx context.Context, + blockNumber uint64, + resolver *applicationAdapterResolver, +) bool { + r.Logger.Debug("Retrieving enabled applications") + observableApps, _, err := listEnabledApplications(ctx, r.repository) + if err != nil { + level := slog.LevelError + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Error retrieving L1-observable applications", "error", err) + return false + } + + if len(observableApps) == 0 { + if r.hasEnabledApps { + r.Logger.Info("No registered applications enabled for L1 observation") + } + r.hasEnabledApps = false + return true + } + if !r.hasEnabledApps { + r.Logger.Info("Found applications enabled for L1 observation") + } + r.hasEnabledApps = true + + apps := resolver.buildAppContracts(observableApps) + if len(apps) == 0 { + r.Logger.Info("No correctly configured applications running") + return false + } + + return r.runBlockScanners(ctx, apps, blockNumber) && len(apps) == len(observableApps) +} + +// runBlockScanners reports whether observation completed without shared failures. +// Known input corruption with a persisted integrity status is application-local +// degradation; observation still runs on later ticks. An idle scan is healthy. +// Always run remaining scanners so one application's stall cannot block others. +// Stop dispatching new work when the service context ends; the active scanner +// has already handled its operation's error. +func (r *Service) runBlockScanners( + ctx context.Context, + apps []appContracts, + blockNumber uint64, +) bool { + if ctx.Err() != nil { + return false + } + // Detect foreclosure first so later scanners use the marker observed in this same tick. + success := r.checkForForeclosure(ctx, apps, blockNumber) + if ctx.Err() != nil { + return false + } + + plan := buildBlockScanPlan(apps) + + success = r.scanDaveConsensusEpochsAndInputs(ctx, plan.daveEpochTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } + success = r.scanIConsensusInputs(ctx, plan.iConsensusInputTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } + + success = r.checkForOutputExecution(ctx, plan.outputTargets, blockNumber) && success + if ctx.Err() != nil { + return false + } + + // Post-foreclosure observation dispatches to drive-prove discovery or withdrawal indexing. + success = r.checkPostForeclosure(ctx, plan.postForeclosureTargets, blockNumber) && success + return success +} + +// fetchMostRecentHeader fetches the most recent header up till the +// given default block +func (r *Service) fetchMostRecentHeader( + ctx context.Context, + defaultBlock DefaultBlock, +) (uint64, error) { + + var defaultBlockNumber int64 + switch defaultBlock { + case DefaultBlock_Pending: + defaultBlockNumber = rpc.PendingBlockNumber.Int64() + case DefaultBlock_Latest: + defaultBlockNumber = rpc.LatestBlockNumber.Int64() + case DefaultBlock_Finalized: + defaultBlockNumber = rpc.FinalizedBlockNumber.Int64() + case DefaultBlock_Safe: + defaultBlockNumber = rpc.SafeBlockNumber.Int64() + default: + return 0, fmt.Errorf("default block '%v' not supported", defaultBlock) + } + + header, err := + r.client.HeaderByNumber( + ctx, + new(big.Int).SetInt64(defaultBlockNumber)) + if err != nil { + return 0, fmt.Errorf("failed to retrieve header: %w", err) + } + + if header == nil { + return 0, fmt.Errorf("returned header is nil") + } + if header.Number == nil { + return 0, fmt.Errorf("returned header number is nil") + } + return header.Number.Uint64(), nil +} + +type AdapterFactory interface { + CreateAdapters(app *Application) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) +} + +type DefaultAdapterFactory struct { + Client *ethclient.Client + Filter ethutil.Filter +} + +func (f *DefaultAdapterFactory) CreateAdapters( + app *Application, +) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { + if app == nil { + return nil, nil, nil, fmt.Errorf("application reference is nil, should never happen") + } + + applicationContract, err := NewApplicationContractAdapter(app.IApplicationAddress, f.Client, f.Filter) + if err != nil { + return nil, nil, nil, fmt.Errorf("error building application contract: %w", err) + } + + inputSource, err := NewInputSourceAdapter(app.IInputBoxAddress, f.Client, f.Filter) + if err != nil { + return nil, nil, nil, fmt.Errorf("error building inputbox contract: %w", err) + } + + var daveConsensus DaveConsensusAdapter + if app.IsDaveConsensus() { + daveConsensus, err = NewDaveConsensusAdapter(app.IConsensusAddress, f.Client, f.Filter) + if err != nil { + return nil, nil, nil, fmt.Errorf("error building daveconsensus contract: %w", err) + } + } + + return applicationContract, inputSource, daveConsensus, nil +} diff --git a/internal/evmreader/evmreader_test.go b/internal/evmreader/evmreader_test.go new file mode 100644 index 000000000..9b45193b5 --- /dev/null +++ b/internal/evmreader/evmreader_test.go @@ -0,0 +1,419 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +const ( + suiteTimeout = 10 * time.Second +) + +type EvmReaderSuite struct { + suite.Suite + ctx context.Context + cancel context.CancelFunc + client *MockEthClient + repository *MockRepository + evmReader *Service + supervisor service.Supervisor + contractFactory *MockAdapterFactory + applicationContract1 *MockApplicationContract + applicationContract2 *MockApplicationContract + inputBox *MockInputBox +} + +func TestEvmReaderSuite(t *testing.T) { + suite.Run(t, new(EvmReaderSuite)) +} + +func (s *EvmReaderSuite) SetupSuite() { + config.SetDefaults() +} + +func (s *EvmReaderSuite) SetupTest() { + s.ctx, s.cancel = context.WithTimeout(context.Background(), suiteTimeout) + s.client = newMockEthClient().SetupDefaultBehavior() + s.repository = newMockRepository().SetupDefaultBehavior() + s.applicationContract1 = newMockApplicationContract().SetupDefaultBehavior() + s.applicationContract2 = newMockApplicationContract().SetupDefaultBehavior() + s.inputBox = newMockInputBox().SetupDefaultBehavior() + s.contractFactory = newMockAdapterFactory().SetupDefaultBehavior(s.applicationContract1, s.applicationContract2, s.inputBox) + + logLevel, err := config.GetLogLevel() + s.Require().NoError(err) + + evmReader, err := Create(s.T().Context(), &CreateInfo{ + Config: config.EvmreaderConfig{ + LogLevel: logLevel, + EvmReaderReadyMaxStaleness: 200 * time.Millisecond, + BlockchainDefaultBlock: DefaultBlock_Latest, + BlockchainId: 1, + EvmReaderPollingInterval: 100 * time.Millisecond, + }, + Repository: s.repository, + EthClient: s.client, + AdapterFactory: s.contractFactory, + }) + s.Require().NoError(err) + s.evmReader = evmReader.(*Service) + + supCfg := service.SupervisorConfigs{ + BaseConfigs: service.BaseConfigs{LogLevel: logLevel}, + Factories: []service.FactoryFunction{ + func(context.Context, service.Supervisor) (service.SupervisedService, error) { + return s.evmReader, nil + }, + }, + } + sup, err := service.NewSupervisor(s.ctx, &supCfg) + s.Require().NoError(err) + s.supervisor = sup +} + +func (s *EvmReaderSuite) TearDownTest() { + s.cancel() + s.supervisor.Stop() +} + +// Service tests + +func newCallNotification(c *mock.Call) <-chan struct{} { + ch := make(chan struct{}) + c.Run(func(mock.Arguments) { ch <- struct{}{} }) + return ch +} + +func newBlockedCallNotification(c *mock.Call) (<-chan struct{}, chan struct{}) { + called := make(chan struct{}) + blocked := make(chan struct{}) + c.Run(func(mock.Arguments) { + called <- struct{}{} // notify function was called + <-blocked // block function until notified + }) + return called, blocked +} + +func waitNotification(ch <-chan struct{}) bool { + select { + case <-ch: + return true + case <-time.After(2 * time.Second): + return false + } +} + +func wasntNotified[T any](ch <-chan T) bool { + select { + case <-ch: + return false + default: + return true + } +} + +func (s *EvmReaderSuite) waitError(errCh <-chan error) error { + select { + case err := <-errCh: + return err + case <-time.After(2 * time.Second): + s.Require().Fail("evmreader did not stop after context cancelation") + } + return nil +} + +// Service tests +func (s *EvmReaderSuite) TestItStopsWhenContextIsCanceled() { + called := newCallNotification(s.client.EnqueueNewHead(100)) + + ctx, cancel := context.WithCancel(s.T().Context()) + + errCh := make(chan error, 1) + go func() { errCh <- s.evmReader.Serve(ctx) }() + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + cancel() + + s.Require().ErrorIs(s.waitError(errCh), context.Canceled) +} + +func (s *EvmReaderSuite) TestReadyReflectsServeLifecycle() { + called := newCallNotification(s.client.EnqueueNewHead(100)) + + s.Require().False(s.evmReader.Ready()) + + ctx, cancel := context.WithCancel(s.T().Context()) + + errCh := make(chan error, 1) + go func() { errCh <- s.evmReader.Serve(ctx) }() + + s.Require().True(waitNotification(called)) + s.Require().Eventually(s.evmReader.Ready, time.Second, time.Millisecond) + + s.Require().True(wasntNotified(errCh)) + cancel() + s.Require().ErrorIs(s.waitError(errCh), context.Canceled) + + s.Require().Eventually(s.evmReader.Ready, time.Second, time.Millisecond) + time.Sleep(s.evmReader.readyMaxStaleness) + s.Require().False(s.evmReader.Ready()) +} + +func (s *EvmReaderSuite) TestNotReadyWhilePollingFails() { + var hdr *types.Header + called := newCallNotification(s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(hdr, errors.New("transient connection error"))) + + s.Require().False(s.evmReader.Ready()) + + ctx, cancel := context.WithCancel(s.T().Context()) + + errCh := make(chan error, 1) + go func() { errCh <- s.evmReader.Serve(ctx) }() + + s.Require().True(waitNotification(called)) + s.Require().False(s.evmReader.Ready()) + + cancel() + s.Require().ErrorIs(s.waitError(errCh), context.Canceled) +} + +func (s *EvmReaderSuite) TestReadyIsRecoveredOnPollingSuccess() { + var hdr *types.Header + failCalled := newCallNotification(s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(hdr, errors.New("transient connection error")).Once()) + called := newCallNotification(s.client.EnqueueNewHead(100)) + + s.Require().False(s.evmReader.Ready()) + + ctx, cancel := context.WithCancel(s.T().Context()) + + errCh := make(chan error, 1) + go func() { errCh <- s.evmReader.Serve(ctx) }() + + s.Require().True(waitNotification(failCalled)) + s.Require().False(s.evmReader.Ready()) + + s.Require().True(waitNotification(called)) + s.Require().Eventually(s.evmReader.Ready, time.Second, time.Millisecond) + + s.Require().True(wasntNotified(errCh)) + cancel() + s.Require().ErrorIs(s.waitError(errCh), context.Canceled) + + s.Require().Eventually(s.evmReader.Ready, time.Second, time.Millisecond) + time.Sleep(s.evmReader.readyMaxStaleness) + s.Require().False(s.evmReader.Ready()) +} + +func (s *EvmReaderSuite) TestTickScansWithServiceContext() { + s.client.EnqueueNewHead(100).Once() + + // reset mock calls from 'MockRepository.SetupDefaultBehavior' + s.repository.On( + "UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Unset() + s.repository.On( + "UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Unset() + + assertValidContext := func(args mock.Arguments) { + ctx := args.Get(0).(context.Context) + s.Require().Nil(ctx.Err()) + } + + // setup mock calls expected to receive a valid context + s.repository.On( + "UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(3).Run(assertValidContext) + s.repository.On( + "UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(4).Run(assertValidContext) + + s.Require().NotEmpty(s.supervisor.NotReady()) + + _, err := s.evmReader.Tick(context.Background()) + s.Require().NoError(err) + + s.client.AssertCalled(s.T(), "HeaderByNumber", mock.Anything, mock.Anything) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 7) +} + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderReturnsErrorWhenHeaderNumberIsNil() { + s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(&types.Header{}, nil).Once() + + blockNumber, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock_Latest) + + s.Require().Error(err) + s.Require().ErrorContains(err, "returned header number is nil") + s.Require().Zero(blockNumber) +} + +func (s *EvmReaderSuite) TestTickReturnsHeaderFetchErrorWithoutLocalErrorLog() { + var logBuffer bytes.Buffer + s.evmReader.Logger = slog.New(slog.NewTextHandler(&logBuffer, nil)) + + headerErr := errors.New("transient connection error") + var hdr *types.Header + s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(hdr, headerErr).Once() + + _, err := s.evmReader.Tick(s.ctx) + + s.Require().ErrorIs(err, headerErr) + s.Require().NotContains(logBuffer.String(), "Error fetching most recent block") + s.repository.AssertNumberOfCalls(s.T(), "ListApplications", 0) +} + +func (s *EvmReaderSuite) TestItRunsWhenConnectionFails() { + var hdr *types.Header + called := newCallNotification(s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(hdr, errors.New("transient connection error"))) + + done := make(chan struct{}) + go func() { + err := s.supervisor.Serve() + s.Require().NoError(err) + close(done) + }() + + s.Require().True(waitNotification(called)) + s.Require().True(waitNotification(called)) + s.Require().True(wasntNotified(done)) +} + +func (s *EvmReaderSuite) TestRunResetsRetriesAfterProcessingHeaders() { + s.client.EnqueueNewHead(100).Once() + var hdr *types.Header + s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(hdr, errors.New("transient connection error")).Once() + called := newCallNotification(s.client.EnqueueNewHead(101)) + + done := make(chan struct{}) + go func() { + err := s.supervisor.Serve() + s.Require().NoError(err) + close(done) + }() + + s.Require().True(waitNotification(called)) + s.Require().True(wasntNotified(done)) + + s.client.AssertCalled(s.T(), "HeaderByNumber", mock.Anything, mock.Anything) +} + +// indexApps indexes applications given a key extractor function. +// Only used in tests. +func indexApps[K comparable]( + keyExtractor func(appContracts) K, + apps []appContracts, +) map[K][]appContracts { + result := make(map[K][]appContracts) + for _, item := range apps { + key := keyExtractor(item) + result[key] = append(result[key], item) + } + return result +} + +func (s *EvmReaderSuite) TestIndexApps() { + + s.Run("Ok", func() { + apps := []appContracts{ + {application: &Application{LastInputCheckBlock: 23}}, + {application: &Application{LastInputCheckBlock: 22}}, + {application: &Application{LastInputCheckBlock: 21}}, + {application: &Application{LastInputCheckBlock: 23}}, + } + + keyByProcessedBlock := func(a appContracts) uint64 { + return a.application.LastInputCheckBlock + } + + indexApps := indexApps(keyByProcessedBlock, apps) + + s.Require().Equal(3, len(indexApps)) + apps, ok := indexApps[23] + s.Require().True(ok) + s.Require().Equal(2, len(apps)) + }) + + s.Run("whenIndexAppsArrayEmpty", func() { + apps := []appContracts{} + + keyByProcessedBlock := func(a appContracts) uint64 { + return a.application.LastInputCheckBlock + } + + indexApps := indexApps(keyByProcessedBlock, apps) + + s.Require().Equal(0, len(indexApps)) + }) + + s.Run("whenUsesWrongKey", func() { + apps := []appContracts{ + {application: &Application{LastInputCheckBlock: 23}}, + {application: &Application{LastInputCheckBlock: 22}}, + {application: &Application{LastInputCheckBlock: 21}}, + {application: &Application{LastInputCheckBlock: 23}}, + } + + keyByProcessedBlock := func(a appContracts) uint64 { + return a.application.LastInputCheckBlock + } + + indexApps := indexApps(keyByProcessedBlock, apps) + + s.Require().Equal(3, len(indexApps)) + apps, ok := indexApps[0] + s.Require().False(ok) + s.Require().Nil(apps) + + }) + +} diff --git a/internal/evmreader/fixtures_test.go b/internal/evmreader/fixtures_test.go new file mode 100644 index 000000000..3f428542e --- /dev/null +++ b/internal/evmreader/fixtures_test.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Fixed addresses used across evmreader tests. +var ( + app1Addr = common.HexToAddress("0x2E663fe9aE92275242406A185AA4fC8174339D3E") + app2Addr = common.HexToAddress("0x78c716FDaE477595a820D86D0eFAfe0eE54dF7dB") + inputBoxAddr = common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3") + consensusAddr = common.HexToAddress("0xdeadbeef") +) + +// Test headers — only Number is used by production code. +var ( + header0 = makeHeader(0x11) + header1 = makeHeader(0x12) + header2 = makeHeader(0x13) +) + +// Test input events — all target app1. +var ( + inputAddedEvent0 = makeInputEvent(app1Addr, 0, 0x11) + inputAddedEvent1 = makeInputEvent(app1Addr, 1, 0x12) + inputAddedEvent2 = makeInputEvent(app1Addr, 2, 0x13) + inputAddedEvent3 = makeInputEvent(app1Addr, 3, 0x13) +) + +// applications defines two applications that share one InputBox. +var applications = []*Application{{ + ID: 1, + Name: "my-app-1", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + Enabled: true, + Status: ApplicationStatus_OK, + IInputBoxBlock: 0x01, + EpochLength: 10, + LastInputCheckBlock: 0x00, + LastOutputCheckBlock: 0x00, +}, { + ID: 2, + Name: "my-app-2", + IApplicationAddress: app2Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + Enabled: true, + Status: ApplicationStatus_OK, + IInputBoxBlock: 0x01, + EpochLength: 10, + LastInputCheckBlock: 0x00, + LastOutputCheckBlock: 0x00, +}} + +// makeHeader creates a types.Header with the given block number. +func makeHeader(blockNum uint64) types.Header { + return types.Header{ + Number: new(big.Int).SetUint64(blockNum), + } +} + +// makeInputEvent creates an IInputBoxInputAdded event for testing. +func makeInputEvent( + appAddr common.Address, + index uint64, + blockNum uint64, +) iinputbox.IInputBoxInputAdded { + return iinputbox.IInputBoxInputAdded{ + AppContract: appAddr, + Index: new(big.Int).SetUint64(index), + Input: []byte{0xde, 0xad, byte(index & 0xFF)}, + Raw: types.Log{ + BlockNumber: blockNum, + TxHash: common.BigToHash(new(big.Int).SetUint64(blockNum*1000 + index)), + Index: uint(index), + BlockHash: common.BigToHash(new(big.Int).SetUint64(blockNum)), + }, + } +} diff --git a/internal/evmreader/foreclosure.go b/internal/evmreader/foreclosure.go new file mode 100644 index 000000000..ffa56325a --- /dev/null +++ b/internal/evmreader/foreclosure.go @@ -0,0 +1,274 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// checkForForeclosure runs once per evmreader tick. For each app with a +// zero foreclose_block (the unset sentinel), it polls isForeclosed() +// (cheap, one CallContract). +// On a true result, it filters Foreclosure events over the window +// `[max(deploymentBlock, last_foreclose_check_block+1), mostRecentBlockNumber]` +// and persists (block, txHash) of the first match to the application row. +// +// If isForeclosed() is false, no event scan is needed and the cursor advances +// to mostRecentBlockNumber because the state query proves the app was not +// foreclosed up to that block. If isForeclosed() is true but no matching event +// is found, the scan cursor is left unchanged so the next tick retries the +// same window; advancing would permanently exclude the only block range where +// the event can be found. +// +// Once foreclose_block is non-zero, the app is skipped on subsequent ticks — +// the flag is one-way (the contract has no un-foreclose). +func (r *Service) checkForForeclosure( + ctx context.Context, + apps []appContracts, + mostRecentBlockNumber uint64, +) bool { + success := true + for _, app := range apps { + if app.application.ForecloseBlock != 0 { + continue + } + var deploymentBlock uint64 + if app.application.LastForecloseCheckBlock == 0 { + var err error + deploymentBlock, err = r.foreclosureSearchFloor(ctx, &app, mostRecentBlockNumber) + if err != nil { + if abortForeclosureLoop(r, err, "getDeploymentBlock") { + return false + } + if errors.Is(err, errContractNotDeployedAtBlock) { + r.Logger.Debug("Skipping foreclosure check before application deployment", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "block", mostRecentBlockNumber, + ) + continue + } + success = false + r.Logger.Error("Failed to compute Foreclosure search start block", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err) + continue + } + if deploymentBlock > mostRecentBlockNumber { + r.Logger.Debug("Skipping foreclosure check before application deployment block", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "deployment_block", deploymentBlock, + "most_recent_block", mostRecentBlockNumber, + ) + continue + } + } + if mostRecentBlockNumber <= app.application.LastForecloseCheckBlock { + r.Logger.Debug("Not checking foreclosure: already checked up to the most recent block", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "last_foreclose_check_block", app.application.LastForecloseCheckBlock, + "most_recent_block", mostRecentBlockNumber, + ) + continue + } + foreclosed, err := app.applicationContract.IsForeclosed( + &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + }, + ) + if err != nil { + if abortForeclosureLoop(r, err, "isForeclosed") { + return false + } + success = false + r.Logger.Error("Failed to query isForeclosed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err) + continue + } + if !foreclosed { + if app.application.LastForecloseCheckBlock < mostRecentBlockNumber { + if !r.advanceLastForecloseCheckBlock(ctx, app.application.ID, mostRecentBlockNumber) { + success = false + continue + } + app.application.LastForecloseCheckBlock = mostRecentBlockNumber + } + continue + } + + // On-chain says the app is foreclosed but we don't yet know which + // block emitted Foreclosure(). Determine the lower bound of the + // filter window. Once LastForecloseCheckBlock is non-zero we've + // scanned through deployment already, so we never need to read it + // again for the lifetime of this wait state. + startBlock := app.application.LastForecloseCheckBlock + 1 + if app.application.LastForecloseCheckBlock == 0 { + // deploymentBlock was resolved at the top of the loop under this + // same zero-LastForecloseCheckBlock precondition. + startBlock = deploymentBlock + } + if startBlock > mostRecentBlockNumber { + // Already scanned past the current head; nothing new since the + // previous tick. Re-check isForeclosed next tick. + continue + } + + events, err := app.applicationContract.RetrieveForeclosureEvents( + &bind.FilterOpts{ + Context: ctx, + Start: startBlock, + End: &mostRecentBlockNumber, + }, + ) + if err != nil { + if abortForeclosureLoop(r, err, "retrieveForeclosureEvents") { + return false + } + success = false + r.Logger.Error("Failed to fetch Foreclosure events", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err) + continue + } + if len(events) == 0 { + success = false + r.Logger.Warn( + "isForeclosed() is true but no Foreclosure event found in search window — will retry same window next tick", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber) + continue + } + // `Foreclosure()` is one-way; multiple events on the same app are + // not possible. Use the first. + ev := events[0] + block := ev.Raw.BlockNumber + txHash := ev.Raw.TxHash + + err = r.repository.UpdateApplicationForeclosure( + ctx, app.application.ID, block, txHash, mostRecentBlockNumber, + ) + if errors.Is(err, repository.ErrNotFound) { + // Row was deleted between the ListApplications scan at the top + // of the tick and now. Skip without touching the in-memory + // marker — writing it would diverge from a row that no longer + // exists. + r.Logger.Warn( + "Foreclosure observed but application row is missing; skipping", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "foreclose_block", block, + "foreclose_transaction", txHash) + continue + } + if err != nil { + success = false + r.Logger.Error("Failed to record foreclosure", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "foreclose_block", block, + "foreclose_transaction", txHash, + "error", err) + continue + } + // Reflect the write in the in-memory app so other code paths in + // this tick see the marker. Safe both for "we wrote" and the + // idempotent "already foreclosed" case: the Foreclosure() event + // is one-way so all observers see the same (block, txHash). + app.application.ForecloseBlock = block + txHashCopy := txHash + app.application.ForecloseTransaction = &txHashCopy + app.application.LastForecloseCheckBlock = mostRecentBlockNumber + r.Logger.Info("Application foreclosure observed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "foreclose_block", block, + "foreclose_transaction", txHash) + appstatus.WarnBlockedForeclosure(r.Logger, app.application) + } + return success +} + +// advanceLastForecloseCheckBlock persists the new value and reports success. +// A failed write is non-fatal: the next tick will +// re-scan the same window, paying the cost but producing correct behavior. +func (r *Service) advanceLastForecloseCheckBlock(ctx context.Context, appID int64, head uint64) bool { + success := true + if err := r.repository.UpdateApplicationLastForecloseCheckBlock(ctx, appID, head); err != nil { + success = false + r.Logger.Warn("Failed to advance last_foreclose_check_block", + "application_id", appID, + "head", head, + "error", err) + } + return success +} + +// abortForeclosureLoop reports whether the RPC error should abort the +// per-tick loop entirely. Context cancellation is graceful shutdown — +// silent return. A deadline-exceeded error means the tick's budget is +// gone; every remaining app's RPC call would fail with the same error, +// producing one ERROR log per app for no operational benefit. Log once +// at the site and abort. Other errors stay per-app so a transient RPC +// failure on one app does not block the rest. Mirrors the convention +// documented at memory/feedback_context_error_semantics.md. +func abortForeclosureLoop(r *Service, err error, where string) bool { + if errors.Is(err, context.Canceled) { + return true + } + if errors.Is(err, context.DeadlineExceeded) { + r.Logger.Error("Foreclosure scan deadline exceeded; aborting remaining apps", + "site", where, "error", err) + return true + } + return false +} + +// foreclosureSearchFloor reads the application's on-chain deployment block. +// Called only on the first tick of the wait state (LastForecloseCheckBlock +// == 0); once it advances past zero, the deployment block is irrelevant to +// subsequent ticks. +func (r *Service) foreclosureSearchFloor( + ctx context.Context, + app *appContracts, + mostRecentBlockNumber uint64, +) (uint64, error) { + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + } + deploymentBlock, err := app.applicationContract.GetDeploymentBlockNumber(callOpts) + if err != nil { + if errors.Is(err, bind.ErrNoCode) { + return 0, fmt.Errorf("%w: application %s at block %d: %w", + errContractNotDeployedAtBlock, + app.application.IApplicationAddress, + mostRecentBlockNumber, + err) + } + return 0, fmt.Errorf("get deployment block: %w", err) + } + // Zero is accepted: anvil / genesis-snapshot fixtures can legitimately + // place contract code at block 0, and a zero floor only widens the scan + // window (a performance hit bounded by last_foreclose_check_block on + // the next tick, not a correctness break). The original guard rejected + // zero defensively but produced a hard error on otherwise-valid fixtures. + // A negative value is impossible from a uint256 return. + return deploymentBlock.Uint64(), nil +} diff --git a/internal/evmreader/foreclosure_test.go b/internal/evmreader/foreclosure_test.go new file mode 100644 index 000000000..16b4f2a94 --- /dev/null +++ b/internal/evmreader/foreclosure_test.go @@ -0,0 +1,585 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math/big" + "os" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCheckForForeclosureWarnsForFailedApplicationOnce(t *testing.T) { + s, contract, repo := newForeclosureServiceFixture(t) + defer contract.AssertExpectations(t) + defer repo.AssertExpectations(t) + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := foreclosureTestApp(1) + app.Status = ApplicationStatus_Failed + app.Reason = new("machine process crashed") + app.LastForecloseCheckBlock = 10 + const head, foreclosure = uint64(100), uint64(80) + txHash := common.HexToHash("0xfeed") + contract.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + contract.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(foreclosure, txHash)}, nil).Once() + repo.On("UpdateApplicationForeclosure", mock.Anything, app.ID, foreclosure, txHash, head).Return(nil).Once() + + apps := []appContracts{foreclosureAppContracts(app, contract)} + require.True(t, s.checkForForeclosure(t.Context(), apps, head)) + require.True(t, s.checkForForeclosure(t.Context(), apps, head+1)) + + require.Equal(t, 1, strings.Count(logs.String(), "failure blocks foreclosure drain")) + require.Contains(t, logs.String(), "foreclose_block=80") + require.Contains(t, logs.String(), "machine process crashed") + require.Contains(t, logs.String(), "repair the cause before clearing FAILED") + require.Equal(t, ApplicationStatus_Failed, app.Status, "the diagnostic must not clear the health gate") +} + +// newForeclosureServiceFixture builds the smallest Service surface that +// checkForForeclosure / foreclosureSearchStartBlock reach for, plus the +// mocks bound to it. This avoids the full EvmReaderSuite bootstrap which +// wires up websocket clients, adapter factories, and tick-loop plumbing +// none of which are exercised by these unit tests. +// +// newMockApplicationContract pre-registers .Maybe() stubs for IsForeclosed +// and RetrieveForeclosureEvents (FIFO match). For foreclosure-path tests +// those defaults must be cleared so the per-test .On(...) expectations +// match — see the doc comment on newMockApplicationContract. +func newForeclosureServiceFixture(t *testing.T) ( + *Service, *MockApplicationContract, *MockRepository, +) { + t.Helper() + repo := newMockRepository() + appContract := newMockApplicationContract() + appContract.Unset("IsForeclosed") + appContract.Unset("RetrieveForeclosureEvents") + s := &Service{ + repository: repo, + } + require.NoError(t, service.InitTickServiceTemplate( + &s.TickServiceTemplate, + &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: "evm-reader", + Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), + }, + }, + s, + )) + return s, appContract, repo +} + +// foreclosureAppContracts wraps an Application with the per-app contract +// adapter that checkForForeclosure consults. +func foreclosureAppContracts(app *Application, c *MockApplicationContract) appContracts { + return appContracts{ + application: app, + applicationContract: c, + } +} + +// makeForeclosureEvent constructs a Foreclosure event with the given block +// and tx hash on the Raw log. The Foreclosure event body itself carries no +// fields (see ABI); only Raw.BlockNumber / Raw.TxHash are read by the +// observer. +func makeForeclosureEvent(block uint64, txHash common.Hash) *iapplication.IApplicationForeclosure { + return &iapplication.IApplicationForeclosure{ + Raw: types.Log{BlockNumber: block, TxHash: txHash}, + } +} + +// foreclosureTestApp builds an Application whose ForecloseBlock is zero +// (the "not yet observed" state). Unique address and ID keep mock +// assertions specific. +func foreclosureTestApp(id int64) *Application { + return &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + Status: ApplicationStatus_OK, + } +} + +// --------------------------------------------------------------------------- +// checkForForeclosure +// --------------------------------------------------------------------------- + +// TestCheckForForeclosure_SkipsWhenAlreadyRecorded verifies that the +// in-memory ForecloseBlock guard short-circuits the function: no on-chain +// reads, no DB write. This is the steady state for every foreclosed app +// after its first observation tick. +func TestCheckForForeclosure_SkipsWhenAlreadyRecorded(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + app.ForecloseBlock = 50 + + // No mock expectations — any IsForeclosed / RetrieveForeclosureEvents / + // UpdateApplicationForeclosure call would fail + // testify's assertion. + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) +} + +// TestCheckForForeclosure_SkipsWhenNotForeclosed verifies the common-case +// path: isForeclosed returns false, the function advances the cursor without +// filtering events. +func TestCheckForForeclosure_SkipsWhenNotForeclosed(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const head = uint64(100) + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("IsForeclosed", mock.Anything).Return(false, nil).Once() + repo.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, app.ID, head).Return(nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head) + + assert.Zero(t, app.ForecloseBlock, "ForecloseBlock must remain zero") + assert.Equal(t, head, app.LastForecloseCheckBlock) +} + +// TestCheckForForeclosure_PersistsOnFirstObservation walks the happy path: +// isForeclosed=true, deployment block resolves, exactly one Foreclosure +// event is returned, the repository persists the (block, txHash) pair and +// cursor atomically, and the in-memory ForecloseBlock / ForecloseTransaction +// are populated so other code paths in this tick see the marker. +func TestCheckForForeclosure_PersistsOnFirstObservation(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const head = uint64(100) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", + mock.Anything, + ).Return([]*iapplication.IApplicationForeclosure{ + makeForeclosureEvent(evBlock, txHash), + }, nil).Once() + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, head, + ).Return(nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head) + + assert.Equal(t, evBlock, app.ForecloseBlock, + "in-memory ForecloseBlock must be set so this tick's downstream "+ + "code sees the marker without re-reading the DB") + if assert.NotNil(t, app.ForecloseTransaction) { + assert.Equal(t, txHash, *app.ForecloseTransaction) + } + assert.Equal(t, head, app.LastForecloseCheckBlock) +} + +// TestCheckForForeclosure_DoesNotAdvanceCursorWhenEventNotFound exercises an +// inconsistent RPC/log view where isForeclosed() is true but the matching +// Foreclosure log is absent from the search window. The function must leave +// both foreclose_block and last_foreclose_check_block unchanged so the next +// tick retries the same window. +func TestCheckForForeclosure_DoesNotAdvanceCursorWhenEventNotFound(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const head = uint64(100) + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, nil).Once() + // No atomic foreclosure marker write — the absence is the assertion. + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head) + + assert.Zero(t, app.ForecloseBlock, + "ForecloseBlock must remain zero so the next tick re-scans") + assert.Zero(t, app.LastForecloseCheckBlock, + "LastForecloseCheckBlock must remain unchanged so the same window is retried") +} + +// TestCheckForForeclosure_SkipsAppOnIsForeclosedError verifies the per-app +// failure isolation: a transient RPC failure on one app must not prevent +// other apps in the same tick from being checked. Tested here by ensuring +// IsForeclosed-error leaves ForecloseBlock unset, with no DB write. +func TestCheckForForeclosure_SkipsAppOnIsForeclosedError(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + app.LastForecloseCheckBlock = 10 + c.On("IsForeclosed", mock.Anything).Return(false, errors.New("rpc dial")).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock) +} + +// TestCheckForForeclosure_SkipsAppOnRetrieveError verifies the same +// isolation property for the event-filter call. +func TestCheckForForeclosure_SkipsAppOnRetrieveError(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, errors.New("eth_getLogs failed")).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock) +} + +// TestCheckForForeclosure_SkipsAppOnPersistError verifies the DB-error +// branch. The in-memory marker must NOT be set when the persist failed — +// otherwise the next tick would read a zero DB column but a non-zero +// in-memory marker, racing with restarts that drop the in-memory state. +func TestCheckForForeclosure_SkipsAppOnPersistError(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(evBlock, txHash)}, nil).Once() + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, uint64(100), + ).Return(errors.New("db deadlock")).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock, + "in-memory marker must not run ahead of the DB on persist failure") +} + +// TestCheckForForeclosure_StopsOnContextCanceled verifies the early-exit +// on shutdown. IsForeclosed and RetrieveForeclosureEvents both check for +// context.Canceled and return immediately to avoid log-spam during the +// orchestrator's coordinated stop. +func TestCheckForForeclosure_StopsOnContextCanceled(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + app.LastForecloseCheckBlock = 10 + c.On("IsForeclosed", mock.Anything).Return(false, context.Canceled).Once() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.checkForForeclosure(ctx, []appContracts{foreclosureAppContracts(app, c)}, 100) +} + +// TestCheckForForeclosure_AbortsLoopOnDeadlineExceeded verifies the +// deadline-exceeded short-circuit. Once the tick's context is past +// deadline every subsequent IsForeclosed call would fail the same way; +// surfacing one ERROR per app is wasted noise. The fix logs once at the +// site and aborts the loop, leaving recovery to the next tick — distinct +// from context.Canceled (silent) and other RPC errors (per-app log + +// continue), per the project's context-error-semantics convention. +func TestCheckForForeclosure_AbortsLoopOnDeadlineExceeded(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app1 := foreclosureTestApp(1) + app1.LastForecloseCheckBlock = 10 + app2 := foreclosureTestApp(2) + app2.LastForecloseCheckBlock = 10 + + // Only app1's IsForeclosed call is expected. If the loop kept going, + // app2's call would fail testify's "unexpected call" assertion — + // that is the assertion this test relies on. + c.On("IsForeclosed", mock.Anything).Return(false, context.DeadlineExceeded).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app1, c), foreclosureAppContracts(app2, c)}, 100) +} + +// TestCheckForForeclosure_AbortsOnDeadlineExceededAtRetrieve mirrors the +// previous test for the second blocking RPC: even after IsForeclosed +// succeeds, a deadline during RetrieveForeclosureEvents on the first app +// must abort the loop rather than continuing to the second app. +func TestCheckForForeclosure_AbortsOnDeadlineExceededAtRetrieve(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app1 := foreclosureTestApp(1) + app2 := foreclosureTestApp(2) + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, context.DeadlineExceeded).Once() + // No expectations registered for app2 — an unexpected call fails the test. + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app1, c), foreclosureAppContracts(app2, c)}, 100) +} + +// TestCheckForForeclosure_SkipsInMemoryMarkerOnErrNotFound verifies the +// ErrNotFound branch on the atomic foreclosure write. The row was deleted +// between the tick's ListApplications scan and this write; the caller +// must NOT populate app.ForecloseBlock / app.ForecloseTransaction +// because doing so would diverge from a DB row that no longer exists. +func TestCheckForForeclosure_SkipsInMemoryMarkerOnErrNotFound(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(evBlock, txHash)}, nil).Once() + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, uint64(100), + ).Return(repository.ErrNotFound).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Zero(t, app.ForecloseBlock, + "ErrNotFound means the row is gone — in-memory marker must not be set") + assert.Nil(t, app.ForecloseTransaction) +} + +// TestCheckForForeclosure_SkipsBeforeDeploymentBlock verifies that a newly +// registered app does not query historical contract state at queued block +// headers from before the application contract existed. +func TestCheckForForeclosure_SkipsBeforeDeploymentBlock(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const head = uint64(90) + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int), bind.ErrNoCode).Once() + // No IsForeclosed expectation: querying it at block 90 would be an + // eth_call against an address with no code. + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head) + + assert.Zero(t, app.LastForecloseCheckBlock) +} + +// TestCheckForForeclosure_SetsInMemoryMarkerOnIdempotentNil verifies the +// idempotent path: when the atomic foreclosure write returns nil for the +// "already foreclosed" case, the in-memory marker IS populated. The +// Foreclosure() event is one-way on chain so every observer derives the +// same (block, txHash); writing the marker is safe and lets other code +// paths in this tick see the foreclosure. +func TestCheckForForeclosure_SetsInMemoryMarkerOnIdempotentNil(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const evBlock = uint64(80) + txHash := common.HexToHash("0xfeed") + + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{makeForeclosureEvent(evBlock, txHash)}, nil).Once() + // nil for the idempotent "already foreclosed" path. The repository + // contract distinguishes this from ErrNotFound. + repo.On("UpdateApplicationForeclosure", + mock.Anything, app.ID, evBlock, txHash, uint64(100), + ).Return(nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 100) + + assert.Equal(t, evBlock, app.ForecloseBlock) + if assert.NotNil(t, app.ForecloseTransaction) { + assert.Equal(t, txHash, *app.ForecloseTransaction) + } +} + +// --------------------------------------------------------------------------- +// foreclosureSearchFloor +// --------------------------------------------------------------------------- + +// TestForeclosureSearchFloor_ReturnsDeploymentBlock verifies the happy +// path: a positive deployment block is returned for the lower bound of +// the very first scan window. +func TestForeclosureSearchFloor_ReturnsDeploymentBlock(t *testing.T) { + s, c, _ := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + + app := foreclosureTestApp(1) + ac := foreclosureAppContracts(app, c) + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(123), nil).Once() + + got, err := s.foreclosureSearchFloor(context.Background(), &ac, 200) + require.NoError(t, err) + assert.Equal(t, uint64(123), got) +} + +// TestForeclosureSearchFloor_AcceptsDeploymentBlockZero verifies that a +// zero deployment block is accepted: anvil / genesis-snapshot fixtures can +// legitimately place contract code at block 0, and the previous defensive +// reject tripped on otherwise-valid devnet runs. A zero floor only widens +// the scan window; last_foreclose_check_block bounds it on the next tick. +func TestForeclosureSearchFloor_AcceptsDeploymentBlockZero(t *testing.T) { + s, c, _ := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + + app := foreclosureTestApp(1) + ac := foreclosureAppContracts(app, c) + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(0), nil).Once() + + got, err := s.foreclosureSearchFloor(context.Background(), &ac, 200) + require.NoError(t, err) + assert.Equal(t, uint64(0), got) +} + +// TestForeclosureSearchFloor_PropagatesRPCError verifies that the +// underlying RPC failure surfaces verbatim so the caller can log it with +// the right context. +func TestForeclosureSearchFloor_PropagatesRPCError(t *testing.T) { + s, c, _ := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + + app := foreclosureTestApp(1) + ac := foreclosureAppContracts(app, c) + rpcErr := errors.New("eth_call timeout") + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int), rpcErr).Once() + + _, err := s.foreclosureSearchFloor(context.Background(), &ac, 200) + require.Error(t, err) + assert.ErrorIs(t, err, rpcErr) +} + +// --------------------------------------------------------------------------- +// LastForecloseCheckBlock advancement +// --------------------------------------------------------------------------- + +// TestCheckForForeclosure_RetriesSameWindowWhenEventMissing pins the +// correctness contract: if isForeclosed() is true but no Foreclosure log is +// found, the scan cursor must not advance. The second tick therefore scans +// from the original deployment floor again, extending only the upper bound. +func TestCheckForForeclosure_RetriesSameWindowWhenEventMissing(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + const ( + head1 = uint64(100) + head2 = uint64(110) + ) + + // First tick: LastForecloseCheckBlock==0, so the deployment block is read. + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 10 && opts.End != nil && *opts.End == head1 + }), + ).Return([]*iapplication.IApplicationForeclosure{}, nil).Once() + + // Second tick: LastForecloseCheckBlock is still zero, so the deployment + // floor is read again and the scan retries [deployment, newHead]. + c.On("IsForeclosed", mock.Anything).Return(true, nil).Once() + c.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(10), nil).Once() + c.On("RetrieveForeclosureEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 10 && opts.End != nil && *opts.End == head2 + }), + ).Return([]*iapplication.IApplicationForeclosure{}, nil).Once() + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head1) + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, head2) + + assert.Zero(t, app.LastForecloseCheckBlock) +} + +// TestCheckForForeclosure_SkipsWhenAlreadyScannedPastHead verifies the +// short-circuit: if a previous tick already advanced +// LastForecloseCheckBlock past the current head (e.g. defaultBlock +// policy temporarily falls back), the function must not issue any +// RetrieveForeclosureEvents call and must not read the deployment block +// either (LastForecloseCheckBlock > 0 alone satisfies the lower-bound +// check). +func TestCheckForForeclosure_SkipsWhenAlreadyScannedPastHead(t *testing.T) { + s, c, repo := newForeclosureServiceFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := foreclosureTestApp(1) + app.LastForecloseCheckBlock = 200 + // No GetDeploymentBlockNumber, no RetrieveForeclosureEvents, no + // last_foreclose_check_block update — the short-circuit is the + // assertion. + + s.checkForForeclosure(context.Background(), + []appContracts{foreclosureAppContracts(app, c)}, 150) + + assert.Equal(t, uint64(200), app.LastForecloseCheckBlock, + "last_foreclose_check_block must not regress when head < last block") +} diff --git a/internal/evmreader/input.go b/internal/evmreader/input.go new file mode 100644 index 000000000..afe19ba59 --- /dev/null +++ b/internal/evmreader/input.go @@ -0,0 +1,556 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math" + "math/big" + + "github.com/cartesi/rollups-node/internal/errutil" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +type iConsensusInputScanUnit struct { + inputBoxAddress common.Address + lastInputCheckBlock uint64 + endBlock uint64 + apps []appContracts +} + +type iConsensusInputScanRange struct { + lastInputCheckBlock uint64 + endBlock uint64 +} + +// initializeNewApplicationInputSync initializes input synchronization for a new application +// by finding the appropriate starting block and updating the database +func (r *Service) initializeNewApplicationInputSync( + ctx context.Context, + app *appContracts, + mostRecentBlockNumber uint64, +) (uint64, error) { + r.Logger.Info("Initializing application input sync", + "application", app.application.Name, + "inputbox_block", app.application.IInputBoxBlock, + "current_block", mostRecentBlockNumber, + ) + if app.application.IInputBoxBlock == 0 { + return 0, errors.New("application has no InputBox block number defined") + } + lastInputCheckBlock := app.application.IInputBoxBlock - 1 + + err := r.repository.UpdateEventLastCheckBlock(ctx, []int64{app.application.ID}, MonitoredEvent_InputAdded, lastInputCheckBlock) + if err != nil { + return 0, fmt.Errorf("initialize input cursor at block %d: %w", lastInputCheckBlock, err) + } + r.Logger.Debug("Application input sync initialized", + "application", app.application.Name, + "inputbox_block", app.application.IInputBoxBlock, + "last_input_check_block", lastInputCheckBlock, + "next_search_block", lastInputCheckBlock+1, + "current_block", mostRecentBlockNumber, + ) + + app.application.LastInputCheckBlock = lastInputCheckBlock + return lastInputCheckBlock, nil +} + +func (r *Service) scanIConsensusInputs( + ctx context.Context, + applications []appContracts, + mostRecentBlockNumber uint64, +) bool { + r.Logger.Debug("Checking for new inputs") + + units, success := r.buildIConsensusInputScanUnits(ctx, applications, mostRecentBlockNumber) + for _, unit := range units { + if ctx.Err() != nil { + return false + } + success = r.scanIConsensusInputUnit(ctx, unit) && success + } + return success +} + +func (r *Service) buildIConsensusInputScanUnits( + ctx context.Context, + applications []appContracts, + endBlock uint64, +) ([]iConsensusInputScanUnit, bool) { + success := true + appsByInputBox := map[common.Address][]appContracts{} + for _, app := range applications { + key := app.application.IInputBoxAddress + appsByInputBox[key] = append(appsByInputBox[key], app) + } + + var units []iConsensusInputScanUnit + for inputBoxAddress, inputBoxApps := range appsByInputBox { + r.Logger.Debug("Checking inputs for applications with the same InputBox", + "inputbox_address", inputBoxAddress, + "most_recent_block", endBlock, + ) + + appsByLastInputCheckBlock := make(map[iConsensusInputScanRange][]appContracts) + for _, app := range inputBoxApps { + if ctx.Err() != nil { + return nil, false + } + lastInputCheckBlock := app.application.LastInputCheckBlock + if lastInputCheckBlock == 0 { // New application. Find a safe start block to scan for inputs + var err error + lastInputCheckBlock, err = r.initializeNewApplicationInputSync( + ctx, + &app, + foreclosureBoundedEndBlock(app.application, endBlock), + ) + if err != nil { + success = false + r.reportInputScanError(ctx, app, 0, endBlock, err) + continue + } + } + scanEndBlock := foreclosureBoundedEndBlock(app.application, endBlock) + if lastInputCheckBlock > scanEndBlock { + level := slog.LevelWarn + if app.application.IInputBoxBlock != 0 && lastInputCheckBlock == app.application.IInputBoxBlock-1 { + // Registration can initialize the cursor before deployment is + // visible at the configured observation block. + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, + "Input search skipped: most recent block is lower than the last processed one", + "application", app.application.Name, + "last_processed_block", lastInputCheckBlock, + "most_recent_block", scanEndBlock, + ) + continue + } + if lastInputCheckBlock == scanEndBlock { + r.Logger.Debug("Input search skipped: already checked the most recent block", + "application", app.application.Name, + "last_processed_block", lastInputCheckBlock, + "most_recent_block", scanEndBlock, + ) + continue + } + + scanRange := iConsensusInputScanRange{ + lastInputCheckBlock: lastInputCheckBlock, + endBlock: scanEndBlock, + } + appsByLastInputCheckBlock[scanRange] = append(appsByLastInputCheckBlock[scanRange], app) + } + + for scanRange, apps := range appsByLastInputCheckBlock { + units = append(units, iConsensusInputScanUnit{ + inputBoxAddress: inputBoxAddress, + lastInputCheckBlock: scanRange.lastInputCheckBlock, + endBlock: scanRange.endBlock, + apps: apps, + }) + } + } + return units, success +} + +func foreclosureBoundedEndBlock(app *Application, endBlock uint64) uint64 { + if app != nil && app.ForecloseBlock != 0 && app.ForecloseBlock < endBlock { + return app.ForecloseBlock + } + return endBlock +} + +func (r *Service) scanIConsensusInputUnit( + ctx context.Context, + unit iConsensusInputScanUnit, +) bool { + success := true + appAddresses := appsToAddresses(unit.apps) + + if unit.endBlock > unit.lastInputCheckBlock { + r.Logger.Debug("Checking inputs for applications", + "apps", appAddresses, + "last_processed_block", unit.lastInputCheckBlock, + "most_recent_block", unit.endBlock, + ) + + for _, app := range unit.apps { + if ctx.Err() != nil { + return false + } + err := r.readAndStoreApplicationInputs(ctx, unit.lastInputCheckBlock, unit.endBlock, app) + if err != nil { + success = false + r.reportInputScanError(ctx, app, unit.lastInputCheckBlock, unit.endBlock, err) + } + } + return success + } + + if unit.endBlock < unit.lastInputCheckBlock { + r.Logger.Warn( + "Input search skipped: most recent block is lower than the last processed one", + "apps", appAddresses, + "last_processed_block", unit.lastInputCheckBlock, + "most_recent_block", unit.endBlock, + ) + return success + } + + r.Logger.Debug("Input search skipped: already checked the most recent block", + "apps", appAddresses, + "last_processed_block", unit.lastInputCheckBlock, + "most_recent_block", unit.endBlock, + ) + return success +} + +// The application scan owns operational error logging. Helpers return the cause; +// status transitions retain their existing, separately reported diagnostics. +func (r *Service) reportInputScanError(ctx context.Context, app appContracts, from, to uint64, err error) { + if errors.Is(err, errApplicationStatusReported) { + return + } + level := slog.LevelError + if errors.Is(err, errInconclusiveInputView) { + level = slog.LevelWarn + } + if ctx.Err() == context.Canceled && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Application input scan interrupted", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "last_processed_block", from, + "most_recent_block", to, + "error", err, + ) +} + +// A counter and its logs must agree before the scan can commit. Disagreement +// is an incomplete provider view, not evidence of a stored application fault. +var errInconclusiveInputView = errors.New("input observation is inconclusive") + +// ErrInputForNonOpenEpoch indicates that an input was received for an epoch +// that is not open — a state that should never occur during normal operation. +var ErrInputForNonOpenEpoch = errors.New("received input for non-open epoch") + +// indexInputsIntoEpochs is a pure function that indexes a sorted list of inputs +// into their respective epochs based on block number and epoch length. +// It creates new epochs as needed and closes epochs when subsequent inputs fall +// into a later epoch. It also closes the final epoch if mostRecentBlockNumber +// has advanced past its last block. +// The returned map contains epoch pointers as keys and their inputs as values. +// An epoch may appear with an empty input slice if it was closed without receiving +// new inputs in this batch. +// Returns ErrInputForNonOpenEpoch if an input targets an already-closed epoch. +func indexInputsIntoEpochs( + epochLength uint64, + currentEpoch *Epoch, + inputs []*Input, + mostRecentBlockNumber uint64, +) (map[*Epoch][]*Input, error) { + epochInputMap := make(map[*Epoch][]*Input) + + for _, input := range inputs { + inputEpochIndex := calculateEpochIndex(epochLength, input.BlockNumber) + + // If input belongs into a new epoch, close the previous known one + if currentEpoch != nil { + if currentEpoch.Index == inputEpochIndex { + // Input can only be added to open epochs + if currentEpoch.Status != EpochStatus_Open { + return nil, fmt.Errorf( + "%w: epoch %d status %s, input %d", + ErrInputForNonOpenEpoch, + currentEpoch.Index, currentEpoch.Status, input.Index) + } + currentEpoch.InputIndexUpperBound = input.Index + 1 + } else { + if currentEpoch.Status == EpochStatus_Open { + currentEpoch.Status = EpochStatus_Closed + currentEpoch.InputIndexUpperBound = input.Index + if _, ok := epochInputMap[currentEpoch]; !ok { + epochInputMap[currentEpoch] = []*Input{} + } + } + currentEpoch = nil + } + } + if currentEpoch == nil { + currentEpoch = &Epoch{ + Index: inputEpochIndex, + FirstBlock: inputEpochIndex * epochLength, + LastBlock: (inputEpochIndex * epochLength) + epochLength - 1, + InputIndexLowerBound: input.Index, + InputIndexUpperBound: input.Index + 1, + Status: EpochStatus_Open, + } + epochInputMap[currentEpoch] = []*Input{} + } + + epochInputMap[currentEpoch] = append(epochInputMap[currentEpoch], input) + } + + // Indexed all inputs. Check if it is time to close the last epoch + if currentEpoch != nil && currentEpoch.Status == EpochStatus_Open && + mostRecentBlockNumber >= currentEpoch.LastBlock { + currentEpoch.Status = EpochStatus_Closed + if _, ok := epochInputMap[currentEpoch]; !ok { + epochInputMap[currentEpoch] = []*Input{} + } + } + + return epochInputMap, nil +} + +// recordInputCorruption keeps a recorded integrity fault local to the application. +// A failed status write still fails the scan. Status helpers update app.Status +// only after a successful write and preserve existing integrity terminals. +func (r *Service) recordInputCorruption( + ctx context.Context, + app *Application, + reasonFmt string, + args ...any, +) error { + reason := fmt.Sprintf(reasonFmt, args...) + err := r.setApplicationCorrupted(ctx, app, "%s", reason) + if app.Status == ApplicationStatus_Corrupted || app.Status == ApplicationStatus_Diverged { + r.Logger.Warn("Input observation degraded for application", "application", app.Name, "reason", reason) + return nil + } + return err +} + +// readAndStoreApplicationInputs completes one application's scan before its +// cursor can advance. A failed fetch, epoch lookup, or write leaves it retryable. +func (r *Service) readAndStoreApplicationInputs( + ctx context.Context, + lastProcessedBlock uint64, + mostRecentBlockNumber uint64, + app appContracts, +) error { + address := app.application.IApplicationAddress + epochLength := app.application.EpochLength + if epochLength == 0 { + return r.recordInputCorruption(ctx, app.application, "Application has epoch length of zero") + } + + inputs, err := r.readApplicationInputs(ctx, app, lastProcessedBlock+1, mostRecentBlockNumber) + if err != nil { + return err + } + currentEpoch, err := r.repository.GetEpoch(ctx, address.String(), calculateEpochIndex(epochLength, lastProcessedBlock)) + if err != nil { + return fmt.Errorf("retrieve current epoch: %w", err) + } + epochInputMap, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, ErrInputForNonOpenEpoch) { + return r.recordInputCorruption(ctx, app.application, "Should never happen. %v", err) + } + return fmt.Errorf("index inputs: %w", err) + } + + if len(epochInputMap) == 0 { + // No epoch needs to be stored or closed. Only this application's + // successfully processed range is eligible for the cursor-only update. + err = r.repository.UpdateEventLastCheckBlock( + ctx, []int64{app.application.ID}, MonitoredEvent_InputAdded, mostRecentBlockNumber) + if err != nil { + return fmt.Errorf("update input cursor: %w", err) + } + return nil + } + + // This transaction stores inputs, closes epochs, and advances the cursor. + // An epoch closure with no new inputs must use the same atomic write. + err = r.repository.CreateEpochsAndInputs(ctx, address.String(), epochInputMap, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.recordInputCorruption(ctx, app.application, + "stored input L1 log identity conflicts with rescanned chain data"+ + " (possible reorg past the input cursor); operator reset required. %v", err) + } + return fmt.Errorf("store inputs and epochs: %w", err) + } + + for epoch, epochInputs := range epochInputMap { + if epoch.Status == EpochStatus_Closed { + r.Logger.Info("Closing epoch", + "application", app.application.Name, + "address", address, + "epoch_index", epoch.Index, + "start", epoch.FirstBlock, + "end", epoch.LastBlock) + } + for _, input := range epochInputs { + r.Logger.Info("Found new Input", + "application", app.application.Name, + "address", address, + "index", input.Index, + "block", input.BlockNumber, + "epoch_index", epoch.Index) + } + } + r.Logger.Debug("Inputs and epochs stored successfully", + "application", app.application.Name, + "address", address, + "start_block", lastProcessedBlock+1, + "end_block", mostRecentBlockNumber, + "epoch_count", len(epochInputMap), + "input_count", len(inputs), + ) + return nil +} + +// readApplicationInputs preserves the failure cause for the scan caller. It +// validates the fetched logs against the counter observed in the same walk. +func (r *Service) readApplicationInputs( + ctx context.Context, + app appContracts, + startBlock, endBlock uint64, +) ([]*Input, error) { + inputCount, err := r.repository.GetNumberOfInputs(ctx, app.application.IApplicationAddress.String()) + if err != nil { + return nil, fmt.Errorf("get stored input count: %w", err) + } + inputs, endCount, err := r.fetchInputs( + ctx, app, startBlock, endBlock, new(big.Int).SetUint64(inputCount), 0, math.MaxUint64) + if err != nil { + return nil, fmt.Errorf("fetch inputs: %w", err) + } + expectedNew := endCount - inputCount + if uint64(len(inputs)) != expectedNew { + return nil, fmt.Errorf("%w: input count mismatch: stored %d, on-chain %d, expected %d new inputs, got %d", + errInconclusiveInputView, inputCount, endCount, expectedNew, len(inputs)) + } + return inputs, nil +} + +// fetchInputs locates blocks where new inputs were added via FindTransitions +// and accumulates them. The prevValue, lowerBound, and upperBound are caller- +// determined: IConsensus passes prevValue from the DB input count with full +// bounds [0, MaxUint64), while DaveConsensus sealed epochs use the epoch's +// InputIndexLowerBound as prevValue and the epoch's own index range as bounds. +// +// The second return value is the input counter observed at endBlock during the +// same transition walk, letting callers validate the on-chain count delta +// without a second eth_call pinned to endBlock. +func (r *Service) fetchInputs( + ctx context.Context, + app appContracts, + startBlock, endBlock uint64, + prevValue *big.Int, + lowerBound, upperBound uint64, +) ([]*Input, uint64, error) { + prevCount := uint64(0) + if prevValue != nil { + prevCount = prevValue.Uint64() + } + if startBlock > endBlock { + // Empty range: no new inputs, and the counter is unchanged from prevValue. + return nil, prevCount, nil + } + + r.Logger.Debug("Fetching inputs", + "application", app.application.Name, + "start_block", startBlock, + "end_block", endBlock, + "lower_bound", lowerBound, + "upper_bound", upperBound, + ) + + var observedEndValue *big.Int + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(block), + } + numInputs, err := app.inputSource.GetNumberOfInputs( + callOpts, app.application.IApplicationAddress) + if err != nil { + return nil, fmt.Errorf( + "failed to get number of inputs at block %d: %w", + block, err) + } + if block == endBlock { + observedEndValue = new(big.Int).Set(numInputs) + } + return numInputs, nil + } + + var sortedInputs []*Input + onHit := func(block uint64) error { + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: block, + End: &block, + } + inputEvents, err := app.inputSource.RetrieveInputs( + filterOpts, + []common.Address{app.application.IApplicationAddress}, + nil, + ) + if err != nil { + return fmt.Errorf( + "failed to retrieve inputs at block %d: %w", + block, err) + } + for _, event := range inputEvents { + idx := event.Index.Uint64() + if idx < lowerBound || idx >= upperBound { + continue + } + input := &Input{ + Index: idx, + Status: InputCompletionStatus_None, + RawData: event.Input, + BlockNumber: event.Raw.BlockNumber, + TransactionHash: event.Raw.TxHash, + LogIndex: uint64(event.Raw.Index), + } + var duplicate bool + sortedInputs, duplicate = insertSorted( + sortByInputIndex, sortedInputs, input) + if duplicate { + r.Logger.Warn("Duplicate input event detected, skipping", + "application", app.application.Name, + "index", input.Index, + "block", input.BlockNumber, + ) + } + } + return nil + } + + _, err := ethutil.FindTransitions( + ctx, startBlock, endBlock, prevValue, oracle, onHit) + if err != nil { + return nil, 0, fmt.Errorf( + "failed to walk input transitions: %w", err) + } + if observedEndValue == nil { + return nil, 0, fmt.Errorf("failed to observe input count at end block %d", endBlock) + } + + r.Logger.Debug("Fetched inputs", + "application", app.application.Name, + "start_block", startBlock, + "end_block", endBlock, + "prev_input_count", prevCount, + "end_input_count", observedEndValue.Uint64(), + "new_inputs", len(sortedInputs), + ) + return sortedInputs, observedEndValue.Uint64(), nil +} diff --git a/internal/evmreader/input_deployment_test.go b/internal/evmreader/input_deployment_test.go new file mode 100644 index 000000000..3afbe0bf4 --- /dev/null +++ b/internal/evmreader/input_deployment_test.go @@ -0,0 +1,111 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestInputScanLowerHeadLogsOnlyDeploymentWaitAtDebug(t *testing.T) { + const head uint64 = 90 + for _, test := range []struct { + name string + inputBoxBlock uint64 + cursor uint64 + wantLevel slog.Level + }{ + {name: "new deployment", inputBoxBlock: 100, wantLevel: slog.LevelDebug}, + {name: "deployment floor on later tick", inputBoxBlock: 100, cursor: 99, wantLevel: slog.LevelDebug}, + {name: "previous scan above deployment", inputBoxBlock: 100, cursor: 101, wantLevel: slog.LevelWarn}, + {name: "head regressed after deployment", inputBoxBlock: 10, cursor: 100, wantLevel: slog.LevelWarn}, + {name: "no deployment evidence", cursor: 100, wantLevel: slog.LevelWarn}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + app := inputUnitApp(1, inputBoxAddr, test.cursor) + app.application.IInputBoxBlock = test.inputBoxBlock + if test.cursor == 0 { + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, test.inputBoxBlock-1).Return(nil).Once() + } + + units, success := r.buildIConsensusInputScanUnits(t.Context(), []appContracts{app}, head) + require.True(t, success) + require.Empty(t, units) + require.Contains(t, output.String(), "level="+test.wantLevel.String()+` msg="Input search skipped: most recent block`) + if test.wantLevel == slog.LevelDebug { + require.NotContains(t, output.String(), "level=WARN") + } + repo.AssertExpectations(t) + }) + } +} + +func TestIConsensusPredeploymentObservationDoesNotCorrupt(t *testing.T) { + const ( + head uint64 = 90 + epochLength uint64 = 10 + ) + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + for _, test := range []struct { + name string + inputBoxBlock uint64 + }{ + {name: "InputBox not yet visible", inputBoxBlock: 100}, + {name: "existing InputBox with no inputs", inputBoxBlock: 10}, + } { + t.Run(consensus.String()+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + applicationContract := newMockApplicationContract() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "new-app", Enabled: true, Status: model.ApplicationStatus_OK, ConsensusType: consensus, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, IInputBoxBlock: test.inputBoxBlock, EpochLength: epochLength, + }, + applicationContract: applicationContract, inputSource: inputBox, + } + // Registration saw the application at latest, but neither foreclosure + // nor output observation can see its deployment at this configured head. + applicationContract.On("GetDeploymentBlockNumber", blockRange(head, head+1)). + Return(new(big.Int), bind.ErrNoCode).Twice() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, test.inputBoxBlock-1).Return(nil).Once() + if test.inputBoxBlock <= head { + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(test.inputBoxBlock, head+1), app1Addr). + Return(new(big.Int), nil).Twice() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), (test.inputBoxBlock-1)/epochLength).Return(nil, nil).Once() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_InputAdded, head).Return(nil).Once() + } + + require.True(t, r.runBlockScanners(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Zero(t, app.application.LastOutputCheckBlock) + require.Zero(t, app.application.LastForecloseCheckBlock) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + inputBox.AssertNotCalled(t, "RetrieveInputs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + applicationContract.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/input_inconclusive_test.go b/internal/evmreader/input_inconclusive_test.go new file mode 100644 index 000000000..1635ff31e --- /dev/null +++ b/internal/evmreader/input_inconclusive_test.go @@ -0,0 +1,104 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestIncompleteInputViewRetriesWithoutWritingOrAdvancingCursor(t *testing.T) { + for _, test := range []struct { + name string + stored uint64 + startCount int64 + endCount uint64 + firstLogs []iinputbox.IInputBoxInputAdded + }{ + {name: "counter includes next block input", startCount: 0, endCount: 1}, + {name: "counter includes third input outside filter", stored: 1, startCount: 2, endCount: 3, + firstLogs: []iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 1, 101)}}, + } { + t.Run(test.name, func(t *testing.T) { + app := appContracts{application: &model.Application{ + ID: 1, Name: "retry-input-view", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 1, EpochLength: 10, LastInputCheckBlock: 100, + Status: model.ApplicationStatus_OK, Enabled: true, + }} + repo, input := newMockRepository(), newMockInputBox() + app.inputSource = input + var output bytes.Buffer + reader := &Service{repository: repo, defaultBlock: model.DefaultBlock_Latest} + reader.Logger = slog.New(slog.NewJSONHandler(&output, nil)) + repo.On("GetNumberOfInputs", mock.Anything, app1Addr.Hex()).Return(test.stored, nil).Twice() + // The counter includes an input outside this scan's end block. The + // logs correctly cover only the requested range. + badCount := input.On("GetNumberOfInputs", mock.Anything, app1Addr).Return(new(big.Int).SetUint64(test.endCount), nil) + partial := input.On("RetrieveInputs", mock.Anything, mock.Anything, mock.Anything).Return(test.firstLogs, nil).Once() + err := reader.readAndStoreApplicationInputs(t.Context(), 100, 101, app) + require.ErrorIs(t, err, errInconclusiveInputView) + reader.reportInputScanError(t.Context(), app, 100, 101, err) + require.Contains(t, output.String(), `"level":"WARN"`) + require.NotContains(t, output.String(), `"level":"ERROR"`) + for _, context := range []string{ + `"application":"retry-input-view"`, strings.ToLower(app1Addr.Hex()), + `"last_processed_block":100`, `"most_recent_block":101`, "stored", "on-chain", "got", + } { + require.Contains(t, output.String(), context) + } + repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.EqualValues(t, 100, app.application.LastInputCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + badCount.Unset() + partial.Unset() + input.On("GetNumberOfInputs", inputCountBlock(101), app1Addr).Return(big.NewInt(test.startCount), nil) + input.On("GetNumberOfInputs", inputCountBlock(102), app1Addr).Return(new(big.Int).SetUint64(test.endCount), nil) + if len(test.firstLogs) != 0 { + input.On("RetrieveInputs", inputLogBlock(101), mock.Anything, mock.Anything).Return(test.firstLogs, nil).Once() + } + lastLog := makeInputEvent(app1Addr, test.endCount-1, 102) + input.On("RetrieveInputs", inputLogBlock(102), mock.Anything, mock.Anything). + Return([]iinputbox.IInputBoxInputAdded{lastLog}, nil).Once() + repo.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(10)).Return(&model.Epoch{ + Index: 10, FirstBlock: 100, LastBlock: 109, Status: model.EpochStatus_Open, + }, nil).Once() + repo.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), mock.Anything, uint64(102)). + Run(func(args mock.Arguments) { + var count uint64 + for _, inputs := range args.Get(2).(map[*model.Epoch][]*model.Input) { + count += uint64(len(inputs)) + } + require.Equal(t, test.endCount-test.stored, count) + }).Return(nil).Once() + require.True(t, reader.scanIConsensusInputs(t.Context(), []appContracts{app}, 102)) + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 1) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + input.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} + +func inputCountBlock(block uint64) any { + return mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber != nil && opts.BlockNumber.Uint64() == block + }) +} + +func inputLogBlock(block uint64) any { + return mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == block && opts.End != nil && *opts.End == block + }) +} diff --git a/internal/evmreader/input_indexing_test.go b/internal/evmreader/input_indexing_test.go new file mode 100644 index 000000000..15ccc150f --- /dev/null +++ b/internal/evmreader/input_indexing_test.go @@ -0,0 +1,427 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math" + "testing" + "testing/quick" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +// makeInput creates a test input with the given index and block number. +func makeInput(index, blockNumber uint64) *Input { + return &Input{ + Index: index, + BlockNumber: blockNumber, + Status: InputCompletionStatus_None, + } +} + +// collectEpochs flattens the epoch→inputs map into sorted slices for assertions. +// Returns epochs sorted by Index and a parallel slice of input slices. +func collectEpochs(m map[*Epoch][]*Input) (epochs []*Epoch, inputs [][]*Input) { + for e, ins := range m { + epochs = append(epochs, e) + inputs = append(inputs, ins) + } + // Sort by epoch index for deterministic assertions + for i := 0; i < len(epochs); i++ { + for j := i + 1; j < len(epochs); j++ { + if epochs[j].Index < epochs[i].Index { + epochs[i], epochs[j] = epochs[j], epochs[i] + inputs[i], inputs[j] = inputs[j], inputs[i] + } + } + } + return +} + +func TestIndexInputsIntoEpochs(t *testing.T) { + const epochLength uint64 = 10 + + t.Run("InputsSpanningMultipleEpochs", func(t *testing.T) { + // Inputs in epochs 0, 1, 2 (blocks 5, 15, 25) with mostRecent=29 (epoch 2 not closed) + inputs := []*Input{ + makeInput(0, 5), // epoch 0 + makeInput(1, 15), // epoch 1 + makeInput(2, 25), // epoch 2 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 29) + require.NoError(t, err) + require.Len(t, result, 3) + + epochs, epochInputs := collectEpochs(result) + + // Epoch 0: closed (input 1 at block 15 moved past it) + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Equal(t, uint64(0), epochs[0].FirstBlock) + require.Equal(t, uint64(9), epochs[0].LastBlock) + require.Equal(t, uint64(0), epochs[0].InputIndexLowerBound) + require.Equal(t, uint64(1), epochs[0].InputIndexUpperBound) // set when closing: next input index + require.Len(t, epochInputs[0], 1) + require.Equal(t, uint64(0), epochInputs[0][0].Index) + + // Epoch 1: closed (input 2 at block 25 moved past it) + require.Equal(t, uint64(1), epochs[1].Index) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) + require.Equal(t, uint64(10), epochs[1].FirstBlock) + require.Equal(t, uint64(19), epochs[1].LastBlock) + require.Equal(t, uint64(1), epochs[1].InputIndexLowerBound) + require.Equal(t, uint64(2), epochs[1].InputIndexUpperBound) + require.Len(t, epochInputs[1], 1) + + // Epoch 2: still open (mostRecent=29 < LastBlock=29, i.e. 29 >= 29 → closed!) + // Actually 29 >= 29, so epoch 2 IS closed. + require.Equal(t, uint64(2), epochs[2].Index) + require.Equal(t, EpochStatus_Closed, epochs[2].Status) + require.Equal(t, uint64(20), epochs[2].FirstBlock) + require.Equal(t, uint64(29), epochs[2].LastBlock) + require.Len(t, epochInputs[2], 1) + }) + + t.Run("InputsSpanningMultipleEpochsLastOpen", func(t *testing.T) { + // Same inputs but mostRecent=28, so epoch 2 stays open + inputs := []*Input{ + makeInput(0, 5), // epoch 0 + makeInput(1, 15), // epoch 1 + makeInput(2, 25), // epoch 2 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 28) + require.NoError(t, err) + + epochs, _ := collectEpochs(result) + require.Len(t, epochs, 3) + + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) + require.Equal(t, EpochStatus_Open, epochs[2].Status) // 28 < 29 + }) + + t.Run("NoInputsNoCurrentEpoch", func(t *testing.T) { + result, err := indexInputsIntoEpochs(epochLength, nil, nil, 100) + require.NoError(t, err) + require.Empty(t, result) + }) + + t.Run("NoInputsCurrentEpochStaysOpen", func(t *testing.T) { + // Current epoch at index 1 (blocks 10-19), mostRecent=15: should stay open + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + Status: EpochStatus_Open, + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, nil, 15) + require.NoError(t, err) + require.Empty(t, result) // no inputs means no entries + require.Equal(t, EpochStatus_Open, currentEpoch.Status) + }) + + t.Run("NoInputsCurrentEpochClosedByBlockAdvance", func(t *testing.T) { + // Current epoch at index 1 (blocks 10-19), mostRecent=19: should close + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + Status: EpochStatus_Open, + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, nil, 19) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, EpochStatus_Closed, currentEpoch.Status) + // The epoch should be in the map with empty inputs + inputs, ok := result[currentEpoch] + require.True(t, ok) + require.Empty(t, inputs) + }) + + t.Run("InputForAlreadyClosedEpochReturnsError", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + Status: EpochStatus_Closed, // already closed + } + inputs := []*Input{ + makeInput(5, 15), // targets epoch 1 which is closed + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 25) + require.Error(t, err) + require.ErrorIs(t, err, ErrInputForNonOpenEpoch) + require.Nil(t, result) + }) + + t.Run("InputForInputsProcessedEpochReturnsError", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + Status: EpochStatus_InputsProcessed, + } + inputs := []*Input{ + makeInput(5, 15), + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 25) + require.Error(t, err) + require.ErrorIs(t, err, ErrInputForNonOpenEpoch) + require.Nil(t, result) + }) + + t.Run("MultipleInputsSameEpoch", func(t *testing.T) { + inputs := []*Input{ + makeInput(0, 10), + makeInput(1, 12), + makeInput(2, 18), + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 18) + require.NoError(t, err) + require.Len(t, result, 1) + + epochs, epochInputs := collectEpochs(result) + require.Equal(t, uint64(1), epochs[0].Index) + require.Equal(t, EpochStatus_Open, epochs[0].Status) // 18 < 19 + require.Equal(t, uint64(0), epochs[0].InputIndexLowerBound) + require.Equal(t, uint64(3), epochs[0].InputIndexUpperBound) // last input index + 1 + require.Len(t, epochInputs[0], 3) + }) + + t.Run("ContinuesExistingOpenEpoch", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 1, + FirstBlock: 10, + LastBlock: 19, + InputIndexLowerBound: 0, + InputIndexUpperBound: 2, // already has inputs 0,1 + Status: EpochStatus_Open, + } + inputs := []*Input{ + makeInput(2, 14), // same epoch, new input + makeInput(3, 17), + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 17) + require.NoError(t, err) + + // The existing epoch pointer should be in the map + epochInputs, ok := result[currentEpoch] + require.True(t, ok) + require.Len(t, epochInputs, 2) + require.Equal(t, uint64(4), currentEpoch.InputIndexUpperBound) // updated to 3+1 + require.Equal(t, EpochStatus_Open, currentEpoch.Status) + }) + + t.Run("ExistingEpochClosedByNewEpochInput", func(t *testing.T) { + currentEpoch := &Epoch{ + Index: 0, + FirstBlock: 0, + LastBlock: 9, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + Status: EpochStatus_Open, + } + inputs := []*Input{ + makeInput(1, 10), // epoch 1 → closes epoch 0 + } + + result, err := indexInputsIntoEpochs(epochLength, currentEpoch, inputs, 15) + require.NoError(t, err) + require.Len(t, result, 2) // old epoch (closed) + new epoch + + // Verify old epoch was closed + require.Equal(t, EpochStatus_Closed, currentEpoch.Status) + require.Equal(t, uint64(1), currentEpoch.InputIndexUpperBound) // set to input.Index + + epochs, epochInputs := collectEpochs(result) + + // Epoch 0: closed, no new inputs in this batch (but was in map) + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Empty(t, epochInputs[0]) + + // Epoch 1: open with the new input + require.Equal(t, uint64(1), epochs[1].Index) + require.Equal(t, EpochStatus_Open, epochs[1].Status) // 15 < 19 + require.Len(t, epochInputs[1], 1) + }) + + t.Run("EpochBoundaryExact", func(t *testing.T) { + // Input at exact epoch boundary (block 10 = first block of epoch 1) + inputs := []*Input{ + makeInput(0, 9), // last block of epoch 0 + makeInput(1, 10), // first block of epoch 1 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 10) + require.NoError(t, err) + require.Len(t, result, 2) + + epochs, epochInputs := collectEpochs(result) + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Len(t, epochInputs[0], 1) + + require.Equal(t, uint64(1), epochs[1].Index) + require.Equal(t, EpochStatus_Open, epochs[1].Status) + require.Len(t, epochInputs[1], 1) + }) + + t.Run("SkippedEpochs", func(t *testing.T) { + // Inputs in epoch 0 and epoch 5, skipping 1-4 (no inputs in between) + inputs := []*Input{ + makeInput(0, 5), // epoch 0 + makeInput(1, 55), // epoch 5 + } + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, 59) + require.NoError(t, err) + require.Len(t, result, 2) // only epochs with inputs + closed prev + + epochs, epochInputs := collectEpochs(result) + + // Epoch 0: closed when input at block 55 arrived + require.Equal(t, uint64(0), epochs[0].Index) + require.Equal(t, EpochStatus_Closed, epochs[0].Status) + require.Equal(t, uint64(1), epochs[0].InputIndexUpperBound) // next input index + require.Len(t, epochInputs[0], 1) + + // Epoch 5: closed by mostRecentBlockNumber (59 >= 59) + require.Equal(t, uint64(5), epochs[1].Index) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) + require.Equal(t, uint64(50), epochs[1].FirstBlock) + require.Equal(t, uint64(59), epochs[1].LastBlock) + require.Len(t, epochInputs[1], 1) + }) +} + +func TestCalculateEpochIndex(t *testing.T) { + t.Run("BasicCases", func(t *testing.T) { + require.Equal(t, uint64(0), calculateEpochIndex(10, 0)) + require.Equal(t, uint64(0), calculateEpochIndex(10, 9)) + require.Equal(t, uint64(1), calculateEpochIndex(10, 10)) + require.Equal(t, uint64(1), calculateEpochIndex(10, 19)) + require.Equal(t, uint64(2), calculateEpochIndex(10, 20)) + }) + + t.Run("EpochLengthOne", func(t *testing.T) { + require.Equal(t, uint64(0), calculateEpochIndex(1, 0)) + require.Equal(t, uint64(42), calculateEpochIndex(1, 42)) + }) + + t.Run("LargeEpochLength", func(t *testing.T) { + require.Equal(t, uint64(0), calculateEpochIndex(1000, 999)) + require.Equal(t, uint64(1), calculateEpochIndex(1000, 1000)) + }) +} + +func TestCalculateEpochIndexProperty(t *testing.T) { + t.Run("BlockBelongsToExactlyOneEpoch", func(t *testing.T) { + f := func(epochLength uint64, blockNumber uint64) bool { + if epochLength == 0 { + return true // skip division by zero + } + idx := calculateEpochIndex(epochLength, blockNumber) + firstBlock := idx * epochLength + lastBlock := firstBlock + epochLength - 1 + + // Guard against overflow + if lastBlock < firstBlock { + return true // overflow, skip + } + + return blockNumber >= firstBlock && blockNumber <= lastBlock + } + require.NoError(t, quick.Check(f, nil)) + }) + + t.Run("ConsecutiveBlocksNeverSkipEpochs", func(t *testing.T) { + f := func(epochLength uint64, blockNumber uint64) bool { + if epochLength == 0 || blockNumber == math.MaxUint64 { + return true + } + idx1 := calculateEpochIndex(epochLength, blockNumber) + idx2 := calculateEpochIndex(epochLength, blockNumber+1) + // Consecutive blocks are either in the same epoch or adjacent epochs + return idx2 == idx1 || idx2 == idx1+1 + } + require.NoError(t, quick.Check(f, nil)) + }) +} + +func TestIndexInputsIntoEpochsProperty(t *testing.T) { + t.Run("MonotonicInputsProduceContiguousNonOverlappingEpochs", func(t *testing.T) { + const epochLength uint64 = 10 + + f := func(numInputs uint8) bool { + n := int(numInputs) % 20 // limit to 0-19 inputs + if n == 0 { + return true + } + + inputs := make([]*Input, n) + for i := range n { + inputs[i] = makeInput(uint64(i), uint64(i*3+1)) // monotonic blocks: 1,4,7,10,... + } + mostRecent := inputs[n-1].BlockNumber + epochLength // ensure past last epoch + + result, err := indexInputsIntoEpochs(epochLength, nil, inputs, mostRecent) + if err != nil { + return false + } + + epochs, epochInputs := collectEpochs(result) + + // 1. Every input must be assigned to exactly one epoch + totalInputs := 0 + for _, ins := range epochInputs { + totalInputs += len(ins) + } + if totalInputs != n { + return false + } + + // 2. Epochs must be non-overlapping (sorted by index, no duplicates) + for i := 1; i < len(epochs); i++ { + if epochs[i].Index <= epochs[i-1].Index { + return false + } + } + + // 3. Each input's block must fall within its epoch's block range + for i, ep := range epochs { + for _, inp := range epochInputs[i] { + if inp.BlockNumber < ep.FirstBlock || inp.BlockNumber > ep.LastBlock { + return false + } + } + } + + // 4. Input indices within each epoch must be monotonically increasing + for _, ins := range epochInputs { + for j := 1; j < len(ins); j++ { + if ins[j].Index <= ins[j-1].Index { + return false + } + } + } + + return true + } + require.NoError(t, quick.Check(f, nil)) + }) +} diff --git a/internal/evmreader/input_scan_test.go b/internal/evmreader/input_scan_test.go new file mode 100644 index 000000000..fed23db24 --- /dev/null +++ b/internal/evmreader/input_scan_test.go @@ -0,0 +1,278 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + scanInitializeCursor = "initialize cursor" + scanStoredCount = "stored count" + scanChainCounter = "chain counter" + scanInputLogs = "input logs" + scanEpochLookup = "epoch lookup" + scanEpochWrite = "epoch write" + scanCursorWrite = "cursor write" +) + +// Exercise the scan caller, not just its error classifier. Cancellation occurs +// inside the failing operation, as it does when shutdown interrupts a DB query. +func TestInputScanErrorReporting(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{ + scanInitializeCursor, scanStoredCount, scanChainCounter, scanInputLogs, scanEpochLookup, scanEpochWrite, scanCursorWrite, + } { + t.Run(operation, func(t *testing.T) { + for _, test := range []struct { + name string + err error + cancel bool + quiet bool + }{ + {name: "shutdown", err: fmt.Errorf("query: %w", context.Canceled), cancel: true, quiet: true}, + {name: "joined shutdown", err: errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), + cancel: true, quiet: true}, + {name: "active context cancellation", err: context.Canceled}, + {name: "database failure", err: dbErr}, + {name: "database failure during shutdown", err: dbErr, cancel: true}, + {name: "timeout", err: context.DeadlineExceeded}, + {name: "timeout during shutdown", err: context.DeadlineExceeded, cancel: true}, + {name: "mixed failure", err: errors.Join(context.Canceled, dbErr), cancel: true}, + {name: "mixed timeout", err: errors.Join(context.Canceled, context.DeadlineExceeded), cancel: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, output := inputScanFixture(t, operation, test.err, func(mock.Arguments) { + if test.cancel { + cancel() + } + }) + require.False(t, reader.scanIConsensusInputs(ctx, []appContracts{app}, 110)) + logs := output.String() + require.Equal(t, 1, strings.Count(logs, `"msg":"Application input scan interrupted"`), logs) + if test.quiet { + require.NotContains(t, logs, `"level":"ERROR"`) + } else { + require.Equal(t, 1, strings.Count(logs, `"level":"ERROR"`), logs) + } + require.Contains(t, logs, `"application":"input-scan-app"`) + require.Contains(t, logs, `"address":"`+strings.ToLower(app1Addr.Hex())+`"`) + require.Contains(t, logs, `"most_recent_block":110`) + if errors.Is(test.err, dbErr) { + require.Contains(t, logs, dbErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs, context.DeadlineExceeded.Error()) + } + if operation != scanInitializeCursor && operation != scanCursorWrite { + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + } + if operation != scanEpochWrite { + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 0) + } + }) + } + }) + } +} + +// Set up exactly one failing operation. Unreached operations have no expectation: +// an attempt to write after a failed read fails the test immediately. +func inputScanFixture(t *testing.T, operation string, failure error, during func(mock.Arguments)) ( + *Service, appContracts, *MockRepository, *bytes.Buffer, +) { + t.Helper() + app := appContracts{application: &model.Application{ + ID: 1, Name: "input-scan-app", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + IInputBoxBlock: 90, EpochLength: 10, LastInputCheckBlock: 100, ConsensusType: model.Consensus_Authority, + Enabled: true, Status: model.ApplicationStatus_OK, + }} + repo := newMockRepository() + input := newMockInputBox() + app.inputSource = input + output := new(bytes.Buffer) + reader := &Service{repository: repo, defaultBlock: model.DefaultBlock_Latest} + reader.Logger = slog.New(slog.NewJSONHandler(output, &slog.HandlerOptions{Level: slog.LevelDebug})) + t.Cleanup(func() { + repo.AssertExpectations(t) + input.AssertExpectations(t) + }) + if operation == scanInitializeCursor { + app.application.LastInputCheckBlock = 0 + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{1}, model.MonitoredEvent_InputAdded, uint64(89)). + Run(during).Return(failure).Once() + return reader, app, repo, output + } + count := repo.On("GetNumberOfInputs", mock.Anything, app1Addr.Hex()).Return(uint64(0), nil).Once() + if operation == scanStoredCount { + count.Run(during).Return(uint64(0), failure) + return reader, app, repo, output + } + chainCount := input.On("GetNumberOfInputs", mock.Anything, app1Addr).Return(big.NewInt(0), nil) + if operation == scanChainCounter { + chainCount.Run(during).Return((*big.Int)(nil), failure).Once() + return reader, app, repo, output + } + if operation == scanInputLogs { + chainCount.Return(big.NewInt(1), nil) + input.On("RetrieveInputs", mock.Anything, mock.Anything, mock.Anything). + Run(during).Return([]iinputbox.IInputBoxInputAdded(nil), failure).Once() + return reader, app, repo, output + } + epoch := repo.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(10)).Return((*model.Epoch)(nil), nil).Once() + switch operation { + case scanEpochLookup: + epoch.Run(during).Return((*model.Epoch)(nil), failure) + case scanEpochWrite: + // Closing an epoch with no new inputs still requires an atomic epoch+cursor write. + epoch.Return(&model.Epoch{Index: 10, FirstBlock: 100, LastBlock: 109, Status: model.EpochStatus_Open}, nil) + repo.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), mock.Anything, uint64(110)). + Run(during).Return(failure).Once() + case scanCursorWrite: + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{1}, model.MonitoredEvent_InputAdded, uint64(110)). + Run(during).Return(failure).Once() + default: + t.Fatalf("unknown input-scan operation %q", operation) + } + return reader, app, repo, output +} + +func TestInputScanHelperPreservesError(t *testing.T) { + for _, operation := range []string{scanStoredCount, scanChainCounter, scanInputLogs, scanEpochLookup, scanEpochWrite, scanCursorWrite} { + t.Run(operation, func(t *testing.T) { + cause := errors.New("operation failed") + reader, app, _, logs := inputScanFixture(t, operation, cause, func(mock.Arguments) {}) + err := reader.readAndStoreApplicationInputs(t.Context(), 100, 110, app) + require.ErrorIs(t, err, cause) + require.NotEqual(t, cause.Error(), err.Error(), "the operation must add context") + require.NotContains(t, logs.String(), `"level":"ERROR"`, "the caller owns operational error logging") + require.NotContains(t, logs.String(), "Application input scan interrupted") + }) + } +} + +func TestInputScanClosesEmptyEpochAtomically(t *testing.T) { + reader, app, repo, _ := inputScanFixture(t, scanEpochWrite, nil, func(args mock.Arguments) { + epochs := args.Get(2).(map[*model.Epoch][]*model.Input) + require.Len(t, epochs, 1) + for epoch, inputs := range epochs { + require.Equal(t, model.EpochStatus_Closed, epoch.Status) + require.EqualValues(t, 10, epoch.Index) + require.Empty(t, inputs) + } + }) + require.True(t, reader.scanIConsensusInputs(t.Context(), []appContracts{app}, 110)) + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 1) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) +} + +func TestInputScanStatusFailureReportedOnce(t *testing.T) { + repo := newMockRepository() + repo.On("UpdateApplicationStatus", mock.Anything, int64(1), model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + app := appContracts{application: &model.Application{ + ID: 1, Name: "invalid-epoch-length", IApplicationAddress: app1Addr, + Status: model.ApplicationStatus_OK, LastInputCheckBlock: 100, + }} + var logs bytes.Buffer + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + require.True(t, reader.scanIConsensusInputs(t.Context(), []appContracts{app}, 110), + "the recorded integrity fault is local to this application") + require.Contains(t, logs.String(), "Application has epoch length of zero") + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + require.NotContains(t, logs.String(), "Application input scan interrupted") + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + repo.AssertExpectations(t) +} + +func TestInputScanContinuesAfterApplicationFailure(t *testing.T) { + reader, failed, repo, logs := inputScanFixture(t, scanEpochLookup, errors.New("epoch lookup failed"), func(mock.Arguments) {}) + healthy := appContracts{application: &model.Application{ + ID: 2, Name: "healthy", IApplicationAddress: app2Addr, EpochLength: 10, LastInputCheckBlock: 100, + }} + input := newMockInputBox() + input.On("GetNumberOfInputs", mock.Anything, app2Addr).Return(big.NewInt(0), nil) + healthy.inputSource = input + repo.On("GetNumberOfInputs", mock.Anything, app2Addr.Hex()).Return(uint64(0), nil).Once() + repo.On("GetEpoch", mock.Anything, app2Addr.Hex(), uint64(10)).Return((*model.Epoch)(nil), nil).Once() + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{2}, model.MonitoredEvent_InputAdded, uint64(110)). + Return(nil).Once() + require.False(t, reader.scanIConsensusInputUnit(t.Context(), iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: []appContracts{failed, healthy}, + })) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 1) + input.AssertExpectations(t) +} + +func TestInputScanShutdownStopsLaterWork(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, logs := inputScanFixture(t, scanEpochLookup, context.Canceled, func(mock.Arguments) { cancel() }) + app.application.LastForecloseCheckBlock = 110 + app.application.LastOutputCheckBlock = 100 + + // Output observation would query the repository if dispatch continued after shutdown. + require.False(t, reader.runBlockScanners(ctx, []appContracts{app}, 110)) + repo.AssertNumberOfCalls(t, "GetNumberOfPendingExecutableOutputs", 0) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} + +func TestInputScanFailureThenShutdownKeepsEarlierError(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, failed, repo, logs := inputScanFixture(t, scanStoredCount, errors.New("database unavailable"), func(mock.Arguments) {}) + canceled := appContracts{application: &model.Application{ + ID: 2, Name: "canceled", IApplicationAddress: app2Addr, EpochLength: 10, + }} + repo.On("GetNumberOfInputs", mock.Anything, app2Addr.Hex()). + Run(func(mock.Arguments) { cancel() }).Return(uint64(0), context.Canceled).Once() + // The third scan has no expectations. It must not start after cancellation. + require.False(t, reader.scanIConsensusInputUnit(ctx, iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: []appContracts{failed, canceled, failed}, + })) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + require.Contains(t, logs.String(), "database unavailable") +} + +func TestInputScanCancellationDoesNotRefreshReadiness(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + reader, app, repo, logs := inputScanFixture(t, scanEpochLookup, context.Canceled, func(mock.Arguments) { cancel() }) + app.application.LastForecloseCheckBlock = 110 + app.application.LastOutputCheckBlock = 100 + client := newMockEthClient() + client.On("HeaderByNumber", mock.Anything, mock.Anything).Return(&types.Header{Number: big.NewInt(110)}, nil).Once() + reader.client = client + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app.application}, uint64(1), nil).Once() + reader.resolver = newApplicationAdapterResolver(reader.Logger, + newMockAdapterFactory().SetupDefaultBehaviorSingleApp(newMockApplicationContract(), app.inputSource.(*MockInputBox))) + previous := time.Now().Add(-time.Minute) + reader.lastSuccessfulPoll.Store(&previous) + reader.consecutiveScanFailures.Store(2) + _, err := reader.Tick(ctx) + require.NoError(t, err) + require.Equal(t, previous, *reader.lastSuccessfulPoll.Load()) + require.EqualValues(t, 2, reader.consecutiveScanFailures.Load()) + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} diff --git a/internal/evmreader/input_scan_units_test.go b/internal/evmreader/input_scan_units_test.go new file mode 100644 index 000000000..d5bc2cb03 --- /dev/null +++ b/internal/evmreader/input_scan_units_test.go @@ -0,0 +1,183 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestBuildIConsensusInputScanUnits_GroupsByInputBoxAndCursor(t *testing.T) { + ctx := context.Background() + reader := &Service{ + TickServiceTemplate: service.TickServiceTemplate{ + BaseTemplate: service.BaseTemplate{Logger: testLogger(t)}, + }, + } + inputBoxA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + inputBoxB := common.HexToAddress("0x00000000000000000000000000000000000000b1") + + tests := []struct { + name string + apps []appContracts + units map[common.Address]map[iConsensusInputScanRange][]int64 + }{ + { + name: "same input box and cursor share one unit", + apps: []appContracts{ + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxA, 10), + }, + units: map[common.Address]map[iConsensusInputScanRange][]int64{ + inputBoxA: {{10, 20}: {1, 2}}, + }, + }, + { + name: "same input box and different cursor produce separate units", + apps: []appContracts{ + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxA, 11), + }, + units: map[common.Address]map[iConsensusInputScanRange][]int64{ + inputBoxA: {{10, 20}: {1}, {11, 20}: {2}}, + }, + }, + { + name: "different input boxes produce separate units", + apps: []appContracts{ + inputUnitApp(1, inputBoxA, 10), + inputUnitApp(2, inputBoxB, 10), + }, + units: map[common.Address]map[iConsensusInputScanRange][]int64{ + inputBoxA: {{10, 20}: {1}}, + inputBoxB: {{10, 20}: {2}}, + }, + }, + { + name: "foreclosed app scans only through the foreclose block", + apps: []appContracts{ + inputUnitAppWithForeclose(1, inputBoxA, 10, 15), + }, + units: map[common.Address]map[iConsensusInputScanRange][]int64{ + inputBoxA: {{10, 15}: {1}}, + }, + }, + { + name: "foreclosed app already checked through foreclosure is excluded", + apps: []appContracts{ + inputUnitAppWithForeclose(1, inputBoxA, 15, 15), + }, + units: map[common.Address]map[iConsensusInputScanRange][]int64{}, + }, + { + name: "same input box and cursor split when foreclosure changes end block", + apps: []appContracts{ + inputUnitApp(1, inputBoxA, 10), + inputUnitAppWithForeclose(2, inputBoxA, 10, 15), + }, + units: map[common.Address]map[iConsensusInputScanRange][]int64{ + inputBoxA: {{10, 20}: {1}, {10, 15}: {2}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + units, success := reader.buildIConsensusInputScanUnits(ctx, tt.apps, 20) + require.True(t, success) + require.Equal(t, tt.units, inputScanUnitIDs(units)) + }) + } +} + +func TestBuildIConsensusInputScanUnits_InitializesBeforeGrouping(t *testing.T) { + ctx := context.Background() + repo := newMockRepository() + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{int64(1)}, MonitoredEvent_InputAdded, uint64(6)). + Return(nil).Once() + reader := &Service{ + TickServiceTemplate: service.TickServiceTemplate{ + BaseTemplate: service.BaseTemplate{Logger: testLogger(t)}, + }, + repository: repo, + } + inputBox := common.HexToAddress("0x00000000000000000000000000000000000000a1") + app := inputUnitApp(1, inputBox, 0) + app.application.IInputBoxBlock = 7 + + units, success := reader.buildIConsensusInputScanUnits(ctx, []appContracts{app}, 20) + + require.True(t, success) + require.Equal(t, map[common.Address]map[iConsensusInputScanRange][]int64{ + inputBox: {{6, 20}: {1}}, + }, inputScanUnitIDs(units)) + require.Equal(t, uint64(6), app.application.LastInputCheckBlock) + repo.AssertExpectations(t) +} + +func TestBuildIConsensusInputScanUnits_FailedInitializationExcludesOnlyThatApp(t *testing.T) { + ctx := context.Background() + reader := &Service{ + TickServiceTemplate: service.TickServiceTemplate{ + BaseTemplate: service.BaseTemplate{Logger: testLogger(t)}, + }, + } + inputBox := common.HexToAddress("0x00000000000000000000000000000000000000a1") + broken := inputUnitApp(1, inputBox, 0) + broken.application.IInputBoxBlock = 0 + good := inputUnitApp(2, inputBox, 10) + + units, success := reader.buildIConsensusInputScanUnits(ctx, []appContracts{broken, good}, 20) + require.False(t, success) + + require.Equal(t, map[common.Address]map[iConsensusInputScanRange][]int64{ + inputBox: {{10, 20}: {2}}, + }, inputScanUnitIDs(units)) +} + +func inputUnitApp(id int64, inputBox common.Address, cursor uint64) appContracts { + return inputUnitAppWithForeclose(id, inputBox, cursor, 0) +} + +func inputUnitAppWithForeclose( + id int64, + inputBox common.Address, + cursor uint64, + forecloseBlock uint64, +) appContracts { + return appContracts{application: &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + IInputBoxAddress: inputBox, + IInputBoxBlock: 1, + Enabled: true, + Status: ApplicationStatus_OK, + LastInputCheckBlock: cursor, + ForecloseBlock: forecloseBlock, + }} +} + +func inputScanUnitIDs(units []iConsensusInputScanUnit) map[common.Address]map[iConsensusInputScanRange][]int64 { + result := map[common.Address]map[iConsensusInputScanRange][]int64{} + for _, unit := range units { + byCursor := result[unit.inputBoxAddress] + if byCursor == nil { + byCursor = map[iConsensusInputScanRange][]int64{} + result[unit.inputBoxAddress] = byCursor + } + byCursor[iConsensusInputScanRange{ + lastInputCheckBlock: unit.lastInputCheckBlock, + endBlock: unit.endBlock, + }] = planTargetIDs(unit.apps) + } + return result +} diff --git a/internal/evmreader/input_test.go b/internal/evmreader/input_test.go new file mode 100644 index 000000000..48073b816 --- /dev/null +++ b/internal/evmreader/input_test.go @@ -0,0 +1,507 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func (s *EvmReaderSuite) TestItReadsInputsFromDirectInputBox() { + s.client.EnqueueNewHead(0x11).Once() + s.client.EnqueueNewHead(0x12).Once() + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 3) + s.repository.AssertCalled(s.T(), "CreateEpochsAndInputs", + mock.Anything, applications[0].IApplicationAddress.String(), mock.Anything, uint64(0x13)) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 13) + s.repository.AssertCalled(s.T(), "UpdateEventLastCheckBlock", + mock.Anything, []int64{applications[0].ID}, MonitoredEvent_InputAdded, uint64(0)) + s.repository.AssertCalled(s.T(), "UpdateEventLastCheckBlock", + mock.Anything, []int64{applications[1].ID}, MonitoredEvent_InputAdded, uint64(0x13)) + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertExpectations(s.T()) + s.applicationContract1.AssertExpectations(s.T()) + s.applicationContract2.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked calls +} + +func (s *EvmReaderSuite) TestItUpdatesLastInputCheckBlockWhenThereIsNoInputs() { + // Prepare repository + s.repository.Unset("UpdateEventLastCheckBlock") + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(8) // Two initial cursors, then one update per app per block. + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(4) + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(2) + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(2) + + s.repository.Unset("GetNumberOfInputs") + s.repository.On("GetNumberOfInputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Times(6) + s.repository.Unset("CreateEpochsAndInputs") + + // Prepare sequence of inputs + s.inputBox.Unset("RetrieveInputs") + s.inputBox.Unset("GetNumberOfInputs") + s.inputBox.On( + "GetNumberOfInputs", + mock.Anything, + mock.Anything, + ).Return(new(big.Int).SetUint64(0), nil) + + s.client.EnqueueNewHead(0x11).Once() + s.client.EnqueueNewHead(0x12).Once() + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertNumberOfCalls(s.T(), "RetrieveInputs", 0) + s.inputBox.AssertExpectations(s.T()) + + s.applicationContract1.AssertExpectations(s.T()) + s.applicationContract2.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked connection +} + +func (s *EvmReaderSuite) TestItReadsMultipleInputsFromSingleNewBlock() { + s.applicationContract1.Unset("GetDeploymentBlockNumber") + s.applicationContract1.Unset("GetNumberOfExecutedOutputs") + s.applicationContract1.On("GetNumberOfExecutedOutputs", + mock.Anything, + ).Return(new(big.Int).SetUint64(0), nil).Once() + + // Prepare sequence of inputs + s.inputBox.Unset("RetrieveInputs") + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3}, nil) + + s.inputBox.Unset("GetNumberOfInputs") + s.inputBox.On("GetNumberOfInputs", + mock.Anything, + mock.Anything, + ).Return(new(big.Int).SetUint64(2), nil) + + s.contractFactory = newMockAdapterFactory().SetupDefaultBehaviorSingleApp(s.applicationContract1, s.inputBox) + s.evmReader.adapterFactory = s.contractFactory + s.evmReader.resolver = newApplicationAdapterResolver(s.evmReader.Logger, s.contractFactory) + + // Prepare Repo + s.repository.Unset("ListApplications") + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return([]*Application{{ + Name: "my-app-1", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + Enabled: true, + Status: ApplicationStatus_OK, + IInputBoxBlock: 0x10, + EpochLength: 10, + LastInputCheckBlock: 0x12, + LastOutputCheckBlock: 0x12, + LastForecloseCheckBlock: 0x13, + }}, uint64(1), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + + s.repository.Unset("CreateEpochsAndInputs") + s.repository.On("CreateEpochsAndInputs", + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + ).Once().Run(func(arguments mock.Arguments) { + var epochInputMap map[*Epoch][]*Input + obj := arguments.Get(2) + epochInputMap, ok := obj.(map[*Epoch][]*Input) + s.Require().True(ok) + s.Require().Equal(1, len(epochInputMap)) + for _, inputs := range epochInputMap { + s.Require().Equal(2, len(inputs)) + break + } + }).Return(nil) + + s.repository.Unset("UpdateEventLastCheckBlock") + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Once() + + s.repository.Unset("GetNumberOfInputs") + s.repository.On("GetNumberOfInputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Once() + + s.repository.Unset("GetEpoch") + s.repository.On("GetEpoch", + mock.Anything, + mock.Anything, + uint64(1)).Return(nil, nil).Once() + + s.repository.Unset("GetNumberOfExecutedOutputs") + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Once() + + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertNumberOfCalls(s.T(), "RetrieveInputs", 1) + s.inputBox.AssertExpectations(s.T()) + + s.applicationContract1.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked connection +} + +func (s *EvmReaderSuite) TestItStartsWhenLastProcessedBlockIsTheMostRecentBlock() { + // Prepare Repo + s.repository.Unset("ListApplications") + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return([]*Application{{ + Name: "my-app-1", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + Enabled: true, + Status: ApplicationStatus_OK, + IInputBoxBlock: 0x10, + EpochLength: 10, + LastInputCheckBlock: 0x13, + LastOutputCheckBlock: 0x13, + LastForecloseCheckBlock: 0x13, + }}, uint64(1), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + + s.repository.Unset("CreateEpochsAndInputs") + s.repository.Unset("UpdateEventLastCheckBlock") + s.repository.Unset("GetEpoch") + s.repository.Unset("GetNumberOfInputs") + s.repository.Unset("GetNumberOfExecutedOutputs") + + s.inputBox.Unset("RetrieveInputs") + s.inputBox.Unset("GetNumberOfInputs") + + s.applicationContract1.Unset("GetDeploymentBlockNumber") + s.applicationContract1.Unset("RetrieveOutputExecutionEvents") + s.applicationContract1.Unset("GetNumberOfExecutedOutputs") + + s.contractFactory.Unset("CreateAdapters") + s.contractFactory.On("CreateAdapters", + mock.Anything, + ).Return(s.applicationContract1, s.inputBox, nil, nil).Once() + + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertExpectations(s.T()) + s.inputBox.AssertExpectations(s.T()) + s.applicationContract1.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked connection +} + +func (s *EvmReaderSuite) TestCatchUpForeclosedInputsScansThroughForecloseBlock() { + app := &Application{ + ID: 42, + Name: "foreclosed-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + Enabled: true, + Status: ApplicationStatus_OK, + IInputBoxBlock: 1, + EpochLength: 10, + LastInputCheckBlock: 201, + ForecloseBlock: 202, + } + + s.repository.Unset("GetNumberOfInputs") + s.repository.On("GetNumberOfInputs", + mock.Anything, + app.IApplicationAddress.String(), + ).Return(uint64(0), nil).Once() + + s.inputBox.Unset("GetNumberOfInputs") + s.inputBox.On("GetNumberOfInputs", + mock.Anything, + app.IApplicationAddress, + ).Return(new(big.Int).SetUint64(0), nil).Maybe() + s.inputBox.Unset("RetrieveInputs") + + s.repository.Unset("GetEpoch") + s.repository.On("GetEpoch", + mock.Anything, + app.IApplicationAddress.String(), + uint64(20), + ).Return(nil, nil).Once() + + s.repository.Unset("CreateEpochsAndInputs") + s.repository.Unset("UpdateEventLastCheckBlock") + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + []int64{app.ID}, + MonitoredEvent_InputAdded, + app.ForecloseBlock, + ).Return(nil).Once() + + s.evmReader.scanIConsensusInputs(s.ctx, []appContracts{{ + application: app, + inputSource: s.inputBox, + }}, app.ForecloseBlock+10) + + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 1) + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) + s.inputBox.AssertNumberOfCalls(s.T(), "RetrieveInputs", 0) +} + +// A successful same-block InputAdded event is valid pre-foreclosure work. If a +// later AddInput transaction in the same block reverts after foreclosure, there +// is no InputAdded event for the node to index. +func (s *EvmReaderSuite) TestCatchUpForeclosedInputsStoresSameBlockInput() { + app := &Application{ + ID: 42, + Name: "foreclosed-app", + IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, + Enabled: true, + Status: ApplicationStatus_OK, + IInputBoxBlock: 1, + EpochLength: 10, + LastInputCheckBlock: 201, + ForecloseBlock: 202, + } + + sameBlockInput := makeInputEvent(app.IApplicationAddress, 0, app.ForecloseBlock) + + s.repository.Unset("GetNumberOfInputs") + s.repository.On("GetNumberOfInputs", + mock.Anything, + app.IApplicationAddress.String(), + ).Return(uint64(0), nil).Once() + + s.inputBox.Unset("GetNumberOfInputs") + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() == app.ForecloseBlock + }), + app.IApplicationAddress, + ).Return(new(big.Int).SetUint64(1), nil).Twice() + + s.inputBox.Unset("RetrieveInputs") + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == app.ForecloseBlock && + opts.End != nil && + *opts.End == app.ForecloseBlock + }), + []common.Address{app.IApplicationAddress}, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{sameBlockInput}, nil).Once() + + s.repository.Unset("GetEpoch") + s.repository.On("GetEpoch", + mock.Anything, + app.IApplicationAddress.String(), + uint64(20), + ).Return(nil, nil).Once() + + s.repository.Unset("CreateEpochsAndInputs") + s.repository.On("CreateEpochsAndInputs", + mock.Anything, + app.IApplicationAddress.String(), + mock.Anything, + app.ForecloseBlock, + ).Run(func(arguments mock.Arguments) { + epochInputMap, ok := arguments.Get(2).(map[*Epoch][]*Input) + s.Require().True(ok) + s.Require().Len(epochInputMap, 1) + + for epoch, inputs := range epochInputMap { + s.Require().Equal(uint64(20), epoch.Index) + s.Require().Len(inputs, 1) + s.Require().Equal(uint64(0), inputs[0].Index) + s.Require().Equal(app.ForecloseBlock, inputs[0].BlockNumber) + s.Require().Equal(sameBlockInput.Raw.TxHash, inputs[0].TransactionHash) + s.Require().Equal(uint64(sameBlockInput.Raw.Index), inputs[0].LogIndex) + } + }).Return(nil).Once() + + s.repository.Unset("UpdateEventLastCheckBlock") + + s.evmReader.scanIConsensusInputs(s.ctx, []appContracts{{ + application: app, + inputSource: s.inputBox, + }}, app.ForecloseBlock+10) + + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 1) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + s.inputBox.AssertNumberOfCalls(s.T(), "RetrieveInputs", 1) +} + +// TestCheckpointNotAdvancedOnFetchFailure is a regression test for a bug where +// the input fetch swallowed per-app errors, and the caller then +// advanced LastInputCheckBlock for failed apps — permanently skipping their inputs. +func (s *EvmReaderSuite) TestCheckpointNotAdvancedOnFetchFailure() { + require := require.New(s.T()) + + okAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + failAddr := common.HexToAddress("0x2222222222222222222222222222222222222222") + ibAddr := common.HexToAddress("0xBa3Cf8fB82E43D370117A0b7296f91ED674E94e3") + + appOk := &Application{ + ID: 1, + Name: "app-ok", + IApplicationAddress: okAddr, + IInputBoxAddress: ibAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + appFail := &Application{ + ID: 2, + Name: "app-fail", + IApplicationAddress: failAddr, + IInputBoxAddress: ibAddr, + EpochLength: 10, + LastInputCheckBlock: 100, + } + + // appOk's inputSource succeeds with no inputs (GetNumberOfInputs returns 0 at both blocks) + inputSource1 := &MockInputBox{} + inputSource1.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + + // appFail's inputSource fails + inputSource2 := &MockInputBox{} + inputSource2.On("GetNumberOfInputs", mock.Anything, mock.Anything). + Return((*big.Int)(nil), errors.New("RPC connection refused")) + + apps := []appContracts{ + {application: appOk, inputSource: inputSource1}, + {application: appFail, inputSource: inputSource2}, + } + + // Repository: appOk has 0 inputs in DB + repo := newMockRepository() + repo.On("GetNumberOfInputs", mock.Anything, okAddr.String()). + Return(uint64(0), nil) + repo.On("GetNumberOfInputs", mock.Anything, failAddr.String()). + Return(uint64(0), nil) + + // GetEpoch is called for appOk (which was successfully fetched with 0 inputs) + // calculateEpochIndex(10, 100) = 10 + repo.On("GetEpoch", mock.Anything, okAddr.String(), uint64(10)). + Return(nil, nil) + + // Expect UpdateEventLastCheckBlock to be called with ONLY appOk's ID. + // appFail failed to fetch — its checkpoint must NOT be advanced. + repo.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.MatchedBy(func(ids []int64) bool { + // Must contain only appOk (ID=1), not appFail (ID=2) + for _, id := range ids { + if id == appFail.ID { + return false + } + } + return len(ids) == 1 && ids[0] == appOk.ID + }), + MonitoredEvent_InputAdded, + uint64(110), + ).Return(nil).Once() + + s.evmReader.repository = repo + + require.False(s.evmReader.scanIConsensusInputUnit(s.ctx, iConsensusInputScanUnit{ + lastInputCheckBlock: 100, endBlock: 110, apps: apps, + })) + + repo.AssertExpectations(s.T()) +} diff --git a/internal/evmreader/inputsource_adapter.go b/internal/evmreader/inputsource_adapter.go new file mode 100644 index 000000000..0ae3204a1 --- /dev/null +++ b/internal/evmreader/inputsource_adapter.go @@ -0,0 +1,125 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Interface for Input reading +type InputSourceAdapter interface { + // Wrapper for FilterInputAdded(), which is automatically generated + // by go-ethereum and cannot be used for testing + RetrieveInputs(opts *bind.FilterOpts, appAddresses []common.Address, index []*big.Int, + ) ([]iinputbox.IInputBoxInputAdded, error) + GetNumberOfInputs(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) +} + +// InputBox Wrapper +type InputSourceAdapterImpl struct { + inputbox *iinputbox.IInputBox + client *ethclient.Client + inputBoxAddress common.Address + filter ethutil.Filter +} + +func NewInputSourceAdapter( + inputBoxAddress common.Address, + client *ethclient.Client, + filter ethutil.Filter, +) (InputSourceAdapter, error) { + inputbox, err := iinputbox.NewIInputBox(inputBoxAddress, client) + if err != nil { + return nil, err + } + return &InputSourceAdapterImpl{ + inputbox: inputbox, + client: client, + inputBoxAddress: inputBoxAddress, + filter: filter, + }, nil +} + +func buildInputAddedFilterQuery( + opts *bind.FilterOpts, + inputBoxAddress common.Address, + appContract []common.Address, + index []*big.Int, +) (q ethereum.FilterQuery, err error) { + c, err := iinputbox.IInputBoxMetaData.GetAbi() + if err != nil { + return q, err + } + + var appContractRule []any + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + var indexRule []any + for _, indexItem := range index { + indexRule = append(indexRule, indexItem) + } + + topics, err := abi.MakeTopics( + []any{c.Events[MonitoredEvent_InputAdded.String()].ID}, + appContractRule, + indexRule, + ) + if err != nil { + return q, err + } + + q = ethereum.FilterQuery{ + Addresses: []common.Address{inputBoxAddress}, + FromBlock: new(big.Int).SetUint64(opts.Start), + Topics: topics, + } + if opts.End != nil { + q.ToBlock = new(big.Int).SetUint64(*opts.End) + } + return q, err +} + +func (i *InputSourceAdapterImpl) RetrieveInputs( + opts *bind.FilterOpts, + appContract []common.Address, + index []*big.Int, +) ([]iinputbox.IInputBoxInputAdded, error) { + q, err := buildInputAddedFilterQuery(opts, i.inputBoxAddress, appContract, index) + if err != nil { + return nil, err + } + + itr, err := i.filter.ChunkedFilterLogs(opts.Context, i.client, q) + if err != nil { + return nil, err + } + + var events []iinputbox.IInputBoxInputAdded + for log, err := range itr { + if err != nil { + return nil, err + } + ev, err := i.inputbox.ParseInputAdded(*log) + if err != nil { + return nil, err + } + events = append(events, *ev) + } + return events, nil +} + +func (i *InputSourceAdapterImpl) GetNumberOfInputs(opts *bind.CallOpts, addr common.Address) (*big.Int, error) { + return i.inputbox.GetNumberOfInputs(opts, addr) +} diff --git a/internal/evmreader/invalid_outputs_root_test.go b/internal/evmreader/invalid_outputs_root_test.go new file mode 100644 index 000000000..7b9161306 --- /dev/null +++ b/internal/evmreader/invalid_outputs_root_test.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestInvalidOutputsRootOutputObservation(t *testing.T) { + for _, tc := range []struct { + name string + mismatched bool + }{ + {name: "matching output is indexed"}, + {name: "mismatching output preserves diagnosis and advances cursor", mismatched: true}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newMockRepository() + contract := newMockApplicationContract() + var logs bytes.Buffer + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := &model.Application{ + ID: 1, Name: "invalid-root", Enabled: true, Status: model.ApplicationStatus_InvalidOutputsRoot, + Reason: new("epoch 12: root rule failed"), ForecloseBlock: 0x12, LastOutputCheckBlock: 0x12, + } + // An output originating before P can be executed against a later + // cumulative root. The event gives no root identity, so different + // bytes here must not create a false stop of that earlier prefix. + output := &model.Output{ + EpochIndex: 11, Index: outputExecution0.OutputIndex, RawData: bytes.Clone(outputExecution0.Output), + } + if tc.mismatched { + output.RawData = common.Hex2Bytes("FFBBCCDDEE") + } + contract.On("GetNumberOfExecutedOutputs", blockFrom(0x13)).Return(big.NewInt(1), nil) + contract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 })). + Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + repo.On("GetNumberOfExecutedOutputs", mock.Anything, app.IApplicationAddress.String()).Return(uint64(0), nil).Once() + repo.On("GetOutput", mock.Anything, app.IApplicationAddress.Hex(), output.Index).Return(output, nil).Once() + repo.On("UpdateOutputsExecution", mock.Anything, app.IApplicationAddress.Hex(), + mock.MatchedBy(func(outputs []*model.Output) bool { + if tc.mismatched { + return len(outputs) == 0 + } + return len(outputs) == 1 && outputs[0] == output && + outputs[0].ExecutionTransactionHash != nil && *outputs[0].ExecutionTransactionHash == outputExecution0.Raw.TxHash + }), uint64(0x13)).Return(nil).Once() + + ok := reader.checkForOutputExecution(t.Context(), []appContracts{ + {application: app, applicationContract: contract}, + }, 0x13) + + require.True(t, ok, "terminal mismatch is handled under the existing event cursor policy") + require.Equal(t, model.ApplicationStatus_InvalidOutputsRoot, app.Status) + require.Equal(t, "epoch 12: root rule failed", *app.Reason) + if tc.mismatched { + require.Nil(t, output.ExecutionTransactionHash) + require.Contains(t, logs.String(), "Output mismatch; preserving terminal application status") + require.Contains(t, logs.String(), "application=invalid-root") + require.Contains(t, logs.String(), "index=0") + require.Contains(t, logs.String(), "status=INVALID_OUTPUTS_ROOT") + require.NotContains(t, logs.String(), "unknown status") + } else { + require.Equal(t, outputExecution0.Raw.TxHash, *output.ExecutionTransactionHash) + } + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + contract.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/mocks_test.go b/internal/evmreader/mocks_test.go new file mode 100644 index 000000000..52043dd3c --- /dev/null +++ b/internal/evmreader/mocks_test.go @@ -0,0 +1,696 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "math/big" + "time" + + "github.com/cartesi/rollups-node/internal/config" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// blockRange returns a mock.MatchedBy matcher for CallOpts where BlockNumber is in [lo, hi). +func blockRange(lo, hi uint64) interface{} { + return mock.MatchedBy(func(opts *bind.CallOpts) bool { + b := opts.BlockNumber.Uint64() + return b >= lo && b < hi + }) +} + +// blockFrom returns a mock.MatchedBy matcher for CallOpts where BlockNumber >= lo. +func blockFrom(lo uint64) interface{} { + return mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= lo + }) +} + +// UnsetAll removes all expectations for the given method from a mock. +func UnsetAll(m *mock.Mock, methodName string) { + var index int + for _, call := range m.ExpectedCalls { + if call.Method == methodName { + continue + } + m.ExpectedCalls[index] = call + index++ + } + m.ExpectedCalls = m.ExpectedCalls[:index] +} + +func copyApplications(apps []*Application) []*Application { + copies := make([]*Application, len(apps)) + for i, app := range apps { + if app == nil { + continue + } + copyApp := *app + copies[i] = ©App + } + return copies +} + +// --------------------------------------------------------------------------- +// MockEthClient +// --------------------------------------------------------------------------- + +type MockEthClient struct { + mock.Mock +} + +func newMockEthClient() *MockEthClient { + return &MockEthClient{} +} + +func (m *MockEthClient) SetupDefaultBehavior() *MockEthClient { + m.On("ChainID", mock.Anything).Return(big.NewInt(1), nil) + return m +} + +func (m *MockEthClient) EnqueueNewHead(blknum int64) *mock.Call { + return m.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(&types.Header{Number: big.NewInt(blknum)}, nil) +} + +func (m *MockEthClient) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockEthClient) HeaderByNumber( + ctx context.Context, + number *big.Int, +) (*types.Header, error) { + args := m.Called(ctx, number) + return args.Get(0).(*types.Header), args.Error(1) +} + +func (m *MockEthClient) ChainID(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*big.Int), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockInputBox +// --------------------------------------------------------------------------- + +type MockInputBox struct { + mock.Mock +} + +func newMockInputBox() *MockInputBox { + return &MockInputBox{} +} + +func (m *MockInputBox) SetupDefaultBehavior() *MockInputBox { + // RetrieveInputs: matched by Start block, not call order. + m.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + []common.Address{app1Addr}, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) + + m.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x12 }), + []common.Address{app1Addr}, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent1}, nil) + + m.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + []common.Address{app1Addr}, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent2, inputAddedEvent3}, nil) + + // GetNumberOfInputs: block-based matching models the on-chain state. + // Each range returns the input count at that point in the blockchain. + m.On("GetNumberOfInputs", blockRange(0, 0x11), app1Addr). + Return(new(big.Int).SetUint64(0), nil) + m.On("GetNumberOfInputs", blockRange(0x11, 0x12), app1Addr). + Return(new(big.Int).SetUint64(1), nil) + m.On("GetNumberOfInputs", blockRange(0x12, 0x13), app1Addr). + Return(new(big.Int).SetUint64(2), nil) + m.On("GetNumberOfInputs", blockFrom(0x13), app1Addr). + Return(new(big.Int).SetUint64(4), nil) + m.On("GetNumberOfInputs", mock.Anything, app2Addr). + Return(new(big.Int).SetUint64(0), nil) + return m +} + +func (m *MockInputBox) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockInputBox) RetrieveInputs( + opts *bind.FilterOpts, + appContract []common.Address, + index []*big.Int, +) ([]iinputbox.IInputBoxInputAdded, error) { + args := m.Called(opts, appContract, index) + return args.Get(0).([]iinputbox.IInputBoxInputAdded), args.Error(1) +} + +func (m *MockInputBox) GetNumberOfInputs( + opts *bind.CallOpts, appContract common.Address, +) (*big.Int, error) { + args := m.Called(opts, appContract) + return args.Get(0).(*big.Int), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockRepository +// --------------------------------------------------------------------------- + +type MockRepository struct { + mock.Mock +} + +func newMockRepository() *MockRepository { + m := &MockRepository{} + m.On("GetNumberOfPendingExecutableOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(1), nil).Maybe() + return m +} + +func (m *MockRepository) SetupDefaultBehavior() *MockRepository { + m.On("RegisterObserver", mock.Anything, EvmReaderConfigKey, mock.Anything). + Return(config.PersistentChainConfig{DefaultBlock: DefaultBlock_Latest, ChainID: 1}, nil) + + apps := copyApplications(applications) + m.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 + apps[0].LastOutputCheckBlock = 0x11 + apps[1].LastOutputCheckBlock = 0x11 + m.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 + apps[0].LastOutputCheckBlock = 0x12 + apps[1].LastOutputCheckBlock = 0x12 + m.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + m.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(5) + m.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(8) + m.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil).Maybe() + + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(0), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(1), nil) + m.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(2), nil) + m.On("GetNumberOfInputs", mock.Anything, app2Addr.String()).Return(uint64(0), nil).Times(3) + + m.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Times(6) + m.On("CreateEpochsAndInputs", + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything).Return(nil) + + m.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(0)).Return(nil, nil).Once() + m.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(1)).Return( + &Epoch{ + Index: 1, + FirstBlock: 11, + LastBlock: 20, + Status: EpochStatus_Open, + TxBufferDataBlock: nil, + ClaimTransactionHash: nil, + }, nil).Twice() + m.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(0)).Return(nil, nil).Once() + m.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(1)).Return(nil, nil).Twice() + + // Catch-all: returns empty list for extra headers. + m.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + + return m +} + +func (m *MockRepository) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockRepository) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + pagination repository.Pagination, + descending bool, +) ([]*Application, uint64, error) { + args := m.Called(ctx, f, pagination, descending) + return args.Get(0).([]*Application), args.Get(1).(uint64), args.Error(2) +} + +func (m *MockRepository) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *MockRepository) RegisterObserver( + ctx context.Context, key string, requested config.PersistentChainConfig, +) (config.PersistentChainConfig, error) { + args := m.Called(ctx, key, requested) + return args.Get(0).(config.PersistentChainConfig), args.Error(1) +} + +func (m *MockRepository) SaveNodeConfigRaw( + ctx context.Context, key string, rawJSON []byte, +) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *MockRepository) LoadNodeConfigRaw( + ctx context.Context, key string, +) (rawJSON []byte, createdAt, updatedAt time.Time, err error) { + args := m.Called(ctx, key) + return args.Get(0).([]byte), args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) +} + +func (m *MockRepository) CreateEpochsAndInputs( + ctx context.Context, nameOrAddress string, + epochInputMap map[*Epoch][]*Input, blockNumber uint64, +) error { + args := m.Called(ctx, nameOrAddress, epochInputMap, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) GetEpoch( + ctx context.Context, nameOrAddress string, index uint64, +) (*Epoch, error) { + args := m.Called(ctx, nameOrAddress, index) + obj := args.Get(0) + if obj == nil { + return nil, args.Error(1) + } + return obj.(*Epoch), args.Error(1) +} + +func (m *MockRepository) ListEpochs( + ctx context.Context, nameOrAddress string, + f repository.EpochFilter, p repository.Pagination, descending bool, +) ([]*Epoch, uint64, error) { + args := m.Called(ctx, nameOrAddress, f, p, descending) + return args.Get(0).([]*Epoch), args.Get(1).(uint64), args.Error(2) +} + +func (m *MockRepository) GetOutput( + ctx context.Context, nameOrAddress string, indexKey uint64, +) (*Output, error) { + args := m.Called(ctx, nameOrAddress, indexKey) + obj := args.Get(0) + if obj == nil { + return nil, args.Error(1) + } + return obj.(*Output), args.Error(1) +} + +func (m *MockRepository) UpdateEpochClaimTransactionHash( + ctx context.Context, nameOrAddress string, e *Epoch, +) error { + args := m.Called(ctx, nameOrAddress, e) + return args.Error(0) +} + +func (m *MockRepository) GetLastNonOpenEpoch( + ctx context.Context, nameOrAddress string, +) (*Epoch, error) { + args := m.Called(ctx, nameOrAddress) + obj := args.Get(0) + if obj == nil { + return nil, args.Error(1) + } + return obj.(*Epoch), args.Error(1) +} + +func (m *MockRepository) GetNumberOfInputs( + ctx context.Context, nameOrAddress string, +) (uint64, error) { + args := m.Called(ctx, nameOrAddress) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockRepository) GetNumberOfExecutedOutputs( + ctx context.Context, nameOrAddress string, +) (uint64, error) { + args := m.Called(ctx, nameOrAddress) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockRepository) GetNumberOfPendingExecutableOutputs( + ctx context.Context, nameOrAddress string, +) (uint64, error) { + args := m.Called(ctx, nameOrAddress) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockRepository) UpdateOutputsExecution( + ctx context.Context, nameOrAddress string, + executedOutputs []*Output, blockNumber uint64, +) error { + args := m.Called(ctx, nameOrAddress, executedOutputs, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateApplicationStatus( + ctx context.Context, appID int64, state ApplicationStatus, reason *string, +) error { + args := m.Called(ctx, appID, state, reason) + return args.Error(0) +} + +func (m *MockRepository) UpdateApplicationForeclosure( + ctx context.Context, appID int64, block uint64, txHash common.Hash, blockNumber uint64, +) error { + args := m.Called(ctx, appID, block, txHash, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateApplicationLastForecloseCheckBlock( + ctx context.Context, appID int64, blockNumber uint64, +) error { + args := m.Called(ctx, appID, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateAccountsDriveProved( + ctx context.Context, appID int64, block uint64, txHash common.Hash, root common.Hash, blockNumber uint64, +) error { + args := m.Called(ctx, appID, block, txHash, root, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) UpdateApplicationLastAccountsDriveProvedCheckBlock( + ctx context.Context, appID int64, blockNumber uint64, +) error { + args := m.Called(ctx, appID, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) StoreWithdrawalEvents( + ctx context.Context, appID int64, withdrawals []*Withdrawal, blockNumber uint64, +) error { + args := m.Called(ctx, appID, withdrawals, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) GetNumberOfWithdrawals(ctx context.Context, appID int64) (uint64, error) { + args := m.Called(ctx, appID) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockRepository) InsertWithdrawal(ctx context.Context, w *Withdrawal) error { + args := m.Called(ctx, w) + return args.Error(0) +} + +func (m *MockRepository) UpdateEventLastCheckBlock( + ctx context.Context, appIDs []int64, + event MonitoredEvent, blockNumber uint64, +) error { + args := m.Called(ctx, appIDs, event, blockNumber) + return args.Error(0) +} + +func (m *MockRepository) GetEventLastCheckBlock( + ctx context.Context, appID int64, event MonitoredEvent, +) (uint64, error) { + args := m.Called(ctx, appID, event) + return args.Get(0).(uint64), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockApplicationContract +// --------------------------------------------------------------------------- + +type MockApplicationContract struct { + mock.Mock +} + +func newMockApplicationContract() *MockApplicationContract { + m := &MockApplicationContract{} + // Foreclosure detection runs on every evmreader tick. Default to a + // not-foreclosed app so tests that don't care about this path don't + // need to wire it up. .Maybe() lets AssertExpectations pass even + // when these calls didn't happen (test never reached the foreclosure + // branch). Tests that exercise foreclosure call Unset("IsForeclosed") + // + re-mock with the desired behavior. + m.On("IsForeclosed", mock.Anything).Return(false, nil).Maybe() + m.On("RetrieveForeclosureEvents", mock.Anything). + Return([]*iapplication.IApplicationForeclosure{}, nil).Maybe() + return m +} + +func (m *MockApplicationContract) SetupDefaultBehavior() *MockApplicationContract { + m.On("GetDeploymentBlockNumber", mock.Anything). + Return(new(big.Int).SetUint64(0x10), nil) + m.On("GetNumberOfExecutedOutputs", mock.Anything). + Return(new(big.Int).SetUint64(0), nil) + return m +} + +func (m *MockApplicationContract) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockApplicationContract) RetrieveOutputExecutionEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationOutputExecuted, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationOutputExecuted), args.Error(1) +} + +func (m *MockApplicationContract) RetrieveForeclosureEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationForeclosure, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationForeclosure), args.Error(1) +} + +func (m *MockApplicationContract) GetDeploymentBlockNumber( + opts *bind.CallOpts, +) (*big.Int, error) { + args := m.Called(opts) + return args.Get(0).(*big.Int), args.Error(1) +} + +func (m *MockApplicationContract) GetNumberOfExecutedOutputs( + opts *bind.CallOpts, +) (*big.Int, error) { + args := m.Called(opts) + return args.Get(0).(*big.Int), args.Error(1) +} + +func (m *MockApplicationContract) GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (bool, common.Hash, error) { + args := m.Called(opts) + return args.Bool(0), args.Get(1).(common.Hash), args.Error(2) +} + +func (m *MockApplicationContract) IsForeclosed(opts *bind.CallOpts) (bool, error) { + args := m.Called(opts) + return args.Bool(0), args.Error(1) +} + +func (m *MockApplicationContract) GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) { + args := m.Called(opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*big.Int), args.Error(1) +} + +func (m *MockApplicationContract) RetrieveWithdrawalEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationWithdrawal, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationWithdrawal), args.Error(1) +} + +func (m *MockApplicationContract) RetrieveAccountsDriveProvedEvents( + opts *bind.FilterOpts, +) ([]*iapplication.IApplicationAccountsDriveMerkleRootProved, error) { + args := m.Called(opts) + return args.Get(0).([]*iapplication.IApplicationAccountsDriveMerkleRootProved), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockDaveConsensus +// --------------------------------------------------------------------------- + +type MockDaveConsensus struct { + mock.Mock +} + +func newMockDaveConsensus() *MockDaveConsensus { + return &MockDaveConsensus{} +} + +func (m *MockDaveConsensus) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockDaveConsensus) GetInputBox( + opts *bind.CallOpts, +) (common.Address, error) { + args := m.Called(opts) + return args.Get(0).(common.Address), args.Error(1) +} + +func (m *MockDaveConsensus) GetCurrentSealedEpoch( + opts *bind.CallOpts, +) (DaveCurrentSealedEpoch, error) { + args := m.Called(opts) + return args.Get(0).(DaveCurrentSealedEpoch), args.Error(1) +} + +func (m *MockDaveConsensus) GetApplicationContract( + opts *bind.CallOpts, +) (common.Address, error) { + args := m.Called(opts) + return args.Get(0).(common.Address), args.Error(1) +} + +func (m *MockDaveConsensus) GetTournamentFactory( + opts *bind.CallOpts, +) (common.Address, error) { + args := m.Called(opts) + return args.Get(0).(common.Address), args.Error(1) +} + +func (m *MockDaveConsensus) GetDeploymentBlockNumber( + opts *bind.CallOpts, +) (*big.Int, error) { + args := m.Called(opts) + return args.Get(0).(*big.Int), args.Error(1) +} + +func (m *MockDaveConsensus) RetrieveSealedEpochs( + opts *bind.FilterOpts, +) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) { + args := m.Called(opts) + return args.Get(0).([]*idaveconsensus.IDaveConsensusEpochSealed), args.Error(1) +} + +// --------------------------------------------------------------------------- +// MockAdapterFactory +// --------------------------------------------------------------------------- + +type MockAdapterFactory struct { + mock.Mock +} + +func newMockAdapterFactory() *MockAdapterFactory { + return &MockAdapterFactory{} +} + +func (m *MockAdapterFactory) Unset(methodName string) { + UnsetAll(&m.Mock, methodName) +} + +func (m *MockAdapterFactory) CreateAdapters( + app *Application, +) (ApplicationContractAdapter, InputSourceAdapter, DaveConsensusAdapter, error) { + args := m.Called(app) + + // Safely handle nil values to prevent interface conversion panic + appContract, _ := args.Get(0).(ApplicationContractAdapter) + inputSource, _ := args.Get(1).(InputSourceAdapter) + daveConsensus, _ := args.Get(2).(DaveConsensusAdapter) + + // If we got nil values but no error was returned, return mock implementations + if appContract == nil && args.Error(3) == nil { + appContract = &MockApplicationContract{} + } + + if inputSource == nil && args.Error(3) == nil { + inputSource = newMockInputBox() + } + + return appContract, inputSource, daveConsensus, args.Error(3) +} + +func (m *MockAdapterFactory) SetupDefaultBehavior( + appContract1 *MockApplicationContract, + appContract2 *MockApplicationContract, + inputBox1 *MockInputBox, +) *MockAdapterFactory { + + // Match by application address so adapters are returned correctly regardless of call count + // (adapter caching means CreateAdapters is only called once per app address) + m.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[0].IApplicationAddress + }), + ).Return(appContract1, inputBox1, nil, nil) + m.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[1].IApplicationAddress + }), + ).Return(appContract2, inputBox1, nil, nil) + return m +} + +func (m *MockAdapterFactory) SetupDefaultBehaviorSingleApp( + appContract *MockApplicationContract, + inputBox *MockInputBox, +) *MockAdapterFactory { + m.On("CreateAdapters", + mock.Anything, + ).Return(appContract, inputBox, nil, nil) + return m +} diff --git a/internal/evmreader/observation_shutdown_test.go b/internal/evmreader/observation_shutdown_test.go new file mode 100644 index 000000000..d19262ca2 --- /dev/null +++ b/internal/evmreader/observation_shutdown_test.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestObservationDatabaseErrorReporting(t *testing.T) { + const ( + applicationList = "application list" + pendingOutputs = "pending outputs" + ) + dbErr := errors.New("database unavailable") + for _, operation := range []string{applicationList, pendingOutputs} { + t.Run(operation, func(t *testing.T) { + for _, test := range []struct { + name string + err error + cancel bool + quiet bool + }{ + {name: "shutdown", err: fmt.Errorf("query: %w", context.Canceled), cancel: true, quiet: true}, + {name: "joined shutdown", err: errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), + cancel: true, quiet: true}, + {name: "active context cancellation", err: context.Canceled}, + {name: "database failure", err: dbErr}, + {name: "database failure during shutdown", err: dbErr, cancel: true}, + {name: "timeout", err: context.DeadlineExceeded}, + {name: "timeout during shutdown", err: context.DeadlineExceeded, cancel: true}, + {name: "mixed failure", err: errors.Join(context.Canceled, dbErr), cancel: true}, + {name: "mixed timeout", err: errors.Join(context.Canceled, context.DeadlineExceeded), cancel: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var logs bytes.Buffer + repo := newMockRepository() + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + fail := func(mock.Arguments) { + if test.cancel { + cancel() + } + } + switch operation { + case applicationList: + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Run(fail).Return([]*model.Application(nil), uint64(0), test.err).Once() + require.False(t, reader.processBlockHead(ctx, 110, nil)) + case pendingOutputs: + app := appContracts{application: &model.Application{ + ID: 1, Name: "output-scan-app", IApplicationAddress: app1Addr, LastOutputCheckBlock: 100, + }} + repo.Unset("GetNumberOfPendingExecutableOutputs") + repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, app1Addr.Hex()). + Run(fail).Return(uint64(0), test.err).Once() + require.False(t, reader.checkForOutputExecution(ctx, []appContracts{app}, 110)) + require.Contains(t, logs.String(), `"application":"output-scan-app"`) + } + require.Equal(t, 1, strings.Count(logs.String(), `"error":`), logs.String()) + if test.quiet { + require.NotContains(t, logs.String(), `"level":"ERROR"`) + require.Contains(t, logs.String(), `"level":"DEBUG"`) + } else { + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`), logs.String()) + } + if errors.Is(test.err, dbErr) { + require.Contains(t, logs.String(), dbErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs.String(), context.DeadlineExceeded.Error()) + } + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + repo.AssertNumberOfCalls(t, "UpdateOutputsExecution", 0) + repo.AssertExpectations(t) + }) + } + }) + } +} + +func TestOutputScanStopsAfterShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := newMockRepository() + repo.Unset("GetNumberOfPendingExecutableOutputs") + repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, app1Addr.Hex()). + Run(func(mock.Arguments) { cancel() }).Return(uint64(0), context.Canceled).Once() + reader := &Service{repository: repo} + reader.Logger = testLogger(t) + apps := []appContracts{ + {application: &model.Application{ID: 1, IApplicationAddress: app1Addr, LastOutputCheckBlock: 100}}, + {application: &model.Application{ID: 2, IApplicationAddress: app2Addr, LastOutputCheckBlock: 100}}, + } + require.False(t, reader.checkForOutputExecution(ctx, apps, 110)) + repo.AssertExpectations(t) +} diff --git a/internal/evmreader/openepoch_inconclusive_test.go b/internal/evmreader/openepoch_inconclusive_test.go new file mode 100644 index 000000000..d42c01104 --- /dev/null +++ b/internal/evmreader/openepoch_inconclusive_test.go @@ -0,0 +1,99 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveOpenInputViewRetriesFromSameCursorWithoutPublishingIncompleteInputs(t *testing.T) { + const head uint64 = 100 + for _, partial := range []bool{false, true} { + t.Run(map[bool]string{false: "missing all logs", true: "missing last log"}[partial], func(t *testing.T) { + repo, inputBox := newMockRepository(), newMockInputBox() + app := newDaveAppContracts(inputBox, nil) + app.application.LastEpochCheckBlock = head + app.application.LastInputCheckBlock = head - 1 + app.application.Status = model.ApplicationStatus_OK + sealed := &model.Epoch{Index: 0, LastBlock: head - 2, Status: model.EpochStatus_Closed} + var open *model.Epoch + stored, total := uint64(0), uint64(1) + var firstLogs []iinputbox.IInputBoxInputAdded + if partial { + stored, total = 1, 3 + open = &model.Epoch{Index: 1, FirstBlock: sealed.LastBlock, LastBlock: head - 1, + InputIndexUpperBound: stored, Status: model.EpochStatus_Open} + firstLogs = []iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, stored, head)} + } + var output bytes.Buffer + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewJSONHandler(&output, nil)) + repo.On("GetLastNonOpenEpoch", mock.Anything, app1Addr.Hex()).Return(sealed, nil).Twice() + repo.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(1)).Return(open, nil).Twice() + repo.On("GetEventLastCheckBlock", mock.Anything, app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Twice() + repo.On("GetNumberOfInputs", mock.Anything, app1Addr.Hex()).Return(stored, nil).Twice() + inputBox.On("GetNumberOfInputs", inputCountBlock(head), app1Addr). + Return(new(big.Int).SetUint64(total), nil).Twice() + inputBox.On("RetrieveInputs", inputLogBlock(head), []common.Address{app1Addr}, mock.Anything). + Return(firstLogs, nil).Once() + require.False(t, reader.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Contains(t, output.String(), `"level":"WARN"`) + require.NotContains(t, output.String(), `"level":"ERROR"`) + require.Contains(t, output.String(), "Open epoch input reads disagree; retrying from the saved cursor") + for _, context := range []string{ + app.application.Name, strings.ToLower(app1Addr.Hex()), `"from_block":99`, `"to_block":100`, + "stored", "on-chain", "got", + } { + require.Contains(t, output.String(), context) + } + require.Equal(t, head-1, app.application.LastInputCheckBlock) + if open != nil { + require.Equal(t, stored, open.InputIndexUpperBound) + require.Equal(t, head-1, open.LastBlock) + } + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + + // The next response is complete at the same head. The persisted cursor + // still selects the original block, including the missing input. + inputBox.Unset("RetrieveInputs") + var complete []iinputbox.IInputBoxInputAdded + for index := stored; index < total; index++ { + complete = append(complete, makeInputEvent(app1Addr, index, head)) + } + inputBox.On("RetrieveInputs", inputLogBlock(head), []common.Address{app1Addr}, mock.Anything). + Return(complete, nil).Once() + repo.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), mock.Anything, head). + Run(func(args mock.Arguments) { + for epoch, inputs := range args.Get(2).(map[*model.Epoch][]*model.Input) { + require.Equal(t, uint64(1), epoch.Index) + require.Equal(t, total, epoch.InputIndexUpperBound) + require.Len(t, inputs, len(complete)) + } + app.application.LastInputCheckBlock = head + }).Return(nil).Once() + output.Reset() + require.True(t, reader.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, head, app.application.LastInputCheckBlock) + require.NotContains(t, output.String(), `"level":"ERROR"`) + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 1) + repo.AssertNumberOfCalls(t, "UpdateEventLastCheckBlock", 0) + inputBox.AssertExpectations(t) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/openepoch_integrity_test.go b/internal/evmreader/openepoch_integrity_test.go new file mode 100644 index 000000000..d2fdf6120 --- /dev/null +++ b/internal/evmreader/openepoch_integrity_test.go @@ -0,0 +1,123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveOpenEpochChecksInputCountBeforePublication(t *testing.T) { + const head uint64 = 100 + for _, foreclosed := range []bool{false, true} { + name := "active" + if foreclosed { + name = "foreclosed" + } + for _, test := range []struct { + name string + storedCount uint64 + lowerBound uint64 + endCount uint64 + logIndices []uint64 + wantIndices []uint64 + mismatch bool + }{ + {name: "missing first log", endCount: 2, logIndices: []uint64{1}, mismatch: true}, + {name: "missing all logs", endCount: 1, mismatch: true}, + {name: "existing open epoch misses a log", storedCount: 4, lowerBound: 2, endCount: 6, + logIndices: []uint64{5}, mismatch: true}, + {name: "overlap block includes sealed inputs", storedCount: 2, lowerBound: 2, endCount: 3, + logIndices: []uint64{0, 1, 2}, wantIndices: []uint64{2}}, + {name: "no new inputs", storedCount: 2, lowerBound: 2, endCount: 2}, + {name: "existing open inputs", storedCount: 4, lowerBound: 2, endCount: 5, + logIndices: []uint64{4}, wantIndices: []uint64{4}}, + } { + t.Run(name+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(inputBox, nil) + app.application.Status = model.ApplicationStatus_OK + app.application.LastEpochCheckBlock = head + app.application.LastInputCheckBlock = head - 1 + observedHead := head + if foreclosed { + app.application.ForecloseBlock = head + observedHead += 10 + } + sealed := &model.Epoch{Index: 0, LastBlock: head, + InputIndexUpperBound: test.lowerBound, Status: model.EpochStatus_Closed} + var open *model.Epoch + if test.storedCount > test.lowerBound { + sealed.LastBlock = head - 2 + open = &model.Epoch{Index: 1, FirstBlock: sealed.LastBlock, LastBlock: head - 1, + InputIndexLowerBound: test.lowerBound, InputIndexUpperBound: test.storedCount, + Status: model.EpochStatus_Open} + } + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(sealed, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(open, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(test.storedCount, nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), app1Addr). + Return(new(big.Int).SetUint64(test.endCount), nil).Once() + if test.endCount > test.storedCount { + events := make([]iinputbox.IInputBoxInputAdded, 0, len(test.logIndices)) + for _, index := range test.logIndices { + events = append(events, makeInputEvent(app1Addr, index, head)) + } + inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + }), []common.Address{app1Addr}, mock.Anything).Return(events, nil).Once() + } + if !test.mismatch { + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, head). + Run(func(args mock.Arguments) { + epochs := args.Get(2).(map[*model.Epoch][]*model.Input) + require.Len(t, epochs, 1) + for epoch, inputs := range epochs { + require.Equal(t, uint64(1), epoch.Index) + require.Equal(t, sealed.LastBlock, epoch.FirstBlock) + require.Equal(t, head, epoch.LastBlock) + require.Equal(t, test.lowerBound, epoch.InputIndexLowerBound) + require.Equal(t, test.endCount, epoch.InputIndexUpperBound) + require.Equal(t, model.EpochStatus_Open, epoch.Status) + require.Len(t, inputs, len(test.wantIndices)) + for i, index := range test.wantIndices { + require.Equal(t, index, inputs[i].Index) + } + } + app.application.LastInputCheckBlock = head + }).Return(nil).Once() + } + + require.Equal(t, !test.mismatch, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, observedHead)) + if test.mismatch { + require.Equal(t, head-1, app.application.LastInputCheckBlock) + if open != nil { + require.Equal(t, test.storedCount, open.InputIndexUpperBound) + require.Equal(t, head-1, open.LastBlock) + } + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + require.Equal(t, head, app.application.LastInputCheckBlock) + } + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/output.go b/internal/evmreader/output.go new file mode 100644 index 000000000..488cf539c --- /dev/null +++ b/internal/evmreader/output.go @@ -0,0 +1,384 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "math/big" + + "github.com/cartesi/rollups-node/internal/errutil" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// Find an appropriate value to start scanning the blockchain for executed outputs of this application. +// Application deployment block is a safe block to start since this event is emitted by the application contract itself. +// We use input box block as a fallback. +func (r *Service) initializeNewApplicationOutputExecutionSync( + ctx context.Context, + app *appContracts, + mostRecentBlockNumber uint64, +) (uint64, error) { + r.Logger.Info("Initializing application output execution sync", + "application", app.application.Name, + "current_block", mostRecentBlockNumber, + ) + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + } + deploymentBlock, err := app.applicationContract.GetDeploymentBlockNumber(callOpts) + if err != nil { + if errors.Is(err, bind.ErrNoCode) { + r.Logger.Debug("Skipping output execution sync before application deployment", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "block", mostRecentBlockNumber, + ) + return 0, fmt.Errorf("%w: application %s at block %d: %w", + errContractNotDeployedAtBlock, + app.application.IApplicationAddress, + mostRecentBlockNumber, + err) + } + r.Logger.Error("Error retrieving application deployment block number", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err, + ) + return 0, err + } + if deploymentBlock.Sign() <= 0 { + r.Logger.Error("Invalid application deployment block number retrieved", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "block_number", deploymentBlock.Uint64(), + ) + return 0, errors.New("invalid application deployment block number retrieved") + } + + lastOutputCheckBlock := deploymentBlock.Uint64() - 1 + err = r.repository.UpdateEventLastCheckBlock(ctx, []int64{app.application.ID}, MonitoredEvent_OutputExecuted, lastOutputCheckBlock) + if err != nil { + r.Logger.Error("Failed to update application LastOutputCheckBlock", + "application", app.application.Name, + "last_output_check_block", lastOutputCheckBlock, + "error", err, + ) + return 0, err + } + r.Logger.Debug("Application output execution sync initialized", + "application", app.application.Name, + "deployment_block", deploymentBlock.Uint64(), + "next_search_block", lastOutputCheckBlock+1, + "current_block", mostRecentBlockNumber, + ) + app.application.LastOutputCheckBlock = lastOutputCheckBlock + return app.application.LastOutputCheckBlock, nil +} + +func (r *Service) checkForOutputExecution( + ctx context.Context, + apps []appContracts, + mostRecentBlockNumber uint64, +) bool { + success := true + + appAddresses := appsToAddresses(apps) + + r.Logger.Debug("Checking for new Output Executed Events", "apps", appAddresses) + + for _, app := range apps { + if ctx.Err() != nil { + return false + } + lastOutputCheck := app.application.LastOutputCheckBlock + if lastOutputCheck == 0 { // New application. Find a safe start block to scan for outputs + var err error + lastOutputCheck, err = r.initializeNewApplicationOutputExecutionSync(ctx, &app, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, errContractNotDeployedAtBlock) { + continue + } + success = false + r.Logger.Error("Failed to initialize application output execution sync", + "application", app.application.Name, + "most_recent_block", mostRecentBlockNumber, + "error", err, + ) + continue + } + } + + if mostRecentBlockNumber > lastOutputCheck { + pending, err := r.repository.GetNumberOfPendingExecutableOutputs(ctx, app.application.IApplicationAddress.Hex()) + if err != nil { + level := slog.LevelError + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + level = slog.LevelDebug + } + r.Logger.Log(ctx, level, "Error counting pending executable outputs", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "error", err, + ) + success = false + continue + } + if pending == 0 { + r.Logger.Debug("Not reading output execution: no pending executable outputs", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_output_check_block", lastOutputCheck, + "most_recent_block", mostRecentBlockNumber, + ) + continue + } + + r.Logger.Debug("Checking output execution for application", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_output_check_block", lastOutputCheck, + "most_recent_block", mostRecentBlockNumber) + + success = r.readAndUpdateOutputs(ctx, app, lastOutputCheck, mostRecentBlockNumber) && success + + } else if mostRecentBlockNumber < lastOutputCheck { + r.Logger.Warn( + "Not reading output execution: most recent block is lower than the last processed one", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_output_check_block", lastOutputCheck, + "most_recent_block", mostRecentBlockNumber, + ) + } else { + r.Logger.Debug("Not reading output execution: already checked the most recent blocks", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_output_check_block", lastOutputCheck, + "most_recent_block", mostRecentBlockNumber, + ) + } + } + + return success +} + +func (r *Service) readAndUpdateOutputs( + ctx context.Context, app appContracts, lastOutputCheck, mostRecentBlockNumber uint64) bool { + + nextSearchBlock := lastOutputCheck + 1 + outputExecutedEvents, err := r.readOutputExecutionsFromBlockChain(ctx, app, nextSearchBlock, mostRecentBlockNumber) + if err != nil { + if errors.Is(err, context.Canceled) { + return false // shutting down + } + r.Logger.Error("Error reading output events", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "error", err) + return false + } + + if len(outputExecutedEvents) == 0 { + r.Logger.Debug("No output executed events found", + "application", app.application.Name, "address", app.application.IApplicationAddress) + err := r.repository.UpdateEventLastCheckBlock( + ctx, []int64{app.application.ID}, MonitoredEvent_OutputExecuted, mostRecentBlockNumber) + if err != nil { + // Shutdown cancels the ctx mid-update; downgrade to Debug + // for the graceful-stop case so it does not show up as a + // spurious ERR line during shutdown. DeadlineExceeded would + // still flow through the Error branch and demand attention. + if errors.Is(err, context.Canceled) { + r.Logger.Debug( + "UpdateEventLastCheckBlock canceled during shutdown", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "block_number", mostRecentBlockNumber, + "error", err, + ) + } else { + r.Logger.Error("Failed to update LastOutputCheckBlock for applications without inputs", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "block_number", mostRecentBlockNumber, + "error", err, + ) + } + // The cursor write is part of a successful scan. + } else { + r.Logger.Debug("Updated LastOutputCheckBlock for applications without inputs", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "block_number", mostRecentBlockNumber, + ) + } + return err == nil + } + r.Logger.Debug("Found output executed events", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "output_executed_events", len(outputExecutedEvents), + ) + + // Should we check the output hash?? + executedOutputs := make([]*Output, 0, len(outputExecutedEvents)) + for _, event := range outputExecutedEvents { + + // Compare output to check it is the correct one + output, err := r.repository.GetOutput(ctx, app.application.IApplicationAddress.Hex(), event.OutputIndex) + if err != nil { + r.Logger.Error("Error retrieving output", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "index", event.OutputIndex, + "error", err) + return false + } + + if output == nil { + r.Logger.Warn("Found OutputExecuted event but output does not exist in the database yet", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "index", event.OutputIndex) + return false + } + + if !bytes.Equal(output.RawData, event.Output) { + reasonFmt := + "Output mismatch. Application is in an invalid state. Output Index %d, raw data %s != event data %s" + localOutput := "0x" + hex.EncodeToString(output.RawData) + chainOutput := "0x" + hex.EncodeToString(event.Output) + args := []any{ + output.Index, + localOutput, + chainOutput, + } + + switch { + case app.application.Status == ApplicationStatus_OK || + app.application.Status == ApplicationStatus_Failed: + _ = r.setApplicationDiverged(ctx, app.application, reasonFmt, args...) + if app.application.Status != ApplicationStatus_Diverged { + return false // persistence failed; retry this event next tick + } + case app.application.Status.IsExecutionTerminal(): + // This verdict assumes the mismatch refutes a valid local root. + // An earlier IOR may still be undiscovered, or may already have + // tightened the cutoff while status remains ET. OutputExecuted + // does not identify the accepted cumulative root used as proof; + // a sound source-aware verdict needs the separate output reform. + _ = r.setApplicationCorrupted(ctx, app.application, reasonFmt, args...) + if app.application.Status != ApplicationStatus_Corrupted { + return false // persistence failed; retry this event next tick + } + case app.application.Status == ApplicationStatus_InvalidOutputsRoot: + // A later accepted cumulative root can prove different bytes at + // an earlier output index. The event does not identify that root, + // so this mismatch cannot refute the accepted root of that earlier + // epoch. Per-epoch root comparisons retain their integrity checks. + r.Logger.Error("Output mismatch; preserving terminal application status", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "index", output.Index, + "status", app.application.Status, + "local_output", localOutput, + "chain_output", chainOutput) + case app.application.Status == ApplicationStatus_Diverged || + app.application.Status == ApplicationStatus_Corrupted: + // The integrity failure is already durable. Do not retry an + // impossible status rewrite on every polling tick. + default: + r.Logger.Error("Output mismatch found for application with unknown status", + "application", app.application.Name, + "status", app.application.Status) + return false + } + continue + } + + r.Logger.Info("Output executed", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "index", event.OutputIndex) + output.ExecutionTransactionHash = &event.Raw.TxHash + executedOutputs = append(executedOutputs, output) + } + + err = r.repository.UpdateOutputsExecution( + ctx, app.application.IApplicationAddress.Hex(), executedOutputs, mostRecentBlockNumber) + if errors.Is(err, repository.ErrLockTimeout) { + r.Logger.Warn("output execution update waits for a lock; retrying at the next tick", + "application", app.application.Name, "address", app.application.IApplicationAddress, "error", err) + return true + } + if err != nil { + r.Logger.Error("Error storing output execution statuses", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "error", err) + } + + return err == nil +} + +func (r *Service) readOutputExecutionsFromBlockChain( + ctx context.Context, + app appContracts, + startBlock, endBlock uint64, +) ([]*iapplication.IApplicationOutputExecuted, error) { + r.Logger.Debug("Fetching Output Execution events for application", + "application", app.application.Name, + "start_block", startBlock, + "end_block", endBlock, + ) + + // Define oracle function that returns the number of output execution events at a given block + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(block), + } + numOutputs, err := app.applicationContract.GetNumberOfExecutedOutputs(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to get number of executed outputs at block %d: %w", block, err) + } + return numOutputs, nil + } + + var executedOutputs []*iapplication.IApplicationOutputExecuted + // Define onHit function that accumulates executed outputs at transition blocks + onHit := func(block uint64) error { + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: block, + End: &block, + } + execEvents, err := app.applicationContract.RetrieveOutputExecutionEvents(filterOpts) + if err != nil { + return fmt.Errorf("failed to retrieve output execution events at block %d: %w", block, err) + } + executedOutputs = append(executedOutputs, execEvents...) + return nil + } + + prevValue := &big.Int{} + execCount, err := r.repository.GetNumberOfExecutedOutputs(ctx, app.application.IApplicationAddress.String()) + if err != nil { + return nil, fmt.Errorf("failed to get number of executed outputs from repository: %w", err) + } + prevValue.SetUint64(execCount) + + // Use FindTransitions to find blocks where outputs were executed + _, err = ethutil.FindTransitions(ctx, startBlock, endBlock, prevValue, oracle, onHit) + if err != nil { + return nil, fmt.Errorf("failed to walk output execution transitions: %w", err) + } + + r.Logger.Debug("Fetched output executed events for application", + "application", app.application.Name, + "start_block", startBlock, + "end_block", endBlock, + "prev_executed_output_count", prevValue.Uint64(), + "new_executed_outputs", len(executedOutputs), + ) + return executedOutputs, nil +} diff --git a/internal/evmreader/output_lock_timeout_test.go b/internal/evmreader/output_lock_timeout_test.go new file mode 100644 index 000000000..300596800 --- /dev/null +++ b/internal/evmreader/output_lock_timeout_test.go @@ -0,0 +1,90 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestOutputExecutionLockWaitPreservesReadiness(t *testing.T) { + const head uint64 = 100 + for _, lockWait := range []bool{true, false} { + t.Run(fmt.Sprintf("lock_wait=%t", lockWait), func(t *testing.T) { + repo := newMockRepository() + client := newMockEthClient() + contract := newMockApplicationContract() + app := &model.Application{ + ID: 1, Name: "output-lock", Enabled: true, Status: model.ApplicationStatus_OK, + ConsensusType: model.Consensus_Authority, IApplicationAddress: app1Addr, + IConsensusAddress: consensusAddr, IInputBoxAddress: inputBoxAddr, + LastInputCheckBlock: head, LastOutputCheckBlock: head - 1, LastForecloseCheckBlock: head, + } + client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil) + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + adapters := newMockAdapterFactory() + adapters.On("CreateAdapters", app).Return(contract, newMockInputBox(), nil, nil).Once() + repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, app1Addr.Hex()).Return(uint64(1), nil) + repo.On("GetNumberOfExecutedOutputs", mock.Anything, app1Addr.Hex()).Return(uint64(0), nil) + contract.On("GetNumberOfExecutedOutputs", blockFrom(head)).Return(big.NewInt(1), nil) + event := *outputExecution0 + event.Raw.BlockNumber = head + contract.On("RetrieveOutputExecutionEvents", mock.Anything). + Return([]*iapplication.IApplicationOutputExecuted{&event}, nil) + output := &model.Output{Index: event.OutputIndex, RawData: bytes.Clone(event.Output)} + repo.On("GetOutput", mock.Anything, app1Addr.Hex(), event.OutputIndex).Return(output, nil) + storeErr := errors.New("connection lost") + if lockWait { + storeErr = fmt.Errorf("output lock: %w", repository.ErrLockTimeout) + } + repo.On("UpdateOutputsExecution", mock.Anything, app1Addr.Hex(), mock.Anything, head). + Return(storeErr).Times(maxConsecutiveScanFailures) + var logs bytes.Buffer + reader := &Service{client: client, repository: repo, + defaultBlock: model.DefaultBlock_Finalized, readyMaxStaleness: time.Hour} + reader.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + reader.resolver = newApplicationAdapterResolver(reader.Logger, adapters) + for cycle := 1; cycle <= maxConsecutiveScanFailures; cycle++ { + reschedule, err := reader.Tick(t.Context()) + require.NoError(t, err) + require.False(t, reschedule) + if lockWait { + require.Zero(t, reader.consecutiveScanFailures.Load()) + } else { + require.EqualValues(t, cycle, reader.consecutiveScanFailures.Load()) + } + require.Equal(t, lockWait || cycle < maxConsecutiveScanFailures, reader.Ready()) + require.Equal(t, head-1, app.LastOutputCheckBlock) + } + if lockWait { + require.Equal(t, maxConsecutiveScanFailures, + strings.Count(logs.String(), "output execution update waits for a lock; retrying at the next tick")) + require.Contains(t, logs.String(), `"level":"WARN"`) + require.NotContains(t, logs.String(), `"level":"ERROR"`) + } else { + require.Contains(t, logs.String(), `"level":"ERROR"`) + } + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + contract.AssertExpectations(t) + adapters.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/output_test.go b/internal/evmreader/output_test.go new file mode 100644 index 000000000..b200c6159 --- /dev/null +++ b/internal/evmreader/output_test.go @@ -0,0 +1,916 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "math/big" + "time" + + "github.com/cartesi/rollups-node/internal/config" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" +) + +// Prepare Output Executed Events +var output0 = &Output{ + Index: 0, + RawData: common.Hex2Bytes("AABBCCDDEE"), +} +var output1 = &Output{ + Index: 1, + RawData: common.Hex2Bytes("AABBCCDDEE"), +} +var outputExecution0 = &iapplication.IApplicationOutputExecuted{ + OutputIndex: output0.Index, + Output: output0.RawData, + Raw: types.Log{ + TxHash: common.HexToHash("0xdeadbeef"), + }, +} +var outputExecution1 = &iapplication.IApplicationOutputExecuted{ + OutputIndex: output1.Index, + Output: output1.RawData, + Raw: types.Log{ + TxHash: common.HexToHash("0xbeefbeef"), + }, +} + +func (s *EvmReaderSuite) setupOutputExecution() { + // On-chain state: 0 executed outputs before 0x11, 1 at 0x11-0x12, 2 from 0x13 + s.applicationContract1.Unset("GetNumberOfExecutedOutputs") + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0x11, 0x13)). + Return(new(big.Int).SetUint64(1), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(2), nil) + + s.applicationContract1.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) + s.applicationContract1.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution1}, nil) + + s.repository.Unset("UpdateEventLastCheckBlock") + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(5) + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(6) + + s.repository.Unset("GetOutput") + s.repository.On("GetOutput", + mock.Anything, + mock.Anything, + mock.Anything).Return(output0, nil).Once() + s.repository.On("GetOutput", + mock.Anything, + mock.Anything, + mock.Anything).Return(output1, nil).Once() + + s.repository.Unset("GetNumberOfExecutedOutputs") + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Twice() + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(1), nil).Once() + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Once() + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(1), nil).Once() + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Once() + + s.repository.Unset("UpdateOutputsExecution") + s.repository.On("UpdateOutputsExecution", + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + ).Run(func(arguments mock.Arguments) { + obj := arguments.Get(2) + outputs, ok := obj.([]*Output) + s.Require().True(ok) + s.Require().Equal(1, len(outputs)) + output := outputs[0] + s.Require().NotNil(output) + s.Require().Equal(uint64(0), output.Index) + s.Require().Equal(outputExecution0.Raw.TxHash, *output.ExecutionTransactionHash) + }).Return(nil).Once() + s.repository.On("UpdateOutputsExecution", + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything, + ).Run(func(arguments mock.Arguments) { + obj := arguments.Get(2) + outputs, ok := obj.([]*Output) + s.Require().True(ok) + s.Require().Equal(1, len(outputs)) + output := outputs[0] + s.Require().NotNil(output) + s.Require().Equal(uint64(1), output.Index) + s.Require().Equal(outputExecution1.Raw.TxHash, *output.ExecutionTransactionHash) + }).Return(nil).Once() +} + +func (s *EvmReaderSuite) TestOutputExecution() { + s.client.EnqueueNewHead(0x11).Once() + s.client.EnqueueNewHead(0x12).Once() + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + s.setupOutputExecution() + + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 2) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertExpectations(s.T()) + s.applicationContract1.AssertExpectations(s.T()) + s.applicationContract2.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked connection +} + +func (s *EvmReaderSuite) TestOutputExecutionOnFinalizedBlocks() { + s.evmReader.defaultBlock = DefaultBlock_Finalized + + s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(&header0, nil).Once() + s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(&header1, nil).Once() + s.client.On("HeaderByNumber", + mock.Anything, + mock.Anything, + ).Return(&header2, nil).Once() + + s.setupOutputExecution() + + s.client.EnqueueNewHead(0x11).Once() + s.client.EnqueueNewHead(0x12).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 2) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertExpectations(s.T()) + s.applicationContract1.AssertExpectations(s.T()) + s.applicationContract2.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked connection +} + +// TestOutputExecutionContinuesForForeclosedApps verifies foreclosure does not +// block output-execution scanning: Application.executeOutput stays callable +// after foreclosure, so a foreclosed app's OutputExecuted events must still be +// observed and persisted. buildBlockScanPlan routes foreclosed apps into +// outputTargets (see block_scan_plan_test.go); this exercises the consuming +// scan with a foreclosed app. +func (s *EvmReaderSuite) TestOutputExecutionContinuesForForeclosedApps() { + s.repository = newMockRepository() + s.evmReader.repository = s.repository + applicationContract := newMockApplicationContract() + + foreclosedApp := copyApplications(applications)[0] + foreclosedApp.ID = 1 + foreclosedApp.ForecloseBlock = 0x12 + foreclosedApp.LastOutputCheckBlock = 0x12 + + applicationContract.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(1), nil) + + applicationContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + + s.repository.Unset("GetNumberOfExecutedOutputs") + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + foreclosedApp.IApplicationAddress.String(), + ).Return(uint64(0), nil).Once() + + s.repository.Unset("GetOutput") + s.repository.On("GetOutput", + mock.Anything, + foreclosedApp.IApplicationAddress.Hex(), + outputExecution0.OutputIndex, + ).Return(output0, nil).Once() + + s.repository.Unset("UpdateOutputsExecution") + s.repository.On("UpdateOutputsExecution", + mock.Anything, + foreclosedApp.IApplicationAddress.Hex(), + mock.MatchedBy(func(outputs []*Output) bool { + return len(outputs) == 1 && + outputs[0].Index == outputExecution0.OutputIndex && + outputs[0].ExecutionTransactionHash != nil && + *outputs[0].ExecutionTransactionHash == outputExecution0.Raw.TxHash + }), + uint64(0x13), + ).Return(nil).Once() + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{ + {application: foreclosedApp, applicationContract: applicationContract}, + }, 0x13) + + s.repository.AssertExpectations(s.T()) + applicationContract.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestOutputExecutionSkipsAppsWithoutPendingExecutableOutputs() { + repo := newMockRepository() + repo.Unset("GetNumberOfPendingExecutableOutputs") + repo.On("GetNumberOfPendingExecutableOutputs", + mock.Anything, + app1Addr.String(), + ).Return(uint64(0), nil).Once() + s.evmReader.repository = repo + + applicationContract := newMockApplicationContract() + app := appContracts{ + application: &Application{ + ID: 1, + Name: "test-app", + IApplicationAddress: app1Addr, + LastOutputCheckBlock: 0x12, + }, + applicationContract: applicationContract, + } + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{app}, 0x13) + + repo.AssertNumberOfCalls(s.T(), "GetNumberOfExecutedOutputs", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + repo.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + repo.AssertExpectations(s.T()) + applicationContract.AssertNumberOfCalls(s.T(), "GetNumberOfExecutedOutputs", 0) +} + +// TestOutputExecutionWaitsWhenOutputRowMissing verifies the OutputExecuted- +// before-its-row edge: when an OutputExecuted event is seen but the output row +// is not yet in the DB, the scan must not advance the cursor or persist — +// it waits so the next tick can retry once the row lands. +func (s *EvmReaderSuite) TestOutputExecutionWaitsWhenOutputRowMissing() { + s.repository = newMockRepository() + s.evmReader.repository = s.repository + applicationContract := newMockApplicationContract() + + foreclosedApp := copyApplications(applications)[0] + foreclosedApp.ID = 1 + foreclosedApp.ForecloseBlock = 0x12 + foreclosedApp.LastOutputCheckBlock = 0x12 + + applicationContract.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(1), nil) + + applicationContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + + s.repository.Unset("GetNumberOfExecutedOutputs") + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + foreclosedApp.IApplicationAddress.String(), + ).Return(uint64(0), nil).Once() + + s.repository.Unset("GetOutput") + s.repository.On("GetOutput", + mock.Anything, + foreclosedApp.IApplicationAddress.Hex(), + outputExecution0.OutputIndex, + ).Return((*Output)(nil), nil).Once() + + s.repository.Unset("UpdateOutputsExecution") + s.repository.Unset("UpdateEventLastCheckBlock") + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{ + {application: foreclosedApp, applicationContract: applicationContract}, + }, 0x13) + + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + s.repository.AssertExpectations(s.T()) + applicationContract.AssertExpectations(s.T()) +} + +// TestOutputExecutionMismatchMarksApplicationDiverged verifies that an +// OutputExecuted event whose on-chain output bytes differ from the locally +// stored output marks the application DIVERGED (the chain executed an output +// this node never produced). +func (s *EvmReaderSuite) TestOutputExecutionMismatchMarksApplicationDiverged() { + s.repository = newMockRepository() + s.evmReader.repository = s.repository + applicationContract := newMockApplicationContract() + + foreclosedApp := copyApplications(applications)[0] + foreclosedApp.ID = 1 + foreclosedApp.Status = ApplicationStatus_OK + foreclosedApp.ForecloseBlock = 0x12 + foreclosedApp.LastOutputCheckBlock = 0x12 + + mismatchedOutput := &Output{ + Index: outputExecution0.OutputIndex, + RawData: common.Hex2Bytes("FFBBCCDDEE"), + } + + applicationContract.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(1), nil) + + applicationContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + foreclosedApp.IApplicationAddress.String(), + ).Return(uint64(0), nil).Once() + + s.repository.On("GetOutput", + mock.Anything, + foreclosedApp.IApplicationAddress.Hex(), + outputExecution0.OutputIndex, + ).Return(mismatchedOutput, nil).Once() + + s.repository.On("UpdateApplicationStatus", + mock.Anything, + foreclosedApp.ID, + ApplicationStatus_Diverged, + mock.Anything, + ).Return(nil).Once() + s.repository.On("UpdateOutputsExecution", + mock.Anything, + foreclosedApp.IApplicationAddress.Hex(), + mock.MatchedBy(func(outputs []*Output) bool { return len(outputs) == 0 }), + uint64(0x13), + ).Return(nil).Once() + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{ + {application: foreclosedApp, applicationContract: applicationContract}, + }, 0x13) + + s.Equal(ApplicationStatus_Diverged, foreclosedApp.Status) + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 1) + s.repository.AssertExpectations(s.T()) + applicationContract.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestOutputExecutionMismatchEscalatesExecutionTerminalToCorrupted() { + s.repository = newMockRepository() + s.evmReader.repository = s.repository + applicationContract := newMockApplicationContract() + + app := copyApplications(applications)[0] + app.ID = 1 + app.Status = ApplicationStatus_MachineHalted + app.ForecloseBlock = 0x12 + app.LastOutputCheckBlock = 0x12 + + mismatchedOutput := &Output{ + Index: outputExecution0.OutputIndex, + RawData: common.Hex2Bytes("FFBBCCDDEE"), + } + applicationContract.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(1), nil) + applicationContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, app.IApplicationAddress.String()).Return(uint64(0), nil).Once() + s.repository.On("GetOutput", + mock.Anything, app.IApplicationAddress.Hex(), outputExecution0.OutputIndex). + Return(mismatchedOutput, nil).Once() + s.repository.On("UpdateApplicationStatus", + mock.Anything, app.ID, ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + s.repository.On("UpdateOutputsExecution", + mock.Anything, + app.IApplicationAddress.Hex(), + mock.MatchedBy(func(outputs []*Output) bool { return len(outputs) == 0 }), + uint64(0x13), + ).Return(nil).Once() + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{ + {application: app, applicationContract: applicationContract}, + }, 0x13) + + s.Equal(ApplicationStatus_Corrupted, app.Status) + s.repository.AssertExpectations(s.T()) + applicationContract.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestOutputExecutionMismatchRetriesWhenStatusWriteFails() { + s.repository = newMockRepository() + s.evmReader.repository = s.repository + applicationContract := newMockApplicationContract() + + app := copyApplications(applications)[0] + app.ID = 1 + app.Status = ApplicationStatus_OK + app.ForecloseBlock = 0x12 + app.LastOutputCheckBlock = 0x12 + mismatchedOutput := &Output{ + Index: outputExecution0.OutputIndex, + RawData: common.Hex2Bytes("FFBBCCDDEE"), + } + dbErr := errors.New("database unavailable") + + applicationContract.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(1), nil) + applicationContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, app.IApplicationAddress.String()).Return(uint64(0), nil).Once() + s.repository.On("GetOutput", + mock.Anything, app.IApplicationAddress.Hex(), outputExecution0.OutputIndex). + Return(mismatchedOutput, nil).Once() + s.repository.On("UpdateApplicationStatus", + mock.Anything, app.ID, ApplicationStatus_Diverged, mock.Anything). + Return(dbErr).Once() + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{ + {application: app, applicationContract: applicationContract}, + }, 0x13) + + s.Equal(ApplicationStatus_OK, app.Status) + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + s.repository.AssertExpectations(s.T()) + applicationContract.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestOutputExecutionMismatchDoesNotRewriteIntegrityTerminal() { + s.repository = newMockRepository() + s.evmReader.repository = s.repository + applicationContract := newMockApplicationContract() + + app := copyApplications(applications)[0] + app.ID = 1 + app.Status = ApplicationStatus_Corrupted + app.ForecloseBlock = 0x12 + app.LastOutputCheckBlock = 0x12 + + mismatchedOutput := &Output{ + Index: outputExecution0.OutputIndex, + RawData: common.Hex2Bytes("FFBBCCDDEE"), + } + applicationContract.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(1), nil) + applicationContract.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil).Once() + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, app.IApplicationAddress.String()).Return(uint64(0), nil).Once() + s.repository.On("GetOutput", + mock.Anything, app.IApplicationAddress.Hex(), outputExecution0.OutputIndex). + Return(mismatchedOutput, nil).Once() + s.repository.On("UpdateOutputsExecution", + mock.Anything, + app.IApplicationAddress.Hex(), + mock.MatchedBy(func(outputs []*Output) bool { return len(outputs) == 0 }), + uint64(0x13), + ).Return(nil).Once() + + s.evmReader.checkForOutputExecution(s.ctx, []appContracts{ + {application: app, applicationContract: applicationContract}, + }, 0x13) + + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) + s.repository.AssertExpectations(s.T()) + applicationContract.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestCheckOutputFailsWhenRetrieveOutputsFails() { + s.setupOutputExecution() + + s.applicationContract1.Unset("RetrieveOutputExecutionEvents") + s.applicationContract1.On("RetrieveOutputExecutionEvents", + mock.Anything, + ).Return([]*iapplication.IApplicationOutputExecuted{}, errors.New("No outputs for you")) + + // On-chain state: same as setupOutputExecution. Retrieval fails but oracle still works. + s.applicationContract1.Unset("GetNumberOfExecutedOutputs") + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0x11, 0x13)). + Return(new(big.Int).SetUint64(1), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(2), nil) + + apps := copyApplications(applications) + s.repository.Unset("ListApplications") + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 + apps[0].LastOutputCheckBlock = 0x0F + apps[1].LastOutputCheckBlock = 0x11 + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 + apps[0].LastOutputCheckBlock = 0x0F + apps[1].LastOutputCheckBlock = 0x12 + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + + s.repository.Unset("GetNumberOfExecutedOutputs") + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Times(6) + + s.repository.Unset("GetOutput") + s.repository.Unset("UpdateOutputsExecution") + + s.repository.Unset("UpdateEventLastCheckBlock") + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(5) + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(5) + + s.client.EnqueueNewHead(0x11).Once() + s.client.EnqueueNewHead(0x12).Once() + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertExpectations(s.T()) + s.applicationContract1.AssertExpectations(s.T()) + s.applicationContract2.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked calls +} + +func (s *EvmReaderSuite) TestCheckOutputFailsWhenGetOutputsFails() { + s.setupOutputExecution() + + s.repository.Unset("GetOutput") + s.repository.On("GetOutput", + mock.Anything, + mock.Anything, + mock.Anything).Return(nil, errors.New("no output for you")).Times(3) + + // On-chain state: same as setupOutputExecution. GetOutput fails but oracle still works. + s.applicationContract1.Unset("GetNumberOfExecutedOutputs") + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0x11, 0x13)). + Return(new(big.Int).SetUint64(1), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x13)). + Return(new(big.Int).SetUint64(2), nil) + + s.applicationContract1.Unset("RetrieveOutputExecutionEvents") + s.applicationContract1.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) + s.applicationContract1.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x13 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution1}, nil) + + apps := copyApplications(applications) + s.repository.Unset("ListApplications") + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x11 + apps[1].LastInputCheckBlock = 0x11 + apps[0].LastOutputCheckBlock = 0x0F + apps[1].LastOutputCheckBlock = 0x11 + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications) + apps[0].LastInputCheckBlock = 0x12 + apps[1].LastInputCheckBlock = 0x12 + apps[0].LastOutputCheckBlock = 0x0F + apps[1].LastOutputCheckBlock = 0x12 + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + + s.repository.Unset("GetNumberOfExecutedOutputs") + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Times(6) + + s.repository.Unset("UpdateOutputsExecution") + + s.repository.Unset("UpdateEventLastCheckBlock") + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(5) + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(5) + + s.client.EnqueueNewHead(0x11).Once() + s.client.EnqueueNewHead(0x12).Once() + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 0) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertExpectations(s.T()) + s.applicationContract1.AssertExpectations(s.T()) + s.applicationContract2.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked calls +} + +func (s *EvmReaderSuite) setupOutputMismatchTest() { + s.client = newMockEthClient() + s.repository = newMockRepository() + s.applicationContract1 = newMockApplicationContract() + s.inputBox = newMockInputBox() + s.contractFactory = newMockAdapterFactory() + + s.evmReader = &Service{ + client: s.client, + repository: s.repository, + defaultBlock: DefaultBlock_Latest, + adapterFactory: s.contractFactory, + hasEnabledApps: true, + } + + logLevel, err := config.GetLogLevel() + s.Require().NoError(err) + + serviceArgs := &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: "evm-reader", + LogLevel: logLevel, + }, + PollInterval: 100 * time.Millisecond, + } + err = service.InitTickServiceTemplate(&s.evmReader.TickServiceTemplate, serviceArgs, s.evmReader) + s.Require().NoError(err) + + s.evmReader.resolver = newApplicationAdapterResolver(s.evmReader.Logger, s.contractFactory) + + apps := copyApplications(applications) + for _, app := range apps { + app.LastForecloseCheckBlock = 0x100 + } + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(2), nil).Once() + + apps = copyApplications(applications[1:2]) + apps[0].LastInputCheckBlock = 0x11 + apps[0].LastOutputCheckBlock = 0x11 + apps[0].LastForecloseCheckBlock = 0x100 + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(1), nil).Once() + + apps = copyApplications(applications[1:2]) + apps[0].LastInputCheckBlock = 0x12 + apps[0].LastOutputCheckBlock = 0x12 + apps[0].LastForecloseCheckBlock = 0x100 + s.repository.On("ListApplications", + mock.Anything, + mock.Anything, + mock.Anything, + false, + ).Return(apps, uint64(1), nil).Once() + // Catch-all for sentinel / extra headers + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{}, uint64(0), nil) + + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_InputAdded, + mock.Anything, + ).Return(nil).Times(5) + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + mock.Anything, + MonitoredEvent_OutputExecuted, + mock.Anything, + ).Return(nil).Times(5) + s.repository.On("UpdateApplicationLastForecloseCheckBlock", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil).Maybe() + + s.repository.On("GetNumberOfInputs", mock.Anything, app1Addr.String()).Once().Return(uint64(0), nil) + s.repository.On("GetNumberOfInputs", mock.Anything, app2Addr.String()).Return(uint64(0), nil).Times(3) + + s.repository.On("GetNumberOfExecutedOutputs", + mock.Anything, + mock.Anything, + ).Return(uint64(0), nil).Times(4) + + s.repository.On("CreateEpochsAndInputs", + mock.Anything, + mock.Anything, + mock.Anything, + mock.Anything).Return(nil).Once() + + s.repository.On("GetEpoch", mock.Anything, app1Addr.String(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", mock.Anything, app2Addr.String(), uint64(1)).Return(nil, nil).Twice() + + output := &Output{ + Index: 1, + RawData: common.Hex2Bytes("FFBBCCDDEE"), + } + s.repository.On("GetOutput", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(output, nil).Once() + + s.repository.On("UpdateApplicationStatus", + mock.Anything, + applications[0].ID, + ApplicationStatus_Diverged, + mock.Anything, + ).Return(nil).Once() + s.repository.On("UpdateOutputsExecution", + mock.Anything, + applications[0].IApplicationAddress.Hex(), + mock.MatchedBy(func(outputs []*Output) bool { return len(outputs) == 0 }), + uint64(0x11), + ).Return(nil).Once() + + s.applicationContract1.On("GetDeploymentBlockNumber", + mock.Anything, + ).Return(new(big.Int).SetUint64(0x10), nil).Once() + + // On-chain state: 0 executed outputs before 0x11, 1 from 0x11 + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockRange(0, 0x11)). + Return(new(big.Int).SetUint64(0), nil) + s.applicationContract1.On("GetNumberOfExecutedOutputs", blockFrom(0x11)). + Return(new(big.Int).SetUint64(1), nil) + + s.applicationContract1.On("RetrieveOutputExecutionEvents", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + ).Return([]*iapplication.IApplicationOutputExecuted{outputExecution0}, nil) + + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { return opts.Start == 0x11 }), + []common.Address{app1Addr}, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{inputAddedEvent0}, nil) + + // On-chain: 0 inputs before block 0x11, 1 from block 0x11 + s.inputBox.On("GetNumberOfInputs", blockRange(0, 0x11), app1Addr). + Return(new(big.Int).SetUint64(0), nil) + s.inputBox.On("GetNumberOfInputs", blockFrom(0x11), app1Addr). + Return(new(big.Int).SetUint64(1), nil) + s.inputBox.On("GetNumberOfInputs", mock.Anything, app2Addr). + Return(new(big.Int).SetUint64(0), nil) + + s.contractFactory.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[0].IApplicationAddress + }), + ).Return(s.applicationContract1, s.inputBox, nil, nil) + s.contractFactory.On("CreateAdapters", + mock.MatchedBy(func(app *Application) bool { + return app.IApplicationAddress == applications[1].IApplicationAddress + }), + ).Return(s.applicationContract2, s.inputBox, nil, nil) +} + +func (s *EvmReaderSuite) TestCheckOutputFailsWhenOutputMismatches() { + s.setupOutputMismatchTest() + + s.client.EnqueueNewHead(0x11).Once() + s.client.EnqueueNewHead(0x12).Once() + s.client.EnqueueNewHead(0x13).Once() + called, blocked := newBlockedCallNotification(s.client.EnqueueNewHead(0x13)) + + // Start service + go s.evmReader.Serve(s.ctx) //nolint: errcheck + + s.Require().True(waitNotification(called), "evmreader did not read new header") + + s.repository.AssertNumberOfCalls(s.T(), "UpdateOutputsExecution", 1) + s.repository.AssertExpectations(s.T()) + + s.inputBox.AssertExpectations(s.T()) + s.applicationContract1.AssertExpectations(s.T()) + s.applicationContract2.AssertExpectations(s.T()) + s.contractFactory.AssertExpectations(s.T()) + s.client.AssertExpectations(s.T()) + + close(blocked) // release blocked calls +} diff --git a/internal/evmreader/post_foreclosure.go b/internal/evmreader/post_foreclosure.go new file mode 100644 index 000000000..22ede1754 --- /dev/null +++ b/internal/evmreader/post_foreclosure.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" +) + +// checkPostForeclosure dispatches per-tick observation work for apps that +// have already been foreclosed on chain. +// +// Apps with `foreclose_block == 0` are skipped — they are still in the +// pre-foreclosure scan surface. For each foreclosed app, dispatches to: +// - checkForDriveProved while `accounts_drive_proved_block == 0` +// (discover the proveAccountsDriveMerkleRoot tx by checking the one-way +// wasProved boolean, then filtering the event when it flips). +// - checkForPostForeclosureWithdrawals once the drive has been proved +// (discover Withdrawal events via FindTransitions on the on-chain +// getNumberOfWithdrawals counter, then FilterLogs on the 1-block +// window and persist each event). +// +// The two halves are mutually exclusive — once drive-prove lands, the +// withdrawal scan takes over for that app. They are mutually exclusive +// because the contract enforces drive-must-be-proved-before-withdraw, so +// the cursor relationship is one-way. +func (r *Service) checkPostForeclosure( + ctx context.Context, + apps []appContracts, + mostRecentBlockNumber uint64, +) bool { + success := true + for _, app := range apps { + if app.application.ForecloseBlock == 0 { + continue + } + if app.application.AccountsDriveProvedBlock == 0 { + success = r.checkForDriveProved(ctx, app, mostRecentBlockNumber) && success + } else { + success = r.checkForPostForeclosureWithdrawals(ctx, app, mostRecentBlockNumber) && success + } + } + return success +} + +// abortPostForeclosureLoop mirrors abortForeclosureLoop's +// context-error convention: context.Canceled is graceful (silent return), +// context.DeadlineExceeded means the tick's budget is gone and every +// remaining per-app RPC would fail the same way — log once at the site +// and stop the loop. Other errors stay per-app so a transient RPC failure +// on one app does not block the rest. +func abortPostForeclosureLoop(r *Service, err error, where string) bool { + if errors.Is(err, context.Canceled) { + return true + } + if errors.Is(err, context.DeadlineExceeded) { + r.Logger.Error("Post-foreclosure scan deadline exceeded; aborting remaining apps", + "site", where, "error", err) + return true + } + return false +} diff --git a/internal/evmreader/post_foreclosure_withdrawal.go b/internal/evmreader/post_foreclosure_withdrawal.go new file mode 100644 index 000000000..dea448c0d --- /dev/null +++ b/internal/evmreader/post_foreclosure_withdrawal.go @@ -0,0 +1,226 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +const withdrawalLedgerDivergenceReasonPrefix = "withdrawal_ledger_divergence:" + +func hasWithdrawalLedgerDivergence(app *Application) bool { + return app.Status == ApplicationStatus_Corrupted && + app.Reason != nil && + strings.HasPrefix(*app.Reason, withdrawalLedgerDivergenceReasonPrefix) +} + +// checkForPostForeclosureWithdrawals runs once per evmreader tick for each +// foreclosed app whose accounts drive has been proved. It performs a +// FindTransitions search on the on-chain `getNumberOfWithdrawals()` counter +// (monotonic) over +// `[max(accounts_drive_proved_block, last_withdrawal_check_block+1), mostRecent]`. +// +// On each transition block N (the counter increased), filter Withdrawal +// events with a 1-block window `[N, N]`. +// +// After a successful scan, the observed events and the per-app +// last_withdrawal_check_block cursor are committed in one repository +// transaction. That keeps the DB withdrawal count aligned with the cursor, so +// the next tick can use the DB count as the previous counter. On any scan or +// persist failure, neither cursor nor in-memory mirror advances. +func (r *Service) checkForPostForeclosureWithdrawals( + ctx context.Context, + app appContracts, + mostRecentBlockNumber uint64, +) bool { + // A withdrawal-ledger divergence (detected below) means this local ledger + // cannot be trusted. Stop re-deriving that specific failure every tick, but + // keep indexing when CORRUPTED came from another subsystem: an output or + // sealed-epoch disagreement says nothing about the withdrawal ledger. + if hasWithdrawalLedgerDivergence(app.application) { + return true + } + + startBlock := app.application.LastWithdrawalCheckBlock + 1 + if floor := app.application.AccountsDriveProvedBlock; startBlock < floor { + startBlock = floor + } + if startBlock > mostRecentBlockNumber { + return true + } + + query := func(ctx context.Context, block uint64) (*big.Int, error) { + return app.applicationContract.GetNumberOfWithdrawals(&bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(block), + }) + } + + var withdrawals []*Withdrawal + + onHit := func(block uint64) error { + blockWithdrawals, err := r.withdrawalsAtBlock(ctx, app, block) + if err != nil { + return err + } + withdrawals = append(withdrawals, blockWithdrawals...) + return nil + } + + prevValue, err := r.previousWithdrawalCount(ctx, app, startBlock) + if err != nil { + if abortPostForeclosureLoop(r, err, "getPreviousWithdrawalCount") { + return false + } + r.Logger.Error("Failed to read previous withdrawal count", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "error", err) + return false + } + + _, err = ethutil.FindTransitions( + ctx, + startBlock, + mostRecentBlockNumber, + prevValue, + query, + onHit, + ) + if err != nil { + if errors.Is(err, ethutil.ErrMonotonicViolation) { + // The local withdrawal-row count exceeds the on-chain counter at + // the scan boundary: the node has recorded withdrawals the chain + // does not. On a single canonical chain with no reorg this is + // unreachable, so it can only come from a reorg, an inconsistent + // RPC, or DB tampering — all of which mean local state has diverged + // from the chain. Fail closed: mark the app terminal and stop. We + // deliberately do not advance the cursor or re-seed from the chain; + // either would silently rewrite committed withdrawal history, which + // is never acceptable for a ledger of fund movements. + r.Logger.Error("Withdrawal ledger divergence detected", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber, + "error", err) + // setApplicationCorrupted always returns non-nil (the reason text), + // and logs any DB failure. An existing integrity-terminal status keeps + // its first cause, so this branch will deliberately detect and report + // the withdrawal divergence again on a later tick rather than relying + // on process-local suppression. + _ = r.setApplicationCorrupted(ctx, app.application, + withdrawalLedgerDivergenceReasonPrefix+ + " local withdrawal count exceeds chain while scanning from block %d: %v", + startBlock, err) + return false + } + if abortPostForeclosureLoop(r, err, "findTransitionsWithdrawals") { + return false + } + r.Logger.Error("Failed to scan withdrawal transitions", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "start_block", startBlock, + "end_block", mostRecentBlockNumber, + "error", err) + return false + } + + if err := r.repository.StoreWithdrawalEvents( + ctx, + app.application.ID, + withdrawals, + mostRecentBlockNumber, + ); err != nil { + r.Logger.Error("Failed to persist withdrawal scan", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "withdrawals", len(withdrawals), + "last_withdrawal_check_block", mostRecentBlockNumber, + "error", err) + return false + } + + for _, w := range withdrawals { + r.Logger.Info("Withdrawal observed", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "account_index", w.AccountIndex, + "block", w.BlockNumber, + "transaction_hash", w.TransactionHash, + ) + } + app.application.LastWithdrawalCheckBlock = mostRecentBlockNumber + return true +} + +func (r *Service) previousWithdrawalCount( + ctx context.Context, + app appContracts, + startBlock uint64, +) (*big.Int, error) { + // No withdrawal can happen before the accounts drive is proved: withdraw() + // validates against the proved root and reverts while it is missing. + if startBlock == app.application.AccountsDriveProvedBlock { + return big.NewInt(0), nil + } + + count, err := r.repository.GetNumberOfWithdrawals(ctx, app.application.ID) + if err != nil { + return nil, err + } + return new(big.Int).SetUint64(count), nil +} + +// withdrawalsAtBlock fetches all Withdrawal events emitted at the given block +// via the IApplication adapter. Multiple Withdrawal events can fire in the +// same block (different account indices); each gets its own row with a +// distinct (application_id, account_index) primary key when persisted. +func (r *Service) withdrawalsAtBlock( + ctx context.Context, + app appContracts, + block uint64, +) ([]*Withdrawal, error) { + events, err := app.applicationContract.RetrieveWithdrawalEvents(&bind.FilterOpts{ + Context: ctx, + Start: block, + End: &block, + }) + if err != nil { + return nil, err + } + if len(events) == 0 { + r.Logger.Warn( + "Withdrawal counter transition reported but no Withdrawal log in block", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "block", block, + ) + return nil, fmt.Errorf("withdrawal counter transition at block %d has no Withdrawal event", block) + } + + withdrawals := make([]*Withdrawal, 0, len(events)) + for _, ev := range events { + withdrawals = append(withdrawals, &Withdrawal{ + ApplicationID: app.application.ID, + AccountIndex: ev.AccountIndex, + Account: append([]byte{}, ev.Account...), + Output: append([]byte{}, ev.Output...), + BlockNumber: ev.Raw.BlockNumber, + TransactionHash: ev.Raw.TxHash, + LogIndex: ev.Raw.Index, + }) + } + return withdrawals, nil +} diff --git a/internal/evmreader/post_foreclosure_withdrawal_test.go b/internal/evmreader/post_foreclosure_withdrawal_test.go new file mode 100644 index 000000000..f4ed37c04 --- /dev/null +++ b/internal/evmreader/post_foreclosure_withdrawal_test.go @@ -0,0 +1,620 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// postForeclosureWithdrawalApp builds an Application that has already been +// foreclosed AND had its accounts drive proved; this is the state where the +// withdrawal scan runs. +func postForeclosureWithdrawalApp(id int64, forecloseBlock, driveProvedBlock uint64) *Application { + return &Application{ + ID: id, + Name: "app", + IApplicationAddress: common.BigToAddress(big.NewInt(id)), + Status: ApplicationStatus_OK, + ForecloseBlock: forecloseBlock, + AccountsDriveProvedBlock: driveProvedBlock, + } +} + +// makeWithdrawalEvent builds a synthetic IApplicationWithdrawal event with +// the given block/log positions and account fields. Used by the +// withdrawal-scan tests to stub RetrieveWithdrawalEvents. +func makeWithdrawalEvent( + block uint64, logIndex uint, txHash common.Hash, + accountIndex uint64, account, output []byte, +) *iapplication.IApplicationWithdrawal { + return &iapplication.IApplicationWithdrawal{ + AccountIndex: accountIndex, + Account: account, + Output: output, + Raw: types.Log{ + BlockNumber: block, + TxHash: txHash, + Index: logIndex, + }, + } +} + +// --------------------------------------------------------------------------- +// checkForPostForeclosureWithdrawals +// --------------------------------------------------------------------------- + +// TestCheckForWithdrawals_NoWithdrawalsYet verifies the common steady-state +// path: the counter stays at zero across the whole scan window, no +// transitions fire, no withdrawals are persisted, and the cursor advances. +func TestCheckForWithdrawals_NoWithdrawalsYet(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.Anything). + Return(big.NewInt(0), nil) + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 0 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_SingleWithdrawal walks the happy path: +// getNumberOfWithdrawals goes 0→1 at block 120, FilterWithdrawal is called +// with a 1-block window [120, 120], one event is returned, and the event plus +// cursor are persisted atomically. +func TestCheckForWithdrawals_SingleWithdrawal(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + txHash := common.HexToHash("0xcafe") + accountBytes := []byte{0xaa, 0xbb} + outputBytes := []byte{0xcc, 0xdd} + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + + c.On("RetrieveWithdrawalEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 120 && opts.End != nil && *opts.End == 120 + })).Return([]*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 7, accountBytes, outputBytes), + }, nil).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 1 && + ws[0].ApplicationID == app.ID && + ws[0].AccountIndex == 7 && + string(ws[0].Account) == string(accountBytes) && + string(ws[0].Output) == string(outputBytes) && + ws[0].BlockNumber == 120 && + ws[0].TransactionHash == txHash && + ws[0].LogIndex == 0 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) +} + +// TestCheckForWithdrawals_WithdrawalAtDriveProvedFloor verifies that the +// scanner detects a transition at AccountsDriveProvedBlock itself. This +// requires seeding FindTransitions with the contract invariant that no +// withdrawal exists before the accounts drive is proved; otherwise a +// withdrawal in the first scanned block is invisible. +func TestCheckForWithdrawals_WithdrawalAtDriveProvedFloor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 120) + const head = uint64(130) + txHash := common.HexToHash("0xcafe") + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + c.On("RetrieveWithdrawalEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 120 && opts.End != nil && *opts.End == 120 + })).Return([]*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 7, []byte{0xaa}, []byte{0xbb}), + }, nil).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 1 && + ws[0].AccountIndex == 7 && + ws[0].BlockNumber == 120 && + ws[0].TransactionHash == txHash + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_WithdrawalAtCursorNextBlock verifies that the +// scanner detects a withdrawal in the first newly scanned block after a +// previous successful tick. +func TestCheckForWithdrawals_WithdrawalAtCursorNextBlock(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 119 + const head = uint64(130) + txHash := common.HexToHash("0xbeef") + + repo.On("GetNumberOfWithdrawals", mock.Anything, app.ID).Return(uint64(0), nil).Once() + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + c.On("RetrieveWithdrawalEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 120 && opts.End != nil && *opts.End == 120 + })).Return([]*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 9, []byte{0x01}, []byte{0x02}), + }, nil).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 1 && + ws[0].AccountIndex == 9 && + ws[0].BlockNumber == 120 && + ws[0].TransactionHash == txHash + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_DoesNotAdvanceCursorWhenDBCountFails verifies that +// later scan windows rely on the DB withdrawal count as the previous counter. +// If that local read fails, no chain scan is attempted and the cursor remains +// unchanged. +func TestCheckForWithdrawals_DoesNotAdvanceCursorWhenDBCountFails(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 119 + const head = uint64(130) + + repo.On("GetNumberOfWithdrawals", mock.Anything, app.ID). + Return(uint64(0), errors.New("db unavailable")).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, uint64(119), app.LastWithdrawalCheckBlock) +} + +// TestCheckForWithdrawals_MultipleInOneBlock verifies the multi-event-per- +// block path: two Withdrawals fire in the same block (different account +// indices). Both must be persisted in the same cursor-advance transaction, +// preserving distinct log_index values. +func TestCheckForWithdrawals_MultipleInOneBlock(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + txHash := common.HexToHash("0xbeef") + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(2), nil) + + c.On("RetrieveWithdrawalEvents", mock.Anything).Return( + []*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, txHash, 3, []byte{0x01}, []byte{0x10}), + makeWithdrawalEvent(120, 1, txHash, 5, []byte{0x02}, []byte{0x20}), + }, nil, + ).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 2 && + ws[0].AccountIndex == 3 && + ws[0].LogIndex == 0 && + ws[1].AccountIndex == 5 && + ws[1].LogIndex == 1 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) +} + +// TestCheckForWithdrawals_CursorRespectsDriveProvedAsFloor pins the +// search-window lower bound. When LastWithdrawalCheckBlock is 0 and the +// drive was proved mid-range, the scan must start at +// AccountsDriveProvedBlock (not 1, not 0) — withdrawals cannot land +// before the drive-prove that gates them. +func TestCheckForWithdrawals_CursorRespectsDriveProvedAsFloor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 500) + const head = uint64(600) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 500 + })).Return(big.NewInt(0), nil) + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 0 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) +} + +// TestCheckForWithdrawals_SkipsWhenCursorPastHead verifies the +// short-circuit: a previous tick already advanced the cursor past head. +// No RPC, no DB write. Mirrors the same check on the drive-prove side. +func TestCheckForWithdrawals_SkipsWhenCursorPastHead(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 200 + const head = uint64(150) + + // No mock expectations. + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, uint64(200), app.LastWithdrawalCheckBlock, + "cursor must not regress when head < last cursor") +} + +// TestCheckForWithdrawals_PersistErrorDoesNotAdvanceCursor verifies the +// atomic persistence contract: if inserting the observed withdrawals or +// advancing the cursor fails, the in-memory cursor must not advance. +func TestCheckForWithdrawals_PersistErrorDoesNotAdvanceCursor(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(2), nil) + + c.On("RetrieveWithdrawalEvents", mock.Anything).Return( + []*iapplication.IApplicationWithdrawal{ + makeWithdrawalEvent(120, 0, common.HexToHash("0xaa"), 1, []byte{0x01}, []byte{0x10}), + makeWithdrawalEvent(120, 1, common.HexToHash("0xbb"), 2, []byte{0x02}, []byte{0x20}), + }, nil, + ).Once() + + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 2 && + ws[0].AccountIndex == 1 && + ws[1].AccountIndex == 2 + }), head).Return(errors.New("constraint violation")).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "insert failure keeps cursor unchanged for retry") +} + +// TestCheckForWithdrawals_DoesNotAdvanceCursorOnQueryError verifies that a +// RetrieveWithdrawalEvents error mid-scan leaves the cursor unchanged. The +// next tick must retry the same block range instead of permanently skipping +// the missing events. +func TestCheckForWithdrawals_DoesNotAdvanceCursorOnQueryError(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + + c.On("RetrieveWithdrawalEvents", mock.Anything). + Return([]*iapplication.IApplicationWithdrawal(nil), errors.New("eth_getLogs failed")).Once() + // No persistence expectation — the scan errored before completion. + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "query failure keeps cursor unchanged for retry") +} + +// TestCheckForWithdrawals_DoesNotAdvanceCursorWhenTransitionHasNoEvent +// verifies the inconsistent RPC/log view path. A counter transition without a +// matching Withdrawal log is treated as retryable and must not advance the +// cursor. +func TestCheckForWithdrawals_DoesNotAdvanceCursorWhenTransitionHasNoEvent(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < 120 + })).Return(big.NewInt(0), nil) + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= 120 + })).Return(big.NewInt(1), nil) + c.On("RetrieveWithdrawalEvents", mock.Anything). + Return([]*iapplication.IApplicationWithdrawal{}, nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "missing event after counter transition keeps cursor unchanged for retry") +} + +// TestCheckForWithdrawals_AbortsOnDeadlineExceeded mirrors the drive-prove +// abort path: a DeadlineExceeded mid-scan aborts the loop without +// advancing the cursor. +func TestCheckForWithdrawals_AbortsOnDeadlineExceeded(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.Anything). + Return((*big.Int)(nil), context.DeadlineExceeded).Maybe() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Zero(t, app.LastWithdrawalCheckBlock, + "DeadlineExceeded aborts before cursor advance") +} + +// TestCheckForWithdrawals_CountDivergenceMarksCorrupted verifies the +// fail-closed path: when the local DB withdrawal count exceeds the on-chain +// counter at the scan boundary, FindTransitions reports a monotonic violation. +// The node must treat this as terminal state corruption — mark the app +// CORRUPTED and stop — rather than self-healing. The cursor must not advance +// and no withdrawals may be persisted (no committed history is rewritten). +func TestCheckForWithdrawals_CountDivergenceMarksCorrupted(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 119 + const head = uint64(130) + + // Local ledger says 5 withdrawals; the chain says only 2 at the boundary. + repo.On("GetNumberOfWithdrawals", mock.Anything, app.ID).Return(uint64(5), nil).Once() + c.On("GetNumberOfWithdrawals", mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() == 120 + })).Return(big.NewInt(2), nil).Once() + + repo.On("UpdateApplicationStatus", + mock.Anything, app.ID, ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + // No StoreWithdrawalEvents expectation — failing closed persists nothing. + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, ApplicationStatus_Corrupted, app.Status, + "count divergence must mark the app CORRUPTED") + assert.True(t, hasWithdrawalLedgerDivergence(app), + "the durable reason must identify withdrawal-ledger corruption") + assert.Equal(t, uint64(119), app.LastWithdrawalCheckBlock, + "cursor must not advance on a detected divergence") +} + +// TestCheckForWithdrawals_SkipsPersistedWithdrawalLedgerDivergence verifies +// that once this specific ledger failure is durable, the scan stops entirely: +// no RPC, no DB read, and no persistence. The mocks have no expectations, so +// any call trips the test. +func TestCheckForWithdrawals_SkipsPersistedWithdrawalLedgerDivergence(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 119 + app.Status = ApplicationStatus_Corrupted + reason := withdrawalLedgerDivergenceReasonPrefix + " test fixture" + app.Reason = &reason + const head = uint64(130) + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, uint64(119), app.LastWithdrawalCheckBlock, + "the diverged withdrawal ledger's cursor must stay frozen") +} + +// TestCheckForWithdrawals_ExistingIntegrityTerminalReportsLedgerDivergence +// verifies the honest fallback when the first terminal cause is immutable: +// preserve it, leave the cursor frozen, and report every detected fund-ledger +// divergence rather than hiding it behind a process-local latch. +func TestCheckForWithdrawals_ExistingIntegrityTerminalReportsLedgerDivergence(t *testing.T) { + for _, status := range []ApplicationStatus{ + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + } { + t.Run(status.String(), func(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.LastWithdrawalCheckBlock = 119 + app.Status = status + originalReason := "earlier integrity failure" + app.Reason = &originalReason + + repo.On("GetNumberOfWithdrawals", mock.Anything, app.ID). + Return(uint64(5), nil).Once() + c.On("GetNumberOfWithdrawals", mock.Anything). + Return(big.NewInt(2), nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, 130) + + assert.Contains(t, logs.String(), "Withdrawal ledger divergence detected") + assert.Equal(t, status, app.Status) + assert.Equal(t, originalReason, *app.Reason) + assert.Equal(t, uint64(119), app.LastWithdrawalCheckBlock) + repo.AssertNumberOfCalls(t, "UpdateApplicationStatus", 0) + }) + } +} + +// TestCheckForWithdrawals_OtherCorruptionCauseStillIndexes verifies that the +// shared CORRUPTED status does not suppress a healthy withdrawal ledger when +// another observer, such as output indexing, discovered the inconsistency. +func TestCheckForWithdrawals_OtherCorruptionCauseStillIndexes(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + app.Status = ApplicationStatus_Corrupted + reason := "Output mismatch. Application is in an invalid state." + app.Reason = &reason + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.Anything).Return(big.NewInt(0), nil) + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 0 + }), head).Return(nil).Once() + + s.checkForPostForeclosureWithdrawals(context.Background(), + appContracts{application: app, applicationContract: c}, head) + + assert.Equal(t, head, app.LastWithdrawalCheckBlock, + "an unrelated corruption cause must not stop withdrawal observation") +} + +// --------------------------------------------------------------------------- +// checkPostForeclosure dispatcher routing +// --------------------------------------------------------------------------- + +// TestCheckPostForeclosure_SkipsNonForeclosedApps verifies the top-level +// dispatcher's gate: apps whose ForecloseBlock is zero must not reach +// either of the two scan branches. The mock has no expectations for any +// adapter or repo method tied to the scans — any call trips the test. +func TestCheckPostForeclosure_SkipsNonForeclosedApps(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := &Application{ + ID: 99, + Name: "not-foreclosed", + IApplicationAddress: common.BigToAddress(big.NewInt(99)), + Status: ApplicationStatus_OK, + // ForecloseBlock left zero — should be skipped. + } + + s.checkPostForeclosure(context.Background(), + []appContracts{{application: app, applicationContract: c}}, 100) +} + +// TestCheckPostForeclosure_RoutesToDriveProvedWhenZero verifies the dispatcher +// routes to the drive-prove scan when AccountsDriveProvedBlock == 0. +// GetAccountsDriveMerkleRoot must be called; GetNumberOfWithdrawals must NOT be. +func TestCheckPostForeclosure_RoutesToDriveProvedWhenZero(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := driveProvedTestApp(1, 100) + const head = uint64(120) + + c.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(false, common.Hash{}, nil).Once() + repo.On("UpdateApplicationLastAccountsDriveProvedCheckBlock", + mock.Anything, app.ID, head).Return(nil).Once() + // No GetNumberOfWithdrawals — assertion by negation. + + s.checkPostForeclosure(context.Background(), + []appContracts{{application: app, applicationContract: c}}, head) +} + +// TestCheckPostForeclosure_RoutesToWithdrawalsWhenProved verifies the +// dispatcher routes to the withdrawal scan once +// AccountsDriveProvedBlock != 0. GetNumberOfWithdrawals must be called; +// RetrieveAccountsDriveProvedEvents must NOT be. +func TestCheckPostForeclosure_RoutesToWithdrawalsWhenProved(t *testing.T) { + s, c, repo := newPostForeclosureFixture(t) + defer c.AssertExpectations(t) + defer repo.AssertExpectations(t) + + app := postForeclosureWithdrawalApp(1, 100, 110) + const head = uint64(130) + + c.On("GetNumberOfWithdrawals", mock.Anything).Return(big.NewInt(0), nil) + repo.On("StoreWithdrawalEvents", + mock.Anything, app.ID, mock.MatchedBy(func(ws []*Withdrawal) bool { + return len(ws) == 0 + }), head).Return(nil).Once() + + s.checkPostForeclosure(context.Background(), + []appContracts{{application: app, applicationContract: c}}, head) +} diff --git a/internal/evmreader/readiness_test.go b/internal/evmreader/readiness_test.go new file mode 100644 index 000000000..abb9b0080 --- /dev/null +++ b/internal/evmreader/readiness_test.go @@ -0,0 +1,236 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "testing" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveScanReportsMissingInputBox(t *testing.T) { + reader := &Service{} + reader.Logger = slog.Default() + apps := []appContracts{{application: &Application{ + ID: 1, Name: "missing-input-box", IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + ConsensusType: Consensus_PRT, + }}} + + require.False(t, reader.scanDaveConsensusEpochsAndInputs(t.Context(), apps, 110), + "a missing InputBox adapter must count as a failed observation cycle") +} + +func TestReadinessTracksScanFailures(t *testing.T) { + for _, failure := range []string{ + "application list", "input counter", "input count mismatch", scanInputLogs, "input cursor", "input store", + "output query", "foreclosure query", "drive cursor", "withdrawal store", + } { + t.Run(failure, func(t *testing.T) { + app := &Application{ + ID: 1, Name: "app", Enabled: true, IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + ConsensusType: Consensus_Authority, EpochLength: 10, + Status: ApplicationStatus_OK, LastInputCheckBlock: 100, + LastOutputCheckBlock: 110, LastForecloseCheckBlock: 110, + } + repo := newMockRepository() + input := newMockInputBox() + contract := newMockApplicationContract() + client := newMockEthClient() + client.On("HeaderByNumber", mock.Anything, mock.Anything).Return(&types.Header{Number: big.NewInt(110)}, nil) + list := repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{app}, uint64(1), nil) + count := input.On("GetNumberOfInputs", mock.Anything, mock.Anything).Return(big.NewInt(0), nil) + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything).Return(uint64(0), nil) + repo.On("GetEpoch", mock.Anything, mock.Anything, mock.Anything).Return((*Epoch)(nil), nil) + cursor := repo.On("UpdateEventLastCheckBlock", mock.Anything, mock.Anything, MonitoredEvent_InputAdded, uint64(110)).Return(nil) + failureErr := errors.New("persistent scan failure") + var failedCall *mock.Call + var recovered []any + switch failure { + case "application list": + failedCall, recovered = list, []any{[]*Application{app}, uint64(1), nil} + list.Return([]*Application(nil), uint64(0), failureErr) + case "input counter": + failedCall, recovered = count, []any{big.NewInt(0), nil} + count.Return((*big.Int)(nil), failureErr) + case "input count mismatch": + count.Return(big.NewInt(1), nil) + failedCall = input.On("RetrieveInputs", mock.Anything, mock.Anything, mock.Anything). + Return([]iinputbox.IInputBoxInputAdded(nil), nil) + recovered = []any{[]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 0, 101)}, nil} + repo.On("CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, uint64(110)).Return(nil) + case scanInputLogs, "input store": + count.Return(big.NewInt(1), nil) + events := []iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 0, 101)} + logs := input.On("RetrieveInputs", mock.Anything, mock.Anything, mock.Anything).Return(events, nil) + store := repo.On("CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, uint64(110)).Return(nil) + if failure == scanInputLogs { + failedCall, recovered = logs, []any{events, nil} + logs.Return([]iinputbox.IInputBoxInputAdded(nil), failureErr) + } else { + failedCall, recovered = store, []any{nil} + store.Return(failureErr) + } + case "input cursor": + failedCall, recovered = cursor, []any{nil} + cursor.Return(failureErr) + case "output query": + app.LastOutputCheckBlock = 100 + repo.Unset("GetNumberOfPendingExecutableOutputs") + failedCall = repo.On("GetNumberOfPendingExecutableOutputs", mock.Anything, mock.Anything).Return(uint64(0), failureErr) + recovered = []any{uint64(0), nil} + case "drive cursor": + app.ForecloseBlock = 100 + contract.On("GetAccountsDriveMerkleRoot", mock.Anything).Return(false, common.Hash{}, nil) + failedCall = repo.On("UpdateApplicationLastAccountsDriveProvedCheckBlock", mock.Anything, mock.Anything, mock.Anything).Return(failureErr) + recovered = []any{nil} + case "withdrawal store": + app.ForecloseBlock = 100 + app.AccountsDriveProvedBlock = 100 + contract.On("GetNumberOfWithdrawals", mock.Anything).Return(big.NewInt(0), nil) + failedCall = repo.On("StoreWithdrawalEvents", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(failureErr) + recovered = []any{nil} + case "foreclosure query": + app.LastForecloseCheckBlock = 100 + contract.Unset("IsForeclosed") + failedCall = contract.On("IsForeclosed", mock.Anything).Return(false, failureErr) + recovered = []any{false, nil} + repo.On("UpdateApplicationLastForecloseCheckBlock", mock.Anything, mock.Anything, mock.Anything).Return(nil) + } + r := &Service{client: client, repository: repo, + defaultBlock: DefaultBlock_Latest, readyMaxStaleness: time.Hour} + r.Logger = slog.Default() + r.resolver = newApplicationAdapterResolver(r.Logger, newMockAdapterFactory().SetupDefaultBehaviorSingleApp(contract, input)) + require.False(t, r.Ready()) + for i := 1; i <= maxConsecutiveScanFailures+1; i++ { + _, err := r.Tick(t.Context()) + require.NoError(t, err) + require.Equal(t, i < maxConsecutiveScanFailures, r.Ready()) + } + // Even a successful header cannot clear the failure streak. + require.EqualValues(t, maxConsecutiveScanFailures, r.consecutiveScanFailures.Load()) + failedCall.Return(recovered...) + _, err := r.Tick(t.Context()) + require.NoError(t, err) + require.True(t, r.Ready()) + require.Zero(t, r.consecutiveScanFailures.Load()) + + // A canceled scan must neither count as recovery nor refresh staleness. + before := *r.lastSuccessfulPoll.Load() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = r.Tick(ctx) + require.NoError(t, err) + require.Equal(t, before, *r.lastSuccessfulPoll.Load()) + }) + } +} + +func TestReadinessSeparatesInputCorruptionFromSharedFailures(t *testing.T) { + for _, failure := range []string{ + "recorded conflict", "status write", "RPC", "store", "epoch query", + "zero epoch", "zero epoch status write", "non-open epoch", "non-open epoch status write", + } { + t.Run(failure, func(t *testing.T) { + bad := &Application{ID: 1, Name: "degraded", Enabled: true, + IApplicationAddress: app1Addr, IInputBoxAddress: inputBoxAddr, + ConsensusType: Consensus_Authority, EpochLength: 10, + Status: ApplicationStatus_OK, LastInputCheckBlock: 100, + LastOutputCheckBlock: 110, LastForecloseCheckBlock: 110} + good := *bad + good.ID, good.Name, good.IApplicationAddress = 2, "healthy", common.HexToAddress("0x2222") + // An integrity-terminal status must not prevent healthy observation. + good.Status = ApplicationStatus_Corrupted + repo := newMockRepository() + list := repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*Application{bad, &good}, uint64(2), nil) + input := newMockInputBox() + input.On("GetNumberOfInputs", mock.Anything, good.IApplicationAddress).Return(big.NewInt(0), nil) + count := input.On("GetNumberOfInputs", mock.Anything, bad.IApplicationAddress).Return(big.NewInt(1), nil) + input.On("RetrieveInputs", mock.Anything, mock.Anything, mock.Anything). + Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(bad.IApplicationAddress, 0, 101)}, nil) + repo.On("GetNumberOfInputs", mock.Anything, mock.Anything).Return(uint64(0), nil) + repo.On("GetEpoch", mock.Anything, good.IApplicationAddress.String(), mock.Anything).Return((*Epoch)(nil), nil) + epoch := repo.On("GetEpoch", mock.Anything, bad.IApplicationAddress.String(), mock.Anything).Return((*Epoch)(nil), nil) + store := repo.On("CreateEpochsAndInputs", mock.Anything, bad.IApplicationAddress.String(), mock.Anything, uint64(110)). + Return(fmt.Errorf("insert: %w", repository.ErrInputLogIdentityConflict)) + status := repo.On("UpdateApplicationStatus", mock.Anything, bad.ID, ApplicationStatus_Corrupted, mock.Anything).Return(nil) + repo.On("UpdateEventLastCheckBlock", mock.Anything, []int64{good.ID}, MonitoredEvent_InputAdded, uint64(110)). + Return(nil).Run(func(mock.Arguments) { good.LastInputCheckBlock = 110 }) + infraErr := errors.New("infrastructure unavailable") + switch failure { + case "status write": + status.Return(infraErr) + case "RPC": + count.Return((*big.Int)(nil), infraErr) + case "store": + store.Return(infraErr) + case "epoch query": + epoch.Return((*Epoch)(nil), infraErr) + case "non-open epoch", "non-open epoch status write": + epoch.Return(&Epoch{Index: 10, Status: EpochStatus_Closed}, nil) + if failure == "non-open epoch status write" { + status.Return(infraErr) + } + case "zero epoch": + bad.EpochLength = 0 + case "zero epoch status write": + bad.EpochLength = 0 + status.Return(infraErr) + } + client := newMockEthClient() + client.On("HeaderByNumber", mock.Anything, mock.Anything).Return(&types.Header{Number: big.NewInt(110)}, nil) + r := &Service{client: client, repository: repo, + defaultBlock: DefaultBlock_Latest, readyMaxStaleness: time.Hour} + r.Logger = slog.Default() + r.resolver = newApplicationAdapterResolver(r.Logger, + newMockAdapterFactory().SetupDefaultBehaviorSingleApp(newMockApplicationContract(), input)) + local := failure == "recorded conflict" || failure == "zero epoch" || failure == "non-open epoch" + for i := 1; i <= maxConsecutiveScanFailures+1; i++ { + _, err := r.Tick(t.Context()) + require.NoError(t, err) + require.Equal(t, local || i < maxConsecutiveScanFailures, r.Ready()) + require.EqualValues(t, 110, good.LastInputCheckBlock) + require.EqualValues(t, 100, bad.LastInputCheckBlock, "failed input observation must not advance its cursor") + } + if failure == "non-open epoch" || failure == "non-open epoch status write" { + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", 0) + } + if failure == "recorded conflict" { + repo.AssertNumberOfCalls(t, "CreateEpochsAndInputs", maxConsecutiveScanFailures+1) + } + if local { + require.Equal(t, ApplicationStatus_Corrupted, bad.Status) + repo.AssertNumberOfCalls(t, "UpdateApplicationStatus", 1) + } + // Disabling the stalled app recovers readiness without changing its status. + previousStatus := bad.Status + bad.Enabled = false + list.Return([]*Application{&good}, uint64(1), nil) + _, err := r.Tick(t.Context()) + require.NoError(t, err) + require.True(t, r.Ready()) + require.Zero(t, r.consecutiveScanFailures.Load()) + require.Equal(t, previousStatus, bad.Status) + // Both cursors at the head also constitute a healthy idle scan. + bad.Enabled, bad.LastInputCheckBlock = true, 110 + list.Return([]*Application{bad, &good}, uint64(2), nil) + _, err = r.Tick(t.Context()) + require.NoError(t, err) + require.True(t, r.Ready()) + }) + } +} diff --git a/internal/evmreader/sealedepochs.go b/internal/evmreader/sealedepochs.go new file mode 100644 index 000000000..51fd69c38 --- /dev/null +++ b/internal/evmreader/sealedepochs.go @@ -0,0 +1,585 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "fmt" + "math" + "math/big" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// A counter transition and its log page must describe the same chain view. +// A provider can return an inconsistent view during mining or a provisional +// reorg. Leave the cursor unchanged and retry on the next observation tick. +var errInconclusiveEpochView = errors.New("inconclusive sealed epoch view") + +func (r *Service) initialSealedEpochSearchBlock( + ctx context.Context, + app appContracts, + mostRecentBlockNumber uint64, +) (uint64, error) { + r.Logger.Debug("Initializing application sealed epoch sync", + "application", app.application.Name, + "current_block", mostRecentBlockNumber, + ) + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(mostRecentBlockNumber), + } + deploymentBlock, err := app.daveConsensus.GetDeploymentBlockNumber(callOpts) + if err != nil { + if errors.Is(err, bind.ErrNoCode) { + r.Logger.Debug("Skipping sealed epoch sync before consensus deployment", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "consensus_address", app.application.IConsensusAddress, + "block", mostRecentBlockNumber, + ) + return 0, fmt.Errorf("%w: consensus %s at block %d: %w", + errContractNotDeployedAtBlock, + app.application.IConsensusAddress, + mostRecentBlockNumber, + err) + } + return 0, fmt.Errorf("failed to retrieve DaveConsensus deployment block: %w", err) + } + if deploymentBlock == nil || !deploymentBlock.IsUint64() || deploymentBlock.Sign() == 0 || + deploymentBlock.Uint64() > mostRecentBlockNumber { + return 0, fmt.Errorf("invalid DaveConsensus deployment block %v at head %d", deploymentBlock, mostRecentBlockNumber) + } + + // This is a local search floor, not evidence of a completed scan. Persisting + // it before the constructor seal is stored could leave a cursor with no epoch. + return deploymentBlock.Uint64(), nil +} + +func (r *Service) scanDaveConsensusEpochsAndInputs( + ctx context.Context, + applications []appContracts, + mostRecentBlockNumber uint64, +) bool { + success := true + r.Logger.Debug("Checking for new epochs and inputs", "apps", applications) + + // Process each application individually since each has its own DaveConsensus contract + for _, app := range applications { + if app.inputSource == nil { + success = false + r.Logger.Error("Cannot scan DaveConsensus epochs: InputBox adapter is missing", + "application", app.application.Name, + "address", app.application.IApplicationAddress, + "input_box", app.application.IInputBoxAddress, + ) + continue + } + r.Logger.Debug("Processing DaveConsensus application", + "application", app.application.Name, + "consensus_address", app.application.IConsensusAddress) + + observationEndBlock := foreclosureBoundedEndBlock(app.application, mostRecentBlockNumber) + err := r.processApplicationSealedEpochs(ctx, app, observationEndBlock) + if err != nil { + if errors.Is(err, context.Canceled) { + return false // shutting down + } + if errors.Is(err, errContractNotDeployedAtBlock) { + // Registration can precede deployment visibility at the configured + // block. Neither sealed nor open epochs can be observed yet. + continue + } + if errors.Is(err, errApplicationStatusReported) { + // Only a recorded integrity status makes this fault app-local. + // A failed escalation from an execution terminal still fails the scan. + if app.application.Status != ApplicationStatus_Corrupted && app.application.Status != ApplicationStatus_Diverged { + success = false + } + continue + } + success = false + if errors.Is(err, errInconclusiveEpochView) { + r.Logger.Warn("Sealed epoch reads disagree; retrying from the saved cursor", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "consensus_address", app.application.IConsensusAddress, + "from_block", app.application.LastEpochCheckBlock, "to_block", observationEndBlock, "error", err) + continue + } + r.Logger.Error("Error processing application sealed epochs", + "application", app.application.Name, + "consensus_address", app.application.IConsensusAddress, + "error", err) + continue + } + + // Open-epoch input ingestion is L1 observation, not machine execution. + // Keep it active for non-executing applications and bound it at the + // foreclosure block just like the sealed-epoch scan above. + err = r.processApplicationOpenEpoch(ctx, app, observationEndBlock) + if err != nil { + if errors.Is(err, context.Canceled) { + return false // shutting down + } + if errors.Is(err, errApplicationStatusReported) { + if app.application.Status != ApplicationStatus_Corrupted && app.application.Status != ApplicationStatus_Diverged { + success = false + } + continue + } + success = false + if errors.Is(err, errInconclusiveInputView) { + r.Logger.Warn("Open epoch input reads disagree; retrying from the saved cursor", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "consensus_address", app.application.IConsensusAddress, + "from_block", app.application.LastInputCheckBlock, "to_block", observationEndBlock, "error", err) + continue + } + r.Logger.Error("Error processing application open epoch", + "application", app.application.Name, + "consensus_address", app.application.IConsensusAddress, + "error", err) + continue + } + } + return success +} + +func (r *Service) processApplicationSealedEpochs( + ctx context.Context, + app appContracts, + mostRecentBlockNumber uint64, +) error { + lastEpochCheckBlock := app.application.LastEpochCheckBlock + initializing := lastEpochCheckBlock == 0 + if initializing { + deploymentBlock, err := r.initialSealedEpochSearchBlock(ctx, app, mostRecentBlockNumber) + if err != nil { + return fmt.Errorf("failed to determine start block for epoch search: %w", err) + } + lastEpochCheckBlock = deploymentBlock - 1 + } + + if mostRecentBlockNumber < lastEpochCheckBlock { + if app.application.ForecloseBlock != 0 && app.application.ForecloseBlock == mostRecentBlockNumber { + r.Logger.Debug("Sealed epoch scan already covers foreclosure block", + "application", app.application.Name, "foreclose_block", mostRecentBlockNumber, + "last_epoch_check_block", lastEpochCheckBlock) + return nil // The independent input cursor can still require a drain. + } + r.Logger.Warn( + "Not reading sealed epochs: most recent block is lower than the last processed one", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_epoch_check_block", app.application.LastEpochCheckBlock, + "most_recent_block", mostRecentBlockNumber, + ) + return nil + } else if mostRecentBlockNumber == lastEpochCheckBlock { + r.Logger.Debug("Not reading sealed epochs: already checked the most recent blocks", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_epoch_check_block", app.application.LastEpochCheckBlock, + "most_recent_block", mostRecentBlockNumber, + ) + return nil + } + + nextSearchBlock := lastEpochCheckBlock + 1 + r.Logger.Debug("Checking sealed epochs for application", + "application", app.application.Name, + "last_epoch_check_block", lastEpochCheckBlock, + "next_search_block", nextSearchBlock, + "most_recent_block", mostRecentBlockNumber, + ) + + // Create oracle function that returns the current sealed epoch number for a given block + oracle := func(ctx context.Context, block uint64) (*big.Int, error) { + r.Logger.Debug("Retrieving current sealed epoch", "application", app.application.Name, "block", block) + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(block), + } + + sealedEpoch, err := app.daveConsensus.GetCurrentSealedEpoch(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to get current sealed epoch at block %d: %w", block, err) + } + + return sealedEpoch.EpochNumber, nil + } + + // A failed window may already have stored a prefix of its epochs. Seed from + // the completed scan boundary, not the newest row, so that prefix is replayed. + prevValue := big.NewInt(-1) + if !initializing { + var err error + prevValue, err = oracle(ctx, lastEpochCheckBlock) + if err != nil { + return err + } + } + previousTransitionValue := prevValue + onHit := func(block uint64) error { + value, err := r.processEpochTransition(ctx, app, block, previousTransitionValue) + if err == nil { + previousTransitionValue = value + } + return err + } + + // Use FindTransitions to find epoch transitions + _, err := ethutil.FindTransitions(ctx, nextSearchBlock, mostRecentBlockNumber, prevValue, oracle, onHit) + if err != nil { + return fmt.Errorf("failed to walk epoch transitions: %w", err) + } + + // Update the last check block for this application + err = r.repository.UpdateEventLastCheckBlock(ctx, []int64{app.application.ID}, MonitoredEvent_EpochSealed, mostRecentBlockNumber) + if err != nil { + return fmt.Errorf("failed to update last epoch check block: %w", err) + } + if initializing { + r.Logger.Info("Application sealed epoch sync initialized", + "application", app.application.Name, "consensus_address", app.application.IConsensusAddress, + "deployment_block", nextSearchBlock, + "last_epoch_check_block", mostRecentBlockNumber) + } + + r.Logger.Debug("Sealed epoch search completed", "application", app.application.Name, "most_recent_block", mostRecentBlockNumber) + + return nil +} + +func (r *Service) processEpochTransition( + ctx context.Context, + app appContracts, + transitionBlock uint64, + previousEpochNumber *big.Int, +) (*big.Int, error) { + r.Logger.Debug("Processing epoch transition", "application", app.application.Name, "block", transitionBlock) + + // Get the sealed epoch information at this block + callOpts := &bind.CallOpts{ + Context: ctx, + BlockNumber: new(big.Int).SetUint64(transitionBlock), + } + + sealedEpoch, err := app.daveConsensus.GetCurrentSealedEpoch(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to get sealed epoch at transition block %d: %w", transitionBlock, err) + } + + // Retrieve the actual EpochSealed events for this transition + filterOpts := &bind.FilterOpts{ + Context: ctx, + Start: transitionBlock, + End: &transitionBlock, + } + + sealedEvents, err := app.daveConsensus.RetrieveSealedEpochs(filterOpts) + if err != nil { + return nil, fmt.Errorf("failed to retrieve sealed epoch events at block %d: %w", transitionBlock, err) + } + + // Validate the whole block before storing any event. Several epochs can seal + // in one block, so a nonempty page can still omit part of the transition. + if sealedEpoch.EpochNumber == nil || !sealedEpoch.EpochNumber.IsUint64() { + return nil, fmt.Errorf("invalid sealed epoch number at block %d: %v", transitionBlock, sealedEpoch.EpochNumber) + } + expectedCount := new(big.Int).Sub(sealedEpoch.EpochNumber, previousEpochNumber) + if expectedCount.Sign() <= 0 || !expectedCount.IsUint64() || expectedCount.Uint64() != uint64(len(sealedEvents)) { + return nil, fmt.Errorf("%w: sealed epoch event count mismatch at block %d: expected %s, got %d", + errInconclusiveEpochView, transitionBlock, expectedCount, len(sealedEvents)) + } + expectedEpoch := new(big.Int).Set(previousEpochNumber) + for _, event := range sealedEvents { + expectedEpoch.Add(expectedEpoch, big.NewInt(1)) + if event == nil || event.EpochNumber == nil || event.EpochNumber.Cmp(expectedEpoch) != 0 || + event.Raw.BlockNumber != transitionBlock { + return nil, fmt.Errorf("%w: invalid sealed epoch event sequence at block %d: expected epoch %s", + errInconclusiveEpochView, transitionBlock, expectedEpoch) + } + } + lastEvent := sealedEvents[len(sealedEvents)-1] + if lastEvent.InputIndexLowerBound.Cmp(sealedEpoch.InputIndexLowerBound) != 0 || + lastEvent.InputIndexUpperBound.Cmp(sealedEpoch.InputIndexUpperBound) != 0 || lastEvent.Tournament != sealedEpoch.Tournament { + return nil, fmt.Errorf("%w: last sealed epoch event does not match current sealed epoch at block %d", + errInconclusiveEpochView, transitionBlock) + } + r.Logger.Info("Found sealed epoch events", + "application", app.application.Name, "block", transitionBlock, + "epoch_number", sealedEpoch.EpochNumber, "count", len(sealedEvents)) + + // Process each sealed epoch event + for _, event := range sealedEvents { + err := r.processSealedEpochEvent(ctx, app, event) + if err != nil { + return nil, fmt.Errorf("failed to process sealed epoch event at block %d: %w", transitionBlock, err) + } + } + + return sealedEpoch.EpochNumber, nil +} + +func (r *Service) processSealedEpochEvent( + ctx context.Context, + app appContracts, + event *idaveconsensus.IDaveConsensusEpochSealed, +) error { + r.Logger.Debug("Processing sealed epoch event", + "epoch_number", event.EpochNumber, + "input_lower_bound", event.InputIndexLowerBound, + "input_upper_bound", event.InputIndexUpperBound, + "tournament", event.Tournament) + + // A seal is emitted by a transaction, so it cannot occur at genesis. + if event.Raw.BlockNumber == 0 { + return errors.New("sealed epoch event has block number zero") + } + + firstBlock := uint64(0) + epochNumber := event.EpochNumber.Uint64() + if epochNumber == 0 { + if app.application.IInputBoxBlock == 0 { + r.Logger.Error("Application has no InputBox block number defined", + "application", app.application.Name, + "inputbox", app.application.IInputBoxAddress, + "iinputbox_block", app.application.IInputBoxBlock, + ) + return errors.New("application has no InputBox block number defined") + } + firstBlock = app.application.IInputBoxBlock + } else { + prevEpochNumber := epochNumber - 1 + prevEpoch, err := r.repository.GetEpoch(ctx, app.application.IApplicationAddress.Hex(), prevEpochNumber) + if err != nil { + return fmt.Errorf("failed to fetch epoch %d: %w", prevEpochNumber, err) + } + if prevEpoch == nil { + return fmt.Errorf("failed to fetch previous epoch %d: should not be nil", prevEpochNumber) + } + + prevEpoch.ClaimTransactionHash = &event.Raw.TxHash + err = r.repository.UpdateEpochClaimTransactionHash(ctx, app.application.IApplicationAddress.Hex(), prevEpoch) + if err != nil { + return fmt.Errorf("failed to update previous epoch %d: %w", prevEpochNumber, err) + } + firstBlock = prevEpoch.LastBlock + } + + epoch, err := r.repository.GetEpoch(ctx, app.application.IApplicationAddress.Hex(), epochNumber) + if err != nil { + return fmt.Errorf("failed to fetch epoch %d: %w", epochNumber, err) + } + + if epoch == nil { + // Create new epoch from sealed event + epoch = &Epoch{ + Index: event.EpochNumber.Uint64(), + FirstBlock: firstBlock, // Will be calculated based on epoch length + LastBlock: event.Raw.BlockNumber, + InputIndexLowerBound: event.InputIndexLowerBound.Uint64(), + InputIndexUpperBound: event.InputIndexUpperBound.Uint64(), + TournamentAddress: &event.Tournament, + Status: EpochStatus_Closed, // Sealed epochs are closed + } + } else { + if epoch.FirstBlock != firstBlock || epoch.InputIndexLowerBound != event.InputIndexLowerBound.Uint64() { + return fmt.Errorf("epoch %d data mismatch with sealed event", epoch.Index) + } + if epoch.Status != EpochStatus_Open { + if epoch.LastBlock != event.Raw.BlockNumber || epoch.InputIndexUpperBound != event.InputIndexUpperBound.Uint64() || + epoch.TournamentAddress == nil || *epoch.TournamentAddress != event.Tournament { + return fmt.Errorf("sealed epoch %d data mismatch with replayed event", epoch.Index) + } + // The epoch and its inputs were stored atomically. Preserve any later + // claim/proof progress when replaying a partially completed scan. + return nil + } + epoch.LastBlock = event.Raw.BlockNumber + epoch.InputIndexUpperBound = event.InputIndexUpperBound.Uint64() + epoch.TournamentAddress = &event.Tournament + epoch.Status = EpochStatus_Closed // Sealed epochs are closed + } + + // Fetch inputs for this epoch from the InputBox. + // Always search from epoch.FirstBlock (not lastInputCheckBlock+1) because with + // PRT's overlapping block boundaries (sealed epoch's LastBlock = next epoch's + // FirstBlock), inputs for this epoch may exist at the overlap block — added in + // a later transaction than the previous epoch's seal within the same block. + // Use InputIndexLowerBound as prevValue rather than the DB input count, since + // the open epoch may have already stored some of these inputs ahead of us. + var inputs []*Input + if epoch.InputIndexUpperBound > epoch.InputIndexLowerBound { + var err error + prevValue := new(big.Int).SetUint64(epoch.InputIndexLowerBound) + inputs, _, err = r.fetchInputs(ctx, app, + epoch.FirstBlock, epoch.LastBlock, + prevValue, + epoch.InputIndexLowerBound, epoch.InputIndexUpperBound) + if err != nil { + return fmt.Errorf("failed to fetch inputs for epoch %d: %w", epoch.Index, err) + } + + expectedCount := epoch.InputIndexUpperBound - epoch.InputIndexLowerBound + if uint64(len(inputs)) != expectedCount { + return fmt.Errorf( + "%w: epoch %d input count mismatch: expected %d (indices %d to %d), got %d", + errInconclusiveEpochView, epoch.Index, expectedCount, + epoch.InputIndexLowerBound, epoch.InputIndexUpperBound, + len(inputs)) + } + } + // The seal covers every input before its transaction, but later inputs in + // the same block belong to the next epoch. Publish only complete blocks; + // the open-epoch scan must finish this boundary block before drain is safe. + epochInputMap := map[*Epoch][]*Input{epoch: inputs} + + r.Logger.Debug("Storing sealed epoch", "application", app.application.Name, "epoch_number", epoch.Index) + + err = r.repository.CreateEpochsAndInputs( + ctx, + app.application.IApplicationAddress.String(), + epochInputMap, + event.Raw.BlockNumber-1, + ) + if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.setApplicationCorrupted(ctx, app.application, + "sealed epoch %d input L1 log identity conflicts with stored data; operator reset required: %v", epoch.Index, err) + } + return fmt.Errorf("failed to store epoch and inputs: %w", err) + } + + r.Logger.Debug("Stored sealed epoch and inputs", + "application", app.application.Name, + "epoch_number", epoch.Index, + "num_inputs", len(inputs), + "block", event.Raw.BlockNumber) + + return nil +} + +func (r *Service) processApplicationOpenEpoch( + ctx context.Context, + app appContracts, + mostRecentBlockNumber uint64, +) error { + // The input cursor covers complete blocks. A sealed batch in this tick + // can advance it only to the block before its seal. + if mostRecentBlockNumber < app.application.LastInputCheckBlock { + r.Logger.Warn( + "Not checking for inputs on current open epoch: most recent block is lower than the last processed one", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_input_check_block", app.application.LastInputCheckBlock, + "most_recent_block", mostRecentBlockNumber, + ) + return nil + } else if mostRecentBlockNumber == app.application.LastInputCheckBlock { + r.Logger.Debug("Not checking for inputs on current open epoch: already checked the most recent blocks", + "application", app.application.Name, "address", app.application.IApplicationAddress, + "last_input_check_block", app.application.LastInputCheckBlock, + "most_recent_block", mostRecentBlockNumber, + ) + return nil + } + + r.Logger.Debug("Checking for inputs on current open epoch", + "application", app.application.Name, + "most_recent_block", mostRecentBlockNumber, + ) + + lastEpoch, err := r.repository.GetLastNonOpenEpoch(ctx, app.application.IApplicationAddress.String()) + if err != nil { + return fmt.Errorf("failed to get last non open epoch: %w", err) + } + if lastEpoch == nil { + return r.setApplicationCorrupted(ctx, app.application, + "invalid state. no non open epochs found for application") + } + + nextEpochNumber := lastEpoch.Index + 1 + openEpoch, err := r.repository.GetEpoch(ctx, app.application.IApplicationAddress.Hex(), nextEpochNumber) + if err != nil { + return fmt.Errorf("failed to fetch epoch %d: %w", nextEpochNumber, err) + } + if openEpoch == nil { + // Create epoch from sealed event + openEpoch = &Epoch{ + Index: nextEpochNumber, + FirstBlock: lastEpoch.LastBlock, + LastBlock: mostRecentBlockNumber, + InputIndexLowerBound: lastEpoch.InputIndexUpperBound, + InputIndexUpperBound: lastEpoch.InputIndexUpperBound, + Status: EpochStatus_Open, + } + } + + lastInputCheckBlock, err := r.repository.GetEventLastCheckBlock(ctx, app.application.ID, MonitoredEvent_InputAdded) + if err != nil { + return fmt.Errorf("failed to get last input check block: %w", err) + } + if lastInputCheckBlock >= mostRecentBlockNumber { + return nil + } + + // Fetch inputs for this epoch from the InputBox + inputCount, err := r.repository.GetNumberOfInputs( + ctx, app.application.IApplicationAddress.String()) + if err != nil { + return fmt.Errorf( + "failed to get number of inputs from repository: %w", err) + } + prevValue := new(big.Int).SetUint64(inputCount) + inputs, endCount, err := r.fetchInputs(ctx, app, + lastInputCheckBlock+1, mostRecentBlockNumber, + prevValue, + openEpoch.InputIndexLowerBound, math.MaxUint64) + if err != nil { + return fmt.Errorf("failed to fetch inputs for epoch %d: %w", openEpoch.Index, err) + } + if endCount < inputCount || endCount-inputCount != uint64(len(inputs)) { + return fmt.Errorf("%w: open epoch %d input count mismatch: stored %d, on-chain %d, got %d", + errInconclusiveInputView, openEpoch.Index, inputCount, endCount, len(inputs)) + } + + // increase the upper bound according to the number of fetched inputs + openEpoch.InputIndexUpperBound += uint64(len(inputs)) + openEpoch.LastBlock = mostRecentBlockNumber + + r.Logger.Debug("Storing open epoch", + "application", app.application.Name, + "epoch_number", openEpoch.Index, + "new_inputs", len(inputs), + ) + // Store epoch and inputs + epochInputMap := map[*Epoch][]*Input{openEpoch: inputs} + + err = r.repository.CreateEpochsAndInputs( + ctx, + app.application.IApplicationAddress.String(), + epochInputMap, + mostRecentBlockNumber, + ) + + if err != nil { + if errors.Is(err, repository.ErrInputLogIdentityConflict) { + return r.setApplicationCorrupted(ctx, app.application, + "open epoch %d input L1 log identity conflicts with stored data; operator reset required: %v", openEpoch.Index, err) + } + return fmt.Errorf("failed to store epoch and inputs: %w", err) + } + + r.Logger.Debug("Stored open epoch and inputs", + "application", app.application.Name, + "epoch_number", nextEpochNumber, + "num_inputs", len(inputs), + "block", mostRecentBlockNumber) + + return nil +} diff --git a/internal/evmreader/sealedepochs_checkpoint_test.go b/internal/evmreader/sealedepochs_checkpoint_test.go new file mode 100644 index 000000000..38a4472df --- /dev/null +++ b/internal/evmreader/sealedepochs_checkpoint_test.go @@ -0,0 +1,168 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "io" + "log/slog" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// A sealed batch contains only inputs before the seal transaction, not the +// later inputs in that block. A failed open scan must not publish the block as +// complete, even if the sealed scan has finished and the reader restarts. +func TestDaveInputCheckpointAfterFailedOpenScan(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + ctx := context.Background() + release, err := db.LockTestPostgres(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(release) + + for _, test := range []struct { + name string + sealedSizes []uint64 + cancelWrite bool + }{ + {name: "empty seal then RPC failure", sealedSizes: []uint64{0}}, + {name: "input seal input then RPC failure", sealedSizes: []uint64{1}}, + {name: "two seals then cancelled write", sealedSizes: []uint64{1, 1}, cancelWrite: true}, + } { + t.Run(test.name, func(t *testing.T) { + const ( + previousSealBlock uint64 = 50 + forecloseBlock uint64 = 100 + ) + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { repo.Close() }) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(ctx, t, repo) + address := app.IApplicationAddress.Hex() + previousEpoch := &Epoch{Index: 0, FirstBlock: 10, LastBlock: previousSealBlock, Status: EpochStatus_Closed} + require.NoError(t, repo.CreateEpochsAndInputs(ctx, address, + map[*Epoch][]*Input{previousEpoch: nil}, forecloseBlock-2)) + require.NoError(t, repo.UpdateEventLastCheckBlock(ctx, []int64{app.ID}, + MonitoredEvent_EpochSealed, forecloseBlock-2)) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + + var sealedInputs uint64 + var events []*idaveconsensus.IDaveConsensusEpochSealed + for i, size := range test.sealedSizes { + events = append(events, makeSealedEpochEvent(int64(i+1), sealedInputs, sealedInputs+size, + forecloseBlock, repotest.UniqueAddress())) + sealedInputs += size + } + lastEvent := events[len(events)-1] + dave := newMockDaveConsensus() + dave.On("GetCurrentSealedEpoch", blockRange(0, forecloseBlock)). + Return(makeSealedEpochResult(0, 0, 0, common.Address{}), nil) + dave.On("GetCurrentSealedEpoch", blockFrom(forecloseBlock)).Return(makeSealedEpochResult( + lastEvent.EpochNumber.Int64(), lastEvent.InputIndexLowerBound.Uint64(), sealedInputs, lastEvent.Tournament), nil) + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == forecloseBlock && opts.End != nil && *opts.End == forecloseBlock + })).Return(events, nil).Once() + + inputBox := newMockInputBox() + inputBox.On("GetNumberOfInputs", blockRange(0, forecloseBlock), app.IApplicationAddress). + Return(big.NewInt(0), nil).Maybe() + inputBox.On("GetNumberOfInputs", blockFrom(forecloseBlock), app.IApplicationAddress). + Return(new(big.Int).SetUint64(sealedInputs+1), nil) + inputEvents := make([]iinputbox.IInputBoxInputAdded, 0, sealedInputs+1) + for index := uint64(0); index <= sealedInputs; index++ { + inputEvents = append(inputEvents, makeInputEvent(app.IApplicationAddress, index, forecloseBlock)) + } + inputBlock := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == forecloseBlock && opts.End != nil && *opts.End == forecloseBlock + }) + inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything). + Return(inputEvents, nil) + contracts := appContracts{application: app, inputSource: inputBox, daveConsensus: dave} + reader := &Service{repository: repo} + reader.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + require.NoError(t, reader.processApplicationSealedEpochs(ctx, contracts, forecloseBlock)) + + // Interrupt the open scan either at its RPC read or before the input + // transaction starts. No open epoch or final input may be published. + inputBox.Unset("RetrieveInputs") + openCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + failedRead := inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything).Once() + if test.cancelWrite { + failedRead.Run(func(mock.Arguments) { cancel() }).Return(inputEvents, nil) + } else { + failedRead.Return([]iinputbox.IInputBoxInputAdded{}, context.Canceled) + } + require.ErrorIs(t, reader.processApplicationOpenEpoch(openCtx, contracts, forecloseBlock), context.Canceled) + count, err := repo.GetNumberOfInputs(ctx, address) + require.NoError(t, err) + require.Equal(t, sealedInputs, count) + openEpochIndex := lastEvent.EpochNumber.Uint64() + 1 + openEpoch, err := repo.GetEpoch(ctx, address, openEpochIndex) + require.NoError(t, err) + require.Nil(t, openEpoch) + + // Recreate both the repository connection and reader, then discover + // foreclosure in the same block. No tick-start snapshot survives. + repo.Close() + repo, err = factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + require.NoError(t, repo.UpdateApplicationForeclosure(ctx, app.ID, forecloseBlock, + repotest.UniqueHash(), forecloseBlock)) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + assert.Equal(t, forecloseBlock, app.LastEpochCheckBlock) + assert.Equal(t, forecloseBlock-1, app.LastInputCheckBlock, "the seal does not cover the whole block") + assert.False(t, app.ForeclosureScanCaughtUp(), "drain must wait for the missing open input") + + inputBox.Unset("RetrieveInputs") + inputBox.On("RetrieveInputs", inputBlock, []common.Address{app.IApplicationAddress}, mock.Anything). + Return(inputEvents, nil).Once() + contracts.application = app + reader = &Service{repository: repo} + reader.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + reader.scanDaveConsensusEpochsAndInputs(ctx, []appContracts{contracts}, forecloseBlock+10) + + input, err := repo.GetInput(ctx, address, sealedInputs) + require.NoError(t, err) + require.NotNil(t, input, "retry must fetch the input after the last seal") + assert.Equal(t, openEpochIndex, input.EpochIndex) + assert.Equal(t, forecloseBlock, input.BlockNumber) + openEpoch, err = repo.GetEpoch(ctx, address, openEpochIndex) + require.NoError(t, err) + require.NotNil(t, openEpoch) + assert.Equal(t, forecloseBlock, openEpoch.FirstBlock) + assert.Equal(t, forecloseBlock, openEpoch.LastBlock) + assert.Equal(t, sealedInputs, openEpoch.InputIndexLowerBound) + assert.Equal(t, sealedInputs+1, openEpoch.InputIndexUpperBound) + assert.Equal(t, EpochStatus_Open, openEpoch.Status) + app, err = repo.GetApplication(ctx, address) + require.NoError(t, err) + assert.Equal(t, forecloseBlock, app.LastInputCheckBlock) + assert.True(t, app.ForeclosureScanCaughtUp()) + undrained, err := repo.HasUndrainedEpochsBeforeBlock(ctx, app.ID, forecloseBlock) + require.NoError(t, err) + assert.True(t, undrained, "the stored input must remain visible to the machine drain") + dave.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_cursor_test.go b/internal/evmreader/sealedepochs_cursor_test.go new file mode 100644 index 000000000..70f11773e --- /dev/null +++ b/internal/evmreader/sealedepochs_cursor_test.go @@ -0,0 +1,92 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestOpenEpochSkipsAlreadyScannedFreshCursor(t *testing.T) { + const head uint64 = 200 + for _, test := range []struct { + name string + cursor uint64 + }{ + {name: "equal to head", cursor: head}, + {name: "above head", cursor: head + 1}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, IApplicationAddress: app1Addr, LastInputCheckBlock: head - 1, + }, + inputSource: inputBox, + } + sealed := &model.Epoch{Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed} + open := &model.Epoch{Index: 1, FirstBlock: sealed.LastBlock, LastBlock: test.cursor, Status: model.EpochStatus_Open} + before := *open + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(sealed, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), open.Index).Return(open, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(test.cursor, nil).Once() + + require.NoError(t, r.processApplicationOpenEpoch(t.Context(), app, head)) + require.Equal(t, before, *open, "the stored epoch must not be rewritten to an older head") + require.Empty(t, inputBox.Calls) + repo.AssertNotCalled(t, "GetNumberOfInputs", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestForeclosureInputDrainRunsBelowSealedCursor(t *testing.T) { + const ( + sealedCursor uint64 = 200 + foreclosure uint64 = 180 + inputCursor uint64 = 150 + ) + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(inputBox, dave) + app.application.Status = model.ApplicationStatus_MachineHalted + app.application.ForecloseBlock = foreclosure + app.application.LastEpochCheckBlock = sealedCursor + app.application.LastInputCheckBlock = inputCursor + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: 100, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(inputCursor, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(inputCursor+1, foreclosure+1), app1Addr). + Return(new(big.Int), nil).Twice() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch, inputs := range epochs { + return len(epochs) == 1 && epoch.LastBlock == foreclosure && epoch.Status == model.EpochStatus_Open && len(inputs) == 0 + } + return false + }), foreclosure).Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, sealedCursor+10)) + require.Equal(t, model.ApplicationStatus_MachineHalted, app.application.Status) + require.Empty(t, dave.Calls, "the sealed cursor already covers foreclosure") + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) +} diff --git a/internal/evmreader/sealedepochs_deployment_test.go b/internal/evmreader/sealedepochs_deployment_test.go new file mode 100644 index 000000000..0af52939f --- /dev/null +++ b/internal/evmreader/sealedepochs_deployment_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "errors" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func (s *SealedEpochsSuite) TestDaveScanWaitsForDeploymentAndResumes() { + const ( + beforeDeployment uint64 = 90 + deploymentBlock uint64 = 100 + ) + var output bytes.Buffer + s.evmReader.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "new-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxAddress: inputBoxAddr, IInputBoxBlock: 10, + }, + inputSource: s.inputBox, daveConsensus: s.dave, + } + s.dave.On("GetDeploymentBlockNumber", blockRange(beforeDeployment, beforeDeployment+1)). + Return(new(big.Int), bind.ErrNoCode).Once() + + // The absent contract must not prevent a later application from being scanned. + otherInputBox := newMockInputBox() + other := appContracts{ + application: &model.Application{ + ID: 2, Name: "existing-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app2Addr, LastEpochCheckBlock: beforeDeployment, + LastInputCheckBlock: beforeDeployment - 1, + }, + inputSource: otherInputBox, + } + s.repository.On("GetLastNonOpenEpoch", s.ctx, app2Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: beforeDeployment - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + s.repository.On("GetEpoch", s.ctx, app2Addr.Hex(), uint64(1)).Return(nil, nil).Once() + s.repository.On("GetEventLastCheckBlock", s.ctx, other.application.ID, model.MonitoredEvent_InputAdded). + Return(beforeDeployment-1, nil).Once() + s.repository.On("GetNumberOfInputs", s.ctx, app2Addr.Hex()).Return(uint64(0), nil).Once() + otherInputBox.On("GetNumberOfInputs", blockRange(beforeDeployment, beforeDeployment+1), app2Addr). + Return(new(big.Int), nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app2Addr.Hex(), mock.Anything, beforeDeployment). + Return(nil).Once() + + s.True(s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app, other}, beforeDeployment)) + s.Equal(model.ApplicationStatus_OK, app.application.Status) + s.Zero(app.application.LastEpochCheckBlock) + s.Zero(app.application.LastInputCheckBlock) + s.repository.AssertNotCalled(s.T(), "GetLastNonOpenEpoch", s.ctx, app1Addr.Hex()) + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) + s.repository.AssertNumberOfCalls(s.T(), "UpdateEventLastCheckBlock", 0) + s.repository.AssertCalled(s.T(), "CreateEpochsAndInputs", s.ctx, app2Addr.Hex(), mock.Anything, beforeDeployment) + s.Empty(s.inputBox.Calls) + s.NotContains(output.String(), "level=ERROR") + s.repository.AssertExpectations(s.T()) + s.dave.AssertExpectations(s.T()) + otherInputBox.AssertExpectations(s.T()) + + // The configured head now includes deployment. The same registered app must + // ingest the constructor's sealed epoch and then create its open epoch. + tournament := common.HexToAddress("0x1234") + s.dave.On("GetDeploymentBlockNumber", blockRange(deploymentBlock, deploymentBlock+1)). + Return(new(big.Int).SetUint64(deploymentBlock), nil).Once() + s.repository.On("UpdateEventLastCheckBlock", s.ctx, []int64{app.application.ID}, + model.MonitoredEvent_EpochSealed, deploymentBlock).Return(nil).Once() + sealed := &model.Epoch{ + Index: 0, FirstBlock: app.application.IInputBoxBlock, LastBlock: deploymentBlock, + Status: model.EpochStatus_Closed, TournamentAddress: &tournament, + } + s.repository.On("GetLastNonOpenEpoch", s.ctx, app1Addr.Hex()).Return(sealed, nil).Once() + s.repository.On("GetEpoch", s.ctx, app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + s.repository.On("GetEpoch", s.ctx, app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + s.dave.On("GetCurrentSealedEpoch", blockRange(deploymentBlock, deploymentBlock+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + s.dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == deploymentBlock && opts.End != nil && *opts.End == deploymentBlock + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, deploymentBlock, tournament), + }, nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch := range epochs { + return len(epochs) == 1 && epoch.Index == 0 && epoch.Status == model.EpochStatus_Closed + } + return false + }), deploymentBlock-1).Return(nil).Once() + s.repository.On("GetEventLastCheckBlock", s.ctx, app.application.ID, model.MonitoredEvent_InputAdded). + Return(deploymentBlock-1, nil).Once() + s.repository.On("GetNumberOfInputs", s.ctx, app1Addr.Hex()).Return(uint64(0), nil).Once() + s.inputBox.On("GetNumberOfInputs", blockRange(deploymentBlock, deploymentBlock+1), app1Addr). + Return(new(big.Int), nil).Once() + s.repository.On("CreateEpochsAndInputs", s.ctx, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*model.Epoch][]*model.Input) bool { + for epoch := range epochs { + return len(epochs) == 1 && epoch.Index == 1 && epoch.Status == model.EpochStatus_Open + } + return false + }), deploymentBlock).Return(nil).Once() + + s.True(s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app}, deploymentBlock)) + s.Equal(model.ApplicationStatus_OK, app.application.Status) + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) + s.repository.AssertExpectations(s.T()) + s.dave.AssertExpectations(s.T()) + s.inputBox.AssertExpectations(s.T()) +} + +func TestFailedSealedInitializationWaitsForDeploymentAgain(t *testing.T) { + const deploymentBlock uint64 = 100 + for _, regressedHead := range []uint64{80, deploymentBlock - 1} { + t.Run(new(big.Int).SetUint64(regressedHead).String(), func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + inputBox := newMockInputBox() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(inputBox, dave) + app.application.IInputBoxBlock = 10 + app.application.Status = model.ApplicationStatus_OK + tournament := common.HexToAddress("0x1234") + dave.On("GetDeploymentBlockNumber", blockRange(deploymentBlock, deploymentBlock+1)). + Return(new(big.Int).SetUint64(deploymentBlock), nil).Twice() + dave.On("GetCurrentSealedEpoch", blockRange(deploymentBlock, deploymentBlock+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil) + filter := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == deploymentBlock && opts.End != nil && *opts.End == deploymentBlock + }) + dave.On("RetrieveSealedEpochs", filter). + Return([]*idaveconsensus.IDaveConsensusEpochSealed{}, errors.New("log retrieval interrupted")).Once() + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, deploymentBlock)) + require.Empty(t, repo.Calls, "a failed first scan must not persist the deployment floor") + require.Zero(t, app.application.LastEpochCheckBlock) + require.NotContains(t, output.String(), "sync initialized") + require.Contains(t, output.String(), "log retrieval interrupted") + output.Reset() + + dave.On("GetDeploymentBlockNumber", blockRange(regressedHead, regressedHead+1)). + Return(new(big.Int), bind.ErrNoCode).Once() + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, regressedHead)) + require.Empty(t, repo.Calls, "the open scan must not run before deployment is visible again") + require.Zero(t, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Empty(t, output.String(), "ordinary deployment lag needs no Info or Error log") + + dave.On("RetrieveSealedEpochs", filter).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, deploymentBlock, tournament), + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, deploymentBlock-1).Return(nil).Once() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, + model.MonitoredEvent_EpochSealed, deploymentBlock).Return(nil).Once() + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, FirstBlock: 10, LastBlock: deploymentBlock, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(deploymentBlock-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(deploymentBlock, deploymentBlock+1), app1Addr). + Return(new(big.Int), nil).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, deploymentBlock).Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, deploymentBlock)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Contains(t, output.String(), "sync initialized") + require.NotContains(t, output.String(), "level=ERROR") + repo.AssertExpectations(t) + dave.AssertExpectations(t) + inputBox.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_identity_test.go b/internal/evmreader/sealedepochs_identity_test.go new file mode 100644 index 000000000..b2239fcad --- /dev/null +++ b/internal/evmreader/sealedepochs_identity_test.go @@ -0,0 +1,209 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "errors" + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + daveOpenScan = "open" + daveSealedScan = "sealed" +) + +func TestDaveInputStoreErrorClassification(t *testing.T) { + const head uint64 = 100 + for _, sealed := range []bool{true, false} { + path := daveOpenScan + if sealed { + path = daveSealedScan + } + for _, test := range []struct { + name string + cause error + corrupt bool + }{ + {name: "identity conflict", cause: repository.ErrInputLogIdentityConflict, corrupt: true}, + {name: "retryable database error", cause: errors.New("database unavailable")}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := appContracts{ + application: &model.Application{ + ID: 1, Name: "input-store-app", Status: model.ApplicationStatus_OK, + IApplicationAddress: app1Addr, IConsensusAddress: consensusAddr, + IInputBoxBlock: head, LastEpochCheckBlock: head - 1, LastInputCheckBlock: head - 1, + }, + inputSource: inputBox, daveConsensus: dave, + } + checkpoint := head + if sealed { + checkpoint-- + tournament := common.HexToAddress("0x1234") + app.application.LastEpochCheckBlock = 0 + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int).SetUint64(head), nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(head, head+1)). + Return(makeSealedEpochResult(0, 0, 1, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 1, head, tournament), + }, nil).Once() + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), app1Addr.Hex()).Return(uint64(0), nil).Once() + } + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), app1Addr).Return(big.NewInt(1), nil).Once() + inputBox.On("RetrieveInputs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == head && opts.End != nil && *opts.End == head + }), []common.Address{app1Addr}, mock.Anything). + Return([]iinputbox.IInputBoxInputAdded{makeInputEvent(app1Addr, 0, head)}, nil).Once() + storeErr := fmt.Errorf("insert inputs: %w", test.cause) + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, checkpoint). + Return(storeErr).Once() + if test.corrupt { + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, + model.ApplicationStatus_Corrupted, mock.Anything).Return(nil).Once() + } + + var err error + if sealed { + err = r.processApplicationSealedEpochs(t.Context(), app, head) + } else { + err = r.processApplicationOpenEpoch(t.Context(), app, head) + } + require.ErrorContains(t, err, storeErr.Error()) + if test.corrupt { + require.Equal(t, model.ApplicationStatus_Corrupted, app.application.Status) + require.ErrorContains(t, err, path+" epoch") + require.ErrorContains(t, err, "operator reset required") + } else { + require.ErrorIs(t, err, test.cause) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + if sealed { + require.Zero(t, app.application.LastEpochCheckBlock) + } else { + require.Equal(t, head-1, app.application.LastEpochCheckBlock) + } + require.Equal(t, head-1, app.application.LastInputCheckBlock) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } + } +} + +func TestDaveRecordedIntegrityFaultKeepsOtherApplicationsScanning(t *testing.T) { + const head uint64 = 100 + for _, path := range []string{daveSealedScan, daveOpenScan} { + for _, test := range []struct { + name string + status model.ApplicationStatus + statusWriteErr error + }{ + {name: "new fault", status: model.ApplicationStatus_OK}, + {name: "already corrupted", status: model.ApplicationStatus_Corrupted}, + {name: "already diverged", status: model.ApplicationStatus_Diverged}, + {name: "escalate halted machine", status: model.ApplicationStatus_MachineHalted}, + {name: "failed integrity write", status: model.ApplicationStatus_OK, statusWriteErr: errors.New("write unavailable")}, + {name: "failed terminal escalation", status: model.ApplicationStatus_MachineHalted, + statusWriteErr: errors.New("write unavailable")}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + repo := newMockRepository() + inputBox := newMockInputBox() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + bad := newDaveAppContracts(inputBox, dave) + bad.application.Status = test.status + bad.application.IInputBoxBlock = head + bad.application.LastEpochCheckBlock = head + bad.application.LastInputCheckBlock = head - 1 + good := newDaveAppContracts(inputBox, nil) + good.application.ID = 2 + good.application.IApplicationAddress = app2Addr + good.application.Status = model.ApplicationStatus_OK + good.application.LastEpochCheckBlock = head + good.application.LastInputCheckBlock = head - 1 + + checkpoint := head + if path == daveSealedScan { + checkpoint-- + bad.application.LastEpochCheckBlock = 0 + tournament := common.HexToAddress("0x1234") + dave.On("GetDeploymentBlockNumber", blockRange(head, head+1)).Return(new(big.Int).SetUint64(head), nil).Once() + dave.On("GetCurrentSealedEpoch", blockRange(head, head+1)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", mock.Anything).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(0, 0, 0, head, tournament), + }, nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + } + for _, app := range []appContracts{bad, good} { + if app.application.ID == bad.application.ID && path == daveSealedScan { + continue + } + address := app.application.IApplicationAddress + repo.On("GetLastNonOpenEpoch", t.Context(), address.Hex()).Return(&model.Epoch{ + Index: 0, LastBlock: head - 1, Status: model.EpochStatus_Closed, + }, nil).Once() + repo.On("GetEpoch", t.Context(), address.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("GetEventLastCheckBlock", t.Context(), app.application.ID, model.MonitoredEvent_InputAdded). + Return(head-1, nil).Once() + repo.On("GetNumberOfInputs", t.Context(), address.Hex()).Return(uint64(0), nil).Once() + inputBox.On("GetNumberOfInputs", blockRange(head, head+1), address).Return(new(big.Int), nil).Once() + } + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, checkpoint). + Return(repository.ErrInputLogIdentityConflict).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app2Addr.Hex(), mock.Anything, head).Return(nil).Once() + integrityRecorded := test.status == model.ApplicationStatus_Corrupted || test.status == model.ApplicationStatus_Diverged + if !integrityRecorded { + repo.On("UpdateApplicationStatus", t.Context(), bad.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(test.statusWriteErr).Once() + } + + require.Equal(t, test.statusWriteErr == nil, + r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{bad, good}, head)) + if !integrityRecorded && test.statusWriteErr == nil { + require.Equal(t, model.ApplicationStatus_Corrupted, bad.application.Status) + } else { + require.Equal(t, test.status, bad.application.Status) + } + require.Equal(t, model.ApplicationStatus_OK, good.application.Status) + require.Equal(t, head-1, bad.application.LastInputCheckBlock) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + inputBox.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } + } +} diff --git a/internal/evmreader/sealedepochs_logging_test.go b/internal/evmreader/sealedepochs_logging_test.go new file mode 100644 index 000000000..0f3e37cfd --- /dev/null +++ b/internal/evmreader/sealedepochs_logging_test.go @@ -0,0 +1,104 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDaveCorruptionReasonIsNotLoggedAgainOnEveryScan(t *testing.T) { + const head uint64 = 100 + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), nil) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_OK + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(nil).Once() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_Corrupted, app.application.Status) + require.Equal(t, 1, strings.Count(output.String(), "level=ERROR"), "the status helper logs the first corruption once") + require.Contains(t, output.String(), "no non open epochs found") + output.Reset() + + require.True(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.NotContains(t, output.String(), "level=ERROR", "the same terminal reason needs no repeated Error log") + repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestDaveStatusWriteFailureRemainsVisibleAndRetryable(t *testing.T) { + const head uint64 = 100 + repo := newMockRepository() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), nil) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_OK + writeErr := errors.New("status database unavailable") + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", t.Context(), app.application.ID, model.ApplicationStatus_Corrupted, mock.Anything). + Return(writeErr).Twice() + + err := r.processApplicationOpenEpoch(t.Context(), app, head) + require.ErrorIs(t, err, writeErr, "the marker must preserve the actual database error") + require.ErrorIs(t, err, errApplicationStatusReported) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + output.Reset() + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + require.Equal(t, 2, strings.Count(output.String(), "level=ERROR"), "retain the reason and the failed-write log") + require.Contains(t, output.String(), "failed to update application status") + require.Contains(t, output.String(), writeErr.Error()) + repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestDaveTerminalApplicationStillLogsObservationFailure(t *testing.T) { + const head uint64 = 100 + for _, sealed := range []bool{true, false} { + name := daveOpenScan + if sealed { + name = daveSealedScan + } + t.Run(name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + var output bytes.Buffer + r := &Service{repository: repo} + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), dave) + app.application.LastEpochCheckBlock = head + app.application.Status = model.ApplicationStatus_Corrupted + readErr := errors.New("observation temporarily unavailable") + if sealed { + app.application.LastEpochCheckBlock-- + dave.On("GetCurrentSealedEpoch", blockRange(head-1, head)).Return(DaveCurrentSealedEpoch{}, readErr).Once() + } else { + repo.On("GetLastNonOpenEpoch", t.Context(), app1Addr.Hex()).Return(nil, readErr).Once() + } + + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, head)) + require.Equal(t, 1, strings.Count(output.String(), "level=ERROR")) + require.Contains(t, output.String(), readErr.Error()) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } +} diff --git a/internal/evmreader/sealedepochs_replay_test.go b/internal/evmreader/sealedepochs_replay_test.go new file mode 100644 index 000000000..a50c990e6 --- /dev/null +++ b/internal/evmreader/sealedepochs_replay_test.go @@ -0,0 +1,280 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSealedEpochTransitionValidatesCompletePageBeforeWrites(t *testing.T) { + const block uint64 = 150 + tournament := common.HexToAddress("0x1234") + for _, test := range []struct { + name string + indices []int64 + change func(*idaveconsensus.IDaveConsensusEpochSealed) + }{ + {name: "empty"}, + {name: "missing first", indices: []int64{2, 3}}, + {name: "missing middle", indices: []int64{1, 3}}, + {name: "missing last", indices: []int64{1, 2}}, + {name: "duplicate", indices: []int64{1, 1, 3}}, + {name: "out of order", indices: []int64{2, 1, 3}}, + {name: "wrong block", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.Raw.BlockNumber++ + }}, + {name: "wrong lower bound", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.InputIndexLowerBound.SetUint64(1) + }}, + {name: "wrong upper bound", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.InputIndexUpperBound.SetUint64(1) + }}, + {name: "wrong tournament", indices: []int64{1, 2, 3}, change: func(e *idaveconsensus.IDaveConsensusEpochSealed) { + e.Tournament = common.HexToAddress("0x5678") + }}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockRepository() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + var output bytes.Buffer + r.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := newDaveAppContracts(newMockInputBox(), dave) + app.application.LastEpochCheckBlock = block - 1 + app.application.Status = model.ApplicationStatus_OK + events := make([]*idaveconsensus.IDaveConsensusEpochSealed, 0, len(test.indices)) + for _, index := range test.indices { + events = append(events, makeSealedEpochEvent(index, 0, 0, block, tournament)) + } + if test.change != nil { + test.change(events[len(events)-1]) + } + dave.On("GetCurrentSealedEpoch", blockRange(block-1, block)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + dave.On("GetCurrentSealedEpoch", blockRange(block, block+1)). + Return(makeSealedEpochResult(3, 0, 0, tournament), nil).Times(4) + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == block && opts.End != nil && *opts.End == block + })).Return(events, nil).Twice() + + require.ErrorIs(t, r.processApplicationSealedEpochs(t.Context(), app, block), errInconclusiveEpochView) + output.Reset() + require.False(t, r.scanDaveConsensusEpochsAndInputs(t.Context(), []appContracts{app}, block)) + require.Contains(t, output.String(), "level=WARN") + require.Contains(t, output.String(), "Sealed epoch reads disagree; retrying from the saved cursor") + require.NotContains(t, output.String(), "level=ERROR") + require.Empty(t, repo.Calls, "the entire page must be checked before any repository work") + require.Equal(t, block-1, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + dave.AssertExpectations(t) + }) + } +} + +// This fake makes completed per-epoch writes survive a failed scan. Reads return +// copies, as PostgreSQL does; the test does not rely on shared model pointers. +type sealedEpochReplayRepository struct { + *MockRepository + epochs map[uint64]*model.Epoch + stores []uint64 + failEpoch uint64 + storeErr error +} + +func (r *sealedEpochReplayRepository) GetEpoch(_ context.Context, _ string, index uint64) (*model.Epoch, error) { + if epoch := r.epochs[index]; epoch != nil { + copyEpoch := *epoch + return ©Epoch, nil + } + return nil, nil +} + +func (r *sealedEpochReplayRepository) CreateEpochsAndInputs( + _ context.Context, _ string, epochs map[*model.Epoch][]*model.Input, _ uint64, +) error { + for epoch := range epochs { + if epoch.Index == r.failEpoch && r.storeErr != nil { + return r.storeErr + } + copyEpoch := *epoch + r.epochs[epoch.Index] = ©Epoch + r.stores = append(r.stores, epoch.Index) + } + return nil +} + +func TestSealedEpochScanReplaysCommittedPrefix(t *testing.T) { + const ( + cursor uint64 = 100 + firstBlock uint64 = 150 + secondBlock uint64 = 175 + head uint64 = 200 + ) + tournament := common.HexToAddress("0x1234") + for _, sameBlockFailure := range []bool{true, false} { + name := "later transition page omitted" + if sameBlockFailure { + name = "second seal in same block fails to store" + } + t.Run(name, func(t *testing.T) { + repo := &sealedEpochReplayRepository{ + MockRepository: newMockRepository(), + epochs: map[uint64]*model.Epoch{0: { + Index: 0, FirstBlock: 10, LastBlock: cursor, Status: model.EpochStatus_Closed, TournamentAddress: &tournament, + }}, + } + if sameBlockFailure { + repo.failEpoch = 2 + repo.storeErr = errors.New("second seal write interrupted") + } + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, dave) + app.application.LastEpochCheckBlock = cursor + app.application.IInputBoxBlock = 10 + app.application.Status = model.ApplicationStatus_OK + dave.On("GetCurrentSealedEpoch", blockRange(cursor, firstBlock)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil) + dave.On("GetCurrentSealedEpoch", blockRange(firstBlock, secondBlock)). + Return(makeSealedEpochResult(2, 0, 0, tournament), nil) + dave.On("GetCurrentSealedEpoch", blockRange(secondBlock, head+1)). + Return(makeSealedEpochResult(3, 0, 0, tournament), nil) + dave.On("RetrieveSealedEpochs", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == firstBlock && opts.End != nil && *opts.End == firstBlock + })).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(1, 0, 0, firstBlock, tournament), + makeSealedEpochEvent(2, 0, 0, firstBlock, tournament), + }, nil).Twice() + secondPage := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == secondBlock && opts.End != nil && *opts.End == secondBlock + }) + if !sameBlockFailure { + dave.On("RetrieveSealedEpochs", secondPage).Return([]*idaveconsensus.IDaveConsensusEpochSealed{}, nil).Once() + } + dave.On("RetrieveSealedEpochs", secondPage).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(3, 0, 0, secondBlock, tournament), + }, nil).Once() + repo.On("UpdateEpochClaimTransactionHash", t.Context(), app1Addr.Hex(), mock.Anything).Return(nil) + + require.Error(t, r.processApplicationSealedEpochs(t.Context(), app, head)) + require.Equal(t, cursor, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateEventLastCheckBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + require.NotNil(t, repo.epochs[1], "the failed scan must have a committed prefix") + // Other services can process that prefix before the scan retries. + repo.epochs[1].Status = model.EpochStatus_ClaimComputed + commitment := common.HexToHash("0x4567") + repo.epochs[1].Commitment = &commitment + repo.epochs[1].CommitmentProof = []common.Hash{common.HexToHash("0x89ab")} + before := *repo.epochs[1] + repo.storeErr = nil + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, model.MonitoredEvent_EpochSealed, head). + Return(nil).Once() + + require.NoError(t, r.processApplicationSealedEpochs(t.Context(), app, head)) + require.Equal(t, before, *repo.epochs[1], "replay must preserve claim and proof progress") + require.Equal(t, []uint64{1, 2, 3}, repo.stores, "already sealed rows must not be re-stored") + require.Equal(t, firstBlock, repo.epochs[2].LastBlock, "the second same-block seal must survive retry") + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + dave.AssertExpectations(t) + }) + } +} + +func TestSealedEpochReplayRejectsConflictingStoredFields(t *testing.T) { + const block uint64 = 100 + tournament := common.HexToAddress("0x1234") + for _, field := range []string{"last block", "upper bound", "missing tournament", "different tournament"} { + t.Run(field, func(t *testing.T) { + repo := newMockRepository() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(nil, nil) + app.application.IInputBoxBlock = 10 + epoch := &model.Epoch{Index: 0, FirstBlock: 10, LastBlock: block, + Status: model.EpochStatus_ClaimComputed, TournamentAddress: &tournament} + switch field { + case "last block": + epoch.LastBlock++ + case "upper bound": + epoch.InputIndexUpperBound++ + case "missing tournament": + epoch.TournamentAddress = nil + case "different tournament": + other := common.HexToAddress("0x5678") + epoch.TournamentAddress = &other + } + before := *epoch + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(epoch, nil).Once() + err := r.processSealedEpochEvent(t.Context(), app, makeSealedEpochEvent(0, 0, 0, block, tournament)) + require.ErrorContains(t, err, "data mismatch with replayed event") + require.Equal(t, before, *epoch) + repo.AssertNotCalled(t, "CreateEpochsAndInputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestSealedEpochDisappearingTransitionRetriesWithoutProgress(t *testing.T) { + const block uint64 = 150 + repo := newMockRepository() + dave := newMockDaveConsensus() + r := &Service{repository: repo} + r.Logger = testLogger(t) + app := newDaveAppContracts(newMockInputBox(), dave) + app.application.LastEpochCheckBlock = block - 1 + app.application.IInputBoxBlock = 10 + app.application.Status = model.ApplicationStatus_OK + tournament := common.HexToAddress("0x1234") + atTransition := blockRange(block, block+1) + page := mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == block && opts.End != nil && *opts.End == block + }) + dave.On("GetCurrentSealedEpoch", blockRange(block-1, block)). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Twice() + dave.On("GetCurrentSealedEpoch", atTransition). + Return(makeSealedEpochResult(1, 0, 0, tournament), nil).Once() + // The transition found by the search disappears when its page is read. + dave.On("GetCurrentSealedEpoch", atTransition). + Return(makeSealedEpochResult(0, 0, 0, tournament), nil).Once() + dave.On("RetrieveSealedEpochs", page).Return([]*idaveconsensus.IDaveConsensusEpochSealed{}, nil).Once() + + err := r.processApplicationSealedEpochs(t.Context(), app, block) + require.ErrorIs(t, err, errInconclusiveEpochView) + require.ErrorContains(t, err, "expected 0, got 0") + require.Empty(t, repo.Calls, "a disappearing transition cannot store epochs or advance a cursor") + require.Equal(t, block-1, app.application.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, app.application.Status) + + // A coherent next tick must recover from the same persisted boundary. + dave.On("GetCurrentSealedEpoch", atTransition). + Return(makeSealedEpochResult(1, 0, 0, tournament), nil).Twice() + dave.On("RetrieveSealedEpochs", page).Return([]*idaveconsensus.IDaveConsensusEpochSealed{ + makeSealedEpochEvent(1, 0, 0, block, tournament), + }, nil).Once() + previous := &model.Epoch{Index: 0, LastBlock: 100, Status: model.EpochStatus_Closed} + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(0)).Return(previous, nil).Once() + repo.On("UpdateEpochClaimTransactionHash", t.Context(), app1Addr.Hex(), previous).Return(nil).Once() + repo.On("GetEpoch", t.Context(), app1Addr.Hex(), uint64(1)).Return(nil, nil).Once() + repo.On("CreateEpochsAndInputs", t.Context(), app1Addr.Hex(), mock.Anything, block-1).Return(nil).Once() + repo.On("UpdateEventLastCheckBlock", t.Context(), []int64{app.application.ID}, model.MonitoredEvent_EpochSealed, block). + Return(nil).Once() + require.NoError(t, r.processApplicationSealedEpochs(t.Context(), app, block)) + repo.AssertExpectations(t) + dave.AssertExpectations(t) +} diff --git a/internal/evmreader/sealedepochs_test.go b/internal/evmreader/sealedepochs_test.go new file mode 100644 index 000000000..8818e5be8 --- /dev/null +++ b/internal/evmreader/sealedepochs_test.go @@ -0,0 +1,313 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "bytes" + "context" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type SealedEpochsSuite struct { + suite.Suite + ctx context.Context + cancel context.CancelFunc + repository *MockRepository + inputBox *MockInputBox + dave *MockDaveConsensus + evmReader *Service +} + +func TestSealedEpochsSuite(t *testing.T) { + suite.Run(t, new(SealedEpochsSuite)) +} + +func (s *SealedEpochsSuite) SetupSuite() { + s.ctx, s.cancel = context.WithCancel(context.Background()) + config.SetDefaults() +} + +func (s *SealedEpochsSuite) TearDownSuite() { + s.cancel() +} + +func (s *SealedEpochsSuite) SetupTest() { + s.repository = newMockRepository() + s.inputBox = newMockInputBox() + s.dave = newMockDaveConsensus() + + s.evmReader = &Service{ + repository: s.repository, + defaultBlock: DefaultBlock_Latest, + hasEnabledApps: true, + } + + logLevel, err := config.GetLogLevel() + s.Require().NoError(err) + serviceArgs := &service.BaseConfigs{Name: "evm-reader", LogLevel: logLevel} + service.InitServiceTemplate(&s.evmReader.BaseTemplate, serviceArgs) +} + +// TestProcessSealedEpochFindsInputAtOverlapBlock verifies that when an input +// is added at the same block where the previous epoch was sealed (the overlap +// block in PRT's block boundary design), the sealed epoch processing correctly +// finds it. Without the fix, the search would start from lastInputCheckBlock+1, +// skipping the overlap block entirely. +func (s *SealedEpochsSuite) TestProcessSealedEpochFindsInputAtOverlapBlock() { + const ( + sealBlock0 uint64 = 100 // Epoch 0 sealed here + sealBlock1 uint64 = 200 // Epoch 1 sealed here + ) + + tournamentAddr := common.HexToAddress("0xAAAA") + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.IInputBoxAddress = inputBoxAddr + app.application.IInputBoxBlock = 10 + + // Epoch 0 was already stored with LastBlock=100 and InputIndexUpperBound=3. + // An earlier open-epoch scan can already have reached block 100, so its + // input cursor must not determine where this sealed-epoch scan starts. + // + // Now epoch 1 is sealed at block 200: + // FirstBlock = prevEpoch.LastBlock = 100 (PRT overlap) + // InputIndexLowerBound = 3, InputIndexUpperBound = 4 + // + // Input index 3 was added at block 100 (same block as epoch 0 seal, later tx). + // The search must start from epoch.FirstBlock=100 (not lastInputCheckBlock+1=101) + // to find this input. + + sealedEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), + InputIndexLowerBound: big.NewInt(3), + InputIndexUpperBound: big.NewInt(4), + Tournament: tournamentAddr, + Raw: types.Log{ + BlockNumber: sealBlock1, + TxHash: common.BigToHash(big.NewInt(999)), + }, + } + + // Epoch 0 exists (the previous epoch) — needed to compute FirstBlock. + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(0)). + Return(&Epoch{ + Index: 0, + FirstBlock: 10, + LastBlock: sealBlock0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 3, + }, nil) + s.repository.On("UpdateEpochClaimTransactionHash", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + // Epoch 1 does not exist yet (first time seeing it). + s.repository.On("GetEpoch", mock.Anything, mock.Anything, uint64(1)). + Return(nil, nil) + + // On-chain: 3 inputs at block 99, 4 inputs from block 100 onward + // (input 3 was added at block 100, same block as epoch 0 seal). + s.inputBox.Unset("GetNumberOfInputs") + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() < sealBlock0 + }), + mock.Anything, + ).Return(big.NewInt(3), nil) + s.inputBox.On("GetNumberOfInputs", + mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber.Uint64() >= sealBlock0 + }), + mock.Anything, + ).Return(big.NewInt(4), nil) + + // Input 3 is at block 100 (the overlap block). + overlapInput := makeInputEvent(app1Addr, 3, sealBlock0) + s.inputBox.Unset("RetrieveInputs") + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == sealBlock0 + }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{overlapInput}, nil) + + // No inputs at other blocks in the range. + s.inputBox.On("RetrieveInputs", + mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start != sealBlock0 + }), + mock.Anything, + mock.Anything, + ).Return([]iinputbox.IInputBoxInputAdded{}, nil) + + // CreateEpochsAndInputs captures what was stored. + var storedInputs []*Input + s.repository.On("CreateEpochsAndInputs", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + epochInputMap := args.Get(2).(map[*Epoch][]*Input) + for _, inputs := range epochInputMap { + storedInputs = inputs + } + }).Return(nil) + + err := s.evmReader.processSealedEpochEvent(s.ctx, app, sealedEvent) + s.Require().NoError(err, "processSealedEpochEvent should succeed when input is at the overlap block") + s.Require().Len(storedInputs, 1, "should find exactly one input") + s.Require().Equal(uint64(3), storedInputs[0].Index, "input should have index 3") + s.Require().Equal(sealBlock0, storedInputs[0].BlockNumber, "input should be at the overlap block") +} + +func (s *SealedEpochsSuite) TestCatchUpForeclosedSealedEpochsAdvancesCursor() { + const ( + lastEpochCheckBlock uint64 = 50 + forecloseBlock uint64 = 70 + ) + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.Name = "test-prt-app" + app.application.ConsensusType = Consensus_PRT + app.application.ForecloseBlock = forecloseBlock + app.application.LastEpochCheckBlock = lastEpochCheckBlock + app.application.LastInputCheckBlock = forecloseBlock + app.application.LastOutputCheckBlock = lastEpochCheckBlock + + currentSealedEpoch := DaveCurrentSealedEpoch{ + EpochNumber: big.NewInt(2), + InputIndexLowerBound: big.NewInt(0), + InputIndexUpperBound: big.NewInt(0), + Tournament: common.Address{}, + } + s.dave.On("GetCurrentSealedEpoch", mock.Anything). + Return(currentSealedEpoch, nil) + + s.repository.On("UpdateEventLastCheckBlock", + mock.Anything, + []int64{app.application.ID}, + MonitoredEvent_EpochSealed, + forecloseBlock, + ).Return(nil).Once() + + s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app}, forecloseBlock+10) + + s.repository.AssertExpectations(s.T()) + s.dave.AssertExpectations(s.T()) +} + +func (s *SealedEpochsSuite) TestTerminalDaveConsensusAppProcessesOpenEpochToForeclosure() { + const forecloseBlock uint64 = 70 + + app := newDaveAppContracts(s.inputBox, s.dave) + app.application.Name = "test-prt-app" + app.application.ConsensusType = Consensus_PRT + app.application.Status = ApplicationStatus_MachineHalted + app.application.ForecloseBlock = forecloseBlock + app.application.LastEpochCheckBlock = forecloseBlock + app.application.LastInputCheckBlock = forecloseBlock - 1 + + s.repository.On("GetLastNonOpenEpoch", + mock.Anything, app.application.IApplicationAddress.String()). + Return(&Epoch{ + Index: 2, + LastBlock: 50, + InputIndexUpperBound: 0, + }, nil).Once() + s.repository.On("GetEpoch", + mock.Anything, app.application.IApplicationAddress.Hex(), uint64(3)). + Return(nil, nil).Once() + s.repository.On("GetEventLastCheckBlock", + mock.Anything, app.application.ID, MonitoredEvent_InputAdded). + Return(forecloseBlock-1, nil).Once() + s.repository.On("GetNumberOfInputs", + mock.Anything, app.application.IApplicationAddress.String()). + Return(uint64(0), nil).Once() + s.inputBox.On("GetNumberOfInputs", mock.Anything, app.application.IApplicationAddress). + Return(big.NewInt(0), nil) + s.repository.On("CreateEpochsAndInputs", + mock.Anything, + app.application.IApplicationAddress.String(), + mock.MatchedBy(func(epochInputs map[*Epoch][]*Input) bool { + if len(epochInputs) != 1 { + return false + } + for epoch, inputs := range epochInputs { + return epoch.Status == EpochStatus_Open && + epoch.Index == 3 && + epoch.LastBlock == forecloseBlock && + len(inputs) == 0 + } + return false + }), + forecloseBlock, + ).Return(nil).Once() + + s.evmReader.scanDaveConsensusEpochsAndInputs(s.ctx, []appContracts{app}, forecloseBlock+10) + + s.repository.AssertExpectations(s.T()) + s.inputBox.AssertExpectations(s.T()) + s.dave.AssertNumberOfCalls(s.T(), "GetCurrentSealedEpoch", 0) +} + +func (s *SealedEpochsSuite) TestDaveConsensusWithMissingInputBoxAdapterDoesNotPanic() { + var logs bytes.Buffer + s.evmReader.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := appContracts{ + application: &Application{ + ID: 1, + Name: "missing-input-box-adapter-prt-app", + IApplicationAddress: app1Addr, + IInputBoxAddress: inputBoxAddr, + ConsensusType: Consensus_PRT, + Status: ApplicationStatus_OK, + }, + daveConsensus: s.dave, + } + + s.NotPanics(func() { + s.evmReader.scanDaveConsensusEpochsAndInputs( + s.ctx, []appContracts{app}, 100) + }) + s.Contains(logs.String(), "InputBox adapter is missing") + s.Equal(ApplicationStatus_OK, app.application.Status) + s.dave.AssertNotCalled(s.T(), "GetCurrentSealedEpoch") + s.repository.AssertNumberOfCalls(s.T(), "UpdateApplicationStatus", 0) +} + +func (s *SealedEpochsSuite) TestSealedEpochAtGenesisDoesNotPublishCursor() { + event := makeSealedEpochEvent(0, 0, 0, 0, common.HexToAddress("0xAAAA")) + err := s.evmReader.processSealedEpochEvent(s.ctx, appContracts{}, event) + s.Require().ErrorContains(err, "sealed epoch event has block number zero") + s.repository.AssertNumberOfCalls(s.T(), "CreateEpochsAndInputs", 0) +} + +func (s *SealedEpochsSuite) TestSealedEpochAtFirstBlockPublishesZeroCursor() { + app := appContracts{application: &Application{IApplicationAddress: app1Addr, IInputBoxBlock: 1}} + event := makeSealedEpochEvent(0, 0, 0, 1, common.HexToAddress("0xAAAA")) + s.repository.On("GetEpoch", mock.Anything, app1Addr.Hex(), uint64(0)).Return(nil, nil).Once() + s.repository.On("CreateEpochsAndInputs", mock.Anything, app1Addr.Hex(), + mock.MatchedBy(func(epochs map[*Epoch][]*Input) bool { + if len(epochs) != 1 { + return false + } + for epoch, inputs := range epochs { + return epoch.FirstBlock == 1 && epoch.LastBlock == 1 && len(inputs) == 0 + } + return false + }), uint64(0)).Return(nil).Once() + s.Require().NoError(s.evmReader.processSealedEpochEvent(s.ctx, app, event)) + s.repository.AssertExpectations(s.T()) +} diff --git a/internal/evmreader/service.go b/internal/evmreader/service.go new file mode 100644 index 000000000..54f1dde0c --- /dev/null +++ b/internal/evmreader/service.go @@ -0,0 +1,189 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "fmt" + "log/slog" + "math/big" + "sync/atomic" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/ethclient" +) + +type CreateInfo struct { + Config config.EvmreaderConfig + Logger *slog.Logger + Repository EvmReaderRepository + EthClient EthClientInterface + AdapterFactory AdapterFactory +} + +type Service struct { + service.TickServiceTemplate + + client EthClientInterface + adapterFactory AdapterFactory + resolver *applicationAdapterResolver + repository EvmReaderRepository + chainID uint64 + defaultBlock model.DefaultBlock + hasEnabledApps bool + lastBlockNumber atomic.Uint64 + lastSuccessfulPoll atomic.Pointer[time.Time] + consecutiveScanFailures atomic.Uint32 + readyMaxStaleness time.Duration +} + +const EvmReaderConfigKey = "evm-reader" + +type PersistentConfig = config.PersistentChainConfig + +func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { + err := ctx.Err() + if err != nil { + return nil, err // This returns context.Canceled or context.DeadlineExceeded. + } + + readyMaxStaleness, err := readinessBudget(&c.Config) + if err != nil { + return nil, err + } + + s := &Service{} + tickCfg := &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: config.ServiceEvmReader, + Logger: c.Logger, + LogLevel: c.Config.LogLevel, + LogColor: c.Config.LogColor, + }, + PollInterval: c.Config.EvmReaderPollingInterval, + } + err = service.InitTickServiceTemplate(&s.TickServiceTemplate, tickCfg, s) + if err != nil { + return nil, err + } + + authOpt, err := config.HTTPAuthorizationOption() + if err != nil { + return nil, err + } + + ethClient := c.EthClient + if ethClient == nil { + //nolint:forbidigo // Protected Ethereum connection. + ethClient, err = ethutil.NewEthClient(ctx, c.Config.BlockchainHttpEndpoint.Raw(), s.Logger, + ethutil.RetryConfig{ + MaxRetries: c.Config.BlockchainHttpMaxRetries, + RetryMinWait: c.Config.BlockchainHttpRetryMinWait, + RetryMaxWait: c.Config.BlockchainHttpRetryMaxWait, + RequestTimeout: c.Config.BlockchainHttpRequestTimeout, + }, authOpt) + if err != nil { + return nil, err + } + } + + chainID, err := ethClient.ChainID(ctx) + if err != nil { + return nil, err + } + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err + } + + s.repository = c.Repository + if s.repository == nil { + return nil, fmt.Errorf("repository on evmreader service Create is nil") + } + + nodeConfig, err := s.setupPersistentConfig(ctx, &c.Config) + if err != nil { + return nil, err + } + + s.client = ethClient + s.chainID = nodeConfig.ChainID + s.defaultBlock = nodeConfig.DefaultBlock + if s.defaultBlock != model.DefaultBlock_Finalized { + s.Logger.Warn("EVM Reader stores provisional observations; reorg rollback is unsupported", + "block_policy", s.defaultBlock) + } + s.hasEnabledApps = true + + if c.AdapterFactory != nil { + s.adapterFactory = c.AdapterFactory + } else { + fullEthClient, ok := ethClient.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("EthClient must be *ethclient.Client when AdapterFactory is not provided") + } + s.adapterFactory = &DefaultAdapterFactory{ + Client: fullEthClient, + Filter: ethutil.Filter{ + MaxChunkSize: new(big.Int).SetUint64(c.Config.BlockchainMaxBlockRange), + Logger: s.Logger, + }, + } + } + + s.resolver = newApplicationAdapterResolver(s.Logger, s.adapterFactory) + s.readyMaxStaleness = readyMaxStaleness + s.lastSuccessfulPoll.Store(&time.Time{}) + + s.Logger.Info("Created", "config", c.Config) + + return s, nil +} + +func (s *Service) setupPersistentConfig( + ctx context.Context, + c *config.EvmreaderConfig, +) (*PersistentConfig, error) { + requested := PersistentConfig{DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId} + if err := requested.Validate(); err != nil { + return nil, fmt.Errorf("invalid evm-reader config: %w", err) + } + saved, err := s.repository.RegisterObserver(ctx, EvmReaderConfigKey, requested) + if err != nil { + return nil, fmt.Errorf("register evm-reader observation policy: %w", err) + } + s.Logger.Info("Evm-reader observation policy registered", "config", saved) + return &saved, nil +} + +// readinessBudget is independent of the HTTP retry-backoff cap. +func readinessBudget(c *config.EvmreaderConfig) (time.Duration, error) { + if c.EvmReaderReadyMaxStaleness < 0 { + return 0, fmt.Errorf("CARTESI_EVM_READER_READY_MAX_STALENESS must be non-negative") + } + if c.EvmReaderReadyMaxStaleness > 0 { + return c.EvmReaderReadyMaxStaleness, nil + } + + // Saturate large configured durations instead of overflowing to a small budget. + multiply := func(d time.Duration, n uint64) time.Duration { + const limit = time.Duration(1<<63 - 1) + if d <= 0 { + return 0 + } + if n > uint64(limit/d) { + return limit + } + return d * time.Duration(n) + } + requestBudget := multiply(c.BlockchainHttpRequestTimeout, c.BlockchainHttpMaxRetries) + const limit = time.Duration(1<<63 - 1) + if c.BlockchainHttpRequestTimeout > 0 { + requestBudget += min(c.BlockchainHttpRequestTimeout, limit-requestBudget) + } + return max(time.Second, multiply(c.EvmReaderPollingInterval, 3), requestBudget), nil +} diff --git a/internal/evmreader/service_config_test.go b/internal/evmreader/service_config_test.go new file mode 100644 index 000000000..083c89d44 --- /dev/null +++ b/internal/evmreader/service_config_test.go @@ -0,0 +1,243 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "context" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --- Create() entry point tests --- + +func TestCreateWithCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Create(ctx, &CreateInfo{}) + require.ErrorIs(t, err, context.Canceled) +} + +func TestCreateAcceptsRequestTimeoutBelowPollingInterval(t *testing.T) { + config.SetDefaults() + logLevel, err := config.GetLogLevel() + require.NoError(t, err) + + const chainID = uint64(1) + pollInterval := 12 * time.Second + requestTimeout := 5 * time.Second + + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + })) + defer rpcServer.Close() + + client, err := ethclient.Dial(rpcServer.URL) + require.NoError(t, err) + defer client.Close() + + requested := PersistentConfig{DefaultBlock: DefaultBlock_Finalized, ChainID: chainID} + repo := newMockRepository() + repo.On("RegisterObserver", mock.Anything, EvmReaderConfigKey, requested). + Return(requested, nil).Once() + + _, err = Create(t.Context(), &CreateInfo{ + Config: config.EvmreaderConfig{ + LogLevel: logLevel, + BlockchainDefaultBlock: DefaultBlock_Finalized, + BlockchainHttpRequestTimeout: requestTimeout, + BlockchainId: chainID, + EvmReaderPollingInterval: pollInterval, + }, + EthClient: client, + Repository: repo, + }) + require.NoError(t, err) + + repo.AssertExpectations(t) +} + +// --- fetchMostRecentHeader tests --- + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderRPCError() { + s.client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return((*types.Header)(nil), errors.New("RPC connection timeout")) + + _, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock_Finalized) + s.Require().Error(err) + s.Require().ErrorContains(err, "failed to retrieve header") + s.Require().ErrorContains(err, "RPC connection timeout") +} + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderNilHeader() { + s.client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return((*types.Header)(nil), nil) + + _, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock_Finalized) + s.Require().Error(err) + s.Require().ErrorContains(err, "returned header is nil") +} + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderUnsupportedBlock() { + _, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock("INVALID")) + s.Require().Error(err) + s.Require().ErrorContains(err, "not supported") +} + +func (s *EvmReaderSuite) TestFetchMostRecentHeaderSuccess() { + expected := &types.Header{Number: big.NewInt(42)} + s.client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return(expected, nil) + + header, err := s.evmReader.fetchMostRecentHeader(s.ctx, DefaultBlock_Finalized) + s.Require().NoError(err) + s.Require().Equal(expected.Number.Uint64(), header) +} + +// --- setupPersistentConfig tests --- + +func (s *EvmReaderSuite) TestSetupPersistentConfigFirstRun() { + requested := PersistentConfig{DefaultBlock: DefaultBlock_Finalized, ChainID: 42} + repo := newMockRepository() + repo.On("RegisterObserver", mock.Anything, EvmReaderConfigKey, requested). + Return(requested, nil).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainDefaultBlock: DefaultBlock_Finalized, BlockchainId: 42, + }) + s.Require().NoError(err) + s.Require().Equal(requested, *result) + repo.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigRejectsConcurrentInitializer() { + // The repository serializes registration and detects a different winner. + errConflict := errors.New("observation policy mismatch: database=LATEST, configured=FINALIZED") + repo := newMockRepository() + repo.On("RegisterObserver", mock.Anything, EvmReaderConfigKey, mock.Anything). + Return(PersistentConfig{}, errConflict).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) + s.Require().Nil(result) + s.Require().ErrorIs(err, errConflict) + repo.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigPropagatesSavedFieldErrors() { + // Decoding and persistent validation live inside the coordinated repository + // operation. Its concrete behavior has PostgreSQL registration fixtures. + for _, message := range []string{ + "persistent config requires non-null DefaultBlock and ChainID", + "ChainID must be greater than zero", + "invalid DefaultBlock", + "observation policy mismatch: database=LATEST, configured=FINALIZED", + } { + s.Run(message, func() { + errSaved := errors.New(message) + repo := newMockRepository() + repo.On("RegisterObserver", mock.Anything, EvmReaderConfigKey, mock.Anything). + Return(PersistentConfig{}, errSaved).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) + s.Require().Nil(result) + s.Require().ErrorIs(err, errSaved) + repo.AssertExpectations(s.T()) + }) + } +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigRejectsDifferentConfig() { + errMismatch := errors.New("chain ID mismatch: database=99, configured=1") + repo := newMockRepository() + repo.On("RegisterObserver", mock.Anything, EvmReaderConfigKey, mock.Anything). + Return(PersistentConfig{}, errMismatch).Once() + s.evmReader.repository = repo + result, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainDefaultBlock: DefaultBlock_Latest, BlockchainId: 1, + }) + s.Require().Nil(result) + s.Require().ErrorIs(err, errMismatch) + repo.AssertExpectations(s.T()) +} + +func (s *EvmReaderSuite) TestSetupPersistentConfigDBError() { + errDB := errors.New("database unreachable") + repo := newMockRepository() + repo.On("RegisterObserver", mock.Anything, EvmReaderConfigKey, mock.Anything). + Return(PersistentConfig{}, errDB).Once() + s.evmReader.repository = repo + _, err := s.evmReader.setupPersistentConfig(s.ctx, &config.EvmreaderConfig{ + BlockchainId: 42, BlockchainDefaultBlock: DefaultBlock_Finalized, + }) + s.Require().ErrorIs(err, errDB) + repo.AssertExpectations(s.T()) +} + +func TestReadinessBudget(t *testing.T) { + for _, tc := range []struct { + name string + cfg config.EvmreaderConfig + want time.Duration + }{ + {"floor", config.EvmreaderConfig{}, time.Second}, + {"poll interval", config.EvmreaderConfig{EvmReaderPollingInterval: 12 * time.Second}, 36 * time.Second}, + {"requests with retries", config.EvmreaderConfig{EvmReaderPollingInterval: 12 * time.Second, BlockchainHttpRequestTimeout: 120 * time.Second, BlockchainHttpMaxRetries: 3}, 480 * time.Second}, + {"retry backoff ignored", config.EvmreaderConfig{BlockchainHttpRetryMaxWait: time.Hour}, time.Second}, + {"explicit override", config.EvmreaderConfig{EvmReaderReadyMaxStaleness: 5 * time.Second, EvmReaderPollingInterval: time.Minute}, 5 * time.Second}, + {"overflow saturates", config.EvmreaderConfig{BlockchainHttpRequestTimeout: time.Second, BlockchainHttpMaxRetries: ^uint64(0)}, time.Duration(1<<63 - 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + budget, err := readinessBudget(&tc.cfg) + require.NoError(t, err) + require.Equal(t, tc.want, budget) + }) + } +} + +func TestCreateRejectsNegativeReadinessBudget(t *testing.T) { + _, err := Create(t.Context(), &CreateInfo{Config: config.EvmreaderConfig{ + EvmReaderReadyMaxStaleness: -time.Second, + }}) + require.ErrorContains(t, err, "CARTESI_EVM_READER_READY_MAX_STALENESS must be non-negative") +} + +func (s *EvmReaderSuite) TestReadinessRefreshesAfterScan() { + s.client.EnqueueNewHead(100).Once() + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything).Unset() + var scanFinished time.Time + s.repository.On("ListApplications", mock.Anything, mock.Anything, mock.Anything). + Return([]*Application{}, uint64(0), nil).Once().Run(func(mock.Arguments) { + s.Require().False(s.evmReader.Ready(), "fetching a header must not mark an unfinished scan ready") + scanFinished = time.Now() + }) + _, err := s.evmReader.Tick(s.ctx) + s.Require().NoError(err) + s.Require().False(s.evmReader.lastSuccessfulPoll.Load().Before(scanFinished)) + s.Require().True(s.evmReader.Ready()) + + stale := time.Now().Add(-s.evmReader.readyMaxStaleness) + s.evmReader.lastSuccessfulPoll.Store(&stale) + s.Require().False(s.evmReader.Ready()) + s.client.On("HeaderByNumber", mock.Anything, mock.Anything). + Return((*types.Header)(nil), errors.New("unavailable")).Once() + _, err = s.evmReader.Tick(s.ctx) + s.Require().Error(err) + s.Require().Equal(stale, *s.evmReader.lastSuccessfulPoll.Load()) + s.Require().False(s.evmReader.Ready()) +} diff --git a/internal/evmreader/util.go b/internal/evmreader/util.go new file mode 100644 index 000000000..aafb6d5dd --- /dev/null +++ b/internal/evmreader/util.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package evmreader + +import ( + "cmp" + "slices" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +// calculateEpochIndex calculates the epoch index given the input block number +// and epoch length +func calculateEpochIndex(epochLength uint64, blockNumber uint64) uint64 { + return blockNumber / epochLength +} + +// appsToAddresses extracts IApplicationAddress from each appContracts entry. +func appsToAddresses(apps []appContracts) []common.Address { + var addresses []common.Address + for _, app := range apps { + addresses = append(addresses, app.application.IApplicationAddress) + } + return addresses +} + +// sortByInputIndex is a compare function that orders Inputs +// by index field. It is intended to be used with `insertSorted`, see insertSorted() +func sortByInputIndex(a, b *Input) int { + return cmp.Compare(a.Index, b.Index) +} + +// insertSorted inserts the received input in the slice at the position defined +// by its index property. Returns the updated slice and true if the item was a duplicate. +func insertSorted[T any](compare func(a, b *T) int, slice []*T, item *T) ([]*T, bool) { + i, found := slices.BinarySearchFunc( + slice, + item, + compare) + if found { + return slice, true + } + return slices.Insert(slice, i, item), false +} diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go new file mode 100644 index 000000000..73913fbde --- /dev/null +++ b/internal/httpclient/client.go @@ -0,0 +1,391 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package httpclient keeps configured endpoint credentials below HTTP client +// error and redirect handling. Successful response bodies are never redacted. +package httpclient + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "syscall" + + "github.com/cartesi/rollups-node/internal/errutil" +) + +// Validate accepts HTTP(S) endpoints with a DNS name or IP address. Its errors never +// include the input, including failures from net/url. +func Validate(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, errors.New("invalid HTTP endpoint") + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { + return nil, errors.New("HTTP endpoint requires http or https and a DNS name or IP address") + } + return u, nil +} + +// Options configures endpoint composition and trusted diagnostic messages. +type Options struct { + // AppendOperation appends the request path and query to the endpoint. + AppendOperation bool + // AllowedErrorMessages are fixed, nonsensitive messages that can pass through + // in error responses. Values from configuration or responses are not trusted. + AllowedErrorMessages []string + // PublicationReplies preserves only vetted eth_sendRawTransaction categories + // from a parsed error.message. Transport bodies and error.data are untrusted. + PublicationReplies bool +} + +// New gives callers a host-only request URL and a transport which restores the +// actual endpoint only in a private request copy. Options control whether the +// operation path and query are appended and which fixed messages can be retained. +// An injected next transport must follow the net/http RoundTripper contract. +func New(raw string, next http.RoundTripper, options Options) (string, http.RoundTripper, error) { + u, err := Validate(raw) + if err != nil { + return "", nil, err + } + if next == nil { + next = http.DefaultTransport + } + safeBase := (&url.URL{Scheme: u.Scheme, Host: u.Host}).String() + return safeBase, &transport{ + endpoint: *u, safeBase: safeBase, next: next, appendOperation: options.AppendOperation, + allowedErrorMessages: slices.Clone(options.AllowedErrorMessages), + publicationReplies: options.PublicationReplies, + }, nil +} + +// NewClient copies client and protects its transport. It preserves timeout, +// cookie jar, and other client settings, but always disables redirects. A nil +// client uses the standard library's default client settings. +func NewClient(raw string, client *http.Client, options Options) (string, *http.Client, error) { + if client == nil { + client = http.DefaultClient + } + protected := *client + safeBase, next, err := New(raw, client.Transport, options) + if err != nil { + return "", nil, err + } + protected.Transport = next + if client.Jar != nil { + protected.Jar = &endpointJar{next: client.Jar, endpoint: next.(*transport)} + } + protected.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return safeBase, &protected, nil +} + +type transport struct { + endpoint url.URL + safeBase string + next http.RoundTripper + appendOperation bool + allowedErrorMessages []string + publicationReplies bool +} + +// Error bodies are diagnostic input, not consensus input. Bound the work done +// before retry classification; larger responses use a fixed fallback message. +const maxDiagnosticResponseBytes = 64 << 10 + +func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { + if req == nil || req.URL == nil { + return nil, errors.New("HTTP request requires a URL") + } + wire := req.Clone(req.Context()) + u := t.wireURL(req.URL) + wire.URL = u + if wire.Host == "" || wire.Host == req.URL.Host { + wire.Host = u.Host + } + if wire.Header == nil { + wire.Header = make(http.Header) + } + if u.User != nil && wire.Header.Get("Authorization") == "" { + password, _ := u.User.Password() + wire.SetBasicAuth(u.User.Username(), password) + } + resp, err := t.next.RoundTrip(wire) + if err != nil { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + return nil, Diagnostic(req.Context(), err, "HTTP request", t.safeBase) + } + if resp == nil { + return nil, errors.New("HTTP transport returned no response") + } + // net/http uses Response.Request.URL in redirect errors, and parses Location + // before invoking CheckRedirect. Neither may see the wire endpoint. + // Keep the response itself: net/http fills unannounced trailers on this + // object when its body is consumed. + resp.Header = resp.Header.Clone() + if resp.Header == nil { + resp.Header = make(http.Header) + } + resp.Request = req + resp.Header.Del("Location") + resp.Status = strconv.Itoa(resp.StatusCode) + if text := http.StatusText(resp.StatusCode); text != "" { + resp.Status += " " + text + } + if resp.Body == nil { + resp.Body = http.NoBody + } + resp.Body = &diagnosticBody{ReadCloser: resp.Body, safeBase: t.safeBase, ctx: req.Context()} + if req.Method == http.MethodHead || resp.StatusCode < http.StatusOK || + resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotModified { + // A 101 response can carry an upgraded protocol stream. These HTTP API + // clients do not support upgrades; close it instead of exposing its bytes. + // Retain the status so existing HTTP error classification still applies. + _ = resp.Body.Close() + resp.Body = http.NoBody + return resp, nil + } + if resp.StatusCode >= http.StatusMultipleChoices { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiagnosticResponseBytes+1)) + _ = resp.Body.Close() + interrupted := req.Context().Err() != nil + select { + //nolint:staticcheck,nolintlint // Client.Timeout requires this deliberate use of legacy Cancel. + case <-req.Cancel: + interrupted = true + default: + } + if err != nil && interrupted { + // Keep the status visible to retry policy. The decoder above retry + // handling must still receive the captured safe interruption cause. + resp.Body = io.NopCloser(failedDiagnosticRead{err: err}) + return resp, nil + } + if err != nil || len(body) > maxDiagnosticResponseBytes { + body = nil + } + if t.publicationReplies { + body = publicationResponse(body, isPublicationRequest(req)) + } else { + body = diagnosticResponse(body, t.allowedErrorMessages) + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.TransferEncoding = nil + resp.Header.Del("Content-Length") + resp.Header.Del("Transfer-Encoding") + resp.Header.Del("Content-Encoding") + contentType := "text/plain; charset=utf-8" + if json.Valid(body) { + contentType = "application/json" + } + resp.Header.Set("Content-Type", contentType) + } + return resp, nil +} + +func (t *transport) wireURL(operation *url.URL) *url.URL { + u := t.endpoint + if t.appendOperation { + // Trim only a literal separator, never an encoded trailing slash. RawPath + // must continue to decode to Path or net/url silently discards it. + if strings.HasSuffix(t.endpoint.EscapedPath(), "/") { + u.Path = strings.TrimSuffix(u.Path, "/") + } + u.Path += operation.Path + u.RawPath = strings.TrimSuffix(t.endpoint.EscapedPath(), "/") + operation.EscapedPath() + if u.RawQuery == "" { + u.RawQuery = operation.RawQuery + } else if operation.RawQuery != "" { + u.RawQuery += "&" + operation.RawQuery + } + } + return &u +} + +// Cookie paths must be selected and stored against the wire URL, rather than +// the host-only request seen by http.Client. The same URL composition is used +// for requests and both cookie-jar operations. +type endpointJar struct { + next http.CookieJar + endpoint *transport +} + +func (j *endpointJar) Cookies(u *url.URL) []*http.Cookie { + return j.next.Cookies(j.endpoint.wireURL(u)) +} + +func (j *endpointJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.next.SetCookies(j.endpoint.wireURL(u), cookies) +} + +func (t *transport) CloseIdleConnections() { + if closer, ok := t.next.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } +} + +// A read failure must stay above retry status handling after its original body +// has been closed. The stored error already has a safe diagnostic message. +type failedDiagnosticRead struct{ err error } + +func (b failedDiagnosticRead) Read([]byte) (int, error) { return 0, b.err } + +// diagnosticResponse retains numeric RPC codes and the fixed nonce rejection +// phrase used by existing classifiers. An explicit client policy can also retain +// exact trusted messages. All other provider text may echo credentials. +func diagnosticResponse(body []byte, allowedErrorMessages []string) []byte { + rpcMessage := rpcFailedMessage + if strings.Contains(strings.ToLower(string(body)), nonceLowMessage) { + rpcMessage = nonceLowMessage + } + var input struct { + Error *struct { + Code *int `json:"code"` + } `json:"error"` + } + if json.Unmarshal(body, &input) == nil && input.Error != nil && input.Error.Code != nil { + response := struct { + JSONRPC string `json:"jsonrpc"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + }{JSONRPC: "2.0"} + response.Error.Code = *input.Error.Code + response.Error.Message = rpcMessage + encoded, _ := json.Marshal(response) // Integers and strings cannot fail to marshal. + return encoded + } + if rpcMessage == nonceLowMessage { + return []byte(rpcMessage) + } + message := strings.TrimSuffix(string(body), "\n") + if slices.Contains(allowedErrorMessages, message) { + return []byte(message) + } + return []byte(httpFailedMessage) +} + +type diagnosticBody struct { + io.ReadCloser + safeBase string + ctx context.Context +} + +func (b *diagnosticBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + if err != nil && err != io.EOF { + err = Diagnostic(b.ctx, err, "HTTP response read", b.safeBase) + } + return n, err +} + +func (b *diagnosticBody) Close() error { + return Diagnostic(b.ctx, b.ReadCloser.Close(), "HTTP response close", b.safeBase) +} + +// Diagnostic supplies input-independent output while preserving causes for +// errors.Is/errors.As and timeout classification. Unwrapped causes are for +// classification only: callers must render this error rather than the cause. +// Valid successful RPC messages and credential-bearing hostnames remain outside +// this guarantee. operation and safeBase must be trusted, nonsensitive text. +func Diagnostic(ctx context.Context, err error, operation, safeBase string) error { + if err == nil { + return nil + } + category := "" + switch { + case errutil.IsOnlyCancellation(err): + category = "context canceled" + case ctx != nil && ctx.Err() == context.DeadlineExceeded && errors.Is(err, ctx.Err()): + category = "context deadline exceeded" + default: + var timeout net.Error + var network *net.OpError + var dns *net.DNSError + var unknownCA x509.UnknownAuthorityError + var hostname x509.HostnameError + var certificate *tls.CertificateVerificationError + var invalidCertificate x509.CertificateInvalidError + var record tls.RecordHeaderError + switch { + case errors.As(err, &network) && network.Op == "proxyconnect": + category = "proxy connection failed" + case errors.As(err, &timeout) && timeout.Timeout(): + category = "timeout" + case errors.As(err, &dns): + category = "DNS lookup failed" + case errors.Is(err, syscall.ECONNREFUSED): + category = "connection refused" + case errors.Is(err, syscall.ECONNRESET): + category = "connection reset" + case errors.As(err, &unknownCA): + category = "unknown certificate authority" + case errors.As(err, &hostname): + category = "certificate hostname mismatch" + case errors.As(err, &certificate), errors.As(err, &invalidCertificate): + category = "certificate verification failed" + case errors.As(err, &record): + category = "TLS handshake failed" + case errors.Is(err, io.ErrUnexpectedEOF): + category = "unexpected EOF" + case errors.Is(err, io.EOF): + if operation == "decode RPC response" || operation == "decode JSON-RPC response" { + category = "empty response" + } else { + category = "connection closed" + } + } + } + message := operation + " failed for " + safeBase + if category != "" { + message += ": " + category + } + return &diagnosticError{cause: err, message: message} +} + +// ClassificationError restores the original transport cause for dependency +// classifiers which inspect concrete types or message text. Its result must +// never be logged, returned, or rendered. Use the original safe diagnostic for +// output. This only removes this package's adapters and copies url.Error. +func ClassificationError(err error) error { + switch e := err.(type) { + case *diagnosticError: + return e.cause + case *url.Error: + original := *e + original.Err = ClassificationError(e.Err) + return &original + default: + return err + } +} + +type diagnosticError struct { + cause error + message string +} + +func (e *diagnosticError) Error() string { return e.message } +func (e *diagnosticError) Unwrap() error { return e.cause } +func (e *diagnosticError) Timeout() bool { + var timeout net.Error + return errors.As(e.cause, &timeout) && timeout.Timeout() +} +func (e *diagnosticError) Temporary() bool { + var temporary interface{ Temporary() bool } + return errors.As(e.cause, &temporary) && temporary.Temporary() +} +func (e *diagnosticError) GoString() string { return e.message } diff --git a/internal/httpclient/client_test.go b/internal/httpclient/client_test.go new file mode 100644 index 000000000..2449e86db --- /dev/null +++ b/internal/httpclient/client_test.go @@ -0,0 +1,368 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package httpclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/stretchr/testify/require" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestValidate(t *testing.T) { + for _, raw := range []string{"http://localhost:8545", "https://u:p@example.com/path?q=key", "http://[::1]:8545/rpc"} { + u, err := Validate(raw) + require.NoError(t, err) + require.NotEmpty(t, u.Hostname()) + } + for _, raw := range []string{ + "", "SECRET.sock", "wss://host/SECRET", "ws://host/SECRET", "ipc://host/SECRET", + "https:///SECRET", "http://:8545/SECRET", "http:SECRET", "http://host/%SECRET", "http://host:SECRET", + } { + _, err := Validate(raw) + require.Error(t, err, raw) + require.NotContains(t, err.Error(), "SECRET") + } +} + +func TestWireURLAndAuthentication(t *testing.T) { + const marker = "secret_Aa23%2Ffragment" + type observation struct{ URI, Host, Auth, Body string } + var got []observation + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + got = append(got, observation{r.RequestURI, r.Host, r.Header.Get("Authorization"), string(body)}) + _, _ = io.WriteString(w, "successful payload "+marker) + })) + defer server.Close() + u, err := url.Parse(server.URL + "/one%2Ftwo//./path?key=a%2Fb&key=second+&empty=#ignored") + require.NoError(t, err) + u.User = url.UserPassword("user:name", "3") + for _, auth := range []string{"", "Bearer explicit"} { + for _, protect := range []bool{false, true} { + base, client := u.String(), server.Client() + if protect { + base, client, err = NewClient(base, client, Options{}) + require.NoError(t, err) + } + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, base, strings.NewReader("rpc payload")) + require.NoError(t, err) + if auth != "" { + req.Header.Set("Authorization", auth) + } + resp, err := client.Do(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, "successful payload "+marker, string(body)) + } + require.Equal(t, got[len(got)-2], got[len(got)-1], "wire behavior differs from net/http") + } +} + +func TestZoneScopedEndpointPreservesWireURL(t *testing.T) { + const endpoint = "http://operator:credential@[fe80::1%25lo0]:8545/base%2Froute?key=credential" + var received bool + base, client, err := NewClient(endpoint, &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + received = true + require.Equal(t, endpoint, req.URL.String()) + require.Equal(t, "/base%2Froute?key=credential", req.URL.RequestURI()) + user, password, ok := req.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, "credential", password) + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + })}, Options{}) + require.NoError(t, err) + require.Equal(t, "http://[fe80::1%25lo0]:8545", base) + resp, err := client.Get(base) + require.NoError(t, err) + defer resp.Body.Close() + require.True(t, received) +} + +func TestOperationCompositionAndImmutableCopies(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + const endpoint = "https://user:pass@host.example/base%2Fkey//?k=a%2Fb&k=second+" //nolint:gosec // Synthetic Basic auth credentials. + base, rt, err := New(endpoint, roundTripFunc(func(wire *http.Request) (*http.Response, error) { + require.Equal(t, ctx, wire.Context()) + require.Equal(t, "/base%2Fkey//inspect/app%2Fname?k=a%2Fb&k=second+&q=last+", wire.URL.RequestURI()) + require.Equal(t, "custom.host", wire.Host) + user, password, ok := wire.BasicAuth() + require.True(t, ok) + require.Equal(t, "user", user) + require.Equal(t, "pass", password) + wire.Header.Set("X-Original", "changed") + return &http.Response{ + StatusCode: 200, Status: "200 provider text", Header: http.Header{"Location": {"%malformed"}}, + Body: io.NopCloser(strings.NewReader("ok")), Request: wire, + }, nil + }), Options{AppendOperation: true}) + require.NoError(t, err) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inspect/app%2Fname?q=last+", strings.NewReader("data")) + require.NoError(t, err) + req.Header.Set("X-Original", "original") + req.Host = "custom.host" + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Same(t, req, resp.Request) + require.Equal(t, "200 OK", resp.Status) + require.Empty(t, resp.Header.Get("Location")) + require.Nil(t, req.URL.User) + require.Empty(t, req.Header.Get("Authorization")) + require.Equal(t, "original", req.Header.Get("X-Original")) + require.Equal(t, "/inspect/app%2Fname?q=last+", req.URL.RequestURI()) +} + +func TestOperationRetainsEncodedTrailingSlash(t *testing.T) { + base, rt, err := New("http://host/provider%2F?k=1", roundTripFunc(func(wire *http.Request) (*http.Response, error) { + require.Equal(t, "/provider%2F/inspect/app?k=1", wire.URL.RequestURI()) + require.Equal(t, "/provider//inspect/app", wire.URL.Path) + return &http.Response{StatusCode: 200, Body: http.NoBody}, nil + }), Options{AppendOperation: true}) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodGet, base+"/inspect/app", nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) +} + +func TestCookieJarUsesActualEndpointPath(t *testing.T) { + for _, appendOperation := range []bool{false, true} { + t.Run(fmt.Sprint(appendOperation), func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Contains(t, r.URL.Path, "/base/") + cookie, err := r.Cookie("scoped") + require.NoError(t, err) + require.Equal(t, "present", cookie.Value) + if requests.Add(1) == 2 { + cookie, err := r.Cookie("default-path") + require.NoError(t, err) + require.Equal(t, "stored", cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "default-path", Value: "stored", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, + }) + _, _ = io.WriteString(w, "ok") + })) + defer server.Close() + jar, err := cookiejar.New(nil) + require.NoError(t, err) + endpoint, err := url.Parse(server.URL + "/base/rpc") + require.NoError(t, err) + jar.SetCookies(endpoint, []*http.Cookie{{ + Name: "scoped", Value: "present", Path: "/base", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, + }}) + original := server.Client() + original.Jar = jar + base, client, err := NewClient(endpoint.String(), original, Options{AppendOperation: appendOperation}) + require.NoError(t, err) + if appendOperation { + base += "/inspect/app" + } + for range 2 { + resp, err := client.Get(base) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + } + require.Same(t, jar, original.Jar) + }) + } +} + +func TestProtocolErrorsNeverReflectProviderText(t *testing.T) { + const marker = "SECRET_aB09_PathQuery" + for _, tc := range []struct { + name, reply string + read bool + }{ + {"status", "HTTP/1.1 " + marker + " reason\r\nContent-Length: 0\r\n\r\n", false}, + {"version", marker + " 200 OK\r\nContent-Length: 0\r\n\r\n", false}, + {"header", "HTTP/1.1 200 OK\r\n" + marker + "\r\nContent-Length: 0\r\n\r\n", false}, + {"header value", "HTTP/1.1 200 OK\r\nX-Echo: " + marker + "\x00\r\nContent-Length: 0\r\n\r\n", false}, + {"trailer", "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}\r\n0\r\n" + marker + "\r\n\r\n", true}, + } { + t.Run(tc.name, func(t *testing.T) { + var received atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received.Store(strings.Contains(r.RequestURI, marker)) + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer conn.Close() + _, _ = io.WriteString(conn, tc.reply) + })) + defer server.Close() + base, client, err := NewClient(server.URL+"/"+marker+"?key="+marker, server.Client(), Options{}) + require.NoError(t, err) + resp, err := client.Get(base) + if tc.read { + require.NoError(t, err) + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + } + require.Error(t, err) + require.True(t, received.Load(), "configured wire operation was not sent") + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), marker) + require.NotContains(t, fmt.Sprintf("%#v", err), marker) + }) + } +} + +func TestNonSuccessDiagnosticsPreserveOnlyNumericRPCCode(t *testing.T) { + for _, code := range []int{-32047, -32600, -32005, 400} { + const marker = "echo_Aa17_secret" + base, rt, err := New("http://user:3@host/"+marker, roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := fmt.Sprintf( + `{"jsonrpc":"2.0","id":"%s","error":{"code":%d,"message":"%s password=3","data":"%s"},"unknown":"%s"}`, + marker, code, marker, marker, marker, + ) + return &http.Response{ + StatusCode: 400, Status: "400 " + marker, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}, Request: req, + }, nil + }), Options{}) + require.NoError(t, err) + req, _ := http.NewRequest(http.MethodPost, base, nil) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, "400 Bad Request", resp.Status) + require.Equal(t, int64(len(body)), resp.ContentLength) + require.NotContains(t, string(body), marker) + require.NotContains(t, string(body), "password=3") + var parsed struct { + Error struct { + Code int `json:"code"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(body, &parsed)) + require.Equal(t, code, parsed.Error.Code) + } + for _, body := range []string{"plain secret", `{"error":{"code":"secret","message":"secret"}}`, `{"error":null}`, `[]`} { + require.NotContains(t, string(diagnosticResponse([]byte(body), nil)), "secret") + } +} + +func TestRedirectsAreNotFollowedOrParsed(t *testing.T) { + for _, status := range []int{301, 302, 303, 307, 308} { + for _, location := range []string{"/elsewhere", "http://%SECRET"} { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Location", location) + w.WriteHeader(status) + })) + base, client, err := NewClient(server.URL+"/SECRET", server.Client(), Options{}) + require.NoError(t, err) + resp, err := client.Get(base) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, status, resp.StatusCode) + require.Empty(t, resp.Header.Get("Location")) + require.Equal(t, int32(1), hits.Load()) + server.Close() + } + } +} + +type timeoutError struct{} + +func (timeoutError) Error() string { return "SECRET timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } + +func TestDiagnosticClassificationAndFormatting(t *testing.T) { + for _, original := range []error{ + context.Canceled, context.DeadlineExceeded, timeoutError{}, + errors.Join(context.Canceled, errors.New("SECRET failure")), io.ErrUnexpectedEOF, + } { + safe := Diagnostic(t.Context(), original, "HTTP response read", "https://host") + require.ErrorIs(t, safe, original) + require.Equal(t, errutil.IsOnlyCancellation(original), errutil.IsOnlyCancellation(safe)) + for _, format := range []string{"%v", "%+v", "%#v"} { + require.NotContains(t, fmt.Sprintf(format, safe), "SECRET") + require.Contains(t, fmt.Sprintf(format, safe), "https://host") + } + } + safe := Diagnostic(t.Context(), timeoutError{}, "HTTP request", "https://host") + require.True(t, (&url.Error{Op: "Post", URL: "https://host", Err: safe}).Timeout()) + require.Nil(t, Diagnostic(t.Context(), nil, "HTTP request", "https://host")) +} + +type readFailure struct{ closed bool } + +func (r *readFailure) Read(p []byte) (int, error) { + return copy(p, "unchanged SECRET bytes"), errors.New("SECRET trailer") +} +func (r *readFailure) Close() error { r.closed = true; return nil } + +func TestReadErrorsDoNotChangeSuccessfulBytes(t *testing.T) { + body := &readFailure{} + safe := &diagnosticBody{ReadCloser: body, safeBase: "https://host"} + buffer := make([]byte, 100) + n, err := safe.Read(buffer) + require.Equal(t, "unchanged SECRET bytes", string(buffer[:n])) + require.Error(t, err) + require.NotContains(t, err.Error(), "SECRET") + require.NoError(t, safe.Close()) + require.True(t, body.closed) +} + +type closingTransport struct{ closed atomic.Bool } + +func (*closingTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("unused") +} +func (t *closingTransport) CloseIdleConnections() { t.closed.Store(true) } + +func TestClientSettingsCleanupAndConcurrency(t *testing.T) { + next := &closingTransport{} + original := &http.Client{Transport: next, Timeout: time.Second} + _, protected, err := NewClient("http://host/SECRET", original, Options{}) + require.NoError(t, err) + require.Equal(t, original.Timeout, protected.Timeout) + require.Same(t, next, original.Transport) + protected.CloseIdleConnections() + require.True(t, next.closed.Load()) + base, rt, err := New("http://u:p@host/key?x=1", roundTripFunc(func(wire *http.Request) (*http.Response, error) { + require.Equal(t, "/key?x=1", wire.URL.RequestURI()) + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(nil))}, nil + }), Options{}) + require.NoError(t, err) + req, _ := http.NewRequest(http.MethodPost, base, nil) + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { resp, err := rt.RoundTrip(req); require.NoError(t, err); _ = resp.Body.Close() }) + } + wg.Wait() + require.Nil(t, req.URL.User) + require.Empty(t, req.Header.Get("Authorization")) +} diff --git a/internal/httpclient/diagnostics_test.go b/internal/httpclient/diagnostics_test.go new file mode 100644 index 000000000..648e73d10 --- /dev/null +++ b/internal/httpclient/diagnostics_test.go @@ -0,0 +1,334 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package httpclient + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "syscall" + "testing" + "testing/iotest" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDiagnosticNamesTypedFailuresWithoutRenderingCauses(t *testing.T) { + const marker = "PROVIDER_CREDENTIAL" + expired, cancel := context.WithDeadline(t.Context(), time.Unix(1, 0)) + defer cancel() + for _, test := range []struct { + name, category string + cause error + ctx context.Context + }{ + {"DNS", "DNS lookup failed", &net.DNSError{Err: marker, Name: marker}, t.Context()}, + {"refused", "connection refused", fmt.Errorf("%s: %w", marker, syscall.ECONNREFUSED), t.Context()}, + {"proxy", "proxy connection failed", &net.OpError{Op: "proxyconnect", Err: syscall.ECONNREFUSED}, t.Context()}, + {"proxy timeout", "proxy connection failed", &net.OpError{Op: "proxyconnect", Err: timeoutError{}}, t.Context()}, + {"reset", "connection reset", fmt.Errorf("%s: %w", marker, syscall.ECONNRESET), t.Context()}, + {"CA", "unknown certificate authority", &tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{}}, t.Context()}, + { + "hostname", "certificate hostname mismatch", + &tls.CertificateVerificationError{Err: x509.HostnameError{Host: marker}}, t.Context(), + }, + {"certificate", "certificate verification failed", &tls.CertificateVerificationError{Err: errors.New(marker)}, t.Context()}, + {"TLS record", "TLS handshake failed", tls.RecordHeaderError{Msg: marker}, t.Context()}, + {"timeout", "timeout", timeoutError{}, t.Context()}, + {"deadline", "context deadline exceeded", fmt.Errorf("%s: %w", marker, context.DeadlineExceeded), expired}, + {"cancellation", "context canceled", fmt.Errorf("%s: %w", marker, context.Canceled), t.Context()}, + {"unexpected EOF", "unexpected EOF", fmt.Errorf("%s: %w", marker, io.ErrUnexpectedEOF), t.Context()}, + {"EOF", "connection closed", fmt.Errorf("%s: %w", marker, io.EOF), t.Context()}, + } { + t.Run(test.name, func(t *testing.T) { + safe := Diagnostic(test.ctx, test.cause, "HTTP request", "https://provider.example") + require.Contains(t, safe.Error(), test.category) + require.Contains(t, safe.Error(), "https://provider.example") + require.NotContains(t, safe.Error(), marker) + require.ErrorIs(t, safe, test.cause) + }) + } +} + +type observedBody struct { + io.Reader + read int + closed bool +} + +func (b *observedBody) Read(p []byte) (int, error) { + n, err := b.Reader.Read(p) + b.read += n + return n, err +} + +func (b *observedBody) Close() error { b.closed = true; return nil } + +func TestErrorResponseReadIsBounded(t *testing.T) { + const budget = 64 << 10 + original := &observedBody{Reader: strings.NewReader("nonce too low " + strings.Repeat("x", budget*4) + "CREDENTIAL")} + base, rt, err := New("http://provider.example/CREDENTIAL", roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: original}, nil + }), Options{}) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, base, nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.LessOrEqual(t, original.read, budget+1) + require.True(t, original.closed) + require.Contains(t, string(body), "HTTP request failed") + require.NotContains(t, string(body), "CREDENTIAL") + require.NotContains(t, string(body), "nonce too low", "oversized input must use the generic fallback") +} + +func TestDiagnosticReadBoundaryAndSuccessfulPayload(t *testing.T) { + const budget = 64 << 10 + for _, size := range []int{budget - 1, budget, budget + 1} { + for _, status := range []int{http.StatusOK, http.StatusBadRequest} { + t.Run(fmt.Sprintf("size=%d/status=%d", size, status), func(t *testing.T) { + payload := "nonce too low " + strings.Repeat("x", size-len("nonce too low ")) + base, rt, err := New("http://provider.example/credential", roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(payload))}, nil + }), Options{}) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodGet, base, nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + switch { + case status == http.StatusOK: + require.Equal(t, payload, string(body)) + case size <= budget: + require.Equal(t, "nonce too low", string(body)) + require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + default: + require.Equal(t, "HTTP request failed", string(body)) + } + }) + } + } +} + +func TestUnannouncedResponseTrailerSurvives(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer conn.Close() + _, _ = io.WriteString(conn, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"+ + "1\r\na\r\n0\r\nX-Unannounced: trailer-value\r\n\r\n") + })) + defer server.Close() + base, client, err := NewClient(server.URL+"/credential", nil, Options{}) + require.NoError(t, err) + resp, err := client.Get(base) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "a", string(body)) + require.Equal(t, "trailer-value", resp.Trailer.Get("X-Unannounced")) +} + +func TestBodylessResponsesRemainBodyless(t *testing.T) { + for _, test := range []struct { + method string + status int + }{ + {http.MethodHead, http.StatusNotFound}, + {http.MethodHead, http.StatusNoContent}, + {http.MethodGet, http.StatusNoContent}, + {http.MethodGet, http.StatusNotModified}, + {http.MethodGet, http.StatusSwitchingProtocols}, + } { + t.Run(fmt.Sprintf("%s/%d", test.method, test.status), func(t *testing.T) { + base, rt, err := New("http://provider.example/credential", roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: test.status, Header: http.Header{}, + Body: io.NopCloser(strings.NewReader("CREDENTIAL"))}, nil + }), Options{}) + require.NoError(t, err) + req, err := http.NewRequest(test.method, base, nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Empty(t, body) + require.Empty(t, resp.Header.Get("Content-Type")) + }) + } +} + +func TestErrorMessagesRequireExplicitPolicyAndCompleteMatch(t *testing.T) { + for _, message := range []string{"Known service failure", "Another service failure"} { + t.Run(message, func(t *testing.T) { + for _, allowed := range []bool{false, true} { + options := Options{} + if allowed { + options.AllowedErrorMessages = []string{message} + } + for _, raw := range []string{message, message + "\n", message + " CREDENTIAL", "CREDENTIAL " + message} { + base, rt, err := New("http://provider.example/CREDENTIAL", roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader(raw))}, nil + }), options) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, base, nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + want := "HTTP request failed" + if allowed && (raw == message || raw == message+"\n") { + want = message + } + require.Equal(t, want, string(body)) + } + } + }) + } +} + +func TestErrorMessagePolicyCopiesCallerSlice(t *testing.T) { + const trustedMessage = "Known service failure" + allowed := []string{trustedMessage} + raw := "CREDENTIAL" + base, rt, err := New("http://provider.example/CREDENTIAL", roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader(raw))}, nil + }), Options{AllowedErrorMessages: allowed}) + require.NoError(t, err) + allowed[0] = raw + for _, test := range []struct{ input, want string }{ + {raw, "HTTP request failed"}, + {trustedMessage, trustedMessage}, + } { + raw = test.input + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, base, nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, test.want, string(body)) + } +} + +func TestProxyAndTransportTimeoutLabels(t *testing.T) { + t.Run("proxy connection", func(t *testing.T) { + proxy := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxy.Close() + next := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + defer next.CloseIdleConnections() + base, client, err := NewClient("http://provider.invalid/CREDENTIAL", &http.Client{Transport: next}, Options{}) + require.NoError(t, err) + _, err = client.Get(base) + require.Error(t, err) + require.Contains(t, err.Error(), "proxy connection failed") + require.Contains(t, err.Error(), "http://provider.invalid") + require.NotContains(t, err.Error(), "CREDENTIAL") + require.NotContains(t, err.Error(), proxy.URL) + }) + t.Run("response header timeout", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + next := &http.Transport{ResponseHeaderTimeout: 10 * time.Millisecond} + defer next.CloseIdleConnections() + base, client, err := NewClient(server.URL+"/CREDENTIAL", &http.Client{Transport: next}, Options{}) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, base, nil) + require.NoError(t, err) + _, err = client.Do(req) + require.Error(t, err) + require.NoError(t, req.Context().Err()) + require.Contains(t, err.Error(), ": timeout") + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), "context deadline") + require.NotContains(t, err.Error(), "CREDENTIAL") + }) + t.Run("dial timeout", func(t *testing.T) { + next := &http.Transport{DialContext: (&net.Dialer{Timeout: time.Nanosecond}).DialContext} + defer next.CloseIdleConnections() + base, client, err := NewClient("http://127.0.0.1:1/CREDENTIAL", &http.Client{Transport: next}, Options{}) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, base, nil) + require.NoError(t, err) + _, err = client.Do(req) + require.Error(t, err) + require.NoError(t, req.Context().Err()) + require.Contains(t, err.Error(), ": timeout") + require.Contains(t, err.Error(), "http://127.0.0.1:1") + require.NotContains(t, err.Error(), "context deadline") + require.NotContains(t, err.Error(), "CREDENTIAL") + }) +} + +func TestDiagnosticBodyReadFailuresKeepSafeStatus(t *testing.T) { + body := &readFailure{} + base, rt, err := New("http://provider.example/CREDENTIAL", roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusBadRequest, Body: body}, nil + }), Options{}) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, base, nil) + require.NoError(t, err) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "HTTP request failed", string(data)) + require.True(t, body.closed) +} + +func TestClosedLegacyCancelKeepsReadFailureAboveRetryStatus(t *testing.T) { + cause := errors.New("CREDENTIAL body read failure") + body := &observedBody{Reader: iotest.ErrReader(cause)} + base, rt, err := New("http://provider.example/CREDENTIAL", roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusBadRequest, Body: body}, nil + }), Options{}) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, base, nil) + require.NoError(t, err) + canceled := make(chan struct{}) + close(canceled) + //nolint:staticcheck,nolintlint // Exercise the deliberate legacy Cancel exemption used for Client.Timeout. + req.Cancel = canceled + require.NoError(t, req.Context().Err()) + resp, err := rt.RoundTrip(req) + require.NoError(t, err, "retry policy must receive the non-2xx status") + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + data, err := io.ReadAll(resp.Body) + require.Empty(t, data, "partial diagnostic bytes must not be exposed") + require.ErrorIs(t, err, cause) + require.NotErrorIs(t, err, context.Canceled, "legacy cancellation must not manufacture a context cause") + require.NotErrorIs(t, err, context.DeadlineExceeded) + require.Contains(t, err.Error(), "http://provider.example") + require.NotContains(t, err.Error(), "CREDENTIAL") + require.True(t, body.closed) +} diff --git a/internal/httpclient/publication.go b/internal/httpclient/publication.go new file mode 100644 index 000000000..d623dd7bc --- /dev/null +++ b/internal/httpclient/publication.go @@ -0,0 +1,116 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package httpclient + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +const ( + maxPublicationRequestBytes = 1 << 20 + rpcFailedMessage = "RPC request failed" + httpFailedMessage = "HTTP request failed" + nonceLowMessage = "nonce too low" + alreadyKnownMessage = "already known" + publicationMethod = "eth_sendRawTransaction" +) + +func isPublicationRequest(req *http.Request) bool { + if req.GetBody == nil { + return false + } + body, err := req.GetBody() + if err != nil { + return false + } + data, readErr := io.ReadAll(io.LimitReader(body, maxPublicationRequestBytes+1)) + closeErr := body.Close() + if readErr != nil || closeErr != nil || len(data) > maxPublicationRequestBytes { + return false + } + var call struct { + Method string `json:"method"` + } + return json.Unmarshal(data, &call) == nil && call.Method == publicationMethod +} + +// PublicationMessage returns a fixed diagnostic category. Callers must only +// supply the message from an eth_sendRawTransaction error envelope. +func PublicationMessage(code int, message string) string { + m := strings.ToLower(message) + if code == 3 || strings.Contains(m, "execution reverted") { + return rpcFailedMessage + } + if m == "known transaction" || strings.HasPrefix(m, "known transaction:") { + return alreadyKnownMessage + } + for _, category := range []struct { + message string + phrases []string + }{ + {alreadyKnownMessage, []string{alreadyKnownMessage, "transaction already imported"}}, + {nonceLowMessage, []string{nonceLowMessage}}, + {"replacement transaction underpriced", []string{"replacement transaction underpriced"}}, + {"transaction underpriced", []string{ + "transaction underpriced", "transaction gas price below minimum", "gas price below configured minimum gas price", + }}, + {"max fee per gas less than block base fee", []string{ + "max fee per gas less than block base fee", "gas price below current base fee", + }}, + {"insufficient funds", []string{"insufficient funds"}}, + {"intrinsic gas too low", []string{ + "exceeds block gas limit", "transaction gas limit too high", "intrinsic gas too low", + "insufficient gas for floor data gas cost", + }}, + {"invalid sender", []string{ + "max priority fee per gas higher than max fee per gas", "invalid sender", + "only replay-protected (eip-155) transactions allowed over rpc", + }}, + {"txpool is full", []string{ + "txpool is full", "future transaction tries to replace pending", "account limit exceeded", "out of capacity", + }}, + } { + for _, phrase := range category.phrases { + if strings.Contains(m, phrase) { + return category.message + } + } + } + if strings.Contains(m, "tx fee (") && strings.Contains(m, "exceeds the configured cap") { + return "tx fee (redacted) exceeds the configured cap" + } + return rpcFailedMessage +} + +func publicationResponse(body []byte, publication bool) []byte { + var envelope struct { + Error *struct { + Code *int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(body, &envelope) != nil || envelope.Error == nil || envelope.Error.Code == nil { + return []byte(httpFailedMessage) + } + response := struct { + JSONRPC string `json:"jsonrpc"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + }{JSONRPC: "2.0"} + response.Error.Code = *envelope.Error.Code + response.Error.Message = rpcFailedMessage + if publication { + response.Error.Message = PublicationMessage(*envelope.Error.Code, envelope.Error.Message) + } + data, err := json.Marshal(response) + if err != nil { + return []byte(httpFailedMessage) + } + return data +} diff --git a/internal/httpclient/publication_test.go b/internal/httpclient/publication_test.go new file mode 100644 index 000000000..c196d57e6 --- /dev/null +++ b/internal/httpclient/publication_test.go @@ -0,0 +1,51 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package httpclient + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPublicationTransportPreservesOnlyParsedMessageCategories(t *testing.T) { + for _, test := range []struct{ method, body, want string }{ + {publicationMethod, `{"error":{"code":-32000,"message":"already known SECRET"}}`, "already known"}, + {publicationMethod, `{"error":{"code":-32000,"message":"unknown","data":"nonce too low SECRET"}}`, rpcFailedMessage}, + {publicationMethod, `{"error":{"code":3,"message":"already known SECRET"}}`, rpcFailedMessage}, + {"eth_call", `{"error":{"code":-32000,"message":"already known SECRET"}}`, rpcFailedMessage}, + {publicationMethod, `nonce too low SECRET`, httpFailedMessage}, + } { + t.Run(test.method+test.body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, err := io.WriteString(w, test.body) + require.NoError(t, err) + })) + defer server.Close() + safe, client, err := NewClient(server.URL, nil, Options{PublicationReplies: true}) + require.NoError(t, err) + requestBody, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": test.method}) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, safe, bytes.NewReader(requestBody)) + require.NoError(t, err) + response, err := client.Do(req) + require.NoError(t, err) + data, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Contains(t, string(data), test.want) + require.NotContains(t, string(data), "SECRET") + if test.want == rpcFailedMessage { + require.NotContains(t, string(data), nonceLowMessage) + require.NotContains(t, string(data), "already known") + } + }) + } +} diff --git a/internal/inspect/client_contract_test.go b/internal/inspect/client_contract_test.go new file mode 100644 index 000000000..979665385 --- /dev/null +++ b/internal/inspect/client_contract_test.go @@ -0,0 +1,170 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package inspect + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/inspectmessages" + "github.com/cartesi/rollups-node/internal/manager" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/inspectclient" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +type inspectHandlerTransport func(*http.Request) (*http.Response, error) + +func (f inspectHandlerTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +type failedInspectBody struct{} + +func (failedInspectBody) Read([]byte) (int, error) { return 0, errors.New("BODY_READ_CREDENTIAL") } + +const inspectLogRequestID = "11111111-1111-4111-8111-111111111111" + +func requireInspectRequestLogs(t *testing.T, logs *bytes.Buffer, levels []slog.Level) { + t.Helper() + decoder := json.NewDecoder(logs) + var records []map[string]any + for { + var record map[string]any + err := decoder.Decode(&record) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + records = append(records, record) + } + require.Len(t, records, len(levels), "adding the request ID must not add log events") + for i, record := range records { + require.Equal(t, levels[i].String(), record["level"]) + require.Equal(t, inspectLogRequestID, record["request_id"]) + } +} + +func TestInspectorFixedHTTPMessagesReachProtectedClient(t *testing.T) { + const applicationName = "app-1" + for _, test := range []struct { + message string + status int + configure func(*MockMachine, *MachinesMock, *MockRepository) + method, dapp string + body func() io.Reader + levels []slog.Level + }{ + {message: inspectmessages.MissingApplicationAddress, status: http.StatusBadRequest, levels: []slog.Level{slog.LevelInfo}}, + {message: inspectmessages.MethodNotAllowed, status: http.StatusMethodNotAllowed, + dapp: applicationName, method: http.MethodGet, levels: []slog.Level{slog.LevelInfo}}, + {message: inspectmessages.PayloadTooLarge, status: http.StatusRequestEntityTooLarge, dapp: applicationName, + body: func() io.Reader { return strings.NewReader(strings.Repeat("x", maxPayloadSize+1)) }, + levels: []slog.Level{slog.LevelInfo}}, + {message: inspectmessages.BadRequest, status: http.StatusBadRequest, dapp: applicationName, + body: func() io.Reader { return failedInspectBody{} }, levels: []slog.Level{slog.LevelInfo}}, + {message: inspectmessages.TerminalApplication, status: http.StatusServiceUnavailable, dapp: applicationName, + levels: []slog.Level{slog.LevelInfo, slog.LevelInfo}, + configure: func(machine *MockMachine, _ *MachinesMock, _ *MockRepository) { + machine.application.Status = model.ApplicationStatus_Corrupted + }}, + {message: inspectmessages.MachineNotReady, status: http.StatusServiceUnavailable, dapp: applicationName, + levels: []slog.Level{slog.LevelInfo, slog.LevelWarn}, + configure: func(_ *MockMachine, machines *MachinesMock, _ *MockRepository) { delete(machines.Map, 1) }}, + {message: inspectmessages.ForeclosedApplication, status: http.StatusServiceUnavailable, dapp: applicationName, + levels: []slog.Level{slog.LevelInfo, slog.LevelInfo}, + configure: func(machine *MockMachine, machines *MachinesMock, _ *MockRepository) { + machine.application.ForecloseBlock = 1 + delete(machines.Map, 1) + }}, + {message: inspectmessages.ApplicationNotFound, status: http.StatusNotFound, dapp: applicationName, + levels: []slog.Level{slog.LevelInfo, slog.LevelInfo}, + configure: func(_ *MockMachine, _ *MachinesMock, repository *MockRepository) { repository.apps = nil }}, + {message: inspectmessages.InspectAtCapacity, status: http.StatusServiceUnavailable, dapp: applicationName, + levels: []slog.Level{slog.LevelInfo, slog.LevelInfo}, + configure: func(machine *MockMachine, machines *MachinesMock, _ *MockRepository) { + machine.inspectError = manager.ErrInspectAtCapacity + machines.Map[1] = *machine + }}, + } { + t.Run(test.message, func(t *testing.T) { + machine := newMockMachine(1) + machine.application.Status = model.ApplicationStatus_OK + machines := newMockMachines() + machines.Map[1] = *machine + repository := newMockRepository() + repository.apps = append(repository.apps, machine.application) + if test.configure != nil { + test.configure(machine, machines, repository) + } + inspector := &Inspector{repository: repository, IInspectMachines: machines} + var logs bytes.Buffer + inspector.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + var calls int + client, err := inspectclient.NewSafeClient( + "http://operator:ENDPOINT_CREDENTIAL@example.invalid/base?key=ENDPOINT_CREDENTIAL", + inspectclient.WithHTTPClient(&http.Client{ + Transport: inspectHandlerTransport(func(request *http.Request) (*http.Response, error) { + calls++ + defer request.Body.Close() + recorder := httptest.NewRecorder() + service.RequestIDMiddleware(inspector).ServeHTTP(recorder, request) + return recorder.Result(), nil + }), + })) + require.NoError(t, err) + var body io.Reader = strings.NewReader("payload") + if test.body != nil { + body = test.body() + } + response, err := client.InspectPostWithBody(t.Context(), test.dapp, "application/octet-stream", body, + func(_ context.Context, request *http.Request) error { + request.Header.Set("X-Request-ID", inspectLogRequestID) + request.SetPathValue("dapp", test.dapp) + if test.method != "" { + request.Method = test.method + } + return nil + }) + require.NoError(t, err) + defer response.Body.Close() + require.Equal(t, 1, calls) + require.Equal(t, test.status, response.StatusCode) + message, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, test.message, string(message)) + require.NotContains(t, string(message), "CREDENTIAL") + require.Equal(t, inspectLogRequestID, response.Header.Get("X-Request-ID")) + requireInspectRequestLogs(t, &logs, test.levels) + inspector.Logger.Info("outside the request") + require.NotContains(t, logs.String(), "request_id") + }) + } +} + +func TestInspectorUnexpectedFailureLogsRequestIDAndPrivateCause(t *testing.T) { + var logs bytes.Buffer + inspector, app := newInspectorForTest(t, errors.New("SAFE_MACHINE_FAILURE")) + inspector.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + request := httptest.NewRequest(http.MethodPost, "/inspect/"+app.Name, strings.NewReader("payload")) + request.Header.Set("X-Request-ID", inspectLogRequestID) + recorder := httptest.NewRecorder() + inspector.Server.Handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusInternalServerError, recorder.Code) + require.Equal(t, inspectLogRequestID, recorder.Header().Get("X-Request-ID")) + require.Contains(t, recorder.Body.String(), "Internal server error (request_id="+inspectLogRequestID+")") + require.NotContains(t, recorder.Body.String(), "SAFE_MACHINE_FAILURE") + require.Contains(t, logs.String(), "SAFE_MACHINE_FAILURE", "the cause must stay in the server log") + requireInspectRequestLogs(t, &logs, []slog.Level{slog.LevelInfo, slog.LevelError}) +} diff --git a/internal/inspect/hardening_test.go b/internal/inspect/hardening_test.go new file mode 100644 index 000000000..6a40af983 --- /dev/null +++ b/internal/inspect/hardening_test.go @@ -0,0 +1,555 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package inspect + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/manager" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/stretchr/testify/require" +) + +// newInspectorForTest constructs an Inspector via NewInspector with a mock +// repository and machine set, but returns only the http.Handler — the real +// http.Server is never started. Tests exercise it with httptest. +// +// When machineErr is non-nil, the mock machine's Inspect call returns it, +// so tests can cover the internal-error path. +func newInspectorForTest(t *testing.T, machineErr error) (*Inspector, *Application) { + t.Helper() + + app := &Application{ + ID: 1, + IApplicationAddress: randomAddress(), + Name: "test-app", + ExecutionParameters: ExecutionParameters{ + InspectMaxDeadline: 10 * time.Second, + }, + } + repo := newMockRepository() + repo.apps = append(repo.apps, app) + + mm := newMockMachines() + mm.Map[1] = MockMachine{application: app} + + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.AdvancerConfig{ + LogLevel: slog.LevelError, + InspectAddress: "127.0.0.1:0", + }, + Repository: repo, + Machines: hardeningMachines{MachinesMock: mm, err: machineErr}, + }) + require.NoError(t, err) + + insp := svc.(*Inspector) + require.NotNil(t, insp.Logger) + + return insp, app +} + +// hardeningMachines wraps MachinesMock so an injected error propagates out +// of the MockMachine.Inspect call used by tests. The underlying MockMachine +// never returns an error on its own. +type hardeningMachines struct { + *MachinesMock + err error +} + +func (h hardeningMachines) GetMachine(appID int64) (manager.MachineInstance, bool) { + inst, ok := h.MachinesMock.GetMachine(appID) + if !ok { + return nil, false + } + if h.err != nil { + return &erroringMachine{inner: inst, err: h.err}, true + } + return inst, true +} + +type erroringMachine struct { + inner manager.MachineInstance + err error +} + +func (m *erroringMachine) Inspect(_ context.Context, _ []byte) (*manager.InspectResult, error) { + if m.err == errPanicSentinel { + panic("boom-from-machine") + } + return nil, m.err +} + +// errPanicSentinel is a marker value understood by erroringMachine.Inspect: +// when passed as the machineErr to newInspectorForTest, the mock machine +// panics instead of returning an error, so tests can exercise the +// RecoverMiddleware branch without monkey-patching the inspect handler. +var errPanicSentinel = errors.New("sentinel: make machine panic") + +// Forward the rest to the inner machine so the type satisfies the interface +// without reimplementing the stubs. Tests only reach Inspect. +func (m *erroringMachine) Advance(ctx context.Context, input []byte, a, b uint64, c bool) (*AdvanceResult, error) { + return m.inner.Advance(ctx, input, a, b, c) +} +func (m *erroringMachine) Application() *Application { return m.inner.Application() } +func (m *erroringMachine) ProcessedInputs() uint64 { return m.inner.ProcessedInputs() } +func (m *erroringMachine) StateProof(ctx context.Context) (*StateProof, error) { + return m.inner.StateProof(ctx) +} +func (m *erroringMachine) CreateSnapshot(ctx context.Context, processedInputs uint64, path string) error { + return m.inner.CreateSnapshot(ctx, processedInputs, path) +} +func (m *erroringMachine) Hash(ctx context.Context) ([32]byte, error) { return m.inner.Hash(ctx) } +func (m *erroringMachine) Close() error { return m.inner.Close() } + +// ----------------------------------------------------------------------------- + +func TestInspector_NewWithCreateInfo(t *testing.T) { + insp, _ := newInspectorForTest(t, nil) + // Package-internal access: the hardened http.Server is unexported and + // tests pin its fields directly rather than via a public accessor. + srv := insp.Server + require.NotNil(t, srv) + + opts := service.DefaultInspectOptions() + require.Equal(t, opts.ReadHeaderTimeout, srv.ReadHeaderTimeout) + require.Equal(t, opts.ReadTimeout, srv.ReadTimeout) + require.Equal(t, opts.WriteTimeout, srv.WriteTimeout) + require.Equal(t, opts.IdleTimeout, srv.IdleTimeout) + require.Equal(t, opts.MaxHeaderBytes, srv.MaxHeaderBytes) +} + +func TestInspector_NewRejectsNilMachines(t *testing.T) { + _, err := Create(t.Context(), &CreateInfo{ + Config: config.AdvancerConfig{InspectAddress: "127.0.0.1:0"}, + Repository: newMockRepository(), + }) + require.ErrorIs(t, err, ErrInvalidMachines) +} + +func TestInspector_OversizedPayloadReturns413(t *testing.T) { + insp, app := newInspectorForTest(t, nil) + body := bytes.NewReader(make([]byte, maxPayloadSize+1)) + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), body) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code) + require.Contains(t, rr.Body.String(), "Payload too large") +} + +func TestInspector_ExactBoundaryAccepted(t *testing.T) { + insp, app := newInspectorForTest(t, nil) + body := bytes.NewReader(make([]byte, maxPayloadSize)) + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), body) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code, "body at exact limit must be accepted") +} + +func TestInspector_GETReturns405WithAllowHeader(t *testing.T) { + insp, app := newInspectorForTest(t, nil) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/inspect/%s", app.Name), nil) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusMethodNotAllowed, rr.Code) + require.Equal(t, http.MethodPost, rr.Header().Get("Allow")) +} + +func TestInspector_PUTReturns405WithAllowHeader(t *testing.T) { + insp, app := newInspectorForTest(t, nil) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/inspect/%s", app.Name), nil) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusMethodNotAllowed, rr.Code) + require.Equal(t, http.MethodPost, rr.Header().Get("Allow")) +} + +func TestInspector_InternalErrorBodyIsGeneric(t *testing.T) { + secret := errors.New("secret-credentials-xyzzy") + insp, app := newInspectorForTest(t, secret) + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusInternalServerError, rr.Code) + require.Contains(t, rr.Body.String(), "Internal server error (request_id=") + require.NotContains(t, rr.Body.String(), "secret-credentials-xyzzy", + "internal error body must never leak err.Error()") +} + +func TestInspector_InternalErrorIncludesRequestID(t *testing.T) { + insp, app := newInspectorForTest(t, errors.New("boom")) + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + req.Header.Set("X-Request-ID", "pinned-id-42") + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusInternalServerError, rr.Code) + require.Contains(t, rr.Body.String(), "request_id=pinned-id-42") + require.Equal(t, "pinned-id-42", rr.Header().Get("X-Request-ID")) +} + +// TestInspector_ChainOrder_RecoverCoversRequestID pins the customer- +// visible consequences of the chain order built by +// [service.InitHTTPServiceTemplate]: a panic from deep in the handler chain +// must turn into a clean 500 Internal Server Error (not a dropped TCP +// connection), and the response must still carry X-Request-ID on the way +// out so callers and log aggregators retain correlation. +// +// The precise semantics of how RecoverMiddleware reads the request id +// from the outer request (via the shared ResponseWriter header, not +// r.Context()) are a property of [service.RecoverMiddleware] itself and +// are documented and tested there. +func TestInspector_ChainOrder_RecoverCoversRequestID(t *testing.T) { + insp, app := newInspectorForTest(t, errPanicSentinel) + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + req.Header.Set("X-Request-ID", "chain-order-test-id") + rr := httptest.NewRecorder() + + require.NotPanics(t, func() { + insp.Server.Handler.ServeHTTP(rr, req) + }, "panic in handler must be caught by RecoverMiddleware, not propagate to the test") + + require.Equal(t, http.StatusInternalServerError, rr.Code, + "Recover must turn handler panics into a 500") + require.Equal(t, "chain-order-test-id", rr.Header().Get("X-Request-ID"), + "X-Request-ID must be echoed on the 500 response so clients can correlate") +} + +func TestInspector_HappyPathStillWorks(t *testing.T) { + insp, app := newInspectorForTest(t, nil) + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), `"status":"Accepted"`) +} + +func TestInspector_EmptyDappPathReturns404(t *testing.T) { + insp, _ := newInspectorForTest(t, nil) + // An empty dapp path value does not match the /inspect/{dapp} pattern, + // so this exercises the 404 branch. Ensure the request instead just + // does not match the inspect route. + req := httptest.NewRequest(http.MethodPost, "/inspect/", strings.NewReader("x")) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +// TestInspector_RealServer_PayloadTooLarge runs the oversized-body path +// through a real httptest.Server so the full middleware chain and +// MaxBytesReader wire up correctly end-to-end. This is the keystone test +// for the responseWriterTap.Unwrap() behaviour; if the tap ever loses +// Unwrap, MaxBytesReader silently stops enforcing connection-close and +// this test is the first thing to notice. +func TestInspector_RealServer_PayloadTooLarge(t *testing.T) { + insp, app := newInspectorForTest(t, nil) + + srv := httptest.NewServer(insp.Server.Handler) + defer srv.Close() + + body := bytes.NewReader(make([]byte, maxPayloadSize+1)) + resp, err := http.Post( + fmt.Sprintf("%s/inspect/%s", srv.URL, app.Name), + "application/octet-stream", + body) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) + respBody, _ := io.ReadAll(resp.Body) + require.Contains(t, string(respBody), "Payload too large") +} + +// ----------------------------------------------------------------------------- +// Admission control integration +// ----------------------------------------------------------------------------- + +// newInspectorWithAdmission is a variant of newInspectorForTest that also +// accepts a *service.SemaphoreAdmission to exercise the admission +// middleware in the full handler chain. +func newInspectorWithAdmission(t *testing.T, maxInFlight uint64) (*Inspector, *Application) { + t.Helper() + + app := &Application{ + ID: 1, + IApplicationAddress: randomAddress(), + Name: "test-app", + ExecutionParameters: ExecutionParameters{ + InspectMaxDeadline: 10 * time.Second, + }, + } + repo := newMockRepository() + repo.apps = append(repo.apps, app) + + mm := newMockMachines() + mm.Map[1] = MockMachine{application: app} + + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.AdvancerConfig{ + LogLevel: slog.LevelError, + InspectAddress: "127.0.0.1:0", + InspectMaxInflight: maxInFlight, + }, + Repository: repo, + Machines: mm, + }) + require.NoError(t, err) + + insp := svc.(*Inspector) + + return insp, app +} + +func TestInspector_AdmissionAccessor(t *testing.T) { + insp, _ := newInspectorWithAdmission(t, 1) + require.Same(t, insp.Admission, insp.Admission, + "Admission() must return the instance passed via CreateInfo") + + inspNil, _ := newInspectorWithAdmission(t, 0) + require.Nil(t, inspNil.Admission, + "Admission() must return nil when admission control is disabled") +} + +func TestInspector_AdmissionRejectsWhenExhausted(t *testing.T) { + // Pre-fill a single-permit admission so every subsequent request + // bounces with 503 regardless of payload shape. + insp, app := newInspectorWithAdmission(t, 1) + insp.Admission.TryAcquire() // pre-fill the single permit + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusServiceUnavailable, rr.Code) + retryAfter, err := strconv.Atoi(rr.Header().Get("Retry-After")) + require.NoError(t, err) + require.GreaterOrEqual(t, retryAfter, 1) + require.LessOrEqual(t, retryAfter, 3) + require.Contains(t, rr.Body.String(), "service at capacity") + require.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options")) + require.Equal(t, uint64(1), insp.Admission.Rejected()) +} + +func TestInspector_AdmissionDisabledWhenNil(t *testing.T) { + // nil Admission should disable the gate entirely. Any request + // should reach the handler. + insp, app := newInspectorWithAdmission(t, 0) + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestInspector_AdmissionPermitReleasedAfterRequest(t *testing.T) { + insp, app := newInspectorWithAdmission(t, 1) + + for range 5 { + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "sequential requests must always succeed at limit=1") + } + require.Equal(t, uint64(0), insp.Admission.Rejected()) +} + +// ----------------------------------------------------------------------------- +// Per-app capacity (ErrInspectAtCapacity) +// ----------------------------------------------------------------------------- + +func TestInspector_PerAppCapacityReturns503(t *testing.T) { + // Inject ErrInspectAtCapacity as the machine-level error to simulate a + // saturated per-app semaphore. The handler should return 503 with a + // body that says "Application inspect at capacity". + insp, app := newInspectorForTest(t, manager.ErrInspectAtCapacity) + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusServiceUnavailable, rr.Code) + require.Contains(t, rr.Body.String(), "Application inspect at capacity") +} + +// ----------------------------------------------------------------------------- +// CORS integration +// ----------------------------------------------------------------------------- + +func newInspectorWithCORS(t *testing.T, origins string) (*Inspector, *Application) { + t.Helper() + + app := &Application{ + ID: 1, + IApplicationAddress: randomAddress(), + Name: "test-app", + } + repo := newMockRepository() + repo.apps = append(repo.apps, app) + + mm := newMockMachines() + mm.Map[1] = MockMachine{application: app} + + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.AdvancerConfig{ + LogLevel: slog.LevelError, + InspectAddress: "127.0.0.1:0", + InspectCorsAllowedOrigins: origins, + }, + Repository: repo, + Machines: mm, + }) + require.NoError(t, err) + + insp := svc.(*Inspector) + + return insp, app +} + +func TestInspector_CORSDisabledByDefault(t *testing.T) { + insp, app := newInspectorWithCORS(t, "") + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + req.Header.Set("Origin", "http://evil.com") + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Empty(t, rr.Header().Get("Vary")) +} + +func TestInspector_CORSAllowedOriginEchoed(t *testing.T) { + insp, app := newInspectorWithCORS(t, "http://trusted.example.com") + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + req.Header.Set("Origin", "http://trusted.example.com") + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, "http://trusted.example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") +} + +func TestInspector_CORSDisallowedOriginNoGrant(t *testing.T) { + insp, app := newInspectorWithCORS(t, "http://trusted.example.com") + + req := httptest.NewRequest(http.MethodPost, + fmt.Sprintf("/inspect/%s", app.Name), + strings.NewReader("hello")) + req.Header.Set("Origin", "http://evil.com") + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") +} + +func TestInspector_CORSPreflightShortCircuits(t *testing.T) { + insp, app := newInspectorWithCORS(t, "http://trusted.example.com") + + req := httptest.NewRequest(http.MethodOptions, + fmt.Sprintf("/inspect/%s", app.Name), nil) + req.Header.Set("Origin", "http://trusted.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rr := httptest.NewRecorder() + insp.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusNoContent, rr.Code) + require.Equal(t, "http://trusted.example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "3600", rr.Header().Get("Access-Control-Max-Age")) +} + +// ----------------------------------------------------------------------------- + +// TestInspector_ServeReturnsNilOnGracefulShutdown verifies the new Serve() +// method swallows http.ErrServerClosed and returns nil, matching the +// contract the advancer relies on. +func TestInspector_ServeReturnsNilOnGracefulShutdown(t *testing.T) { + insp, _ := newInspectorForTest(t, nil) + + // Pre-bind a listener on an OS-assigned port so the test knows the + // real address (http.Server never rewrites Addr after binding to :0). + // Inject it via the listen hook so Serve() uses it. + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := listener.Addr().String() + insp.HTTPServiceTemplate.Listen = func(string, string) (net.Listener, error) { return listener, nil } + + serveErr := make(chan error, 1) + go func() { serveErr <- insp.Serve(t.Context()) }() + + // Wait until the server is actually accepting connections before + // shutting down. Any HTTP response (including 404) confirms the + // accept loop is live. + deadline := time.Now().Add(2 * time.Second) + ready := false + for time.Now().Before(deadline) { + resp, err := http.Get("http://" + addr) + if err == nil { + resp.Body.Close() + ready = true + break + } + time.Sleep(5 * time.Millisecond) + } + require.True(t, ready, "server did not start listening in time") + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, insp.HTTPServiceTemplate.Server.Shutdown(ctx)) + + select { + case err := <-serveErr: + require.NoError(t, err, "Serve() must return nil on graceful shutdown") + case <-time.After(2 * time.Second): + t.Fatal("Serve did not return after Shutdown") + } +} diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go new file mode 100644 index 000000000..6ddfb0b0c --- /dev/null +++ b/internal/inspect/inspect.go @@ -0,0 +1,368 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package inspect + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/inspectmessages" + "github.com/cartesi/rollups-node/internal/manager" + . "github.com/cartesi/rollups-node/internal/model" + pkgmachine "github.com/cartesi/rollups-node/pkg/machine" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// httpShutdownTimeout is how long to wait for in-flight inspect HTTP requests +// to drain before forcibly closing the server during shutdown. +const httpShutdownTimeout = 10 * time.Second + +// maxPayloadSize is the maximum allowed inspect request body size. +// This matches the Cartesi Machine's CMIO RX buffer (2 MiB, defined as log2 size 21 +// in the machine emulator's pma-defines.h). Payloads larger than this are rejected +// by the machine anyway, so there is no reason to read them into memory. +const maxPayloadSize = 1 << 21 // 2 MiB + +// inspectResponseHeadroom is the time budget reserved for HTTP response +// serialization after the Cartesi Machine's inspect deadline fires. +const inspectResponseHeadroom = 30 * time.Second + +const ( + inspectStatusFailed = "Failed" + inspectFailureMessage = "The node could not complete the inspection" +) + +var ( + ErrInvalidMachines = errors.New("machines must not be nil") + ErrNoApp = errors.New("no application") + ErrMachineNotReady = errors.New("machine not ready for application") + ErrForeclosedAppNoMachine = errors.New("application was foreclosed; machine unavailable") + ErrTerminalAppNoInspect = errors.New("application is terminal; inspect unavailable") +) + +type IInspectMachines interface { + GetMachine(appId int64) (manager.MachineInstance, bool) +} + +type InspectRepository interface { + GetApplication(ctx context.Context, nameOrAddress string) (*Application, error) +} + +type Inspector struct { + IInspectMachines + service.HTTPServiceTemplate + repository InspectRepository + deadlineWarnedMu sync.Mutex + deadlineWarned map[int64]struct{} +} + +type ReportResponse struct { + Payload string `json:"payload"` +} + +type InspectResponse struct { + Status string `json:"status"` + Error string `json:"error,omitempty"` + ExceptionData string `json:"exception_data,omitempty"` + Reports []ReportResponse `json:"reports"` + ProcessedInputs uint64 `json:"processed_input_count"` +} + +// CreateInfo bundles the parameters for [NewInspector]. +type CreateInfo struct { + Config config.AdvancerConfig + Repository InspectRepository + Machines IInspectMachines +} + +func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { + var err error + if err = ctx.Err(); err != nil { + return nil, err // This returns context.Canceled or context.DeadlineExceeded. + } + if c.Machines == nil { + return nil, ErrInvalidMachines + } + + inspector := &Inspector{ + IInspectMachines: c.Machines, + repository: c.Repository, + deadlineWarned: make(map[int64]struct{}), + } + + mux := http.NewServeMux() + mux.Handle("/inspect/{dapp}", inspector) + + httpCfg := &service.HTTPServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: "inspect", + LogLevel: config.ResolveServiceLogLevel(config.ServiceAdvancer, c.Config.LogLevel), + LogColor: c.Config.LogColor, + }, + HTTPServerOptions: service.DefaultInspectOptions(), + Address: c.Config.InspectAddress, + SafeRequestID: true, + CorsAllowedOrigins: c.Config.InspectCorsAllowedOrigins, + MaxInflight: c.Config.InspectMaxInflight, + ShutdownTimeout: httpShutdownTimeout, + } + service.InitHTTPServiceTemplate(&inspector.HTTPServiceTemplate, httpCfg, mux) + + return inspector, nil +} + +func (inspect *Inspector) ServeHTTP(w http.ResponseWriter, r *http.Request) { + requestID := service.RequestIDFromContext(r.Context()) + logger := inspect.Logger.With("request_id", requestID) + dapp := r.PathValue("dapp") + + if dapp == "" { + logger.Info("Bad request", + "err", "Missing application address") + http.Error(w, inspectmessages.MissingApplicationAddress, http.StatusBadRequest) + return + } + + if r.Method != http.MethodPost { + logger.Info("HTTP method not allowed", "application", dapp, "method", r.Method) + w.Header().Set("Allow", http.MethodPost) + http.Error(w, inspectmessages.MethodNotAllowed, http.StatusMethodNotAllowed) + return + } + + // Cap the request body at the machine's CMIO RX buffer size. MaxBytesReader + // both enforces the limit and signals the server to close the connection + // on over-limit so clients can't pipeline further requests on it. + r.Body = http.MaxBytesReader(w, r.Body, maxPayloadSize) + payload, err := io.ReadAll(r.Body) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + logger.Info("Payload too large", + "limit", maxPayloadSize, + "application", dapp) + http.Error(w, inspectmessages.PayloadTooLarge, http.StatusRequestEntityTooLarge) + return + } + logger.Info("Bad request", "err", err, "application", dapp) + http.Error(w, inspectmessages.BadRequest, http.StatusBadRequest) + return + } + + logger.Info("Got new inspect request", "application", dapp) + + app, machine, resolveErr := inspect.resolveApp(r.Context(), dapp) + if resolveErr != nil { + if errors.Is(resolveErr, ErrTerminalAppNoInspect) { + logger.Info("Terminal application inspect unavailable", + "application", dapp, "err", resolveErr) + http.Error(w, inspectmessages.TerminalApplication, http.StatusServiceUnavailable) + return + } + if errors.Is(resolveErr, ErrMachineNotReady) { + logger.Warn("Machine not ready", "application", dapp, "err", resolveErr) + http.Error(w, inspectmessages.MachineNotReady, http.StatusServiceUnavailable) + return + } + if errors.Is(resolveErr, ErrForeclosedAppNoMachine) { + logger.Info("Foreclosed application machine unavailable", "application", dapp, "err", resolveErr) + http.Error(w, inspectmessages.ForeclosedApplication, http.StatusServiceUnavailable) + return + } + if errors.Is(resolveErr, ErrNoApp) { + logger.Info("Application not found", "application", dapp, "err", resolveErr) + http.Error(w, inspectmessages.ApplicationNotFound, http.StatusNotFound) + return + } + service.WriteInternalError(r.Context(), w, inspect.Logger, + fmt.Errorf("inspect resolve failed: %w", resolveErr)) + return + } + + deadline := app.ExecutionParameters.InspectMaxDeadline + inspectResponseHeadroom + if inspect.Server != nil && deadline > inspect.Server.WriteTimeout { + inspect.warnDeadlineExceedsWriteTimeout(app, deadline) + } + ctx, cancel := context.WithTimeout(r.Context(), deadline) + defer cancel() + + result, err := machine.Inspect(ctx, payload) + if err != nil { + switch { + case errors.Is(err, manager.ErrInspectAtCapacity): + logger.Info("Application inspect at capacity", + "application", dapp) + http.Error(w, inspectmessages.InspectAtCapacity, http.StatusServiceUnavailable) + return + case errors.Is(err, manager.ErrMachineClosed): + logger.Info("Application machine unavailable", + "application", dapp) + http.Error(w, inspectmessages.MachineNotReady, http.StatusServiceUnavailable) + return + } + service.WriteInternalError(ctx, w, inspect.Logger, + fmt.Errorf("inspect processing failed: %w", err)) + return + } + + response := inspect.buildInspectResponse(dapp, requestID, result) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + // Headers are already flushed; we can only log. Writing a 500 via + // WriteInternalError here would produce "superfluous WriteHeader" + // warnings and garble the response. + logger.Error("failed to encode inspect response", + "err", err, + "application", dapp, + ) + return + } + logger.Info("Request executed", + "status", response.Status, + "application", dapp) +} + +// buildInspectResponse maps the manager result to the public inspect contract. +// Machine execution details remain in trusted logs; anonymous clients receive +// a stable sanitized failure message and any reports emitted before the stop. +func (inspect *Inspector) buildInspectResponse( + dapp string, + requestID string, + result *manager.InspectResult, +) InspectResponse { + response := InspectResponse{ + Reports: make([]ReportResponse, 0, len(result.Reports)), + ProcessedInputs: result.ProcessedInputs, + } + for _, report := range result.Reports { + response.Reports = append(response.Reports, ReportResponse{Payload: hexutil.Encode(report)}) + } + + if result.Error != nil { + response.Status = inspectStatusFailed + response.Error = inspectFailureMessage + // Inspect is an anonymous endpoint. Keep machine positions and local + // policy values in operator logs so clients cannot discover configured + // limits or price a resource-exhaustion input. + inspect.Logger.Warn("Machine failed while inspecting", + "application", dapp, + "error", result.Error, + "request_id", requestID, + ) + return response + } + + switch result.Status { + case pkgmachine.CompletionStatusAccepted: + response.Status = "Accepted" + case pkgmachine.CompletionStatusRejected: + response.Status = "Rejected" + case pkgmachine.CompletionStatusException: + response.Status = "Exception" + response.Error = "The machine raised an exception while inspecting" + response.ExceptionData = hexutil.Encode(result.ExceptionData) + inspect.Logger.Debug("Machine returned a guest inspect exception", + "application", dapp, + "request_id", requestID, + ) + case pkgmachine.CompletionStatusHalted: + response.Status = "MachineHalted" + inspect.Logger.Debug("Machine halted while inspecting", + "application", dapp, + "request_id", requestID, + ) + case pkgmachine.CompletionStatusOverflow: + // The current public inspect schema predates the machine overflow + // condition. Preserve its sanitized failure contract until that API is + // versioned to expose overflow directly. + response.Status = inspectStatusFailed + response.Error = inspectFailureMessage + inspect.Logger.Debug("Machine reached mcycle overflow while inspecting", + "application", dapp, + "request_id", requestID, + ) + case pkgmachine.CompletionStatusUnexpectedYield: + // As with overflow, keep the existing public inspect contract stable and + // expose the new outcome only through trusted logs for now. + response.Status = inspectStatusFailed + response.Error = inspectFailureMessage + inspect.Logger.Debug("Machine returned an unexpected manual yield while inspecting", + "application", dapp, + "request_id", requestID, + ) + case pkgmachine.CompletionStatusUnknown, pkgmachine.CompletionStatusInvalidOutputsRoot: + // InvalidOutputsRoot is advance-only. Do not expose it as an inspect + // result if an implementation incorrectly returns it here. + response.Status = inspectStatusFailed + response.Error = inspectFailureMessage + inspect.Logger.Warn("Machine returned an incomplete or advance-only inspect result", + "application", dapp, + "request_id", requestID, + ) + default: + response.Status = inspectStatusFailed + response.Error = inspectFailureMessage + inspect.Logger.Warn("Machine returned an unknown inspect status", + "application", dapp, + "status", result.Status, + "request_id", requestID, + ) + } + return response +} + +func (inspect *Inspector) warnDeadlineExceedsWriteTimeout(app *Application, deadline time.Duration) { + inspect.deadlineWarnedMu.Lock() + defer inspect.deadlineWarnedMu.Unlock() + if _, seen := inspect.deadlineWarned[app.ID]; seen { + return + } + inspect.deadlineWarned[app.ID] = struct{}{} + inspect.Logger.Warn( + "application inspect deadline exceeds HTTP WriteTimeout; response may be truncated", + "application", app.Name, + "inspect_max_deadline", app.ExecutionParameters.InspectMaxDeadline, + "response_headroom", inspectResponseHeadroom, + "effective_deadline", deadline, + "http_write_timeout", inspect.Server.WriteTimeout, + ) +} + +func (inspect *Inspector) resolveApp( + ctx context.Context, + nameOrAddress string, +) (*Application, manager.MachineInstance, error) { + app, err := inspect.repository.GetApplication(ctx, nameOrAddress) + if app == nil { + if err != nil { + return nil, nil, fmt.Errorf("%w %s", err, nameOrAddress) + } + return nil, nil, fmt.Errorf("%w %s", ErrNoApp, nameOrAddress) + } + if app.Status.IsTerminal() { + return nil, nil, fmt.Errorf( + "%w: application %s has status %s", + ErrTerminalAppNoInspect, + nameOrAddress, + app.Status, + ) + } + machine, exists := inspect.GetMachine(app.ID) + if !exists { + if app.IsForeclosed() { + return nil, nil, fmt.Errorf("%w %s", ErrForeclosedAppNoMachine, nameOrAddress) + } + return nil, nil, fmt.Errorf("%w %s", ErrMachineNotReady, nameOrAddress) + } + return app, machine, nil +} diff --git a/internal/inspect/inspect_test.go b/internal/inspect/inspect_test.go new file mode 100644 index 000000000..0d60a40c6 --- /dev/null +++ b/internal/inspect/inspect_test.go @@ -0,0 +1,604 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package inspect + +import ( + "bytes" + "context" + crand "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/manager" + . "github.com/cartesi/rollups-node/internal/model" + inspectclient "github.com/cartesi/rollups-node/pkg/inspectclient" + pkgmachine "github.com/cartesi/rollups-node/pkg/machine" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/suite" +) + +func TestInspect(t *testing.T) { + suite.Run(t, new(InspectSuite)) +} + +type InspectSuite struct { + suite.Suite +} + +func (s *InspectSuite) TestPostOk() { + inspect, app, payload := s.setup() + + srv := s.startServer(inspect) + defer srv.Close() + + resp, err := http.Post(fmt.Sprintf("%s/inspect/%s", srv.URL, app.IApplicationAddress.Hex()), + "application/octet-stream", + bytes.NewBuffer(payload.Bytes())) + if err != nil { + s.FailNow(err.Error()) + } + s.assertResponse(resp, payload.Hex()) +} + +func (s *InspectSuite) TestPostWithNameOk() { + inspect, app, payload := s.setup() + + srv := s.startServer(inspect) + defer srv.Close() + + resp, err := http.Post(fmt.Sprintf("%s/inspect/%s", srv.URL, app.Name), + "application/octet-stream", + bytes.NewBuffer(payload.Bytes())) + if err != nil { + s.FailNow(err.Error()) + } + s.assertResponse(resp, payload.Hex()) +} + +func (s *InspectSuite) TestPostNoApp() { + inspect, _, payload := s.setup() + + srv := s.startServer(inspect) + defer srv.Close() + + resp, err := http.Post(fmt.Sprintf("%s/inspect/%s", srv.URL, "Aloha"), + "application/octet-stream", + bytes.NewBuffer(payload.Bytes())) + s.Require().Nil(err) + s.Equal(http.StatusNotFound, resp.StatusCode) + + resp, err = http.Post(fmt.Sprintf("%s/inspect/%s", srv.URL, + "0x1000000000000000000000000000000000000000"), + "application/octet-stream", + bytes.NewBuffer(payload.Bytes())) + s.Require().Nil(err) + s.Equal(http.StatusNotFound, resp.StatusCode) +} + +func (s *InspectSuite) TestPostMachineNotReady() { + app := &Application{ + ID: 42, + IApplicationAddress: randomAddress(), + Name: "app-no-machine", + } + repo := newMockRepository() + repo.apps = append(repo.apps, app) + machines := newMockMachines() + + inspect := &Inspector{ + repository: repo, + IInspectMachines: machines, + } + service.InitServiceTemplate(&inspect.BaseTemplate, &service.BaseConfigs{}) + + srv := s.startServer(inspect) + defer srv.Close() + + respByName, err := http.Post(fmt.Sprintf("%s/inspect/%s", srv.URL, app.Name), + "application/octet-stream", + bytes.NewBuffer([]byte("hello"))) + s.Require().Nil(err) + defer respByName.Body.Close() + s.Equal(http.StatusServiceUnavailable, respByName.StatusCode) + + respByAddr, err := http.Post(fmt.Sprintf("%s/inspect/%s", srv.URL, app.IApplicationAddress.Hex()), + "application/octet-stream", + bytes.NewBuffer([]byte("hello"))) + s.Require().Nil(err) + defer respByAddr.Body.Close() + s.Equal(http.StatusServiceUnavailable, respByAddr.StatusCode) +} + +func (s *InspectSuite) TestPostForeclosedMachineUnavailable() { + app := &Application{ + ID: 42, + IApplicationAddress: randomAddress(), + Name: "app-foreclosed", + Status: ApplicationStatus_OK, + ForecloseBlock: 100, + } + repo := newMockRepository() + repo.apps = append(repo.apps, app) + machines := newMockMachines() + + inspect := &Inspector{ + repository: repo, + IInspectMachines: machines, + } + service.InitServiceTemplate(&inspect.BaseTemplate, &service.BaseConfigs{}) + + srv := s.startServer(inspect) + defer srv.Close() + + resp, err := http.Post(fmt.Sprintf("%s/inspect/%s", srv.URL, app.Name), + "application/octet-stream", + bytes.NewBuffer([]byte("hello"))) + s.Require().Nil(err) + defer resp.Body.Close() + s.Equal(http.StatusServiceUnavailable, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + s.Require().Nil(err) + s.Contains(string(body), "Application was foreclosed; machine unavailable") +} + +func (s *InspectSuite) TestPostTerminalApplicationUnavailable() { + for _, status := range ApplicationStatusAllValues { + if !status.IsTerminal() { + continue + } + s.Run(status.String(), func() { + inspect, app, _ := s.setup() + app.Status = status + request := httptest.NewRequest( + http.MethodPost, + "/inspect/"+app.Name, + bytes.NewBufferString("query"), + ) + request.SetPathValue("dapp", app.Name) + recorder := httptest.NewRecorder() + + inspect.ServeHTTP(recorder, request) + + s.Equal(http.StatusServiceUnavailable, recorder.Code) + s.Contains(recorder.Body.String(), "Application is terminal; inspect unavailable") + }) + } +} + +func (s *InspectSuite) TestPostMachineClosedDuringInspectIsUnavailable() { + inspect, app, _ := s.setup() + machine := inspect.IInspectMachines.(*MachinesMock).Map[app.ID] + machine.inspectError = manager.ErrMachineClosed + inspect.IInspectMachines.(*MachinesMock).Map[app.ID] = machine + request := httptest.NewRequest( + http.MethodPost, + "/inspect/"+app.Name, + bytes.NewBufferString("query"), + ) + request.SetPathValue("dapp", app.Name) + recorder := httptest.NewRecorder() + + inspect.ServeHTTP(recorder, request) + + s.Equal(http.StatusServiceUnavailable, recorder.Code) + s.Contains(recorder.Body.String(), "Machine not ready") +} + +func (s *InspectSuite) TestPostMaxPayloadSize() { + inspect, app, _ := s.setup() + + srv := s.startServer(inspect) + defer srv.Close() + + payload := make([]byte, maxPayloadSize) + _, err := crand.Read(payload) + s.Require().NoError(err) + + resp, err := http.Post( + fmt.Sprintf("%s/inspect/%s", srv.URL, app.IApplicationAddress.Hex()), + "application/octet-stream", + bytes.NewReader(payload)) + s.Require().NoError(err) + defer resp.Body.Close() + s.Equal(http.StatusOK, resp.StatusCode) + + var r InspectResponse + err = json.NewDecoder(resp.Body).Decode(&r) + s.Require().NoError(err) + s.Equal("Accepted", r.Status) + s.Require().Len(r.Reports, 1) +} + +func (s *InspectSuite) TestPostPayloadTooLarge() { + inspect, app, _ := s.setup() + + srv := s.startServer(inspect) + defer srv.Close() + + payload := make([]byte, maxPayloadSize+1) + _, err := crand.Read(payload) + s.Require().NoError(err) + + resp, err := http.Post( + fmt.Sprintf("%s/inspect/%s", srv.URL, app.IApplicationAddress.Hex()), + "application/octet-stream", + bytes.NewReader(payload)) + s.Require().NoError(err) + defer resp.Body.Close() + s.Equal(http.StatusRequestEntityTooLarge, resp.StatusCode) +} + +func (s *InspectSuite) TestPostResponseMatchesGeneratedClientContract() { + tests := []struct { + name string + result manager.InspectResult + wantStatus inspectclient.CompletionStatus + wantError string + wantExceptionData []byte + }{ + { + name: "accepted", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusAccepted, + Reports: [][]byte{{0xde, 0xad}, {}}, + ProcessedInputs: 17, + }, + wantStatus: inspectclient.Accepted, + }, + { + name: "rejected", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusRejected, + Reports: [][]byte{{0xbe, 0xef}}, + ProcessedInputs: 23, + }, + wantStatus: inspectclient.Rejected, + }, + { + name: "exception", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusException, + ExceptionData: []byte{0xff, 0x00, 0x80}, + Reports: [][]byte{{0xca, 0xfe}}, + ProcessedInputs: 42, + }, + wantStatus: inspectclient.Exception, + wantError: "The machine raised an exception while inspecting", + wantExceptionData: []byte{0xff, 0x00, 0x80}, + }, + { + name: "halted", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusHalted, + Reports: [][]byte{{0xfa, 0xce}}, + ProcessedInputs: 51, + }, + wantStatus: inspectclient.MachineHalted, + }, + { + name: "overflow", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusOverflow, + Reports: [][]byte{{0xab, 0xcd}}, + ProcessedInputs: 52, + }, + wantStatus: inspectclient.Failed, + wantError: inspectFailureMessage, + }, + { + name: "unexpected yield", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusUnexpectedYield, + Reports: [][]byte{{0xab, 0xce}}, + ProcessedInputs: 53, + }, + wantStatus: inspectclient.Failed, + wantError: inspectFailureMessage, + }, + { + name: "advance-only invalid outputs root", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusInvalidOutputsRoot, + }, + wantStatus: inspectclient.Failed, + wantError: inspectFailureMessage, + }, + { + name: "failed", + result: manager.InspectResult{ + Status: pkgmachine.CompletionStatusUnknown, + Reports: [][]byte{{0xba, 0xdd}}, + ProcessedInputs: 63, + Error: errors.New("backend disconnected"), + }, + wantStatus: inspectclient.Failed, + wantError: inspectFailureMessage, + }, + } + + for _, test := range tests { + s.Run(test.name, func() { + inspect, app := s.setupWithInspectResult(test.result) + app.Status = ApplicationStatus_OK + request := httptest.NewRequest(http.MethodPost, "/inspect/"+app.Name, + bytes.NewBufferString("query")) + request.SetPathValue("dapp", app.Name) + recorder := httptest.NewRecorder() + inspect.ServeHTTP(recorder, request) + s.Equal(http.StatusOK, recorder.Code) + + var got inspectclient.InspectResult + s.Require().NoError(json.NewDecoder(recorder.Body).Decode(&got)) + s.Equal(test.wantStatus, got.Status) + s.EqualValues(test.result.ProcessedInputs, got.ProcessedInputCount) + s.Require().Len(got.Reports, len(test.result.Reports)) + for i, report := range test.result.Reports { + s.Equal(fmt.Sprintf("0x%x", report), got.Reports[i].Payload) + } + if test.wantError == "" { + s.Nil(got.Error) + } else { + s.Require().NotNil(got.Error) + s.Equal(test.wantError, *got.Error) + } + if test.wantExceptionData == nil { + s.Nil(got.ExceptionData) + } else { + s.Require().NotNil(got.ExceptionData) + s.Equal(fmt.Sprintf("0x%x", test.wantExceptionData), *got.ExceptionData) + } + s.Equal(ApplicationStatus_OK, app.Status, "inspect limits must not change application status") + }) + } +} + +func (s *InspectSuite) TestCycleLimitIsSanitizedFailedResultWithoutApplicationFailure() { + detailedErr := fmt.Errorf( + "inspect stopped at absolute_mcycle=123456 with configured_cap=789: %w", + pkgmachine.ErrReachedLimitMcycle, + ) + inspect, app := s.setupWithInspectResult(manager.InspectResult{ + Status: pkgmachine.CompletionStatusUnknown, + ProcessedInputs: 42, + Error: detailedErr, + }) + var logs bytes.Buffer + inspect.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app.Status = ApplicationStatus_OK + req := httptest.NewRequest(http.MethodPost, "/inspect/"+app.Name, bytes.NewBufferString("query")) + req.SetPathValue("dapp", app.Name) + recorder := httptest.NewRecorder() + + inspect.ServeHTTP(recorder, req) + + s.Equal(http.StatusOK, recorder.Code) + var got inspectclient.InspectResult + s.Require().NoError(json.NewDecoder(recorder.Body).Decode(&got)) + s.Equal(inspectclient.Failed, got.Status) + s.Require().NotNil(got.Error) + s.Equal(inspectFailureMessage, *got.Error) + s.NotContains(*got.Error, "absolute_mcycle") + s.NotContains(*got.Error, "configured_cap") + s.Contains(logs.String(), "absolute_mcycle=123456") + s.Contains(logs.String(), "configured_cap=789") + s.EqualValues(42, got.ProcessedInputCount) + s.Equal(ApplicationStatus_OK, app.Status) +} + +func (s *InspectSuite) TestGuestExceptionUsesDebugLogLevel() { + inspect, app := s.setupWithInspectResult(manager.InspectResult{ + Status: pkgmachine.CompletionStatusException, + ExceptionData: []byte("guest exception details"), + ProcessedInputs: 42, + }) + var logs bytes.Buffer + inspect.Logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + req := httptest.NewRequest(http.MethodPost, "/inspect/"+app.Name, bytes.NewBufferString("query")) + req.SetPathValue("dapp", app.Name) + recorder := httptest.NewRecorder() + + inspect.ServeHTTP(recorder, req) + + s.Equal(http.StatusOK, recorder.Code) + s.Contains(logs.String(), "level=DEBUG") + s.Contains(logs.String(), "Machine returned a guest inspect exception") + s.NotContains(logs.String(), "level=WARN") + s.NotContains(logs.String(), "guest exception details") +} + +func (s *InspectSuite) startServer(inspect *Inspector) *httptest.Server { + router := http.NewServeMux() + router.Handle("/inspect/{dapp}", inspect) + return httptest.NewServer(router) +} + +func (s *InspectSuite) setup() (*Inspector, *Application, common.Hash) { + payload := randomHash() + inspect, app := s.setupWithInspectResult(manager.InspectResult{ + Status: pkgmachine.CompletionStatusAccepted, + Reports: [][]byte{payload.Bytes()}, + }) + return inspect, app, payload +} + +func (s *InspectSuite) setupWithInspectResult(result manager.InspectResult) (*Inspector, *Application) { + m := newMockMachine(1) + m.inspectResult = &result + repo := newMockRepository() + repo.apps = append(repo.apps, m.application) + machines := newMockMachines() + machines.Map[1] = *m + inspect := &Inspector{ + repository: repo, + IInspectMachines: machines, + } + service.InitServiceTemplate(&inspect.BaseTemplate, &service.BaseConfigs{}) + return inspect, m.application +} + +func (s *InspectSuite) assertResponse(resp *http.Response, payload string) { + s.Equal(http.StatusOK, resp.StatusCode) + + defer resp.Body.Close() + + var r InspectResponse + err := json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + s.FailNow("failed to read response body. ", err) + } + s.Equal(payload, r.Reports[0].Payload) +} + +// ------------------------------------------------------------------------------------------------ + +type MachinesMock struct { + Map map[int64]MockMachine +} + +func newMockMachines() *MachinesMock { + return &MachinesMock{ + Map: map[int64]MockMachine{}, + } +} + +func (mock *MachinesMock) GetMachine(appId int64) (manager.MachineInstance, bool) { + machine, exists := mock.Map[appId] + if !exists { + return nil, false + } + return &machine, exists +} + +// ------------------------------------------------------------------------------------------------ + +type MockMachine struct { + application *Application + inspectResult *manager.InspectResult + inspectError error +} + +func (mock *MockMachine) Inspect( + _ context.Context, + query []byte, +) (*manager.InspectResult, error) { + if mock.inspectError != nil { + return nil, mock.inspectError + } + if mock.inspectResult != nil { + result := *mock.inspectResult + return &result, nil + } + + var res manager.InspectResult + var reports [][]byte + + reports = append(reports, query) + res.Status = pkgmachine.CompletionStatusAccepted + res.ProcessedInputs = 0 + res.Error = nil + res.Reports = reports + + return &res, nil +} + +// Not used in inspect tests, but needed to satisfy the interface +func (mock *MockMachine) Advance( + _ context.Context, + input []byte, + _ uint64, + _ uint64, + _ bool, +) (*AdvanceResult, error) { + return nil, nil +} + +func (mock *MockMachine) Application() *Application { + return mock.application +} + +func (mock *MockMachine) ProcessedInputs() uint64 { + return 0 +} + +func (mock *MockMachine) StateProof(_ context.Context) (*StateProof, error) { + return nil, nil +} + +// Not used in inspect tests, but needed to satisfy the interface +func (mock *MockMachine) CreateSnapshot(ctx context.Context, processedInputs uint64, path string) error { + return nil +} + +// Retrieves the hash of the current machine state +func (m *MockMachine) Hash(ctx context.Context) ([32]byte, error) { + return [32]byte{}, nil +} + +// Not used in inspect tests, but needed to satisfy the interface +func (mock *MockMachine) Close() error { + return nil +} + +func newMockMachine(id int64) *MockMachine { + return &MockMachine{ + application: &Application{ + ID: id, + IApplicationAddress: randomAddress(), + Name: fmt.Sprintf("app-%v", id), + ExecutionParameters: ExecutionParameters{ + InspectMaxDeadline: 10 * time.Second, + }, + }, + } +} + +// ------------------------------------------------------------------------------------------------ + +type MockRepository struct { + apps []*Application +} + +func (mock *MockRepository) GetApplication(ctx context.Context, nameOrAddress string) (*Application, error) { + for _, app := range mock.apps { + if app.Name == nameOrAddress || app.IApplicationAddress == common.HexToAddress(nameOrAddress) { + return app, nil + } + } + return nil, nil +} + +func newMockRepository() *MockRepository { + return &MockRepository{apps: []*Application{}} +} + +// ------------------------------------------------------------------------------------------------ + +func randomAddress() common.Address { + address := make([]byte, 20) + _, err := crand.Read(address) + if err != nil { + panic(err) + } + return common.Address(address) +} + +func randomHash() common.Hash { + hash := make([]byte, 32) + _, err := crand.Read(hash) + if err != nil { + panic(err) + } + return common.Hash(hash) +} diff --git a/internal/inspectmessages/messages.go b/internal/inspectmessages/messages.go new file mode 100644 index 000000000..2bd78a050 --- /dev/null +++ b/internal/inspectmessages/messages.go @@ -0,0 +1,18 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package inspectmessages defines the fixed HTTP error messages used by the +// inspect server and retained by clients in protected diagnostics. +package inspectmessages + +const ( + MissingApplicationAddress = "Missing application address" + MethodNotAllowed = "Method not allowed" + PayloadTooLarge = "Payload too large" + BadRequest = "Bad request" + TerminalApplication = "Application is terminal; inspect unavailable" + MachineNotReady = "Machine not ready" + ForeclosedApplication = "Application was foreclosed; machine unavailable" + ApplicationNotFound = "Application not found" + InspectAtCapacity = "Application inspect at capacity" +) diff --git a/internal/jsonrpc/api/decode.go b/internal/jsonrpc/api/decode.go new file mode 100644 index 000000000..7160ce28c --- /dev/null +++ b/internal/jsonrpc/api/decode.go @@ -0,0 +1,281 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package api + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +// ParseOutputType parses a hex-encoded 4-byte output type selector. +func ParseOutputType(s string) ([]byte, error) { + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + s = s[2:] + } + if len(s) != 8 { //nolint: mnd + return []byte{}, fmt.Errorf("invalid output type: expected exactly 4 bytes") + } + b, err := hex.DecodeString(s) + if err != nil { + return []byte{}, err + } + return b, nil +} + +// EvmAdvance represents decoded EvmAdvance input data. +type EvmAdvance struct { + ChainID string `json:"chain_id"` + AppContract string `json:"application_contract"` + MsgSender string `json:"sender"` + BlockNumber string `json:"block_number"` + BlockTimestamp string `json:"block_timestamp"` + PrevRandao string `json:"prev_randao"` + Index string `json:"index"` + Payload string `json:"payload"` +} + +// DecodedInput extends model.Input with ABI-decoded data. +type DecodedInput struct { + *model.Input + DecodedData *EvmAdvance `json:"decoded_data"` +} + +// DecodeInput ABI-decodes a raw input into a DecodedInput. +func DecodeInput(input *model.Input, parsedAbi *abi.ABI) (*DecodedInput, error) { + decoded := make(map[string]any) + if len(input.RawData) < 4 { //nolint: mnd + return &DecodedInput{Input: input}, fmt.Errorf("error: input needs at least 4 bytes") + } + + method, ok := parsedAbi.Methods["EvmAdvance"] + if !ok { + return &DecodedInput{Input: input}, fmt.Errorf("EvmAdvance method not found in ABI") + } + + if !bytes.Equal(input.RawData[:4], method.ID) { + return &DecodedInput{Input: input}, + fmt.Errorf("input selector %x does not match EvmAdvance", input.RawData[:4]) + } + + err := method.Inputs.UnpackIntoMap(decoded, input.RawData[4:]) + if err != nil { + return &DecodedInput{Input: input}, err + } + + chainID, ok1 := decoded["chainId"].(*big.Int) + appContract, ok2 := decoded["appContract"].(common.Address) + msgSender, ok3 := decoded["msgSender"].(common.Address) + blockNumber, ok4 := decoded["blockNumber"].(*big.Int) + blockTimestamp, ok5 := decoded["blockTimestamp"].(*big.Int) + prevRandao, ok6 := decoded["prevRandao"].(*big.Int) + index, ok7 := decoded["index"].(*big.Int) + payload, ok8 := decoded["payload"].([]byte) + if !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 || !ok7 || !ok8 { + return &DecodedInput{Input: input}, fmt.Errorf("unable to decode EvmAdvance parameters") + } + + evmAdvance := EvmAdvance{ + ChainID: fmt.Sprintf("0x%x", chainID), + AppContract: appContract.Hex(), + MsgSender: msgSender.Hex(), + BlockNumber: fmt.Sprintf("0x%x", blockNumber), + BlockTimestamp: fmt.Sprintf("0x%x", blockTimestamp), + PrevRandao: fmt.Sprintf("0x%x", prevRandao), + Index: fmt.Sprintf("0x%x", index), + Payload: "0x" + hex.EncodeToString(payload), + } + + return &DecodedInput{ + Input: input, + DecodedData: &evmAdvance, + }, nil +} + +// MarshalJSON produces a flat JSON object merging Input fields with decoded_data. +func (d *DecodedInput) MarshalJSON() ([]byte, error) { + inputJSON, err := d.Input.MarshalJSON() + if err != nil { + return nil, err + } + if d.DecodedData == nil { + return inputJSON, nil + } + return mergeJSONField(inputJSON, "decoded_data", d.DecodedData) +} + +// DecodedData is the single canonical type for decoded output data, used by +// both the server (JSON-RPC API) and client (CLI) sides. It captures the +// superset of fields across all output types (Notice, Voucher, +// DelegateCallVoucher). Use the Type field to discriminate: +// +// - "Notice": Payload is set. +// - "Voucher": Destination, Value, and Payload are set. +// - "DelegateCallVoucher": Destination and Payload are set. +// - Any other value: RawData contains the hex-encoded raw output. +// +// Use the constructor functions (NewNoticeData, NewVoucherData, +// NewDelegateCallVoucherData, NewUnknownData) to create instances with the +// correct fields populated. +type DecodedData struct { + Type string `json:"type"` + Payload string `json:"payload,omitempty"` + Destination string `json:"destination,omitempty"` + Value string `json:"value,omitempty"` + RawData string `json:"raw_data,omitempty"` +} + +// NewNoticeData creates a DecodedData for a Notice output. +func NewNoticeData(payload string) *DecodedData { + return &DecodedData{Type: "Notice", Payload: payload} +} + +// NewVoucherData creates a DecodedData for a Voucher output. +func NewVoucherData(destination, value, payload string) *DecodedData { + return &DecodedData{ + Type: "Voucher", + Destination: destination, + Value: value, + Payload: payload, + } +} + +// NewDelegateCallVoucherData creates a DecodedData for a DelegateCallVoucher output. +func NewDelegateCallVoucherData(destination, payload string) *DecodedData { + return &DecodedData{ + Type: "DelegateCallVoucher", + Destination: destination, + Payload: payload, + } +} + +// NewUnknownData creates a DecodedData for an unrecognized output type. +func NewUnknownData(typeName, rawData string) *DecodedData { + return &DecodedData{Type: typeName, RawData: rawData} +} + +// DecodedOutput extends model.Output with ABI-decoded data. +type DecodedOutput struct { + *model.Output + DecodedData *DecodedData `json:"decoded_data"` +} + +// DecodeOutput ABI-decodes a raw output into a DecodedOutput. +func DecodeOutput(output *model.Output, parsedAbi *abi.ABI) (*DecodedOutput, error) { + decodedOutput := &DecodedOutput{Output: output} + if len(output.RawData) < 4 { //nolint: mnd + return decodedOutput, fmt.Errorf("raw data too short") + } + method, err := parsedAbi.MethodById(output.RawData[:4]) + if err != nil { + return decodedOutput, err + } + decoded := make(map[string]any) + if err := method.Inputs.UnpackIntoMap(decoded, output.RawData[4:]); err != nil { + return decodedOutput, fmt.Errorf("failed to unpack %s: %w", method.Name, err) + } + var result *DecodedData + switch method.Name { + case "Notice": + payload, ok := decoded["payload"].([]byte) + if !ok { + return decodedOutput, fmt.Errorf("unable to decode Notice payload") + } + result = NewNoticeData("0x" + hex.EncodeToString(payload)) + case "Voucher": + dest, ok1 := decoded["destination"].(common.Address) + value, ok2 := decoded["value"].(*big.Int) + payload, ok3 := decoded["payload"].([]byte) + if !ok1 || !ok2 || !ok3 { + return decodedOutput, fmt.Errorf("unable to decode Voucher parameters") + } + result = NewVoucherData( + dest.Hex(), + fmt.Sprintf("0x%x", value), + "0x"+hex.EncodeToString(payload), + ) + case "DelegateCallVoucher": + dest, ok1 := decoded["destination"].(common.Address) + payload, ok2 := decoded["payload"].([]byte) + if !ok1 || !ok2 { + return decodedOutput, fmt.Errorf("unable to decode DelegateCallVoucher parameters") + } + result = NewDelegateCallVoucherData( + dest.Hex(), + "0x"+hex.EncodeToString(payload), + ) + default: + result = NewUnknownData(method.Name, "0x"+hex.EncodeToString(output.RawData)) + } + decodedOutput.DecodedData = result + return decodedOutput, nil +} + +// UnmarshalJSON deserializes a DecodedOutput: it delegates base fields to +// model.Output.UnmarshalJSON, then extracts and parses the decoded_data field. +func (d *DecodedOutput) UnmarshalJSON(data []byte) error { + if d.Output == nil { + d.Output = new(model.Output) + } + if err := d.Output.UnmarshalJSON(data); err != nil { + return err + } + var raw struct { + DecodedData json.RawMessage `json:"decoded_data"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw.DecodedData) > 0 && string(raw.DecodedData) != "null" { + d.DecodedData = new(DecodedData) + if err := json.Unmarshal(raw.DecodedData, d.DecodedData); err != nil { + return err + } + } + return nil +} + +// MarshalJSON produces a flat JSON object merging Output fields with decoded_data. +func (d *DecodedOutput) MarshalJSON() ([]byte, error) { + outputJSON, err := d.Output.MarshalJSON() + if err != nil { + return nil, err + } + if d.DecodedData == nil { + return outputJSON, nil + } + return mergeJSONField(outputJSON, "decoded_data", d.DecodedData) +} + +// mergeJSONField appends a key-value pair to an existing JSON object. +// The base must be a valid JSON object (ending with '}'). The value is +// marshaled and appended as a new field. +func mergeJSONField(base []byte, key string, value any) ([]byte, error) { + if len(base) == 0 || base[len(base)-1] != '}' { + return nil, fmt.Errorf("mergeJSONField: base is not a valid JSON object") + } + fieldJSON, err := json.Marshal(value) + if err != nil { + return nil, err + } + trimmed := bytes.TrimSuffix(base, []byte("}")) + var buf bytes.Buffer + buf.Write(trimmed) + if len(trimmed) > 0 && trimmed[len(trimmed)-1] != '{' { + buf.WriteByte(',') + } + buf.WriteByte('"') + buf.WriteString(key) + buf.WriteString(`":`) + buf.Write(fieldJSON) + buf.WriteByte('}') + return buf.Bytes(), nil +} diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go new file mode 100644 index 000000000..e982b324b --- /dev/null +++ b/internal/jsonrpc/api/params.go @@ -0,0 +1,319 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" +) + +type StringOrList []string + +func (s *StringOrList) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) > 0 && data[0] == '"' { + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = []string{value} + return nil + } + + var values []string + if err := json.Unmarshal(data, &values); err != nil { + return fmt.Errorf("expected a string or an array of strings: %w", err) + } + *s = values + return nil +} + +// ListApplicationsParams aligns with the OpenRPC specification +type ListApplicationsParams struct { + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetApplicationParams aligns with the OpenRPC specification +type GetApplicationParams struct { + Application string `json:"application"` +} + +// ListEpochsParams aligns with the OpenRPC specification +type ListEpochsParams struct { + Application string `json:"application"` + Status *StringOrList `json:"status,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the epoch index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the epoch index (hex) +} + +// GetEpochParams aligns with the OpenRPC specification +type GetEpochParams struct { + Application string `json:"application"` + EpochIndex string `json:"epoch_index"` +} + +// GetEpochByVirtualIndexParams aligns with the OpenRPC specification +type GetEpochByVirtualIndexParams struct { + Application string `json:"application"` + VirtualIndex string `json:"virtual_index"` +} + +// GetLastAcceptedEpochIndexParams with the OpenRPC specification +type GetLastAcceptedEpochIndexParams struct { + Application string `json:"application"` +} + +// ListInputsParams aligns with the OpenRPC specification +type ListInputsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + Sender *string `json:"sender,omitempty"` + TransactionHash *string `json:"transaction_hash,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the input index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the input index (hex) +} + +// GetInputParams aligns with the OpenRPC specification +type GetInputParams struct { + Application string `json:"application"` + InputIndex string `json:"input_index"` +} + +// GetProcessedInputCountParams aligns with the OpenRPC specification +type GetProcessedInputCountParams struct { + Application string `json:"application"` +} + +// ListOutputsParams aligns with the OpenRPC specification +type ListOutputsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + InputIndex *string `json:"input_index,omitempty"` + OutputType *StringOrList `json:"output_type,omitempty"` + VoucherAddress *string `json:"voucher_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the output index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the output index (hex) + Executed *bool `json:"executed,omitempty"` +} + +// GetOutputParams aligns with the OpenRPC specification +type GetOutputParams struct { + Application string `json:"application"` + OutputIndex string `json:"output_index"` +} + +// ListReportsParams aligns with the OpenRPC specification +type ListReportsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + InputIndex *string `json:"input_index,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the report index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the report index (hex) +} + +// GetReportParams aligns with the OpenRPC specification +type GetReportParams struct { + Application string `json:"application"` + ReportIndex string `json:"report_index"` +} + +// ListTournamentsParams aligns with the OpenRPC specification +type ListTournamentsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + Level *string `json:"level,omitempty"` + ParentTournamentAddress *string `json:"parent_tournament_address,omitempty"` + ParentMatchIDHash *string `json:"parent_match_id_hash,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetTournamentParams aligns with the OpenRPC specification +type GetTournamentParams struct { + Application string `json:"application"` + Address string `json:"address"` +} + +// ListCommitmentsParams aligns with the OpenRPC specification +type ListCommitmentsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + TournamentAddress *string `json:"tournament_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetCommitmentParams aligns with the OpenRPC specification +type GetCommitmentParams struct { + Application string `json:"application"` + EpochIndex string `json:"epoch_index"` + TournamentAddress string `json:"tournament_address"` + Commitment string `json:"commitment"` +} + +// ListMatchesParams aligns with the OpenRPC specification +type ListMatchesParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + TournamentAddress *string `json:"tournament_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetMatchParams aligns with the OpenRPC specification +type GetMatchParams struct { + Application string `json:"application"` + EpochIndex string `json:"epoch_index"` + TournamentAddress string `json:"tournament_address"` + IDHash string `json:"id_hash"` +} + +// ListMatchAdvancesParams aligns with the OpenRPC specification +type ListMatchAdvancesParams struct { + Application string `json:"application"` + EpochIndex string `json:"epoch_index"` + TournamentAddress string `json:"tournament_address"` + IDHash string `json:"id_hash"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetMatchAdvanceParams aligns with the OpenRPC specification +type GetMatchAdvanceParams struct { + Application string `json:"application"` + EpochIndex string `json:"epoch_index"` + TournamentAddress string `json:"tournament_address"` + IDHash string `json:"id_hash"` + TxHash string `json:"tx_hash"` + LogIndex string `json:"log_index"` +} + +// ListBondEventsParams selects immutable refund and recovery events. +type ListBondEventsParams struct { + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + TournamentAddress *string `json:"tournament_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetBondEventParams identifies an event by its transaction and block log index. +type GetBondEventParams struct { + Application string `json:"application"` + TxHash string `json:"tx_hash"` + LogIndex string `json:"log_index"` +} + +// ListStandingChangesParams selects standing history within one tournament. +type ListStandingChangesParams struct { + Application string `json:"application"` + EpochIndex string `json:"epoch_index"` + TournamentAddress string `json:"tournament_address"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetStandingChangeParams identifies a scoped event by transaction and block log index. +type GetStandingChangeParams struct { + Application string `json:"application"` + EpochIndex string `json:"epoch_index"` + TournamentAddress string `json:"tournament_address"` + TxHash string `json:"tx_hash"` + LogIndex string `json:"log_index"` +} + +// ListWithdrawalsParams aligns with the OpenRPC specification +type ListWithdrawalsParams struct { + Application string `json:"application"` + AccountIndex *string `json:"account_index,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` +} + +// GetWithdrawalParams aligns with the OpenRPC specification +type GetWithdrawalParams struct { + Application string `json:"application"` + AccountIndex string `json:"account_index"` +} + +// UnmarshalParams supports both by-name (object) and by-position (array) parameter structures. +// If params is an object, it simply does json.Unmarshal; if it's an array, it will attempt +// to unmarshal each positional parameter into the target struct field in declaration order. +func UnmarshalParams(data json.RawMessage, target any) error { + data = bytes.TrimSpace(data) + switch { + case len(data) == 0: + // Parameters field is absent + return nil + case data[0] == '[': + // Unmarshal positional parameters into a slice of json.RawMessage. + var rawParams []json.RawMessage + if err := json.Unmarshal(data, &rawParams); err != nil { + return err + } + // Use reflection to set values in the target struct in the order they appear. + val := reflect.ValueOf(target) + if val.Kind() != reflect.Pointer || val.IsNil() { + return fmt.Errorf("error unmarshalling positional parameters target must be a non-nil pointer to a struct") + } + val = val.Elem() + if val.Kind() != reflect.Struct { + return fmt.Errorf("error unmarshalling positional parameters target must point to a struct") + } + typ := val.Type() + fields := make([]int, 0, typ.NumField()) + for i := 0; i < typ.NumField(); i++ { + if typ.Field(i).Tag.Get("json") != "-" { + fields = append(fields, i) + } + } + if len(rawParams) > len(fields) { + return fmt.Errorf("error unmarshalling positional parameters, expected %d params, got %d", + len(fields), len(rawParams)) + } + // Map positional parameters to JSON-visible fields in declaration order. + for i, fieldIndex := range fields { + if i >= len(rawParams) { + break + } + field := val.Field(fieldIndex) + if !field.CanSet() { + return fmt.Errorf("error unmarshalling positional parameter field %q is not settable", + typ.Field(fieldIndex).Name) + } + // Unmarshal the corresponding raw parameter into the field. + if err := json.Unmarshal(rawParams[i], field.Addr().Interface()); err != nil { + return fmt.Errorf("error unmarshalling positional parameter %d for field %s: %w", + i, typ.Field(fieldIndex).Name, err) + } + } + return nil + default: + // Otherwise, assume by-name structure. + return json.Unmarshal(data, target) + } +} diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go new file mode 100644 index 000000000..6f661fb27 --- /dev/null +++ b/internal/jsonrpc/api/params_test.go @@ -0,0 +1,293 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListOutputsParamsStringOrList(t *testing.T) { + tests := map[string]struct { + input string + expected StringOrList + }{ + "single selector": { + input: `{"output_type":"0x237a816f"}`, + expected: StringOrList{"0x237a816f"}, + }, + "selector list": { + input: `{"output_type":["0x237a816f","0x10321e8b"]}`, + expected: StringOrList{"0x237a816f", "0x10321e8b"}, + }, + "empty list": { + input: `{"output_type":[]}`, + expected: StringOrList{}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var params ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(test.input), ¶ms)) + require.NotNil(t, params.OutputType) + require.Equal(t, test.expected, *params.OutputType) + }) + } +} + +func TestListEpochsParamsStringOrList(t *testing.T) { + tests := map[string]struct { + input string + expected StringOrList + }{ + "single status": { + input: `{"status":"OPEN"}`, + expected: StringOrList{"OPEN"}, + }, + "status list": { + input: `{"status":["OPEN","CLOSED"]}`, + expected: StringOrList{"OPEN", "CLOSED"}, + }, + "empty list": { + input: `{"status":[]}`, + expected: StringOrList{}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var params ListEpochsParams + require.NoError(t, json.Unmarshal([]byte(test.input), ¶ms)) + require.NotNil(t, params.Status) + require.Equal(t, test.expected, *params.Status) + }) + } +} + +func TestListOutputsParamsExecutedIsOptional(t *testing.T) { + var omitted ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) + require.Nil(t, omitted.Executed) + + var executed ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(`{"executed":true}`), &executed)) + require.NotNil(t, executed.Executed) + require.True(t, *executed.Executed) + + var pending ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(`{"executed":false}`), &pending)) + require.NotNil(t, pending.Executed) + require.False(t, *pending.Executed) +} + +func TestPositionalParamsDeclarationOrder(t *testing.T) { + tests := map[string]struct { + newTarget func() any + positional string + named string + }{ + "ListApplicationsParams": { + func() any { return &ListApplicationsParams{} }, + `[25,3,true]`, + `{"limit":25,"offset":3,"descending":true}`, + }, + "GetApplicationParams": { + func() any { return &GetApplicationParams{} }, + `["app"]`, + `{"application":"app"}`, + }, + "ListEpochsParams": { + func() any { return &ListEpochsParams{} }, + `["app",["OPEN","CLOSED"],25,3,true,"0x2","0x9"]`, + `{"application":"app","status":["OPEN","CLOSED"],"limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9"}`, + }, + "GetEpochParams": { + func() any { return &GetEpochParams{} }, + `["app","0x4"]`, + `{"application":"app","epoch_index":"0x4"}`, + }, + "GetEpochByVirtualIndexParams": { + func() any { return &GetEpochByVirtualIndexParams{} }, + `["app","0x7"]`, + `{"application":"app","virtual_index":"0x7"}`, + }, + "GetLastAcceptedEpochIndexParams": { + func() any { return &GetLastAcceptedEpochIndexParams{} }, + `["app"]`, + `{"application":"app"}`, + }, + "ListInputsParams": { + func() any { return &ListInputsParams{} }, + `["app","0x4","sender","transaction-hash",25,3,true,"0x2","0x9"]`, + `{"application":"app","epoch_index":"0x4","sender":"sender","transaction_hash":"transaction-hash",` + + `"limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9"}`, + }, + "GetInputParams": { + func() any { return &GetInputParams{} }, + `["app","0x5"]`, + `{"application":"app","input_index":"0x5"}`, + }, + "GetProcessedInputCountParams": { + func() any { return &GetProcessedInputCountParams{} }, + `["app"]`, + `{"application":"app"}`, + }, + "ListOutputsParams": { + func() any { return &ListOutputsParams{} }, + `["app","0x4","0x5",["0x237a816f","0x10321e8b"],"voucher",25,3,true,"0x2","0x9",true]`, + `{"application":"app","epoch_index":"0x4","input_index":"0x5",` + + `"output_type":["0x237a816f","0x10321e8b"],"voucher_address":"voucher",` + + `"limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9","executed":true}`, + }, + "GetOutputParams": { + func() any { return &GetOutputParams{} }, + `["app","0x6"]`, + `{"application":"app","output_index":"0x6"}`, + }, + "ListReportsParams": { + func() any { return &ListReportsParams{} }, + `["app","0x4","0x5",25,3,true,"0x2","0x9"]`, + `{"application":"app","epoch_index":"0x4","input_index":"0x5","limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9"}`, + }, + "GetReportParams": { + func() any { return &GetReportParams{} }, + `["app","0x7"]`, + `{"application":"app","report_index":"0x7"}`, + }, + "ListTournamentsParams": { + func() any { return &ListTournamentsParams{} }, + `["app","0x4","0x2","parent-tournament","parent-match",25,3,true]`, + `{"application":"app","epoch_index":"0x4","level":"0x2",` + + `"parent_tournament_address":"parent-tournament","parent_match_id_hash":"parent-match",` + + `"limit":25,"offset":3,"descending":true}`, + }, + "GetTournamentParams": { + func() any { return &GetTournamentParams{} }, + `["app","tournament"]`, + `{"application":"app","address":"tournament"}`, + }, + "ListCommitmentsParams": { + func() any { return &ListCommitmentsParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","limit":25,"offset":3,"descending":true}`, + }, + "GetCommitmentParams": { + func() any { return &GetCommitmentParams{} }, + `["app","0x4","tournament","commitment"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","commitment":"commitment"}`, + }, + "ListMatchesParams": { + func() any { return &ListMatchesParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","limit":25,"offset":3,"descending":true}`, + }, + "GetMatchParams": { + func() any { return &GetMatchParams{} }, + `["app","0x4","tournament","id-hash"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","id_hash":"id-hash"}`, + }, + "ListMatchAdvancesParams": { + func() any { return &ListMatchAdvancesParams{} }, + `["app","0x4","tournament","id-hash",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"id_hash":"id-hash","limit":25,"offset":3,"descending":true}`, + }, + "GetMatchAdvanceParams": { + func() any { return &GetMatchAdvanceParams{} }, + `["app","0x4","tournament","id-hash","tx-hash","0x4"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"id_hash":"id-hash","tx_hash":"tx-hash","log_index":"0x4"}`, + }, + "ListBondEventsParams": { + func() any { return &ListBondEventsParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"limit":25,"offset":3,"descending":true}`, + }, + "GetBondEventParams": { + func() any { return &GetBondEventParams{} }, + `["app","tx-hash","0x4"]`, + `{"application":"app","tx_hash":"tx-hash","log_index":"0x4"}`, + }, + "ListStandingChangesParams": { + func() any { return &ListStandingChangesParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"limit":25,"offset":3,"descending":true}`, + }, + "GetStandingChangeParams": { + func() any { return &GetStandingChangeParams{} }, + `["app","0x4","tournament","tx-hash","0x4"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"tx_hash":"tx-hash","log_index":"0x4"}`, + }, + "ListWithdrawalsParams": { + func() any { return &ListWithdrawalsParams{} }, + `["app","0x8",25,3,true]`, + `{"application":"app","account_index":"0x8","limit":25,"offset":3,"descending":true}`, + }, + "GetWithdrawalParams": { + func() any { return &GetWithdrawalParams{} }, + `["app","0x8"]`, + `{"application":"app","account_index":"0x8"}`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + expected := test.newTarget() + require.NoError(t, json.Unmarshal([]byte(test.named), expected)) + + actual := test.newTarget() + require.NoError(t, UnmarshalParams(json.RawMessage(test.positional), actual)) + + require.Equal(t, expected, actual) + }) + } +} + +func TestUnmarshalParamsEmptyRepresentationsLeaveTargetUnchanged(t *testing.T) { + for name, data := range map[string]json.RawMessage{ + "omitted": nil, + "null": json.RawMessage(`null`), + "empty array": json.RawMessage(`[]`), + } { + t.Run(name, func(t *testing.T) { + params := ListApplicationsParams{Limit: 7, Offset: 3, Descending: true} + expected := params + + require.NoError(t, UnmarshalParams(data, ¶ms)) + require.Equal(t, expected, params) + }) + } +} + +func TestUnmarshalParamsRejectsPositionalOverArity(t *testing.T) { + var params GetApplicationParams + err := UnmarshalParams(json.RawMessage(`["app","extra"]`), ¶ms) + + require.EqualError(t, err, "error unmarshalling positional parameters, expected 1 params, got 2") +} + +func TestUnmarshalParamsPositionalOrderSkipsIgnoredJSONFields(t *testing.T) { + type paramsWithIgnoredField struct { + First string `json:"first"` + Ignored string `json:"-"` + Second string `json:"second"` + } + + params := paramsWithIgnoredField{Ignored: "unchanged"} + require.NoError(t, UnmarshalParams(json.RawMessage(`["one","two"]`), ¶ms)) + require.Equal(t, paramsWithIgnoredField{ + First: "one", + Ignored: "unchanged", + Second: "two", + }, params) + + err := UnmarshalParams(json.RawMessage(`["one","two","extra"]`), ¶ms) + require.EqualError(t, err, "error unmarshalling positional parameters, expected 2 params, got 3") +} diff --git a/internal/jsonrpc/api/response.go b/internal/jsonrpc/api/response.go new file mode 100644 index 000000000..69caa614c --- /dev/null +++ b/internal/jsonrpc/api/response.go @@ -0,0 +1,28 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package api + +// Pagination represents common pagination structure used in responses. +type Pagination struct { + TotalCount uint64 `json:"total_count"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` +} + +// ListResponse is the generic envelope for list endpoints. +type ListResponse[T any] struct { + Data []T `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// SingleResponse is the generic envelope for get endpoints. +type SingleResponse[T any] struct { + Data T `json:"data"` +} + +type NodeInfo struct { + ChainID string `json:"chain_id"` + Version string `json:"version"` + DefaultBlock string `json:"default_block"` // FINALIZED | SAFE | LATEST | PENDING +} diff --git a/internal/jsonrpc/application_contract_test.go b/internal/jsonrpc/application_contract_test.go new file mode 100644 index 000000000..7a4e89086 --- /dev/null +++ b/internal/jsonrpc/application_contract_test.go @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiscoverySchemaApplicationInputBox(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + + var spec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + + properties := spec.Components.Schemas["Application"].Properties + require.Contains(t, properties, "iinputbox_address") + require.NotContains(t, properties, "data_availability") +} diff --git a/internal/jsonrpc/batchbudget_test.go b/internal/jsonrpc/batchbudget_test.go new file mode 100644 index 000000000..d72ab8ab8 --- /dev/null +++ b/internal/jsonrpc/batchbudget_test.go @@ -0,0 +1,142 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMethodErrorListsExcludeBatchWideErrors(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Methods []struct { + Name string `json:"name"` + Errors []struct { + Ref string `json:"$ref"` + } `json:"errors"` + } `json:"methods"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + + for _, method := range spec.Methods { + refs := make(map[string]bool, len(method.Errors)) + for _, methodErr := range method.Errors { + name := methodErr.Ref[strings.LastIndex(methodErr.Ref, "/")+1:] + refs[name] = true + } + require.False(t, refs["BatchListItemLimitExceeded"], + "%s must not advertise a whole-batch error as a per-method error", method.Name) + require.True(t, refs["TimeoutError"], "%s must advertise timeout responses", method.Name) + require.True(t, refs["ResponseSizeLimitExceeded"], + "%s must advertise response-size errors", method.Name) + } +} + +func TestBatchListItemLimitSupportsNamedAndPositionalParams(t *testing.T) { + positionalAtLimit := map[string]string{ + "cartesi_listApplications": `[10000]`, + "cartesi_listEpochs": `["app",null,10000]`, + "cartesi_listInputs": `["app",null,null,null,10000]`, + "cartesi_listOutputs": `["app",null,null,null,null,10000]`, + "cartesi_listReports": `["app",null,null,10000]`, + "cartesi_listWithdrawals": `["app",null,10000]`, + "cartesi_listTournaments": `["app",null,null,null,null,10000]`, + "cartesi_listCommitments": `["app",null,null,10000]`, + "cartesi_listMatches": `["app",null,null,10000]`, + "cartesi_listMatchAdvances": `["app","0x0","tournament","id",10000]`, + "cartesi_listBondEvents": `["app",null,null,10000]`, + } + + positionalAtLimit[observerListStandingChangesMethod] = `["app","0x0","tournament",10000]` + + for method, positional := range positionalAtLimit { + t.Run(method, func(t *testing.T) { + requests := []json.RawMessage{ + json.RawMessage(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"params":%s,"id":1}`, method, positional)), + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":1},"id":2}`), + } + require.True(t, batchExceedsListItemLimit(requests)) + }) + } + + require.False(t, batchExceedsListItemLimit([]json.RawMessage{ + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":6000},"id":1}`), + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listOutputs","params":{"limit":4000},"id":2}`), + })) +} + +func TestBatchListItemLimitRegistryCoversEveryListHandler(t *testing.T) { + for method := range jsonrpcHandlers { + if strings.HasPrefix(method, "cartesi_list") { + require.Contains(t, listParamsTypes, method) + } + } +} + +func TestBatchListItemLimitNormalizesLimitsLikeHandlers(t *testing.T) { + // A zero limit uses the default, while a value above the per-list maximum + // is capped at that maximum. + require.False(t, batchExceedsListItemLimit([]json.RawMessage{json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":0},"id":1}`, + )})) + require.False(t, batchExceedsListItemLimit([]json.RawMessage{json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":20000},"id":1}`, + )})) + require.True(t, batchExceedsListItemLimit([]json.RawMessage{ + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":20000},"id":1}`), + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":1},"id":2}`), + })) +} + +func TestJSONRPCBatchRejectsListWorkOverLimitBeforeDispatch(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + withTestRPCHandler(t, s, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":6000},"id":1}, + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":4001},"id":2} + ]`)) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_BATCH_LIST_ITEM_LIMIT_EXCEEDED) + require.Equal(t, "Batch list item limit exceeded", response.Error.Message) + require.Zero(t, calls.Load(), "an over-budget batch must be rejected before dispatch") +} + +func TestJSONRPCBatchAllowsListWorkAtLimit(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + withTestRPCHandler(t, s, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":6000},"id":1}, + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":4000},"id":2} + ]`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Len(t, decodeRPCBatch(t, rr.Body.Bytes()), 2) + require.Equal(t, int32(2), calls.Load()) +} diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go new file mode 100644 index 000000000..7d2785e05 --- /dev/null +++ b/internal/jsonrpc/batchcalls_test.go @@ -0,0 +1,697 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +const ( + testBatchSize = 100 + testBatchSuccessCount = 10 + testLargeResultSize = 1<<20 - 38 // 1 MB - `,{"jsonrpc":"2.0","result":"...","id":??}` + testResponseBudgetSlack = 1 << 20 +) + +func serveRPC(t *testing.T, s *Service, body []byte) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + return rr +} + +func newBatchTestService() *Service { + return &Service{ + HTTPServiceTemplate: service.HTTPServiceTemplate{ + BaseTemplate: service.BaseTemplate{ + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + }, + }, + handlers: cloneDispatchTable(jsonrpcHandlers), + } +} + +func decodeRPCResponse(t *testing.T, body []byte) RPCResponse { + t.Helper() + var response RPCResponse + require.NoError(t, json.Unmarshal(body, &response)) + return response +} + +func decodeRPCBatch(t *testing.T, body []byte) []RPCResponse { + t.Helper() + var responses []RPCResponse + require.NoError(t, json.Unmarshal(body, &responses)) + return responses +} + +func requireRPCError(t *testing.T, response RPCResponse, id any, code int) { + t.Helper() + require.Equal(t, "2.0", response.JSONRPC) + require.Equal(t, id, decodeRPCID(t, response.ID)) + require.NotNil(t, response.Error) + require.Equal(t, code, response.Error.Code) +} + +func decodeRPCID(t *testing.T, id json.RawMessage) any { + t.Helper() + var decoded any + require.NoError(t, json.Unmarshal(id, &decoded)) + return decoded +} + +func TestListOutputsRejectsEmptyOutputTypeList(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`{ + "jsonrpc":"2.0", + "method":"cartesi_listOutputs", + "params":{"application":"app","output_type":[]}, + "id":1 + }`)) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "Invalid output type: expected at least one selector", response.Error.Message) +} + +func TestListOutputsRejectsMalformedOutputType(t *testing.T) { + for name, selector := range map[string]string{ + "wrong length": "0x1234", + "non hex": "0xzzzzzzzz", + } { + t.Run(name, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`{ + "jsonrpc":"2.0", + "method":"cartesi_listOutputs", + "params":{"application":"app","output_type":%q}, + "id":1 + }`, selector)) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Contains(t, response.Error.Message, "Invalid output type") + }) + } +} + +func TestJSONRPCBatchRejectsEmptyBatchWithSingleObject(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`[]`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_INVALID_BATCH) + + var array []RPCResponse + require.Error(t, json.Unmarshal(rr.Body.Bytes(), &array), + "an empty batch error must be one JSON-RPC object, not an array") +} + +func TestJSONRPCBatchRejectsMoreThanMaximumBeforeDispatch(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + const method = "test_batch_cap" + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + requests := make([]json.RawMessage, testBatchSize+1) + for i := range requests { + requests[i] = json.RawMessage(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":%d}`, method, i)) + } + body, err := json.Marshal(requests) + require.NoError(t, err) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_INVALID_BATCH) + require.Zero(t, calls.Load(), "an oversized batch must be rejected before dispatch") +} + +func TestJSONRPCMalformedBatchReturnsParseErrorObject(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`[{"jsonrpc":"2.0","method":"rpc.discover","id":1},`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_PARSE_ERROR) + require.Equal(t, "Parse error", response.Error.Message) +} + +func TestJSONRPCMalformedObjectReturnsJSONContentType(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`{"jsonrpc":"2.0"`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_PARSE_ERROR) + require.Equal(t, "Parse error", response.Error.Message) +} + +func TestJSONRPCDiscoverPreservesLargeIntegerLiterals(t *testing.T) { + s := newBatchTestService() + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &s.discoverSpec)) + + rr := serveRPC(t, s, []byte(`{"jsonrpc":"2.0","method":"rpc.discover","id":1}`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), `"maximum":9223372036854775807`) +} + +func TestJSONRPCBatchMalformedElementDoesNotPoisonValidSiblings(t *testing.T) { + s := newBatchTestService() + body := []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":1}, + 17, + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":3} + ]`) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + require.Nil(t, responses[0].Error) + require.EqualValues(t, 1, decodeRPCID(t, responses[0].ID)) + requireRPCError(t, responses[1], nil, JSONRPC_INVALID_REQUEST) + require.Nil(t, responses[2].Error) + require.EqualValues(t, 3, decodeRPCID(t, responses[2].ID)) +} + +func TestJSONRPCBatchStructurallyInvalidElementsDoNotPoisonValidSiblings(t *testing.T) { + tests := map[string]struct { + request string + id any + }{ + "null": {request: `null`}, + "empty object": {request: `{}`}, + "missing method": {request: `{"jsonrpc":"2.0","id":2}`, id: float64(2)}, + "invalid version": {request: `{"jsonrpc":"1.0","method":"cartesi_getNodeVersion","id":2}`, id: float64(2)}, + "invalid id": {request: `{"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":true}`}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":1}, + %s, + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":3} + ]`, test.request)) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + + require.Nil(t, responses[0].Error) + require.EqualValues(t, 1, decodeRPCID(t, responses[0].ID)) + + requireRPCError(t, responses[1], test.id, JSONRPC_INVALID_REQUEST) + + require.Nil(t, responses[2].Error) + require.EqualValues(t, 3, decodeRPCID(t, responses[2].ID)) + }) + } +} + +func TestJSONRPCValidationErrorsEchoValidID(t *testing.T) { + s := newBatchTestService() + tests := map[string]struct { + body string + id any + message string + }{ + "missing method": { + body: `{"jsonrpc":"2.0","id":"request-id"}`, id: "request-id", message: "Invalid Request", + }, + "invalid version": { + body: `{"jsonrpc":"1.0","method":"cartesi_getNodeVersion","id":42}`, id: float64(42), message: "Unsupported JSON-RPC version", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + response := decodeRPCResponse(t, serveRPC(t, s, []byte(test.body)).Body.Bytes()) + requireRPCError(t, response, test.id, JSONRPC_INVALID_REQUEST) + require.Equal(t, test.message, response.Error.Message) + }) + } +} + +func TestJSONRPCRejectsInvalidIDTypesWithNullID(t *testing.T) { + s := newBatchTestService() + for name, id := range map[string]string{ + "boolean": `true`, + "array": `[]`, + "object": `{}`, + } { + t.Run(name, func(t *testing.T) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":%s}`, id)) + response := decodeRPCResponse(t, serveRPC(t, s, body).Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_INVALID_REQUEST) + require.Equal(t, "Invalid request ID", response.Error.Message) + }) + } +} + +func TestRPCIDRoundTripsWithoutNumericPrecisionLoss(t *testing.T) { + s := newBatchTestService() + single := serveRPC(t, s, []byte(`{ + "jsonrpc":"2.0", + "method":"cartesi_getNodeVersion", + "id":9007199254740993 + }`)) + singleResponse := decodeRPCResponse(t, single.Body.Bytes()) + require.Nil(t, singleResponse.Error) + require.Equal(t, `9007199254740993`, string(singleResponse.ID)) + + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"missing","id":9007199254740993}, + {"jsonrpc":"2.0","method":"missing","id":18446744073709551616}, + {"jsonrpc":"2.0","method":"missing","id":"request-3"} + ]`)) + + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + require.Equal(t, `9007199254740993`, string(responses[0].ID)) + require.Equal(t, `18446744073709551616`, string(responses[1].ID)) + require.Equal(t, `"request-3"`, string(responses[2].ID)) + for _, response := range responses { + require.NotNil(t, response.Error) + require.Equal(t, JSONRPC_METHOD_NOT_FOUND, response.Error.Code) + } +} + +func TestJSONRPCBatchNotificationsReceiveNullIDResponses(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion"}, + {"jsonrpc":"2.0","method":"does_not_exist"} + ]`)) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 2, "notifications are deliberately answered by this server") + require.Nil(t, decodeRPCID(t, responses[0].ID)) + require.Nil(t, responses[0].Error) + requireRPCError(t, responses[1], nil, JSONRPC_METHOD_NOT_FOUND) +} + +func TestJSONRPCBatchAlwaysReturnsHTTP200ForJSONErrors(t *testing.T) { + s := newBatchTestService() + tests := map[string][]byte{ + "parse error": []byte(`[nope`), + "invalid request": []byte(`[]`), + "error entries": []byte(`[false,{"jsonrpc":"2.0","method":"does_not_exist","id":2}]`), + } + for name, body := range tests { + t.Run(name, func(t *testing.T) { + rr := serveRPC(t, s, body) + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + require.True(t, json.Valid(rr.Body.Bytes())) + }) + } +} + +func TestJSONRPCBatchReplacesResponsesAtCumulativeResponseBudget(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + const method = "test_large_batch_result" + largeResult := strings.Repeat("x", testLargeResultSize) + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return largeResult, nil + }) + + requests := make([]json.RawMessage, testBatchSize) + for i := range requests { + requests[i] = json.RawMessage(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"params":{"limit":10000},"id":%d}`, method, i)) + } + body, err := json.Marshal(requests) + require.NoError(t, err) + require.Less(t, len(body), 10<<10, "the request cap must not be mistaken for a response cap") + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, testBatchSize) + require.Equal(t, int32(testBatchSuccessCount+1), calls.Load()) + require.LessOrEqual(t, rr.Body.Len(), (10<<20)+testResponseBudgetSlack) + for i := range responses[:testBatchSuccessCount] { + require.Equal(t, "2.0", responses[i].JSONRPC) + require.Equal(t, float64(i), decodeRPCID(t, responses[i].ID)) + require.Nil(t, responses[i].Error) + require.Equal(t, responses[i].Result, largeResult) + } + for i := testBatchSuccessCount; i < len(responses); i++ { + requireRPCError(t, responses[i], float64(i), JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED) + require.Equal(t, "Response size limit exceeded", responses[i].Error.Message) + } +} + +func TestJSONRPCBatchStopsBetweenEntriesWhenContextIsCanceled(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx, cancel := context.WithCancel(context.Background()) + const method = "test_cancel_batch" + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + cancel() + return true, nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2}, + {"jsonrpc":"2.0","method":%q,"id":3} + ]`, method, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)).WithContext(ctx) + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + + require.Equal(t, int32(1), calls.Load(), + "a canceled request must not run the remaining batch handlers") + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + if line == "" { + continue + } + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + require.False(t, + record["level"] == "ERROR" && strings.Contains(strings.ToLower(line), "context canceled"), + "context.Canceled is a graceful stop and must not be ERROR logged") + } +} + +func TestJSONRPCBatchStopsSilentlyWhenRepositoryCallIsCanceled(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx, cancel := context.WithCancel(context.Background()) + const method = "test_repository_cancel_batch" + withTestRPCHandler(t, s, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + cancel() + return nil, s.repositoryError(ctx, "Unable to retrieve test data from repository", + fmt.Errorf("repository query failed: %w", ctx.Err())) + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2} + ]`, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)).WithContext(ctx) + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + + require.Equal(t, int32(1), calls.Load()) + require.NotContains(t, rr.Body.String(), "Internal server error") + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + if line == "" { + continue + } + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + require.NotEqual(t, "ERROR", record["level"], + "context.Canceled must not be logged as an operator error") + } +} + +func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + const method = "test_deadline_batch" + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q}, + {"jsonrpc":"2.0","method":%q,"id":"three"}, + false, + {"jsonrpc":"2.0","method":%q,"id":true}, + {"jsonrpc":"2.0","method":%q,"id":null} + ]`, method, method, method, method, method)) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)).WithContext(ctx) + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + + require.Zero(t, calls.Load(), "expired batch entries must not be dispatched") + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 6, "not all entries receive deadline errors") + requireRPCError(t, responses[0], float64(1), JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[1], nil, JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[2], "three", JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[3], nil, JSONRPC_INVALID_REQUEST) + requireRPCError(t, responses[4], nil, JSONRPC_INVALID_REQUEST) + requireRPCError(t, responses[5], nil, JSONRPC_TIMEOUT_ERROR) + for i, response := range responses { + if i == 3 { + require.Equal(t, "Invalid Request", response.Error.Message) + } else if i == 4 { + require.Equal(t, "Invalid request ID", response.Error.Message) + } else { + require.Equal(t, "Request timed out", response.Error.Message) + } + } +} + +func TestJSONRPCSingleRequestReturnsTimeoutWhenItsContextExpires(t *testing.T) { + s := newBatchTestService() + s.dispatchTimeout = 5 * time.Millisecond + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + const method = "test_single_request_timeout" + withTestRPCHandler(t, s, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + <-r.Context().Done() + return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", + fmt.Errorf("repository query failed: %w", r.Context().Err())) + }) + + rr := serveRPC(t, s, []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":1}`, method))) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_TIMEOUT_ERROR) + require.Equal(t, "Request timed out", response.Error.Message) + require.Contains(t, logs.String(), "RPC method dispatch timeout") + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} + +func TestJSONRPCUpstreamDeadlineRemainsInternalError(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + const method = "test_upstream_deadline" + withTestRPCHandler(t, s, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", + fmt.Errorf("upstream deadline: %w", context.DeadlineExceeded)) + }) + + rr := serveRPC(t, s, []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":1}`, method))) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INTERNAL_ERROR) + require.Contains(t, logs.String(), `"level":"ERROR"`) +} + +func TestJSONRPCBatchRecoversPanicPerEntry(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + panicMethod := strings.Repeat("p", MAX_LOGGED_METHOD_LEN+32) + const okMethod = "test_after_panic_batch" + withTestRPCHandler(t, s, panicMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + panic("test panic") + }) + withTestRPCHandler(t, s, okMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + return "ok", nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2}, + {"jsonrpc":"2.0","method":%q,"id":3} + ]`, okMethod, panicMethod, okMethod)) + rr := serveRPC(t, s, body) + + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + require.Nil(t, responses[0].Error) + requireRPCError(t, responses[1], float64(2), JSONRPC_INTERNAL_ERROR) + require.Equal(t, "Internal server error", responses[1].Error.Message) + require.Nil(t, responses[2].Error, "entries after a panic must still be dispatched") + require.Contains(t, logs.String(), "RPC method panic") + require.Contains(t, logs.String(), "test panic") + require.Contains(t, logs.String(), "goroutine", "panic log must include a stack trace") + require.Contains(t, logs.String(), truncatedMethod(panicMethod)) + require.NotContains(t, logs.String(), panicMethod) +} + +func TestJSONRPCDoesNotRecoverAbortHandler(t *testing.T) { + s := newBatchTestService() + const method = "test_abort_handler" + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + panic(http.ErrAbortHandler) + }) + + require.PanicsWithValue(t, http.ErrAbortHandler, func() { + serveRPC(t, s, []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":1}`, method))) + }) +} + +func TestJSONRPCBatchUsesOneAdmissionPermit(t *testing.T) { + s := newBatchTestService() + s.Admission = service.NewSemaphoreAdmission(1) + s.Server = &http.Server{ + Handler: rebuildHandlerWithAdmission(s), + ReadHeaderTimeout: 2 * time.Second, + } + var nestedAcquisitions atomic.Int32 + const method = "test_batch_admission" + withTestRPCHandler(t, s, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + if s.Admission.TryAcquire() { + nestedAcquisitions.Add(1) + s.Admission.Release() + } + return true, nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2} + ]`, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)) + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Zero(t, nestedAcquisitions.Load(), + "the HTTP request's one permit must remain held for the whole batch") + require.Len(t, decodeRPCBatch(t, rr.Body.Bytes()), 2) +} + +func TestJSONRPCBatchLoggingHasOneInfoAndDebugMethods(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + + const entries = 3 + body := []byte(`[ + {"jsonrpc":"2.0","method":"attacker_method_0","id":0}, + {"jsonrpc":"2.0","method":"attacker_method_1","id":1}, + {"jsonrpc":"2.0","method":"attacker_method_2","id":2} + ]`) + serveRPC(t, s, body) + + var batchInfo int + debugMethods := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + if line == "" { + continue + } + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + level, _ := record["level"].(string) + encoded := string(line) + if level == "INFO" && strings.Contains(strings.ToLower(encoded), "batch") { + batchInfo++ + require.Contains(t, encoded, fmt.Sprint(entries)) + } + for i := range entries { + method := fmt.Sprintf("attacker_method_%d", i) + if strings.Contains(encoded, method) { + require.Equal(t, "DEBUG", level, "per-entry method names must never be Info logged") + debugMethods[method] = true + } + } + } + require.Equal(t, 1, batchInfo) + require.Len(t, debugMethods, entries) +} + +func TestJSONRPCBatchMethodLoggingIsTruncated(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + method := strings.Repeat("b", MAX_LOGGED_METHOD_LEN+32) + body := []byte(fmt.Sprintf(`[{"jsonrpc":"2.0","method":%q,"id":1}]`, method)) + serveRPC(t, s, body) + + var found bool + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + if record["method"] == truncatedMethod(method) { + require.Equal(t, "DEBUG", record["level"]) + found = true + } + require.NotEqual(t, method, record["method"]) + } + require.True(t, found) +} + +func withTestRPCHandler(t *testing.T, service *Service, method string, handler rpcHandler) { + t.Helper() + previous, existed := service.handlers[method] + service.handlers[method] = handler + t.Cleanup(func() { + if existed { + service.handlers[method] = previous + } else { + delete(service.handlers, method) + } + }) +} + +func TestRPCHandlerOverridesAreServiceLocal(t *testing.T) { + first := newBatchTestService() + second := newBatchTestService() + const method = "test_service_local_handler" + withTestRPCHandler(t, first, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + return true, nil + }) + + _, firstHasHandler := first.handlers[method] + _, secondHasHandler := second.handlers[method] + _, globalHasHandler := jsonrpcHandlers[method] + require.True(t, firstHasHandler) + require.False(t, secondHasHandler) + require.False(t, globalHasHandler) +} diff --git a/internal/jsonrpc/config_test.go b/internal/jsonrpc/config_test.go new file mode 100644 index 000000000..d76607674 --- /dev/null +++ b/internal/jsonrpc/config_test.go @@ -0,0 +1,85 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +type savedConfigRepository struct { + repository.Repository + raw []byte + err error +} + +func (r *savedConfigRepository) LoadNodeConfigRaw( + _ context.Context, key string, +) ([]byte, time.Time, time.Time, error) { + if key != evmreader.EvmReaderConfigKey { + panic("unexpected configuration key") + } + return r.raw, time.Time{}, time.Time{}, r.err +} + +func TestNodeInfoAndChainIDValidateSavedConfig(t *testing.T) { + const nullConfigJSON = "null" + for _, method := range []string{"cartesi_getNodeInfo", "cartesi_getChainId"} { + for _, test := range []struct { + name string + raw string + err error + wantCode int + }{ + {"absent", "", repository.ErrNotFound, JSONRPC_RESOURCE_NOT_FOUND}, + {"nil bytes", "", nil, JSONRPC_INTERNAL_ERROR}, + {"null config", nullConfigJSON, nil, JSONRPC_INTERNAL_ERROR}, + {"empty", "{}", nil, JSONRPC_INTERNAL_ERROR}, + {"missing chain", `{"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"missing policy", `{"ChainID":1}`, nil, JSONRPC_INTERNAL_ERROR}, + {"null chain", `{"ChainID":null,"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"null policy", `{"ChainID":1,"DefaultBlock":null}`, nil, JSONRPC_INTERNAL_ERROR}, + {"zero chain", `{"ChainID":0,"DefaultBlock":"FINALIZED"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"invalid policy", `{"ChainID":1,"DefaultBlock":"INVALID"}`, nil, JSONRPC_INTERNAL_ERROR}, + {"valid", `{"ChainID":31337,"DefaultBlock":"FINALIZED"}`, nil, 0}, + } { + t.Run(method+"/"+test.name, func(t *testing.T) { + var raw []byte + if test.raw != "" { + raw = []byte(test.raw) + } + s := &Service{ + HTTPServiceTemplate: service.HTTPServiceTemplate{ + BaseTemplate: service.BaseTemplate{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, + }, + repository: &savedConfigRepository{raw: raw, err: test.err}, + handlers: cloneDispatchTable(jsonrpcHandlers), + } + request := httptest.NewRequest("POST", "/rpc", nil) + result, err := s.handlers[method](s, request, RPCRequest{}) + if test.wantCode != 0 { + require.Nil(t, result) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.wantCode, rpcErr.Code) + return + } + require.NoError(t, err) + encoded, err := json.Marshal(result) + require.NoError(t, err) + require.Contains(t, string(encoded), "0x7a69") + }) + } + } +} diff --git a/internal/jsonrpc/decode_logging_test.go b/internal/jsonrpc/decode_logging_test.go new file mode 100644 index 000000000..8e2184e34 --- /dev/null +++ b/internal/jsonrpc/decode_logging_test.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "encoding/json" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + contractinputs "github.com/cartesi/rollups-node/pkg/contracts/inputs" + contractoutputs "github.com/cartesi/rollups-node/pkg/contracts/outputs" + + "github.com/stretchr/testify/require" +) + +func TestDecodeFailuresAreAggregatedInLogs(t *testing.T) { + inputABI, err := contractinputs.InputsMetaData.GetAbi() + require.NoError(t, err) + outputABI, err := contractoutputs.OutputsMetaData.GetAbi() + require.NoError(t, err) + + var logs bytes.Buffer + s := newBatchTestService() + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + s.inputABI = inputABI + s.outputABI = outputABI + + decodedInputs := s.decodeInputs(t.Context(), "app", []*model.Input{ + {Index: 7, RawData: nil}, + {Index: 9, RawData: []byte{0x01}}, + }) + decodedOutputs := s.decodeOutputs(t.Context(), "app", []*model.Output{ + {Index: 11, RawData: nil}, + {Index: 13, RawData: []byte{0x01}}, + }) + require.Len(t, decodedInputs, 2, "malformed rows must remain in the response") + require.Len(t, decodedOutputs, 2, "malformed rows must remain in the response") + + debugCount := 0 + warns := map[string]map[string]any{} + for _, line := range bytes.Split(bytes.TrimSpace(logs.Bytes()), []byte("\n")) { + var record map[string]any + require.NoError(t, json.Unmarshal(line, &record)) + switch record["level"] { + case "DEBUG": + debugCount++ + case "WARN": + message, _ := record["msg"].(string) + warns[message] = record + case "ERROR": + t.Fatalf("decode failure was logged at Error: %s", line) + } + } + + require.Equal(t, 4, debugCount, "each malformed row needs one diagnostic Debug log") + require.Len(t, warns, 2, "inputs and outputs each need one aggregate warning") + require.Equal(t, float64(2), warns["Unable to decode Inputs"]["count"]) + require.Equal(t, float64(7), warns["Unable to decode Inputs"]["first_index"]) + require.Equal(t, float64(2), warns["Unable to decode Outputs"]["count"]) + require.Equal(t, float64(11), warns["Unable to decode Outputs"]["first_index"]) +} diff --git a/internal/jsonrpc/execution_outcome_contract_test.go b/internal/jsonrpc/execution_outcome_contract_test.go new file mode 100644 index 000000000..f55d114d5 --- /dev/null +++ b/internal/jsonrpc/execution_outcome_contract_test.go @@ -0,0 +1,112 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "encoding/json" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +func TestDiscoverySchemaExecutionOutcomeContract(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + + var spec struct { + Components struct { + Schemas map[string]json.RawMessage `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + + var completionStatus struct { + Enum []string `json:"enum"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["InputCompletionStatus"], &completionStatus)) + require.Equal(t, []string{ + "NONE", + "ACCEPTED", + "REJECTED", + "EXCEPTION", + "MACHINE_HALTED", + "OVERFLOW", + "UNEXPECTED_YIELD", + "INVALID_OUTPUTS_ROOT", + }, completionStatus.Enum) + require.Len(t, completionStatus.Enum, len(model.InputCompletionStatusAllValues)) + for _, status := range model.InputCompletionStatusAllValues { + require.Contains(t, completionStatus.Enum, status.String()) + } + + var applicationStatus struct { + Enum []string `json:"enum"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["ApplicationStatus"], &applicationStatus)) + require.Equal(t, []string{ + "OK", + "FAILED", + "DIVERGED", + "CORRUPTED", + "GUEST_EXCEPTION", + "MACHINE_HALTED", + "MCYCLE_OVERFLOW", + "UNEXPECTED_YIELD", + "INVALID_OUTPUTS_ROOT", + }, applicationStatus.Enum) + require.Len(t, applicationStatus.Enum, len(model.ApplicationStatusAllValues)) + for _, status := range model.ApplicationStatusAllValues { + require.Contains(t, applicationStatus.Enum, status.String()) + } + + var input struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["Input"], &input)) + require.Contains(t, input.Properties, "exception_data") + require.Contains(t, input.Properties, "tx_buffer_data_block") + require.NotContains(t, input.Properties, "outputs_hash") + + var epoch struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["Epoch"], &epoch)) + for _, field := range []string{ + "tx_buffer_data_block", + "tx_buffer_proof", + "iflags_y_data_block", + "iflags_y_proof", + "htif_tohost_data_block", + "htif_tohost_proof", + } { + require.Contains(t, epoch.Properties, field) + } + require.NotContains(t, epoch.Properties, "outputs_merkle_root") + require.NotContains(t, epoch.Properties, "outputs_merkle_proof") + + var executionParameters struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["ExecutionParameters"], &executionParameters)) + require.Contains(t, executionParameters.Properties, "advance_inc_cycles") + require.Contains(t, executionParameters.Properties, "inspect_inc_cycles") + require.Contains(t, executionParameters.Properties, "advance_max_cycles") + require.Contains(t, executionParameters.Properties, "inspect_max_cycles") + for field, operation := range map[string]string{ + "advance_max_cycles": "advance", + "inspect_max_cycles": "inspect", + } { + var property struct { + Description string `json:"description"` + } + require.NoError(t, json.Unmarshal(executionParameters.Properties[field], &property)) + require.Equal(t, + "Optional "+operation+ + " execution delta from the starting mcycle in range 0..2^48-1; "+ + "0 imposes no operator cap and the machine's imcyclemax governs", + property.Description, + ) + } +} diff --git a/internal/jsonrpc/invalid_outputs_root_test.go b/internal/jsonrpc/invalid_outputs_root_test.go new file mode 100644 index 000000000..ad8d72236 --- /dev/null +++ b/internal/jsonrpc/invalid_outputs_root_test.go @@ -0,0 +1,70 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/require" +) + +type invalidOutputsRootAPIRepository struct { + repository.Repository + app *model.Application + input *model.Input +} + +func (r *invalidOutputsRootAPIRepository) GetApplication(context.Context, string) (*model.Application, error) { + return r.app, nil +} + +func (r *invalidOutputsRootAPIRepository) GetInput(context.Context, string, uint64) (*model.Input, error) { + return r.input, nil +} + +func TestInvalidOutputsRootAPIResponses(t *testing.T) { + reason := "input 7 completed with INVALID_OUTPUTS_ROOT: an accepted yield must declare exactly 32 bytes" + repo := &invalidOutputsRootAPIRepository{ + app: &model.Application{Name: "invalid-root", Status: model.ApplicationStatus_InvalidOutputsRoot, Reason: &reason}, + input: &model.Input{ + Index: 7, Status: model.InputCompletionStatus_InvalidOutputsRoot, + }, + } + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + for _, tc := range []struct { + method string + params string + }{ + {method: "cartesi_getApplication", params: `{"application":"invalid-root"}`}, + {method: "cartesi_getInput", params: `{"application":"invalid-root","input_index":"0x7"}`}, + } { + t.Run(tc.method, func(t *testing.T) { + result, err := jsonrpcHandlers[tc.method](s, httptest.NewRequest(http.MethodPost, "/", nil), + RPCRequest{Params: json.RawMessage(tc.params)}) + require.NoError(t, err) + encoded, err := json.Marshal(result) + require.NoError(t, err) + var response struct { + Data struct { + Status string `json:"status"` + Reason string `json:"reason"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(encoded, &response)) + require.Equal(t, "INVALID_OUTPUTS_ROOT", response.Data.Status) + if tc.method == "cartesi_getApplication" { + require.Equal(t, reason, response.Data.Reason) + } + }) + } +} diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json new file mode 100644 index 000000000..935fcc08a --- /dev/null +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -0,0 +1,4378 @@ +{ + "openrpc": "1.4.1", + "info": { + "title": "Cartesi Rollups Node API", + "version": "2.0.0", + "x-batch-list-work-budget": { + "maximum": 10000, + "unit": "sum of effective limit values across list entries", + "description": "Before dispatching any entry, the server sums the effective limit of every list operation in a batch. Omitted or zero limits count as 50, and limits above 10000 count as 10000. A total above 10000 rejects the entire batch with one error response using code -31004; no entry is dispatched. This bounds row-fetch work to that of one maximal list request. It does not bound the cost of COUNT queries. It also does not meter offset traversal cost: that cost is bounded by the size of the filtered set rather than by the numeric offset value, but PostgreSQL may still scan and discard matching rows before the requested page. Clients should still avoid broad or unnecessary list filters." + }, + "description": "A JSON-RPC API for reading rollups data. It provides information about applications, epochs, inputs, outputs, and reports in a read-only fashion.\n\nResponse limits: every HTTP request has a 10 MB response-size budget. For a single JSON-RPC request, its response must fit within that budget. For a batch, the budget is cumulative across all entries. An entry that would exceed the remaining budget is discarded without consuming it and receives error `-31003`; the budget is then closed, so every remaining batch entry also receives `-31003`, even if its response would otherwise fit. Clients can retry an affected entry individually or in a smaller batch.\n\nBatch requests: JSON-RPC non-empty batch arrays are supported with a maximum of 100 entries per batch; batches outside that size range receive a single response with error code `-32040`. Entries execute sequentially and responses are returned in the same order as their requests. The 1 MB request-body limit applies to the whole batch array. Every batch entry receives a response. Notification suppression is not supported: entries without an ID are answered with `id: null`. This is a documented deviation from JSON-RPC 2.0, under which notifications normally produce no response. A batch response uses HTTP status 200 even when some or all of its entries are errors. Because execution is sequential and subject to the server time limit, heavy list calls should be kept outside large batches.\n\nError handling: every method documents its possible errors under `errors`, and clients can dispatch on the error code. `-31002` (application not found) means the application identifier itself is unknown to this node; for application-scoped methods, this is a configuration error that will not resolve by retrying. `-31001` (resource not found) means the requested resource does not exist in the method's scope. For application-scoped methods, `-31001` means the application is known but the nested entity is missing; for node-scoped methods, it can also report missing node resources such as EVM reader configuration. For forward-looking application resources (e.g. the next epoch, input, or output index), `-31001` is the documented \"not created yet\" signal and is safe to poll. The error message names the missing resource. `-32603` (internal error) is never used for missing resources - clients should treat it as a node-side failure and alarm or back off, not poll. `-32070` (timeout error) indicates the request was not able to be processed in the time limit available. The standard codes `-32700` (parse error), `-32600` (invalid request), and `-32601` (method not found) follow the JSON-RPC 2.0 specification." + }, + "methods": [ + { + "name": "cartesi_listApplications", + "summary": "List all applications", + "description": "Returns a paginated list of all applications.", + "params": [ + { + "name": "limit", + "description": "The maximum number of applications to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of applications to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by application name.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApplicationListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getApplication", + "summary": "Get an application", + "description": "Fetches a single application by its name or address.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApplicationGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listEpochs", + "summary": "List epochs", + "description": "Returns a paginated list of epochs for the specified application. Can filter by one or more epoch statuses.\n\nTo synchronize epochs, request `from` equal to the next unseen epoch index and advance it as new epochs appear. Separately, repeatedly filter previously seen epochs by the non-terminal statuses `OPEN`, `CLOSED`, `INPUTS_PROCESSED`, `CLAIM_COMPUTED`, `CLAIM_SUBMITTED`, and `CLAIM_STAGED`. Terminal statuses (`CLAIM_ACCEPTED`, `CLAIM_REJECTED`, and `CLAIM_FORECLOSED`) never regress, so settled epochs can be removed from the status-refresh set while `from` continues discovering new epochs.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "status", + "description": "Filter epochs by one status or a non-empty list of statuses. Omit this parameter to disable status filtering; an empty list is invalid.", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EpochStatus" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/EpochStatus" + }, + "minItems": 1 + } + ] + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of epochs to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of epochs to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by epoch index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + } + ], + "result": { + "name": "result", + "description": "A paginated list of epochs, with the proofs omitted", + "schema": { + "$ref": "#/components/schemas/EpochListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getEpoch", + "summary": "Get a specific epoch", + "description": "Fetches a single epoch by application and index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "The index of the epoch to be retrieved (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/EpochGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/EpochNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getEpochByVirtualIndex", + "summary": "Get a specific epoch by its virtual index", + "description": "Fetches a single epoch by application and its virtual index, which is the epoch's dense insertion rank — 0, 1, 2, … with no gaps by construction.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "virtual_index", + "description": "The virtual index of the epoch to be retrieved (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/EpochGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/EpochNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getLastAcceptedEpochIndex", + "summary": "Get the last accepted epoch index", + "description": "Fetches the latest accepted epoch index by application.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LastAcceptedEpochIndexResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/EpochNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listInputs", + "summary": "Retrieve a List of Inputs", + "description": "Returns a paginated list of inputs, with options to filter by epoch index, input sender, or transaction hash.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter inputs by a specific epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "sender", + "description": "Filter inputs by the sender's address (hex encoded), extracted from raw data.", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": false + }, + { + "name": "transaction_hash", + "description": "Filter inputs by the L1 transaction hash that emitted the InputAdded event.", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of inputs to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of inputs to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by input index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the input index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the input index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/InputListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getInput", + "summary": "Retrieve a specific input", + "description": "Retrieves a single input from the application using the specified input_index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "input_index", + "description": "The index of the input to be retrieved (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/InputGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InputNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getProcessedInputCount", + "summary": "Retrieve the number of inputs processed by the application", + "description": "Return a integer with the amount of inputs already processed by the application.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProcessedInputCountResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getExecutedOutputCount", + "summary": "Retrieve the number of executed outputs for the application", + "description": "Returns a monotone change signal for output execution. An unchanged count means that no new executions have been observed. When the count changes, re-query the bounded executable-output working set with cartesi_listOutputs using executed=false and output_type=[voucher, delegatecall_voucher], then diff that pending set against the previous result to identify executions. Do not use this count, an output index, or a pagination offset as a resume cursor: executions are observed out of output-index order, so a late execution can occur behind such a cursor. A race-free execution cursor is expected in a future ingestion API.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ExecutedOutputCountResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getPendingExecutableOutputCount", + "summary": "Retrieve the number of pending executable outputs for the application", + "description": "Returns a non-monotone gauge that grows with new executable outputs and shrinks with executions. Do not use it as a change signal or resume cursor. Instead poll cartesi_getExecutedOutputCount; when that monotone count changes, re-query the bounded executable-output working set with cartesi_listOutputs using executed=false and output_type=[voucher, delegatecall_voucher], then diff the pending set against the previous result.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PendingExecutableOutputCountResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listOutputs", + "summary": "Retrieve a List of Outputs", + "description": "Returns a paginated list of outputs, with options to filter by epoch index, input index, output type, voucher address, and execution status. Executions are observed out of output-index order: an old voucher can execute after newer outputs and therefore change behind an index- or offset-based cursor. Do not use output indexes, pagination offsets, or the executed-output count as resume cursors over the executed filter, because doing so can silently skip late executions. To synchronize executions, poll cartesi_getExecutedOutputCount; when it changes, re-query the bounded executable-output working set with executed=false and output_type=[voucher, delegatecall_voucher], then diff the pending set against the previous result. A race-free execution cursor is expected in a future ingestion API.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter outputs by a specific epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "input_index", + "description": "Filter outputs by a specific input index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "output_type", + "description": "Filter outputs by one or more output type selectors (the first 4 bytes of raw data, hex encoded). A single selector string is accepted for compatibility; arrays use OR semantics and must not be empty.", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/FunctionSelector" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/FunctionSelector" + } + } + ] + }, + "required": false + }, + { + "name": "voucher_address", + "description": "Filter outputs by the voucher address (hex encoded), extracted from raw data.", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of outputs to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of outputs to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by output index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the output index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the output index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "executed", + "description": "Filter by execution status: true selects outputs with an execution transaction hash; false selects outputs without one. Executions are observed out of output-index order, so do not build a resume cursor over this filter from an output index, pagination offset, or executed-output count; it can silently skip late executions. Instead poll cartesi_getExecutedOutputCount and, when it changes, re-query the bounded executable-output working set with executed=false and output_type=[voucher, delegatecall_voucher], then diff the pending set against the previous result.", + "schema": { + "type": "boolean" + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OutputListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getOutput", + "summary": "Retrieve a Specific Output", + "description": "Retrieves a single output from the application using the specified output_index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "output_index", + "description": "The index of the output to be retrieved (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OutputGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/OutputNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listReports", + "summary": "List reports", + "description": "Returns a paginated list of reports for a given application. Optionally filters by epoch_index and input_index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Optional filter by epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "input_index", + "description": "Optional filter by input index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by report index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the report index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the report index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ReportListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getReport", + "summary": "Get a specific report", + "description": "Fetches a single report by application and report_index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "report_index", + "description": "The index of the report to be retrieved (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ReportGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/ReportNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listWithdrawals", + "summary": "List post-foreclosure withdrawals", + "description": "Returns a paginated list of Withdrawal events observed for a foreclosed application. Each row corresponds to one (account_index, account, output) tuple emitted on-chain by IApplication after the accounts drive has been proved. The event fires at most once per accountIndex, so listing all rows for an application enumerates every successful withdrawal.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "account_index", + "description": "Optional filter by accountIndex (hex encoded). When omitted, returns all withdrawals for the application.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of withdrawals to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of withdrawals to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by account_index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WithdrawalListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getWithdrawal", + "summary": "Get a specific withdrawal", + "description": "Fetches a single Withdrawal event by application and accountIndex.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "account_index", + "description": "The accountIndex of the withdrawal to retrieve (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WithdrawalGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/WithdrawalNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listTournaments", + "summary": "Retrieve a List of Tournaments", + "description": "Returns a paginated list of Tournaments, with options to filter by epoch index or level.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter tournaments by a specific epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "level", + "description": "Filter tournaments by level.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "parent_tournament_address", + "description": "Filter tournaments by parent tournament address (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": false + }, + { + "name": "parent_match_id_hash", + "description": "Filter tournaments by parent match id hash (hex encoded).", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of tournaments to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of tournaments to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by epoch index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TournamentListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getTournament", + "summary": "Retrieve a specific tournament", + "description": "Retrieves a single tournament from the application using the specified address.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "address", + "description": "The address of the tournament (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TournamentGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/TournamentNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listCommitments", + "summary": "List commitments", + "description": "Returns a paginated list of commitments for the specified application. Can filter by epoch index and tournament address.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter commitments by a specific epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "tournament_address", + "description": "Filter commitments by tournament address (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of commitments to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of commitments to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by epoch index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommitmentListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getCommitment", + "summary": "Get a specific commitment", + "description": "Fetches a single commitment by application, epoch index, tournament address and commitment hash.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "The index of the epoch (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + }, + { + "name": "tournament_address", + "description": "The tournament address (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": true + }, + { + "name": "commitment", + "description": "The commitment hash (hex encoded).", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommitmentGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/CommitmentNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listMatches", + "summary": "List matches", + "description": "Returns a paginated list of matches for the specified application. Can filter by epoch index and tournament address.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter matches by a specific epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "tournament_address", + "description": "Filter matches by tournament address (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of matches to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of matches to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "if true, the list will be sorted in descending order by epoch index.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/MatchListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getMatch", + "summary": "Get a specific match", + "description": "Fetches a single match by application, epoch index, tournament address and ID hash.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "The index of the epoch (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + }, + { + "name": "tournament_address", + "description": "The tournament address (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": true + }, + { + "name": "id_hash", + "description": "The ID hash of the match (hex encoded).", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/MatchGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/MatchNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listMatchAdvances", + "summary": "List match advances", + "description": "Returns complete immutable advance records for one match. Application, epoch, tournament, and match are required. Events are ordered by block number and block-global log index. Each record includes its transaction hash and log index for getMatchAdvance. Repeated other-parent hashes remain distinct events.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "The epoch index of the match (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + }, + { + "name": "tournament_address", + "description": "The tournament address of the match (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": true + }, + { + "name": "id_hash", + "description": "The ID hash of the match (hex encoded).", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + }, + { + "name": "limit", + "description": "The maximum number of match advances to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of match advances to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "If true, reverse both block number and log index ordering.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/MatchAdvancedListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getMatchAdvance", + "summary": "Get a specific match advance", + "description": "Returns one complete immutable advance record within the supplied application, epoch, tournament, and match. Use the transaction hash and block-global log index from listMatchAdvances. List and get return the same record fields.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "The epoch index of the match (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + }, + { + "name": "tournament_address", + "description": "The tournament address of the match (hex encoded).", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": true + }, + { + "name": "id_hash", + "description": "The ID hash of the match (hex encoded).", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + }, + { + "name": "tx_hash", + "description": "Transaction hash of the event.", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + }, + { + "name": "log_index", + "description": "Block-global log index of the event, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/MatchAdvancedGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/MatchAdvancedNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listBondEvents", + "summary": "List bond events", + "description": "Returns partial-refund and bond-recovery events, ordered by block number and block-global log index. A failed partial refund records the requested value, not a payment. A failed terminal bond transfer emits no BondRecovered event; it remains recoverable in the tournament snapshot.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Filter bond events by a specific epoch index (hex encoded).", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "tournament_address", + "description": "Filter bond events by tournament address (hex encoded).", + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/EthereumAddress" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "limit", + "description": "The maximum number of bond events to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "The starting point for the list of bond events to return.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "If true, reverse both block number and log index ordering.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BondEventListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getBondEvent", + "summary": "Get a bond event", + "description": "Returns one refund or recovery event by application, transaction hash, and block-global log index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "tx_hash", + "description": "Transaction hash of the event.", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + }, + { + "name": "log_index", + "description": "Block-global log index of the event, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BondEventGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/BondEventNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_listStandingChanges", + "summary": "List tournament standing changes", + "description": "Returns stored StandingChanged history for one application, epoch, and tournament. Records are ordered by block number, block-global log index, then transaction hash; descending reverses all three keys. Use descending true and limit one for the latest indexed event. Result and expiry instants are prospective. Ingestion supplies tournament provenance; this method does not authenticate arbitrary contracts.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Epoch index of the tournament, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + }, + { + "name": "tournament_address", + "description": "Tournament address within this epoch.", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": true + }, + { + "name": "limit", + "description": "Maximum page size. Omitted or zero selects 50; values above 10000 are capped at 10000.", + "schema": { + "type": "integer", + "minimum": 0, + "default": 50 + }, + "required": false + }, + { + "name": "offset", + "description": "Number of scoped events to skip.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807, + "default": 0 + }, + "required": false + }, + { + "name": "descending", + "description": "Reverse block number, block-global log index, and transaction hash ordering.", + "schema": { + "type": "boolean", + "default": false + }, + "required": false + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/StandingChangedListResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getStandingChange", + "summary": "Get a tournament standing change", + "description": "Returns one stored StandingChanged event by application, epoch, tournament, transaction hash, and block-global log index.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "epoch_index", + "description": "Epoch index of the tournament, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + }, + { + "name": "tournament_address", + "description": "Tournament address within this epoch.", + "schema": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "required": true + }, + { + "name": "tx_hash", + "description": "Transaction hash of the event.", + "schema": { + "$ref": "#/components/schemas/Hash" + }, + "required": true + }, + { + "name": "log_index", + "description": "Block-global log index of the event, hex encoded. Zero is valid.", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/StandingChangedGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/StandingChangedNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getNodeInfo", + "summary": "Get node information", + "description": "Fetches the chain ID, semantic node version, and default blockchain block tag used by the node. `default_block` is the node's finality contract for blockchain-derived data: it identifies the block tag (`FINALIZED`, `SAFE`, `LATEST`, or `PENDING`) up to which the node reads and acts on chain state. Clients should therefore interpret data exposed by this node with the stability guarantees of that tag.", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/NodeInfoResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/NodeConfigNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getChainId", + "summary": "Get node's chain ID", + "description": "Fetches the chain ID that the node is operating on. Deprecated: use `cartesi_getNodeInfo`, which returns the chain ID together with the node version and default blockchain block tag.", + "deprecated": true, + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ChainIdResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/NodeConfigNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + }, + { + "name": "cartesi_getNodeVersion", + "summary": "Get node version", + "description": "Fetches the semantic version of the Cartesi rollups node. Deprecated: use `cartesi_getNodeInfo`, which returns the node version together with the chain ID and default blockchain block tag.", + "deprecated": true, + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/NodeVersionResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] + } + ], + "components": { + "schemas": { + "Pagination": { + "type": "object", + "properties": { + "total_count": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "Application": { + "type": "object", + "properties": { + "name": { + "$ref": "#/components/schemas/ApplicationName" + }, + "iapplication_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "iconsensus_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "iinputbox_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "template_hash": { + "$ref": "#/components/schemas/Hash" + }, + "epoch_length": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "claim_staging_period": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "withdrawal_config": { + "$ref": "#/components/schemas/WithdrawalConfig" + }, + "consensus_type": { + "$ref": "#/components/schemas/Consensus" + }, + "enabled": { + "type": "boolean", + "description": "Operator intent. True means the operator wants the node to keep observing the application and run eligible workflows." + }, + "status": { + "$ref": "#/components/schemas/ApplicationStatus" + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "Human-readable terminal or failure description. Null only while status is OK. Foreclosure is reported separately via foreclose_block, not via status." + }, + "iinputbox_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_epoch_check_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_input_check_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_output_check_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_tournament_check_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_foreclose_check_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_accounts_drive_proved_check_block": { + "description": "Highest block scanned by the post-foreclosure accounts-drive-proved discovery loop. Strictly monotonic; only populated for foreclosed applications.", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_withdrawal_check_block": { + "description": "Highest block scanned by the post-foreclosure Withdrawal-event discovery loop. Strictly monotonic; only populated once the accounts drive has been proved.", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "processed_inputs": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "foreclose_block": { + "description": "Block where the on-chain Foreclosure event was observed. Zero means the node has not yet observed a foreclosure (block 0 is unreachable for the event, so it is an unambiguous sentinel). Non-zero is one-way: once set, normal execution stops and evmreader transitions into post-foreclosure observation (drive-prove discovery, then Withdrawal indexing).", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "foreclose_transaction": { + "description": "Transaction hash of the Foreclosure event. All-zero (0x000...0) when foreclose_block is zero; otherwise the tx that emitted the event.", + "$ref": "#/components/schemas/Hash" + }, + "accounts_drive_proved_block": { + "description": "Block where the proveAccountsDriveMerkleRoot transaction landed for this foreclosed application. Zero means the drive has not yet been proved (or this application is not foreclosed). Non-zero gates withdrawal eligibility — the contract rejects withdraw() before the drive is proved.", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "accounts_drive_proved_transaction": { + "description": "Transaction hash of the proveAccountsDriveMerkleRoot call. Best-effort: when the per-block tx hunt cannot identify the producing transaction, this field is the zero hash. The (block, root) tuple is canonical regardless.", + "$ref": "#/components/schemas/Hash" + }, + "accounts_drive_merkle_root": { + "description": "On-chain accountsDriveMerkleRoot read at accounts_drive_proved_block. All-zero when the drive has not been proved.", + "$ref": "#/components/schemas/Hash" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "execution_parameters": { + "$ref": "#/components/schemas/ExecutionParameters" + } + } + }, + "ApplicationListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Application" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ApplicationGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Application" + } + } + }, + "EpochStatus": { + "type": "string", + "enum": [ + "OPEN", + "CLOSED", + "INPUTS_PROCESSED", + "CLAIM_COMPUTED", + "CLAIM_SUBMITTED", + "CLAIM_STAGED", + "CLAIM_ACCEPTED", + "CLAIM_REJECTED", + "CLAIM_FORECLOSED" + ] + }, + "Epoch": { + "type": "object", + "properties": { + "index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "first_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "last_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "input_index_lower_bound": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "input_index_upper_bound": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "machine_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "tx_buffer_data_block": { + "description": "The 32-byte CMIO TX-buffer memory block in the proved machine state.", + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "tx_buffer_proof": { + "description": "Merkle siblings proving the TX-buffer data block against machine_hash.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Hash" + } + }, + "iflags_y_data_block": { + "description": "The 32-byte memory block containing the machine iflags.Y register.", + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "iflags_y_proof": { + "description": "Merkle siblings proving the iflags.Y data block against machine_hash.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Hash" + } + }, + "htif_tohost_data_block": { + "description": "The 32-byte memory block containing the machine HTIF tohost register.", + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "htif_tohost_proof": { + "description": "Merkle siblings proving the HTIF tohost data block against machine_hash.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Hash" + } + }, + "commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "commitment_proof": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Hash" + } + }, + "claim_transaction_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "tournament_address": { + "oneOf": [ + { + "$ref": "#/components/schemas/EthereumAddress" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/components/schemas/EpochStatus" + }, + "staged_at_block": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "virtual_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "EpochListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Epoch" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "EpochGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Epoch" + } + } + }, + "InputCompletionStatus": { + "type": "string", + "enum": [ + "NONE", + "ACCEPTED", + "REJECTED", + "EXCEPTION", + "MACHINE_HALTED", + "OVERFLOW", + "UNEXPECTED_YIELD", + "INVALID_OUTPUTS_ROOT" + ] + }, + "Input": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "raw_data": { + "$ref": "#/components/schemas/ByteArray" + }, + "decoded_data": { + "oneOf": [ + { + "$ref": "#/components/schemas/EvmAdvance" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/components/schemas/InputCompletionStatus" + }, + "exception_data": { + "description": "Raw guest-provided CMIO exception payload. It is non-null only when status is EXCEPTION; an empty payload is encoded as 0x.", + "oneOf": [ + { + "$ref": "#/components/schemas/ByteArray" + }, + { + "type": "null" + } + ] + }, + "machine_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "tx_buffer_data_block": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "transaction_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "EvmAdvance": { + "type": "object", + "properties": { + "chain_id": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "application_contract": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "sender": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "block_timestamp": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "prev_randao": { + "$ref": "#/components/schemas/UnsignedInteger256" + }, + "index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "payload": { + "$ref": "#/components/schemas/ByteArray" + } + } + }, + "InputListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Input" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "InputGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Input" + } + } + }, + "LastAcceptedEpochIndexResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, + "ProcessedInputCountResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, + "ExecutedOutputCountResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, + "PendingExecutableOutputCountResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, + "Output": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "input_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "raw_data": { + "$ref": "#/components/schemas/ByteArray" + }, + "decoded_data": { + "oneOf": [ + { + "$ref": "#/components/schemas/DecodedOutput" + }, + { + "type": "null" + } + ] + }, + "hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "output_hashes_siblings": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Hash" + } + }, + "execution_transaction_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "Notice": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/FunctionSelector" + }, + "payload": { + "$ref": "#/components/schemas/ByteArray" + } + } + }, + "Voucher": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/FunctionSelector" + }, + "destination": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "value": { + "$ref": "#/components/schemas/UnsignedInteger256", + "description": "Amount of Wei transferred by the voucher's call" + }, + "payload": { + "$ref": "#/components/schemas/ByteArray" + } + } + }, + "DelegateCallVoucher": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/FunctionSelector" + }, + "destination": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "payload": { + "$ref": "#/components/schemas/ByteArray" + } + } + }, + "DecodedOutput": { + "oneOf": [ + { + "$ref": "#/components/schemas/Notice" + }, + { + "$ref": "#/components/schemas/Voucher" + }, + { + "$ref": "#/components/schemas/DelegateCallVoucher" + } + ] + }, + "OutputListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Output" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "OutputGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Output" + } + } + }, + "Report": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "input_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "raw_data": { + "$ref": "#/components/schemas/ByteArray" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ReportListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Report" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ReportGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Report" + } + } + }, + "Withdrawal": { + "type": "object", + "description": "A Withdrawal event observed for a foreclosed IApplication after its accounts drive has been proved. account and output are stored as raw bytes; recipient encoding inside account is defined by the per-app WithdrawalOutputBuilder and is opaque to the node.", + "properties": { + "account_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "account": { + "$ref": "#/components/schemas/ByteArray" + }, + "output": { + "$ref": "#/components/schemas/ByteArray" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "transaction_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "WithdrawalListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Withdrawal" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "WithdrawalGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Withdrawal" + } + } + }, + "SnapshotPolicy": { + "type": "string", + "enum": [ + "NONE", + "EVERY_INPUT", + "EVERY_EPOCH" + ] + }, + "ExecutionParameters": { + "type": "object", + "properties": { + "snapshot_policy": { + "$ref": "#/components/schemas/SnapshotPolicy" + }, + "advance_inc_cycles": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "advance_max_cycles": { + "description": "Optional advance execution delta from the starting mcycle in range 0..2^48-1; 0 imposes no operator cap and the machine's imcyclemax governs", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "inspect_inc_cycles": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "inspect_max_cycles": { + "description": "Optional inspect execution delta from the starting mcycle in range 0..2^48-1; 0 imposes no operator cap and the machine's imcyclemax governs", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "advance_inc_deadline": { + "description": "Duration in nanoseconds", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "advance_max_deadline": { + "description": "Duration in nanoseconds", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "inspect_inc_deadline": { + "description": "Duration in nanoseconds", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "inspect_max_deadline": { + "description": "Duration in nanoseconds", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "load_deadline": { + "description": "Duration in nanoseconds", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "store_deadline": { + "description": "Duration in nanoseconds", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "fast_deadline": { + "description": "Duration in nanoseconds", + "$ref": "#/components/schemas/UnsignedInteger" + }, + "max_concurrent_inspects": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ChainIdResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, + "NodeInfo": { + "type": "object", + "properties": { + "chain_id": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "version": { + "type": "string", + "format": "semver", + "pattern": "^[a-zA-Z0-9_-\\.]+$" + }, + "default_block": { + "type": "string", + "description": "The block tag that defines the finality/stability level of blockchain-derived data read and acted on by this node.", + "enum": [ + "FINALIZED", + "SAFE", + "LATEST", + "PENDING" + ] + } + } + }, + "NodeInfoResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/NodeInfo" + } + } + }, + "NodeVersionResult": { + "type": "object", + "properties": { + "data": { + "type": "string", + "format": "semver", + "pattern": "^[a-zA-Z0-9_-\\.]+$" + } + } + }, + "ApplicationStatus": { + "type": "string", + "enum": [ + "OK", + "FAILED", + "DIVERGED", + "CORRUPTED", + "GUEST_EXCEPTION", + "MACHINE_HALTED", + "MCYCLE_OVERFLOW", + "UNEXPECTED_YIELD", + "INVALID_OUTPUTS_ROOT" + ] + }, + "Consensus": { + "type": "string", + "enum": [ + "AUTHORITY", + "QUORUM", + "PRT" + ] + }, + "EthereumAddress": { + "type": "string", + "format": "hex-byte", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "WithdrawalConfig": { + "type": "object", + "description": "On-chain WithdrawalConfig mirroring the five Application contract immutables (guardian, log2_leaves_per_account, log2_max_num_of_accounts, accounts_drive_start_index, withdrawal_output_builder). The all-zero shape encodes 'no withdrawal handling configured' — applications without a guardian cannot be foreclosed.", + "properties": { + "guardian": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "log2_leaves_per_account": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "log2_max_num_of_accounts": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "accounts_drive_start_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "withdrawal_output_builder": { + "$ref": "#/components/schemas/EthereumAddress" + } + }, + "required": [ + "guardian", + "log2_leaves_per_account", + "log2_max_num_of_accounts", + "accounts_drive_start_index", + "withdrawal_output_builder" + ] + }, + "Hash": { + "type": "string", + "format": "hex-byte", + "pattern": "^0x[a-fA-F0-9]{64}$" + }, + "ByteArray": { + "type": "string", + "format": "hex-byte", + "pattern": "^0x[a-fA-F0-9]*$" + }, + "UnsignedInteger": { + "type": "string", + "format": "hex-uint64", + "pattern": "^0x[a-fA-F0-9]{1,16}$" + }, + "UnsignedInteger256": { + "type": "string", + "format": "hex-uint256", + "pattern": "^0x[a-fA-F0-9]{1,64}$", + "description": "256-bit unsigned integer, hex encoded (the node emits minimal hex)" + }, + "FunctionSelector": { + "type": "string", + "format": "hex-byte", + "pattern": "^0x[a-fA-F0-9]{8}$" + }, + "ApplicationName": { + "type": "string", + "pattern": "^[a-z0-9_-]+$" + }, + "NameOrAddress": { + "oneOf": [ + { + "$ref": "#/components/schemas/ApplicationName" + }, + { + "$ref": "#/components/schemas/EthereumAddress" + } + ] + }, + "WinnerCommitment": { + "type": "string", + "enum": [ + "NONE", + "ONE", + "TWO" + ] + }, + "MatchDeletionReason": { + "type": "string", + "enum": [ + "STEP", + "TIMEOUT", + "CHILD_TOURNAMENT", + "NOT_DELETED" + ] + }, + "Tournament": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "parent_tournament_address": { + "oneOf": [ + { + "$ref": "#/components/schemas/EthereumAddress" + }, + { + "type": "null" + } + ] + }, + "parent_match_id_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "max_level": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "level": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "log2step": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "height": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "initial_hash": { + "$ref": "#/components/schemas/Hash" + }, + "base_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "kind": { + "$ref": "#/components/schemas/TournamentKind" + }, + "start_instant": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Tournament creation block." + }, + "allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Initial clock allowance in blocks." + }, + "creation_event": { + "oneOf": [ + {"$ref": "#/components/schemas/TournamentCreationEvent"}, + {"type": "null"} + ] + }, + "snapshot": { + "$ref": "#/components/schemas/TournamentSnapshot" + }, + "bond_value": { + "$ref": "#/components/schemas/Uint256" + }, + "contested_final_state_one": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "contested_final_state_two": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + } + }, + "description": "Immutable tournament descriptor and parent relation, with a current snapshot read at a stated block. Local epoch acceptance or application failure does not make contract state immutable.", + "required": [ + "epoch_index", + "address", + "parent_tournament_address", + "parent_match_id_hash", + "max_level", + "level", + "log2step", + "height", + "created_at", + "updated_at", + "initial_hash", + "base_cycle", + "kind", + "start_instant", + "allowance", + "creation_event", + "snapshot", + "bond_value", + "contested_final_state_one", + "contested_final_state_two" + ] + }, + "TournamentListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tournament" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "TournamentGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Tournament" + } + } + }, + "Commitment": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tournament_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "commitment": { + "$ref": "#/components/schemas/Hash" + }, + "final_state_hash": { + "$ref": "#/components/schemas/Hash" + }, + "submitter_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "snapshot": { + "$ref": "#/components/schemas/CommitmentSnapshot" + } + }, + "description": "Immutable joined commitment and submitter, with current claimer and clocks. Every indexed commitment has joined; retained clocks or a zero claimer do not prove liveness.", + "required": [ + "epoch_index", + "tournament_address", + "commitment", + "final_state_hash", + "submitter_address", + "block_number", + "tx_hash", + "created_at", + "updated_at", + "log_index", + "snapshot" + ] + }, + "CommitmentListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Commitment" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "CommitmentGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Commitment" + } + } + }, + "Match": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tournament_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "id_hash": { + "$ref": "#/components/schemas/Hash" + }, + "commitment_one": { + "$ref": "#/components/schemas/Hash" + }, + "commitment_two": { + "$ref": "#/components/schemas/Hash" + }, + "left_of_two": { + "$ref": "#/components/schemas/Hash" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "winner_commitment": { + "$ref": "#/components/schemas/WinnerCommitment" + }, + "deletion_reason": { + "$ref": "#/components/schemas/MatchDeletionReason" + }, + "deletion_block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "deletion_tx_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable MatchCreated deadline, not the current responder deadline." + }, + "leaf_seal": { + "oneOf": [ + { + "$ref": "#/components/schemas/LeafMatchSeal" + }, + { + "type": "null" + } + ] + }, + "deletion_log_index": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "snapshot": { + "$ref": "#/components/schemas/MatchSnapshot" + }, + "responder_deadline": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable MatchCreated responder deadline. A later advance or seal supersedes this schedule; child creation ends its use." + } + }, + "required": [ + "epoch_index", + "tournament_address", + "id_hash", + "commitment_one", + "commitment_two", + "left_of_two", + "block_number", + "tx_hash", + "winner_commitment", + "deletion_reason", + "deletion_block_number", + "deletion_tx_hash", + "created_at", + "updated_at", + "log_index", + "eliminable_at", + "leaf_seal", + "deletion_log_index", + "snapshot", + "responder_deadline" + ] + }, + "MatchListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Match" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "MatchGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Match" + } + } + }, + "MatchAdvanced": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tournament_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "id_hash": { + "$ref": "#/components/schemas/Hash" + }, + "other_parent": { + "$ref": "#/components/schemas/Hash" + }, + "left_node": { + "$ref": "#/components/schemas/Hash" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "segment_start_position": { + "$ref": "#/components/schemas/Uint256" + }, + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable both-sides elimination deadline reported by this advance." + }, + "current_height": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "responder_deadline": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Post-advance responder deadline. At current_height one, this is the seal deadline." + } + }, + "required": [ + "epoch_index", + "tournament_address", + "id_hash", + "other_parent", + "left_node", + "block_number", + "tx_hash", + "created_at", + "updated_at", + "log_index", + "segment_start_position", + "eliminable_at", + "current_height", + "responder_deadline" + ] + }, + "MatchAdvancedListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MatchAdvanced" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "MatchAdvancedGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/MatchAdvanced" + } + } + }, + "TournamentKind": { + "type": "string", + "enum": [ + "LEAF", + "NON_LEAF" + ] + }, + "TournamentStandingState": { + "type": "string", + "enum": [ + "MATCHES_ACTIVE", + "AWAITING_CLOSURE", + "ROOT_WINNER", + "ROOT_FAILED", + "INNER_WINNER", + "INNER_ELIMINABLE_NO_WINNER", + "INNER_ELIMINABLE_WINNER_EXPIRED" + ] + }, + "MatchPhase": { + "type": "string", + "enum": [ + "UNINITIALIZED", + "BISECTING", + "READY_TO_SEAL", + "SEALED" + ] + }, + "CommitmentSide": { + "type": "string", + "enum": [ + "ONE", + "TWO" + ] + }, + "MatchTimeoutOutcome": { + "type": "string", + "enum": [ + "NONE", + "ONE_WINS", + "TWO_WINS", + "ELIMINATE_BOTH" + ] + }, + "InnerTournamentDisposition": { + "type": "string", + "enum": [ + "UNSETTLED", + "WINNER", + "ELIMINABLE" + ] + }, + "BondDisposition": { + "type": "string", + "enum": [ + "TOURNAMENT_RUNNING", + "NO_WINNER", + "RECOVERABLE", + "RECOVERED" + ] + }, + "BondEventType": { + "type": "string", + "enum": [ + "PARTIAL_BOND_REFUND", + "BOND_RECOVERED" + ] + }, + "Uint256": { + "type": "string", + "pattern": "^0x(0|[1-9a-f][0-9a-f]{0,63})$", + "description": "An exact uint256 quantity in canonical hexadecimal form. The maximum is 2^256-1. Never a floating-point JSON number." + }, + "TournamentCreationEvent": { + "type": "object", + "description": "Immutable NewInnerTournament creation log from the parent for a child. Root tournaments have a null creation_event.", + "properties": { + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + } + }, + "required": [ + "block_number", + "tx_hash", + "log_index" + ] + }, + "LeafMatchSeal": { + "type": "object", + "properties": { + "eliminable_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Immutable deadline from LeafMatchSealed; both sides are eliminable at this block." + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + }, + "agree_state": { + "$ref": "#/components/schemas/Hash" + }, + "divergence_position": { + "$ref": "#/components/schemas/Uint256" + }, + "final_state_one": { + "$ref": "#/components/schemas/Hash" + }, + "final_state_two": { + "$ref": "#/components/schemas/Hash" + }, + "deadline_one": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "deadline_two": { + "$ref": "#/components/schemas/UnsignedInteger" + } + }, + "required": [ + "eliminable_at", + "block_number", + "tx_hash", + "log_index", + "agree_state", + "divergence_position", + "final_state_one", + "final_state_two", + "deadline_one", + "deadline_two" + ], + "description": "Durable LeafMatchSealed divergence record. It remains present after deletion even when snapshot.sealed becomes null. The two final states and deadlines are independent." + }, + "TournamentInnerResult": { + "type": "object", + "properties": { + "disposition": { + "$ref": "#/components/schemas/InnerTournamentDisposition" + }, + "parent_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "paused_allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "The child winner remaining paused allowance before the parent refill, after deduction of elapsed time since the child result." + } + }, + "required": [ + "disposition", + "parent_commitment", + "paused_allowance" + ] + }, + "TournamentBondRecovery": { + "type": "object", + "description": "Claimer and payment are present only for RECOVERABLE. A present zero payment is valid.", + "properties": { + "disposition": { + "$ref": "#/components/schemas/BondDisposition" + }, + "claimer": { + "oneOf": [ + { + "$ref": "#/components/schemas/EthereumAddress" + }, + { + "type": "null" + } + ] + }, + "payment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Uint256" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "disposition", + "claimer", + "payment" + ] + }, + "TournamentSnapshot": { + "type": "object", + "description": "Current contract state, not historical winner data. An expired inner candidate remains a candidate, but is not a current winner. Inner result is null for a root. Separate API calls do not form one database snapshot.", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Configured-policy block at which every view in this snapshot was read." + }, + "standing": { + "$ref": "#/components/schemas/TournamentStandingState" + }, + "accepts_joins": { + "type": "boolean" + }, + "candidate": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "winner_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "final_state_hash": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "parent_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ] + }, + "finished_at_block": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Completion block, or zero while unfinished. Completion does not stop observation." + }, + "winner_expires_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Expiry block for a live inner winner; zero for other standings." + }, + "inner_result": { + "oneOf": [ + { + "$ref": "#/components/schemas/TournamentInnerResult" + }, + { + "type": "null" + } + ] + }, + "bond_recovery": { + "$ref": "#/components/schemas/TournamentBondRecovery" + } + }, + "required": [ + "as_of_block", + "standing", + "accepts_joins", + "candidate", + "winner_commitment", + "final_state_hash", + "parent_commitment", + "finished_at_block", + "winner_expires_at", + "inner_result", + "bond_recovery" + ] + }, + "CommitmentSnapshot": { + "type": "object", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "claimer": { + "$ref": "#/components/schemas/EthereumAddress", + "description": "Current claimer, which can be zero after elimination or recovery. The original submitter remains an immutable join field." + }, + "clock_running": { + "type": "boolean" + }, + "clock_deadline": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Responder expiry block when running; zero when paused." + }, + "clock_allowance": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Paused allowance in blocks. Clock storage alone does not prove that a commitment is live." + } + }, + "required": [ + "as_of_block", + "claimer", + "clock_running", + "clock_deadline", + "clock_allowance" + ] + }, + "MatchBisectionSnapshot": { + "type": "object", + "description": "Current divergence frontier. Current height is present for BISECTING and null for READY_TO_SEAL. Responder side ONE/TWO is not the deletion winner enum.", + "properties": { + "revealing_parent": { + "$ref": "#/components/schemas/Hash" + }, + "waiting_left": { + "$ref": "#/components/schemas/Hash" + }, + "waiting_right": { + "$ref": "#/components/schemas/Hash" + }, + "segment_start_position": { + "$ref": "#/components/schemas/Uint256" + }, + "segment_start_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "current_height": { + "oneOf": [ + { + "$ref": "#/components/schemas/UnsignedInteger" + }, + { + "type": "null" + } + ] + }, + "responder": { + "$ref": "#/components/schemas/CommitmentSide" + } + }, + "required": [ + "revealing_parent", + "waiting_left", + "waiting_right", + "segment_start_position", + "segment_start_cycle", + "current_height", + "responder" + ] + }, + "MatchSealedSnapshot": { + "type": "object", + "description": "Sealed values are in commitment-side order, not reveal order.", + "properties": { + "agree_state": { + "$ref": "#/components/schemas/Hash" + }, + "divergence_position": { + "$ref": "#/components/schemas/Uint256" + }, + "divergence_cycle": { + "$ref": "#/components/schemas/Uint256" + }, + "final_state_one": { + "$ref": "#/components/schemas/Hash" + }, + "final_state_two": { + "$ref": "#/components/schemas/Hash" + } + }, + "required": [ + "agree_state", + "divergence_position", + "divergence_cycle", + "final_state_one", + "final_state_two" + ] + }, + "MatchSnapshot": { + "type": "object", + "description": "Current match state. Deleted matches have UNINITIALIZED phase and no phase payload; deletion facts remain on the match. A sealed non-leaf match has no local timeout outcome because its child controls progress.", + "properties": { + "as_of_block": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "phase": { + "$ref": "#/components/schemas/MatchPhase" + }, + "bisection": { + "oneOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "type": "null" + } + ] + }, + "sealed": { + "oneOf": [ + { + "$ref": "#/components/schemas/MatchSealedSnapshot" + }, + { + "type": "null" + } + ] + }, + "timeout_outcome": { + "$ref": "#/components/schemas/MatchTimeoutOutcome" + }, + "deferred_charge": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Deferred charge in blocks from the timeout classifier at as_of_block." + } + }, + "required": [ + "as_of_block", + "phase", + "bisection", + "sealed", + "timeout_outcome", + "deferred_charge" + ], + "oneOf": [ + { + "properties": { + "phase": { + "const": "UNINITIALIZED" + }, + "bisection": { + "type": "null" + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "BISECTING" + }, + "bisection": { + "allOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "properties": { + "current_height": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + } + ] + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "READY_TO_SEAL" + }, + "bisection": { + "allOf": [ + { + "$ref": "#/components/schemas/MatchBisectionSnapshot" + }, + { + "properties": { + "current_height": { + "type": "null" + } + } + } + ] + }, + "sealed": { + "type": "null" + } + } + }, + { + "properties": { + "phase": { + "const": "SEALED" + }, + "bisection": { + "type": "null" + }, + "sealed": { + "$ref": "#/components/schemas/MatchSealedSnapshot" + } + } + } + ] + }, + "PartialBondRefund": { + "type": "object", + "description": "Value is the requested refund. It was paid only if success is true.", + "properties": { + "recipient": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "value": { + "$ref": "#/components/schemas/Uint256" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "recipient", + "value", + "success" + ] + }, + "BondRecovered": { + "type": "object", + "properties": { + "commitment": { + "$ref": "#/components/schemas/Hash" + }, + "claimer": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "payment": { + "$ref": "#/components/schemas/Uint256" + }, + "burned": { + "$ref": "#/components/schemas/Uint256" + } + }, + "required": [ + "commitment", + "claimer", + "payment", + "burned" + ] + }, + "BondEvent": { + "type": "object", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tournament_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "type": { + "$ref": "#/components/schemas/BondEventType" + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + }, + "refund": { + "oneOf": [ + { + "$ref": "#/components/schemas/PartialBondRefund" + }, + { + "type": "null" + } + ] + }, + "recovery": { + "oneOf": [ + { + "$ref": "#/components/schemas/BondRecovered" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "epoch_index", + "tournament_address", + "type", + "block_number", + "tx_hash", + "log_index", + "refund", + "recovery", + "created_at", + "updated_at" + ], + "oneOf": [ + { + "properties": { + "type": { + "const": "PARTIAL_BOND_REFUND" + }, + "refund": { + "$ref": "#/components/schemas/PartialBondRefund" + }, + "recovery": { + "type": "null" + } + } + }, + { + "properties": { + "type": { + "const": "BOND_RECOVERED" + }, + "refund": { + "type": "null" + }, + "recovery": { + "$ref": "#/components/schemas/BondRecovered" + } + } + } + ] + }, + "BondEventListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BondEvent" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": [ + "data", + "pagination" + ] + }, + "BondEventGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/BondEvent" + } + }, + "required": [ + "data" + ] + }, + "StandingChangedListResult": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StandingChanged" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": [ + "data", + "pagination" + ] + }, + "StandingChangedGetResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/StandingChanged" + } + }, + "required": [ + "data" + ] + }, + "StandingChanged": { + "type": "object", + "description": "Immutable StandingChanged event for one authenticated tournament. Read the latest event in log order; result and expiry instants are prospective.", + "properties": { + "epoch_index": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tournament_address": { + "$ref": "#/components/schemas/EthereumAddress" + }, + "match_count": { + "$ref": "#/components/schemas/Uint256" + }, + "dangling": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ], + "description": "Unpaired commitment, or null when absent. A present zero hash remains a value." + }, + "parent_commitment": { + "oneOf": [ + { + "$ref": "#/components/schemas/Hash" + }, + { + "type": "null" + } + ], + "description": "Contested parent commitment defended by an inner dangling winner, or null when absent." + }, + "result_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Prospective first inclusive finish instant with no live matches; zero otherwise. This event does not assert that the tournament has already finished." + }, + "winner_expires_at": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Prospective first inclusive expiry instant for an inner dangling winner with no live matches; zero otherwise." + }, + "block_number": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "tx_hash": { + "$ref": "#/components/schemas/Hash" + }, + "log_index": { + "$ref": "#/components/schemas/UnsignedInteger", + "description": "Block-global log index. Zero is valid." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "epoch_index", + "tournament_address", + "match_count", + "dangling", + "parent_commitment", + "result_at", + "winner_expires_at", + "block_number", + "tx_hash", + "log_index", + "created_at", + "updated_at" + ] + } + }, + "errors": { + "InvalidParams": { + "code": -32602, + "message": "Invalid parameters" + }, + "InternalError": { + "code": -32603, + "message": "Internal server error" + }, + "TimeoutError": { + "code": -32070, + "message": "Request timed out" + }, + "ResponseSizeLimitExceeded": { + "code": -31003, + "message": "Response size limit exceeded" + }, + "BatchListItemLimitExceeded": { + "code": -31004, + "message": "Batch list item limit exceeded" + }, + "ApplicationNotFound": { + "code": -31002, + "message": "Application not found" + }, + "EpochNotFound": { + "code": -31001, + "message": "Epoch not found" + }, + "InputNotFound": { + "code": -31001, + "message": "Input not found" + }, + "OutputNotFound": { + "code": -31001, + "message": "Output not found" + }, + "ReportNotFound": { + "code": -31001, + "message": "Report not found" + }, + "WithdrawalNotFound": { + "code": -31001, + "message": "Withdrawal not found" + }, + "TournamentNotFound": { + "code": -31001, + "message": "Tournament not found" + }, + "CommitmentNotFound": { + "code": -31001, + "message": "Commitment not found" + }, + "MatchNotFound": { + "code": -31001, + "message": "Match not found" + }, + "MatchAdvancedNotFound": { + "code": -31001, + "message": "Match advanced not found" + }, + "BondEventNotFound": { + "code": -31001, + "message": "Bond event not found" + }, + "NodeConfigNotFound": { + "code": -31001, + "message": "EVM Reader config not found" + }, + "StandingChangedNotFound": { + "code": -31001, + "message": "Standing change not found" + } + } + } +} diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go new file mode 100644 index 000000000..9325a6dba --- /dev/null +++ b/internal/jsonrpc/jsonrpc.go @@ -0,0 +1,1922 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "reflect" + "runtime/debug" + "unicode/utf8" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/version" + "github.com/cartesi/rollups-node/pkg/service" +) + +//go:embed jsonrpc-discover.json +var discoverSpec embed.FS + +const ( + // Maximum allowed body size (1 MB). + MAX_BODY_SIZE = 1 << 20 //nolint: revive + // Maximum response size for a single request or cumulative response size for + // all entries in a batch (10 MB). + MAX_RESPONSE_SIZE = 10 << 20 //nolint: revive + // Maximum amount of request in a batch (100) + MAX_BATCH_SIZE = 100 //nolint: revive + // Maximum amount of items to list (10,000). + LIST_ITEM_LIMIT = 10000 //nolint: revive + // Default amount of item on a list (50) + LIST_ITEM_DEFAULT = 50 //nolint: revive + // Maximum number of bytes from an RPC method included in a log record. + MAX_LOGGED_METHOD_LEN = 64 //nolint: revive +) + +const ( + // JSON-RPC Standard Error Codes (https://json-rpc.dev/docs/reference/error-codes) + JSONRPC_PARSE_ERROR int = -32700 //nolint: revive + JSONRPC_INVALID_REQUEST int = -32600 //nolint: revive + JSONRPC_METHOD_NOT_FOUND int = -32601 //nolint: revive + JSONRPC_INVALID_PARAMS int = -32602 //nolint: revive + JSONRPC_INTERNAL_ERROR int = -32603 //nolint: revive + JSONRPC_INVALID_BATCH int = -32040 //nolint: revive + JSONRPC_TIMEOUT_ERROR int = -32070 //nolint: revive + + // Resource not found: the requested resource does not exist in the method's + // scope. For application-scoped methods, this means the application exists + // but the requested entity does not; unknown applications use + // JSONRPC_APPLICATION_NOT_FOUND. For forward-looking keys, this can be the + // "not created yet" signal and may be safe to poll depending on the method. + JSONRPC_RESOURCE_NOT_FOUND int = -31001 //nolint: revive + // Application not found: the application identifier itself is unknown to + // this node. A configuration error that will not resolve by retrying. + JSONRPC_APPLICATION_NOT_FOUND int = -31002 //nolint: revive + // Response size limit exceeded: the buffered-response budget was not enough + // for a single response or all responses in a batch. + JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED int = -31003 //nolint: revive + // Batch list item limit exceeded: the cumulative effective list limits in a + // batch exceed the work budget allowed to one HTTP request. + JSONRPC_BATCH_LIST_ITEM_LIMIT_EXCEEDED int = -31004 //nolint: revive +) + +type rpcHandler = func(*Service, *http.Request, RPCRequest) (any, error) +type dispatchTable = map[string]rpcHandler + +func cloneDispatchTable(source dispatchTable) dispatchTable { + clone := make(dispatchTable, len(source)) + for method, handler := range source { + clone[method] = handler + } + return clone +} + +var jsonrpcHandlers = dispatchTable{ + "rpc.discover": handleDiscover, + "cartesi_listApplications": handleListApplications, + "cartesi_getApplication": handleGetApplication, + "cartesi_listEpochs": handleListEpochs, + "cartesi_getEpoch": handleGetEpoch, + "cartesi_getEpochByVirtualIndex": handleGetEpochByVirtualIndex, + "cartesi_getLastAcceptedEpochIndex": handleGetLastAcceptedEpochIndex, + "cartesi_listInputs": handleListInputs, + "cartesi_getInput": handleGetInput, + "cartesi_getProcessedInputCount": handleGetProcessedInputCount, + "cartesi_getExecutedOutputCount": handleGetExecutedOutputCount, + "cartesi_getPendingExecutableOutputCount": handleGetPendingExecutableOutputCount, + "cartesi_listOutputs": handleListOutputs, + "cartesi_getOutput": handleGetOutput, + "cartesi_listReports": handleListReports, + "cartesi_getReport": handleGetReport, + "cartesi_listWithdrawals": handleListWithdrawals, + "cartesi_getWithdrawal": handleGetWithdrawal, + "cartesi_listTournaments": handleListTournaments, + "cartesi_getTournament": handleGetTournament, + "cartesi_listCommitments": handleListCommitments, + "cartesi_getCommitment": handleGetCommitment, + "cartesi_listMatches": handleListMatches, + "cartesi_getMatch": handleGetMatch, + "cartesi_listMatchAdvances": handleListMatchAdvances, + "cartesi_getMatchAdvance": handleGetMatchAdvance, + "cartesi_listBondEvents": handleListBondEvents, + "cartesi_getBondEvent": handleGetBondEvent, + "cartesi_listStandingChanges": handleListStandingChanges, + "cartesi_getStandingChange": handleGetStandingChange, + "cartesi_getNodeInfo": handleGetNodeInfo, + "cartesi_getChainId": handleGetChainID, + "cartesi_getNodeVersion": handleGetNodeVersion, +} + +var listParamsTypes = map[string]reflect.Type{ + "cartesi_listApplications": reflect.TypeOf(api.ListApplicationsParams{}), + "cartesi_listEpochs": reflect.TypeOf(api.ListEpochsParams{}), + "cartesi_listInputs": reflect.TypeOf(api.ListInputsParams{}), + "cartesi_listOutputs": reflect.TypeOf(api.ListOutputsParams{}), + "cartesi_listReports": reflect.TypeOf(api.ListReportsParams{}), + "cartesi_listWithdrawals": reflect.TypeOf(api.ListWithdrawalsParams{}), + "cartesi_listTournaments": reflect.TypeOf(api.ListTournamentsParams{}), + "cartesi_listCommitments": reflect.TypeOf(api.ListCommitmentsParams{}), + "cartesi_listMatches": reflect.TypeOf(api.ListMatchesParams{}), + "cartesi_listMatchAdvances": reflect.TypeOf(api.ListMatchAdvancesParams{}), + "cartesi_listBondEvents": reflect.TypeOf(api.ListBondEventsParams{}), + "cartesi_listStandingChanges": reflect.TypeOf(api.ListStandingChangesParams{}), +} + +func truncatedMethod(method string) string { + if len(method) <= MAX_LOGGED_METHOD_LEN { + return method + } + method = method[:MAX_LOGGED_METHOD_LEN] + for !utf8.ValidString(method) { + method = method[:len(method)-1] + } + return method + "…(truncated)" +} + +// batchExceedsListItemLimit reports whether the sum of the effective limits of +// valid list entries exceeds the amount of row-fetch work allowed to one HTTP +// request. It performs no handler or repository work. +// +// Entries that cannot be decoded are left for normal dispatch, which returns +// their appropriate JSON-RPC error without accessing the repository. Each +// decodable limit is normalized exactly as it is by the list handlers: zero +// selects the default and values above the per-list maximum are capped. +func batchExceedsListItemLimit(requests []json.RawMessage) bool { + var total uint64 + for _, rawRequest := range requests { + var request RPCRequest + if err := json.Unmarshal(rawRequest, &request); err != nil || request.JSONRPC != "2.0" { + continue + } + + paramsType, ok := listParamsTypes[request.Method] + if !ok { + continue + } + params := reflect.New(paramsType) + if err := api.UnmarshalParams(request.Params, params.Interface()); err != nil { + continue + } + + limitField := params.Elem().FieldByName("Limit") + if !limitField.IsValid() || limitField.Kind() != reflect.Uint64 { + return true + } + limit := limitField.Uint() + switch { + case limit == 0: + limit = LIST_ITEM_DEFAULT + case limit > LIST_ITEM_LIMIT: + limit = LIST_ITEM_LIMIT + } + total += limit + if total > LIST_ITEM_LIMIT { + return true + } + } + return false +} + +// ----------------------------------------------------------------------------- +// Dispatching JSON‑RPC methods +// ----------------------------------------------------------------------------- + +// requestLogger keeps request attributes local to this log call. +func (s *Service) requestLogger(ctx context.Context) *slog.Logger { + return s.Logger.With("request_id", service.RequestIDFromContext(ctx)) +} + +func (s *Service) handleWriteResponse(ctx context.Context, err error) bool { + if err == nil { + return true + } + s.requestLogger(ctx).Warn("failed writing response", "error", err) + return false +} + +func (s *Service) writeByte(ctx context.Context, w http.ResponseWriter, c byte) bool { + _, err := w.Write([]byte{c}) + return s.handleWriteResponse(ctx, err) +} + +// writeRPCError sends a generic error response for internal errors. +func (s *Service) writeRPCError( + ctx context.Context, w http.ResponseWriter, id json.RawMessage, code int, message string, +) bool { + err := writeRPCError(w, id, code, message) + return s.handleWriteResponse(ctx, err) +} + +func (s *Service) repositoryError(ctx context.Context, message string, err error) error { + if errors.Is(err, context.Canceled) { + return err + } + if errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded) { + return err + } + s.requestLogger(ctx).Error(message, "err", err) + return newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") +} + +func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) error { + if !validRPCID(req.ID) { + return writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "Invalid request ID") + } + if req.JSONRPC != "2.0" { + return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "Unsupported JSON-RPC version") + } + if req.Method == "" { + return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "Invalid Request") + } + fn, ok := s.handlers[req.Method] + if !ok { + s.requestLogger(r.Context()).Debug("RPC method not found", "method", truncatedMethod(req.Method)) + return writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found") + } + result, err := fn(s, r, req) + if err == nil { + return writeRPCResult(w, req.ID, result) + } + if errors.Is(err, context.Canceled) { + return err + } + if errors.Is(err, context.DeadlineExceeded) && errors.Is(r.Context().Err(), context.DeadlineExceeded) { + s.requestLogger(r.Context()).Warn("RPC method dispatch timeout", "method", truncatedMethod(req.Method)) + return writeRPCError(w, req.ID, JSONRPC_TIMEOUT_ERROR, "Request timed out") + } + + var rpcErr *RPCError + if errors.As(err, &rpcErr) { + // RPC errors describe expected client-facing failures. Do not log them at + // error level; unexpected failures are logged below before being hidden. + return writeRPCError(w, req.ID, rpcErr.Code, rpcErr.Message) + } + + s.requestLogger(r.Context()).Error("RPC method failed", "method", truncatedMethod(req.Method), "error", err) + return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") +} + +func validRPCID(id json.RawMessage) bool { + id = bytes.TrimSpace(id) + if len(id) == 0 || bytes.Equal(id, []byte("null")) { + return true + } + if id[0] == '"' { + var value string + return json.Unmarshal(id, &value) == nil + } + if (id[0] >= '0' && id[0] <= '9') || id[0] == '-' { + var number json.Number + return json.Unmarshal(id, &number) == nil + } + return false +} + +func (s *Service) dispatchOneRequest( + w http.ResponseWriter, + r *http.Request, + req RPCRequest, + budgetResp *budgetWriter, +) (responded bool) { + defer func() { + if recovered := recover(); recovered != nil { + if recovered == http.ErrAbortHandler { + panic(recovered) + } + s.requestLogger(r.Context()).Error("RPC method panic", + "method", truncatedMethod(req.Method), + "panic", recovered, + "stack", string(debug.Stack()), + ) + responded = s.writeRPCError(r.Context(), w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") + } + }() + + buffer := budgetResp.NewLimitedWriter() + if buffer == nil { + return s.writeRPCError(r.Context(), w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") + } + err := s.handleRequest(buffer, r, req) + switch { + case err == nil: + return s.handleWriteResponse(r.Context(), buffer.Flush()) + case errors.Is(err, context.Canceled): + return false + case errors.Is(err, io.ErrShortBuffer): + return s.writeRPCError(r.Context(), w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") + default: + s.requestLogger(r.Context()).Error("RPC method response encode failed", "method", truncatedMethod(req.Method), "error", err) + return s.writeRPCError(r.Context(), w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") + } +} + +func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { + if s.dispatchTimeout > 0 { + ctx, cancel := context.WithTimeout(r.Context(), s.dispatchTimeout) + defer cancel() + r = r.WithContext(ctx) + } + + // Limit request body size and ensure it is closed. + r.Body = http.MaxBytesReader(w, r.Body, MAX_BODY_SIZE) + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + body = bytes.TrimSpace(body) + if len(body) == 0 { + http.Error(w, "Empty request body", http.StatusBadRequest) + return + } + + budgetResp := newBudgetWriter(w, MAX_RESPONSE_SIZE) + w.Header().Set("Content-Type", "application/json") + + switch body[0] { + case '{': + var req RPCRequest + if err := json.Unmarshal(body, &req); err != nil { + s.writeRPCError(r.Context(), w, nil, JSONRPC_PARSE_ERROR, "Parse error") + return + } + s.requestLogger(r.Context()).Info("Dispatching RPC request", "method", truncatedMethod(req.Method)) + s.dispatchOneRequest(w, r, req, budgetResp) + + case '[': + // Keep each batch element raw so malformed requests fail independently and + // the list-item limit can be checked before dispatching any request. + var reqSeq []json.RawMessage + if err := json.Unmarshal(body, &reqSeq); err != nil { + s.writeRPCError(r.Context(), w, nil, JSONRPC_PARSE_ERROR, "Parse error") + return + } + if len(reqSeq) == 0 || len(reqSeq) > MAX_BATCH_SIZE { + s.writeRPCError(r.Context(), w, nil, JSONRPC_INVALID_BATCH, + fmt.Sprintf("invalid request batch size (expected [1..%v])", MAX_BATCH_SIZE)) + return + } + if batchExceedsListItemLimit(reqSeq) { + s.writeRPCError(r.Context(), w, nil, JSONRPC_BATCH_LIST_ITEM_LIMIT_EXCEEDED, "Batch list item limit exceeded") + return + } + + s.requestLogger(r.Context()).Info("Received RPC request batch", "items", len(reqSeq)) + if !s.writeByte(r.Context(), w, '[') { + return + } + + for i, rawReq := range reqSeq { + + if i > 0 && !s.writeByte(r.Context(), w, ',') { + return + } + + var responded bool + var req RPCRequest + + switch r.Context().Err() { + case context.Canceled: + return + case context.DeadlineExceeded: + s.requestLogger(r.Context()).Warn("RPC method dispatch timeout") + if err := json.Unmarshal(rawReq, &req); err != nil { + responded = s.writeRPCError(r.Context(), w, nil, JSONRPC_INVALID_REQUEST, "Invalid Request") + } else if !validRPCID(req.ID) { + responded = s.writeRPCError(r.Context(), w, nil, JSONRPC_INVALID_REQUEST, "Invalid request ID") + } else { + responded = s.writeRPCError(r.Context(), w, req.ID, JSONRPC_TIMEOUT_ERROR, "Request timed out") + } + default: + if err := json.Unmarshal(rawReq, &req); err != nil { + responded = s.writeRPCError(r.Context(), w, nil, JSONRPC_INVALID_REQUEST, "Invalid Request") + } else { + s.requestLogger(r.Context()).Debug("Dispatching RPC request", "method", truncatedMethod(req.Method)) + responded = s.dispatchOneRequest(w, r, req, budgetResp) + } + } + + if !responded { + return + } + } + s.writeByte(r.Context(), w, ']') + + default: + if json.Valid(body) { + s.writeRPCError(r.Context(), w, nil, JSONRPC_INVALID_REQUEST, "Invalid Request") + } else { + s.writeRPCError(r.Context(), w, nil, JSONRPC_PARSE_ERROR, "Parse error") + } + + } +} + +// ----------------------------------------------------------------------------- +// Individual Method Handlers +// ----------------------------------------------------------------------------- + +// Discovery: return the embedded specification. +func handleDiscover(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + return s.discoverSpec, nil +} + +func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListApplicationsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + // Cap limit to 10,000. + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + apps, total, err := s.repository.ListApplications(r.Context(), repository.ApplicationFilter{}, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve applications from repository", err) + } + if apps == nil { + apps = []*model.Application{} + } + + return api.ListResponse[*model.Application]{ + Data: apps, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetApplication(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetApplicationParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + app, err := s.repository.GetApplication(r.Context(), params.Application) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve application from repository", err) + } + if app == nil { + return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") + } + + return api.SingleResponse[*model.Application]{Data: app}, nil +} + +func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListEpochsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + var epochFilter repository.EpochFilter + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + epochFilter.IndexRange = indexRange + if params.Status != nil { + if len(*params.Status) == 0 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid epoch status: expected at least one status") + } + statuses := make([]model.EpochStatus, 0, len(*params.Status)) + for _, value := range *params.Status { + var status model.EpochStatus + if err := status.Scan(value); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err)) + } + statuses = append(statuses, status) + } + epochFilter.Status = statuses + } + + epochs, total, err := s.repository.ListEpochs(r.Context(), params.Application, epochFilter, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve epochs from repository", err) + } + + if len(epochs) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if epochs == nil { + epochs = []*model.Epoch{} + } + + return api.ListResponse[*model.Epoch]{ + Data: epochs, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetEpoch(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetEpochParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + index, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + + epoch, err := s.repository.GetEpoch(r.Context(), params.Application, index) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve epoch from repository", err) + } + if epoch == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found") + } + + return api.SingleResponse[*model.Epoch]{Data: epoch}, nil +} + +func handleGetEpochByVirtualIndex(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetEpochByVirtualIndexParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + index, err := config.ToIndexFromString(params.VirtualIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid virtual index: %v", err)) + } + + epoch, err := s.repository.GetEpochByVirtualIndex(r.Context(), params.Application, index) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve epoch from repository", err) + } + if epoch == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found") + } + + return api.SingleResponse[*model.Epoch]{Data: epoch}, nil +} + +func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetLastAcceptedEpochIndexParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + index, err := s.repository.GetLastAcceptedEpochIndex(r.Context(), params.Application) + if errors.Is(err, repository.ErrNotFound) { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found") + } + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve epoch from repository", err) + } + + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", index)}, nil +} + +func (s *Service) decodeInputs(ctx context.Context, application string, inputs []*model.Input) []*api.DecodedInput { + result := make([]*api.DecodedInput, 0, len(inputs)) + failureCount := 0 + var firstFailingIndex uint64 + for _, input := range inputs { + decoded, err := api.DecodeInput(input, s.inputABI) + if err != nil { + if failureCount == 0 { + firstFailingIndex = input.Index + } + failureCount++ + s.requestLogger(ctx).Debug("Unable to decode Input", "app", application, "index", input.Index, "err", err) + } + result = append(result, decoded) + } + if failureCount > 0 { + s.requestLogger(ctx).Warn("Unable to decode Inputs", + "app", application, + "count", failureCount, + "first_index", firstFailingIndex, + ) + } + return result +} + +func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListInputsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + // Create input filter based on params + inputFilter := repository.InputFilter{} + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + inputFilter.IndexRange = indexRange + if params.EpochIndex != nil { + epochIndex, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + inputFilter.EpochIndex = &epochIndex + } + + // Add sender filter if provided + if params.Sender != nil { + sender, err := config.ToAddressFromString(*params.Sender) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input sender address: %v", err)) + } + inputFilter.Sender = &sender + } + if params.TransactionHash != nil { + transactionHash, err := config.ToHashFromString(*params.TransactionHash) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err)) + } + inputFilter.TransactionHash = &transactionHash + } + + inputs, total, err := s.repository.ListInputs(r.Context(), params.Application, inputFilter, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve inputs from repository", err) + } + if len(inputs) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + + resultInputs := s.decodeInputs(r.Context(), params.Application, inputs) + + return api.ListResponse[*api.DecodedInput]{ + Data: resultInputs, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetInput(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetInputParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + index, err := config.ToIndexFromString(params.InputIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err)) + } + + input, err := s.repository.GetInput(r.Context(), params.Application, index) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve input from repository", err) + } + if input == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Input not found") + } + + decoded := s.decodeInputs(r.Context(), params.Application, []*model.Input{input})[0] + + return api.SingleResponse[*api.DecodedInput]{Data: decoded}, nil +} + +func handleGetProcessedInputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetApplicationParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + processedInputs, err := s.repository.GetProcessedInputCount(r.Context(), params.Application) + if errors.Is(err, repository.ErrNotFound) { + return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") + } + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve application from repository", err) + } + + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", processedInputs)}, nil +} + +func handleGetExecutedOutputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetApplicationParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + count, err := s.repository.GetNumberOfExecutedOutputs(r.Context(), params.Application) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve executed output count from repository", err) + } + if count == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", count)}, nil +} + +func handleGetPendingExecutableOutputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetApplicationParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + count, err := s.repository.GetNumberOfPendingExecutableOutputs(r.Context(), params.Application) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve pending executable output count from repository", err) + } + if count == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", count)}, nil +} + +func (s *Service) decodeOutputs(ctx context.Context, application string, outputs []*model.Output) []*api.DecodedOutput { + result := make([]*api.DecodedOutput, 0, len(outputs)) + failureCount := 0 + var firstFailingIndex uint64 + for _, output := range outputs { + decoded, err := api.DecodeOutput(output, s.outputABI) + if err != nil { + if failureCount == 0 { + firstFailingIndex = output.Index + } + failureCount++ + s.requestLogger(ctx).Debug("Unable to decode Output", "app", application, "index", output.Index, "err", err) + } + result = append(result, decoded) + } + if failureCount > 0 { + s.requestLogger(ctx).Warn("Unable to decode Outputs", + "app", application, + "count", failureCount, + "first_index", firstFailingIndex, + ) + } + return result +} + +func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListOutputsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + // Create output filter based on params + outputFilter := repository.OutputFilter{} + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + outputFilter.IndexRange = indexRange + if params.EpochIndex != nil { + epochIndex, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + outputFilter.EpochIndex = &epochIndex + } + + if params.InputIndex != nil { + inputIndex, err := config.ToIndexFromString(*params.InputIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err)) + } + outputFilter.InputIndex = &inputIndex + } + + // Add output type filter if provided + if params.OutputType != nil { + if len(*params.OutputType) == 0 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid output type: expected at least one selector") + } + outputTypes := make([][]byte, 0, len(*params.OutputType)) + for _, selector := range *params.OutputType { + outputType, err := api.ParseOutputType(selector) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output type: %v", err)) + } + outputTypes = append(outputTypes, outputType) + } + outputFilter.OutputType = &outputTypes + } + outputFilter.Executed = params.Executed + + // Add sender filter if provided + if params.VoucherAddress != nil { + voucherAddress, err := config.ToAddressFromString(*params.VoucherAddress) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid voucher address: %v", err)) + } + outputFilter.VoucherAddress = &voucherAddress + } + + outputs, total, err := s.repository.ListOutputs(r.Context(), params.Application, outputFilter, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve outputs from repository", err) + } + + resultOutputs := s.decodeOutputs(r.Context(), params.Application, outputs) + + if len(resultOutputs) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + + return api.ListResponse[*api.DecodedOutput]{ + Data: resultOutputs, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetOutput(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetOutputParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + index, err := config.ToIndexFromString(params.OutputIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output index: %v", err)) + } + + output, err := s.repository.GetOutput(r.Context(), params.Application, index) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve output from repository", err) + } + if output == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Output not found") + } + + decoded := s.decodeOutputs(r.Context(), params.Application, []*model.Output{output})[0] + + return api.SingleResponse[*api.DecodedOutput]{Data: decoded}, nil +} + +func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListReportsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + // Create report filter based on params + reportFilter := repository.ReportFilter{} + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + reportFilter.IndexRange = indexRange + if params.EpochIndex != nil { + epochIndex, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + reportFilter.EpochIndex = &epochIndex + } + + if params.InputIndex != nil { + inputIndex, err := config.ToIndexFromString(*params.InputIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err)) + } + reportFilter.InputIndex = &inputIndex + } + + reports, total, err := s.repository.ListReports(r.Context(), params.Application, reportFilter, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve reports from repository", err) + } + + if len(reports) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if reports == nil { + reports = []*model.Report{} + } + + return api.ListResponse[*model.Report]{ + Data: reports, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetReport(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetReportParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + index, err := config.ToIndexFromString(params.ReportIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid report index: %v", err)) + } + + report, err := s.repository.GetReport(r.Context(), params.Application, index) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve report from repository", err) + } + if report == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Report not found") + } + + return api.SingleResponse[*model.Report]{Data: report}, nil +} + +func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListWithdrawalsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + withdrawalFilter := repository.WithdrawalFilter{} + if params.AccountIndex != nil { + accountIndex, err := config.ToIndexFromString(*params.AccountIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err)) + } + withdrawalFilter.AccountIndex = &accountIndex + } + + withdrawals, total, err := s.repository.ListWithdrawals( + r.Context(), params.Application, withdrawalFilter, + repository.Pagination{Limit: params.Limit, Offset: params.Offset}, + params.Descending, + ) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve withdrawals from repository", err) + } + + if len(withdrawals) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if withdrawals == nil { + withdrawals = []*model.Withdrawal{} + } + + return api.ListResponse[*model.Withdrawal]{ + Data: withdrawals, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetWithdrawal(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetWithdrawalParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + accountIndex, err := config.ToIndexFromString(params.AccountIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err)) + } + + withdrawal, err := s.repository.GetWithdrawal(r.Context(), params.Application, accountIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve withdrawal from repository", err) + } + if withdrawal == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Withdrawal not found") + } + + return api.SingleResponse[*model.Withdrawal]{Data: withdrawal}, nil +} + +func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListTournamentsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + // Create tournament filter based on params + tournamentFilter := repository.TournamentFilter{} + if params.EpochIndex != nil { + epochIndex, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + tournamentFilter.EpochIndex = &epochIndex + } + + if params.Level != nil { + level, err := config.ToIndexFromString(*params.Level) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid level: %v", err)) + } + tournamentFilter.Level = &level + } + + if params.ParentTournamentAddress != nil { + parentAddress, err := config.ToAddressFromString(*params.ParentTournamentAddress) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent tournament address: %v", err)) + } + tournamentFilter.ParentTournamentAddress = &parentAddress + } + + if params.ParentMatchIDHash != nil { + parentMatchIDHash, err := config.ToHashFromString(*params.ParentMatchIDHash) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent match ID hash: %v", err)) + } + tournamentFilter.ParentMatchIDHash = &parentMatchIDHash + } + + tournaments, total, err := s.repository.ListTournaments(r.Context(), params.Application, tournamentFilter, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve tournaments from repository", err) + } + if len(tournaments) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if tournaments == nil { + tournaments = []*model.Tournament{} + } + + return api.ListResponse[*model.Tournament]{ + Data: tournaments, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetTournament(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetTournamentParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + // Validate tournament address + if _, err := config.ToAddressFromString(params.Address); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + + tournament, err := s.repository.GetTournament(r.Context(), params.Application, params.Address) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve tournament from repository", err) + } + if tournament == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Tournament not found") + } + + return api.SingleResponse[*model.Tournament]{Data: tournament}, nil +} + +func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListCommitmentsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + // Create commitment filter based on params + commitmentFilter := repository.CommitmentFilter{} + if params.EpochIndex != nil { + epochIndex, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + commitmentFilter.EpochIndex = &epochIndex + } + + if params.TournamentAddress != nil { + if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + commitmentFilter.TournamentAddress = params.TournamentAddress + } + + commitments, total, err := s.repository.ListCommitments(r.Context(), params.Application, commitmentFilter, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve commitments from repository", err) + } + if len(commitments) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if commitments == nil { + commitments = []*model.Commitment{} + } + + return api.ListResponse[*model.Commitment]{ + Data: commitments, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetCommitment(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetCommitmentParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + + if len(params.Commitment) == 0 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid commitment hex: Empty string") + } + if _, err := config.ToHashFromString(params.Commitment); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid commitment hex: %v", err)) + } + + commitment, err := s.repository.GetCommitment(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.Commitment) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve commitment from repository", err) + } + if commitment == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Commitment not found") + } + + return api.SingleResponse[*model.Commitment]{Data: commitment}, nil +} + +func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListMatchesParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + // Create match filter based on params + matchFilter := repository.MatchFilter{} + if params.EpochIndex != nil { + epochIndex, err := config.ToIndexFromString(*params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + matchFilter.EpochIndex = &epochIndex + } + + if params.TournamentAddress != nil { + if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + matchFilter.TournamentAddress = params.TournamentAddress + } + + matches, total, err := s.repository.ListMatches(r.Context(), params.Application, matchFilter, repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + }, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve matches from repository", err) + } + if len(matches) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if matches == nil { + matches = []*model.Match{} + } + + return api.ListResponse[*model.Match]{ + Data: matches, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetMatch(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetMatchParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + epochIndex, err := config.ToIndexFromString(params.EpochIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + + if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + + if _, err := config.ToHashFromString(params.IDHash); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) + } + + match, err := s.repository.GetMatch(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve match from repository", err) + } + if match == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match not found") + } + + return api.SingleResponse[*model.Match]{Data: match}, nil +} + +func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListMatchAdvancesParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + epochIndex, err := parseMatchScope(params.EpochIndex, params.TournamentAddress, params.IDHash) + if err != nil { + return nil, err + } + + pagination := repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + } + matchAdvances, total, err := s.repository.ListMatchAdvances(r.Context(), params.Application, + epochIndex, params.TournamentAddress, params.IDHash, pagination, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve match advances from repository", err) + } + if len(matchAdvances) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if matchAdvances == nil { + matchAdvances = []*model.MatchAdvanced{} + } + + return api.ListResponse[*model.MatchAdvanced]{ + Data: matchAdvances, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetMatchAdvance(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetMatchAdvanceParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + txHash, logIndex, err := parseTournamentEventIdentity(params.Application, params.TxHash, params.LogIndex) + if err != nil { + return nil, err + } + epochIndex, err := parseMatchScope(params.EpochIndex, params.TournamentAddress, params.IDHash) + if err != nil { + return nil, err + } + matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, + epochIndex, params.TournamentAddress, params.IDHash, txHash, logIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve match advanced from repository", err) + } + if matchAdvanced == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found") + } + + return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil +} + +func parseTournamentScope(epoch, tournament string) (uint64, error) { + epochIndex, err := config.ToIndexFromString(epoch) + if err != nil { + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + if _, err := config.ToAddressFromString(tournament); err != nil { + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + return epochIndex, nil +} + +func parseMatchScope(epoch, tournament, id string) (uint64, error) { + epochIndex, err := parseTournamentScope(epoch, tournament) + if err != nil { + return 0, err + } + if _, err := config.ToHashFromString(id); err != nil { + return 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) + } + return epochIndex, nil +} + +func parseTournamentEventIdentity(application, hash, index string) (common.Hash, uint64, error) { + if err := validateNameOrAddress(application); err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + txHash, err := config.ToHashFromString(hash) + if err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err)) + } + logIndex, err := config.ToIndexFromString(index) + if err != nil { + return common.Hash{}, 0, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid log index: %v", err)) + } + return txHash, logIndex, nil +} + +func parseTournamentEventFilter(epoch, tournament *string) (*uint64, *common.Address, error) { + var epochIndex *uint64 + if epoch != nil { + value, err := config.ToIndexFromString(*epoch) + if err != nil { + return nil, nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) + } + epochIndex = &value + } + var tournamentAddress *common.Address + if tournament != nil { + value, err := config.ToAddressFromString(*tournament) + if err != nil { + return nil, nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) + } + tournamentAddress = &value + } + return epochIndex, tournamentAddress, nil +} + +func handleListBondEvents(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListBondEventsParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + if params.Limit == 0 { + params.Limit = LIST_ITEM_DEFAULT + } + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + epochIndex, tournamentAddress, err := parseTournamentEventFilter(params.EpochIndex, params.TournamentAddress) + if err != nil { + return nil, err + } + filter := repository.BondEventFilter{EpochIndex: epochIndex, TournamentAddress: tournamentAddress} + pagination := repository.Pagination{Limit: params.Limit, Offset: params.Offset} + events, total, err := s.repository.ListBondEvents(r.Context(), params.Application, filter, pagination, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve bond events from repository", err) + } + if len(events) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if events == nil { + events = []*model.BondEvent{} + } + return api.ListResponse[*model.BondEvent]{ + Data: events, + Pagination: api.Pagination{TotalCount: total, Limit: params.Limit, Offset: params.Offset}, + }, nil +} + +func handleGetBondEvent(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetBondEventParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + txHash, logIndex, err := parseTournamentEventIdentity(params.Application, params.TxHash, params.LogIndex) + if err != nil { + return nil, err + } + event, err := s.repository.GetBondEvent(r.Context(), params.Application, txHash, logIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve bond event from repository", err) + } + if event == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Bond event not found") + } + return api.SingleResponse[*model.BondEvent]{Data: event}, nil +} + +func handleListStandingChanges(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.ListStandingChangesParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } + + // Use default values if not provided + if params.Limit <= 0 { + params.Limit = LIST_ITEM_DEFAULT + } + + if params.Limit > LIST_ITEM_LIMIT { + params.Limit = LIST_ITEM_LIMIT + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + epochIndex, err := parseTournamentScope(params.EpochIndex, params.TournamentAddress) + if err != nil { + return nil, err + } + + pagination := repository.Pagination{ + Limit: params.Limit, + Offset: params.Offset, + } + standingChanges, total, err := s.repository.ListStandingChanges(r.Context(), params.Application, + epochIndex, params.TournamentAddress, pagination, params.Descending) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve standing changes from repository", err) + } + if len(standingChanges) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + if standingChanges == nil { + standingChanges = []*model.StandingChanged{} + } + + return api.ListResponse[*model.StandingChanged]{ + Data: standingChanges, + Pagination: api.Pagination{ + TotalCount: total, + Limit: params.Limit, + Offset: params.Offset, + }, + }, nil +} + +func handleGetStandingChange(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetStandingChangeParams + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { + s.requestLogger(r.Context()).Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + txHash, logIndex, err := parseTournamentEventIdentity(params.Application, params.TxHash, params.LogIndex) + if err != nil { + return nil, err + } + epochIndex, err := parseTournamentScope(params.EpochIndex, params.TournamentAddress) + if err != nil { + return nil, err + } + standingChange, err := s.repository.GetStandingChange(r.Context(), params.Application, + epochIndex, params.TournamentAddress, txHash, logIndex) + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve standing change from repository", err) + } + if standingChange == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Standing change not found") + } + + return api.SingleResponse[*model.StandingChanged]{Data: standingChange}, nil +} + +func handleGetNodeInfo(s *Service, r *http.Request, _ RPCRequest) (any, error) { + cfg, err := repository.LoadNodeConfig[config.PersistentChainConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) + if errors.Is(err, repository.ErrNotFound) { + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") + } + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve evmreader config from repository", err) + } + + return api.SingleResponse[api.NodeInfo]{Data: api.NodeInfo{ + ChainID: fmt.Sprintf("0x%x", cfg.Value.ChainID), + Version: version.BuildVersion, + DefaultBlock: string(cfg.Value.DefaultBlock), // FINALIZED | SAFE | LATEST | PENDING + }}, nil +} + +func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { + cfg, err := repository.LoadNodeConfig[config.PersistentChainConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) + if errors.Is(err, repository.ErrNotFound) { + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") + } + if err != nil { + return nil, s.repositoryError(r.Context(), "Unable to retrieve evmreader config from repository", err) + } + + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", cfg.Value.ChainID)}, nil +} + +func handleGetNodeVersion(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + return api.SingleResponse[string]{Data: version.BuildVersion}, nil +} + +func parseIndexRange(from, to *string) (*repository.Range, error) { + if from == nil && to == nil { + return nil, nil + } + + indexRange := repository.Range{End: math.MaxUint64} + if from != nil { + value, err := config.ToIndexFromString(*from) + if err != nil { + return nil, fmt.Errorf("invalid from index: %w", err) + } + indexRange.Start = value + } + if to != nil { + value, err := config.ToIndexFromString(*to) + if err != nil { + return nil, fmt.Errorf("invalid to index: %w", err) + } + indexRange.End = value + } + if indexRange.Start > indexRange.End { + return nil, fmt.Errorf("invalid index range: from must be less than or equal to to") + } + return &indexRange, nil +} + +func (s *Service) applicationAbsentOrError( + r *http.Request, + validatedNameOrAddress string, +) error { + app, err := s.repository.GetApplication(r.Context(), validatedNameOrAddress) + if err != nil { + return s.repositoryError(r.Context(), "Unable to retrieve application from repository", err) + } else if app == nil { + return newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") + } + return nil +} diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go new file mode 100644 index 000000000..912b937a8 --- /dev/null +++ b/internal/jsonrpc/jsonrpc_test.go @@ -0,0 +1,4247 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Tests for jsonrpc API using test tables of the database. +// +// for a simple coverage check, run: +// ``` +// go test -C internal/jsonrpc/ -cover +// ``` +// +// for an actual report, run: +// ``` +// go test -C internal/jsonrpc/ -coverprofile=coverage.out +// go tool -C internal/jsonrpc/ cover -html=coverage.out +// ``` +package jsonrpc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "math" + "net/http" + "os" + "time" + + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/evmreader" + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/internal/version" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type jsonrpcSchema struct { + Methods []struct { + Name string + } +} + +const matchDeletionBlock = uint64(101) + +// failure: invalid JSON (extra ',' at the end) +func TestInvalidJSON(t *testing.T) { + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "", + "params": {}, + "id": 0, + }`)) + + var resp RPCResponse + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_PARSE_ERROR, resp.Error.Code) + assert.Equal(t, "Parse error", resp.Error.Message) +} + +// failure: invalid method +func TestInvalidMethod(t *testing.T) { + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_invalidMethod", + "params": {}, + "id": 0 + }`)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_METHOD_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Method not found", resp.Error.Message) +} + +func TestJSONRPCSingleRequestReplacesResponseAtResponseBudget(t *testing.T) { + s := newBatchTestService() + const method = "test_large_single_result" + largeResult := strings.Repeat("x", MAX_RESPONSE_SIZE) + var called bool + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + called = true + return largeResult, nil + }) + + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"params":{"limit":10000},"id":1}`, method)) + require.Less(t, len(body), 1<<10, "the request cap must not be mistaken for the response cap") + rr := serveRPC(t, s, body) + + require.True(t, called, "the request handler must run before its oversized response is replaced") + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED) + require.Equal(t, "Response size limit exceeded", response.Error.Message) +} + +// tests for jsonrpc methods grouped by method name. +// At the end we check if all methods ran at least once +func TestMethod(t *testing.T) { + testHistogram := hist{} + + getName := func(name string) string { + part, _ := strings.CutPrefix(name, "TestMethod/") + return part + } + + //////////////////////////////////////////////////////////////////////// + // rpc.discover + //////////////////////////////////////////////////////////////////////// + t.Run("rpc.discover", func(t *testing.T) { + method := getName(t.Name()) + + // success: return discover file contents + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + contents, err := os.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + + var expected any + assert.Nil(t, json.Unmarshal(contents, &expected)) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "rpc.discover", + "params": {}, + "id": 0 + }`)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getApplication + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getApplication", func(t *testing.T) { + method := getName(t.Name()) + + // failure: application not in the database -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getApplication", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: application is in the database -> retrieve application + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getApplication", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, nr, nameToNumber(resp.Result.Data.Name)) + + var raw testRPCResponse[map[string]json.RawMessage] + require.NoError(t, json.Unmarshal(body, &raw)) + require.Contains(t, raw.Result.Data, "iinputbox_address") + require.NotContains(t, raw.Result.Data, "data_availability") + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getNodeInfo + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getNodeInfo", func(t *testing.T) { + method := getName(t.Name()) + + // failure: evm reader not configured -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_getNodeInfo", + "params": {}, + "id": 0 + }`)) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "EVM Reader config not found", resp.Error.Message) + }) + + // success: combine persisted node configuration with the build version + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + ctx := context.Background() + s := newTestService(t, t.Name()) + + chainID := uint64(0xdeadbeef) + defaultBlock := model.DefaultBlock_Safe + err := repotest.SaveNodeConfig(ctx, s.repository.(repotest.NodeConfigWriter), + &model.NodeConfig[evmreader.PersistentConfig]{ + Key: evmreader.EvmReaderConfigKey, + Value: evmreader.PersistentConfig{ + ChainID: chainID, + DefaultBlock: defaultBlock, + }, + }, + ) + require.NoError(t, err) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_getNodeInfo", + "params": {}, + "id": 0 + }`)) + + resp := testRPCResponse[api.NodeInfo]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Nil(t, resp.Error) + assert.Equal(t, hexutil.EncodeUint64(chainID), resp.Result.Data.ChainID) + assert.Equal(t, version.BuildVersion, resp.Result.Data.Version) + assert.Equal(t, string(defaultBlock), resp.Result.Data.DefaultBlock) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getChainId + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getChainId", func(t *testing.T) { + method := getName(t.Name()) + + // failure: evm reader not configured -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, "getChainIdFailure") + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getChainId", + "params": {}, + "id": 0 + }`)) + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "EVM Reader config not found", resp.Error.Message) + }) + + // success: evm reader configured -> retrieve chain id + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + ctx := context.Background() + s := newTestService(t, "getChainIdFailure") + + // NodeConfig provision + nr := uint64(0xdeadbeef) + err := repotest.SaveNodeConfig(ctx, s.repository.(repotest.NodeConfigWriter), + &model.NodeConfig[evmreader.PersistentConfig]{ + Key: evmreader.EvmReaderConfigKey, + Value: evmreader.PersistentConfig{ + ChainID: nr, DefaultBlock: model.DefaultBlock_Finalized, + }, + }, + ) + require.NoError(t, err, "on test case: %v, when saving evm reader config", t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getChainId", + "params": {}, + "id": 0 + }`)) + resp := testRPCResponse[hex64]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, nr, uint64(resp.Result.Data)) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getEpoch + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getEpoch", func(t *testing.T) { + method := getName(t.Name()) + + // failure: epoch_index not hex encoded -> invalid param + t.Run("malformedEpochIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + nr := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpoch", + "params": { + "application": "%v", + "epoch_index": "%v" + }, + "id": 0 + }`, numberToName(app), nr)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch index: expected hex encoded value", resp.Error.Message) + }) + + // failure: epoch not in the database -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(0) + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpoch", + "params": { + "application": "%v", + "epoch_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(nr+1))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Epoch not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpoch", + "params": { + "application": "%v", + "epoch_index": "%v" + }, + "id": 0 + }`, numberToName(nr), hexutil.EncodeUint64(0))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: epoch is in the database -> retrieve epoch + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(1) + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpoch", + "params": { + "application": "%v", + "epoch_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(nr))) + + resp := testRPCResponse[*model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, nr, resp.Result.Data.Index) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getEpochByVirtualIndex + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getEpochByVirtualIndex", func(t *testing.T) { + method := getName(t.Name()) + + // failure: virtual_index not hex encoded -> invalid param + t.Run("malformedVirtualIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": 0 + }, + "id": 0 + }`, numberToName(1))) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid parameters", resp.Error.Message) + }) + + // failure: virtual index not in the database -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(5). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(1))) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Epoch not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": "0x0" + }, + "id": 0 + }`, numberToName(0xdeadbeef))) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: lookup uses the dense virtual index, not the physical epoch index + t.Run("presentWithDivergentPhysicalIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(5). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": "0x0" + }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[*model.Epoch]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Nil(t, resp.Error) + require.NotNil(t, resp.Result.Data) + assert.Equal(t, uint64(5), resp.Result.Data.Index) + assert.Equal(t, uint64(0), resp.Result.Data.VirtualIndex) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getInput + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getInput", func(t *testing.T) { + method := getName(t.Name()) + + // failure: input_index not hex encoded -> invalid param + t.Run("malformedInputIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + nr := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getInput", + "params": { + "application": "%v", + "input_index": "%v" + }, + "id": 0 + }`, numberToName(app), nr)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid input index: expected hex encoded value", resp.Error.Message) + }) + + // failure: input_index not in database -> absent input + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(2) + enr := uint64(1) + inr := uint64(0) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getInput", + "params": { + "application": "%v", + "input_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(inr))) + + resp := testRPCResponse[*model.Input]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Input not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getInput", + "params": { + "application": "%v", + "input_index": "%v" + }, + "id": 0 + }`, numberToName(nr), hexutil.EncodeUint64(0))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: input_index of EvmAdvance in the database -> retrieve input + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(2) + enr := uint64(1) + inr := uint64(0) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + input := repotest.NewInputBuilder(). + WithIndex(inr). + WithRawData(emptyInput()). + Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + s.advanceInput(ctx, t, appID, enr, inr, nil, nil) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getInput", + "params": { + "application": "%v", + "input_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(inr))) + + resp := testRPCResponse[*model.Input]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, enr, resp.Result.Data.EpochIndex) + assert.Equal(t, inr, resp.Result.Data.Index) + }) + + // TODO: also test DecodedInput + }) + + //////////////////////////////////////////////////////////////////////// + // getLastAcceptedEpochIndex + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getLastAcceptedEpochIndex", func(t *testing.T) { + method := getName(t.Name()) + + // failure: application not in the database -> application not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getLastAcceptedEpochIndex", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[hex64]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // failure: application exists but has no accepted epoch yet -> + // epoch not found (the pollable "not yet" signal) + t.Run("no_accepted_epoch", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(0) + s.newTestApplication(ctx, t, nr) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getLastAcceptedEpochIndex", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[hex64]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Epoch not found", resp.Error.Message) + }) + + // success: epoch is in the database -> retrieve epoch index + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(0) + epochIndex := uint64(0xdeadbeef) + + appID := s.newTestApplication(ctx, t, nr) + s.createTestEpoch(ctx, t, numberToName(nr), + repotest.NewEpochBuilder(appID). + WithIndex(epochIndex). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getLastAcceptedEpochIndex", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[hex64]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, epochIndex, uint64(resp.Result.Data)) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getNodeVersion + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getNodeVersion", func(t *testing.T) { + method := getName(t.Name()) + + // success: -> version.BuildVersion + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getNodeVersion", + "params": {}, + "id": 0 + }`)) + + resp := testRPCResponse[string]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, version.BuildVersion, resp.Result.Data) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getOutput + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getOutput", func(t *testing.T) { + method := getName(t.Name()) + + // failure: output_index not hex encoded -> invalid param + t.Run("malformed", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + nr := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getOutput", + "params": { + "application": "%v", + "output_index": "%v" + }, + "id": 0 + }`, numberToName(app), nr)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid output index: expected hex encoded value", resp.Error.Message) + }) + + // failure: input_index not in database -> absent input + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(2) + enr := uint64(1) + inr := uint64(0) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getOutput", + "params": { + "application": "%v", + "output_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(inr))) + + resp := testRPCResponse[*model.Output]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Output not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getOutput", + "params": { + "application": "%v", + "output_index": "%v" + }, + "id": 0 + }`, numberToName(nr), hexutil.EncodeUint64(0))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: output_index of Voucher in the database -> retrieve output + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + enr := uint64(1) + inr := uint64(1) + onr := uint64(0) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + input := repotest.NewInputBuilder(). + WithIndex(inr). + WithRawData(emptyInput()). + Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + s.advanceInput(ctx, t, appID, enr, inr, [][]byte{emptyVoucher()}, nil) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getOutput", + "params": { + "application": "%v", + "output_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(onr))) + + type Result struct { + EpochIndex hex64 `json:"epoch_index"` + InputIndex hex64 `json:"input_index"` + Index hex64 `json:"index"` + RawData string `json:"raw_data"` // hex encoded + + DecodedData *api.DecodedData `json:"decoded_data,omitempty"` + + // ... (ignore the rest of fields for test + } + + resp := testRPCResponse[Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, inr, uint64(resp.Result.Data.InputIndex)) + assert.Equal(t, onr, uint64(resp.Result.Data.Index)) + assert.Equal(t, "0xdeadbeef", resp.Result.Data.DecodedData.Value) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getProcessedInputCount + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getProcessedInputCount", func(t *testing.T) { + method := getName(t.Name()) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getProcessedInputCount", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[hex64]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: application has no inputs -> 0 + t.Run("absentInput", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + + s.newTestApplication(ctx, t, app) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getProcessedInputCount", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[hex64]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, uint64(0), uint64(resp.Result.Data)) + }) + + t.Run("processedInputs", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(0). + WithStatus(model.EpochStatus_Closed). + Build() + inputs := []*model.Input{ + repotest.NewInputBuilder().WithIndex(0).WithRawData(emptyInput()).Build(), + repotest.NewInputBuilder().WithIndex(1).WithRawData(emptyInput()).Build(), + } + err := s.repository.CreateEpochsAndInputs( + ctx, + numberToName(app), + map[*model.Epoch][]*model.Input{epoch: inputs}, + 10, + ) + require.NoError(t, err) + s.advanceInput(ctx, t, appID, 0, 0, nil, nil) + s.advanceInput(ctx, t, appID, 0, 1, nil, nil) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getProcessedInputCount", + "params": { "application": "%s" }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, uint64(2), uint64(resp.Result.Data)) + }) + }) + + for _, methodName := range []string{ + "cartesi_getExecutedOutputCount", + "cartesi_getPendingExecutableOutputCount", + } { + t.Run(methodName, func(t *testing.T) { + method := getName(t.Name()) + + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "%s", + "params": { "application": "%s" }, + "id": 0 + }`, method, numberToName(1))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + t.Run("existingApplicationWithNoOutputs", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + app := uint64(1) + s.newTestApplication(context.Background(), t, app) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "%s", + "params": { "application": "%s" }, + "id": 0 + }`, method, numberToName(app))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, uint64(0), uint64(resp.Result.Data)) + }) + + t.Run("outputsPresent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(0). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + input := repotest.NewInputBuilder(). + WithIndex(0). + WithRawData(emptyInput()). + Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + s.advanceInput(ctx, t, appID, 0, 0, [][]byte{ + emptyVoucher(), + {0x10, 0x32, 0x1e, 0x8b}, + {0xc2, 0x58, 0xd6, 0xe5}, + }, nil) + + txHash := common.HexToHash("0x1") + err := s.repository.UpdateOutputsExecution( + ctx, + numberToName(app), + []*model.Output{ + {InputEpochApplicationID: appID, Index: 0, ExecutionTransactionHash: &txHash}, + {InputEpochApplicationID: appID, Index: 1, ExecutionTransactionHash: &txHash}, + }, + 10, + ) + require.NoError(t, err) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "%s", + "params": { "application": "%s" }, + "id": 0 + }`, method, numberToName(app))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + expected := uint64(2) + if method == "cartesi_getPendingExecutableOutputCount" { + expected = 0 + } + assert.Equal(t, expected, uint64(resp.Result.Data)) + }) + }) + } + + //////////////////////////////////////////////////////////////////////// + // getReport + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getReport", func(t *testing.T) { + method := getName(t.Name()) + + // failure: report_index not hex encoded -> invalid param + t.Run("malformed", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + reportIndex := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getReport", + "params": { + "application": "%v", + "report_index": "%v" + }, + "id": 0 + }`, numberToName(app), reportIndex)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid report index: expected hex encoded value", resp.Error.Message) + }) + + // failure: report_index not in database -> absent report + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(2) + enr := uint64(1) + rnr := uint64(0) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getReport", + "params": { + "application": "%v", + "report_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(rnr))) + + resp := testRPCResponse[*model.Report]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Report not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getReport", + "params": { + "application": "%v", + "report_index": "%v" + }, + "id": 0 + }`, numberToName(nr), hexutil.EncodeUint64(0))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: output_index of Voucher in the database -> retrieve output + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + enr := uint64(1) + inr := uint64(1) + onr := uint64(0) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + input := repotest.NewInputBuilder(). + WithIndex(inr). + WithRawData(emptyInput()). + Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + s.advanceInput(ctx, t, appID, enr, inr, nil, [][]byte{emptyVoucher()}) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getReport", + "params": { + "application": "%v", + "report_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(onr))) + + type Result struct { + EpochIndex hex64 `json:"epoch_index"` + InputIndex hex64 `json:"input_index"` + Index hex64 `json:"index"` + RawData string `json:"raw_data"` // hex encoded + + // ... (ignore the rest of fields for test + } + + resp := testRPCResponse[Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, inr, uint64(resp.Result.Data.InputIndex)) + assert.Equal(t, onr, uint64(resp.Result.Data.Index)) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // listApplications + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listApplications", func(t *testing.T) { + method := getName(t.Name()) + + // success: no application is in the database -> 0 + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "params": { + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, 0, 0, false)) + + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // success: 1 application is in the database -> 1 + t.Run("one", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "params": { + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, 1, 0, false)) + + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 1, len(resp.Result.Data)) + assert.Equal(t, numberToName(nr), resp.Result.Data[0].Name) + + var raw testRPCResponse[[]map[string]json.RawMessage] + require.NoError(t, json.Unmarshal(body, &raw)) + require.Len(t, raw.Result.Data, 1) + require.Contains(t, raw.Result.Data[0], "iinputbox_address") + require.NotContains(t, raw.Result.Data[0], "data_availability") + }) + + // success: 1 application is in the database (array params) -> 1 + t.Run("oneArrayParams", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "params": [%v, %v, %v], + "id": 0 + }`, 1, 0, false)) + + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 1, len(resp.Result.Data)) + assert.Equal(t, numberToName(nr), resp.Result.Data[0].Name) + }) + + // success: 1 application is in the database (array params) -> 1 + t.Run("emptyArrayParams", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "id": 0 + }`)) + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 1, len(resp.Result.Data)) + assert.Equal(t, numberToName(nr), resp.Result.Data[0].Name) + }) + + // success: many applications is in the database -> limit (many - 1) + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + many := uint64(100) + limit := uint64(many / 2) + for i := range many { + s.newTestApplication(ctx, t, i) + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "params": { + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, limit, 0, false)) + + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, nr, nameToNumber(resp.Result.Data[i].Name)) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "params": { + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, limit, 1, false)) + + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, nr, nameToNumber(resp.Result.Data[i].Name)) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "params": { + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, limit, 0, true)) + + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, nr, nameToNumber(resp.Result.Data[i].Name)) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "params": { + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, limit, 1, true)) + + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, nr, nameToNumber(resp.Result.Data[i].Name)) + } + } + }) + }) + + //////////////////////////////////////////////////////////////////////////////// + // listEpochs + //////////////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listEpochs", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no epoch in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no epoch in the database -> 0 + t.Run("absentEpoch", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // failure: query invalid status -> invalid params + t.Run("invalid", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "%v", + "status": "INVALID" + }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch status: invalid value 'INVALID' for EpochStatus enum", resp.Error.Message) + }) + + // failure: any invalid status in a list -> invalid params + t.Run("invalidInList", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "app", + "status": ["OPEN", "INVALID"] + }, + "id": 0 + }`)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch status: invalid value 'INVALID' for EpochStatus enum", resp.Error.Message) + }) + + // failure: an explicitly empty status list -> invalid params + t.Run("emptyStatusList", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "app", + "status": [] + }, + "id": 0 + }`)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch status: expected at least one status", resp.Error.Message) + }) + + // success: status may contain multiple values + t.Run("multipleStatuses", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + appID := s.newTestApplication(ctx, t, nr) + for i, status := range []model.EpochStatus{ + model.EpochStatus_ClaimAccepted, + model.EpochStatus_Closed, + model.EpochStatus_Open, + } { + s.createTestEpoch(ctx, t, numberToName(nr), + repotest.NewEpochBuilder(appID). + WithIndex(uint64(i)). + WithStatus(status). + Build()) + } + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "%v", + "status": ["OPEN", "CLOSED"] + }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Len(t, resp.Result.Data, 2) + assert.Equal(t, model.EpochStatus_Closed, resp.Result.Data[0].Status) + assert.Equal(t, model.EpochStatus_Open, resp.Result.Data[1].Status) + }) + + // success: many epochs is in the database -> limit + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + many := uint64(100) + limit := uint64(many / 2) + appID := s.newTestApplication(ctx, t, nr) + for i := range many { + s.createTestEpoch(ctx, t, numberToName(nr), + repotest.NewEpochBuilder(appID). + WithIndex(i). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(nr), limit, 0, false)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, nr, resp.Result.Data[i].Index) + } + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(nr), limit, 1, false)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, nr, resp.Result.Data[i].Index) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(nr), limit, 0, true)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, nr, resp.Result.Data[i].Index) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(nr), limit, 1, true)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, nr, resp.Result.Data[i].Index) + } + } + + { // inclusive index range + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": {"application": "%v", "from": "0x2", "to": "0x4"}, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 3) + assert.Equal(t, []uint64{2, 3, 4}, []uint64{ + resp.Result.Data[0].Index, resp.Result.Data[1].Index, resp.Result.Data[2].Index, + }) + } + }) + }) + + //////////////////////////////////////////////////////////////////////// + // listInputs + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listInputs", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no epoch in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listInputs", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no epoch in the database -> 0 + t.Run("absentInput", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listInputs", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + t.Run("filterByTransactionHash", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(0). + WithStatus(model.EpochStatus_Closed). + WithBlocks(0, 29). + WithInputBounds(0, 2). + Build() + txHash := common.HexToHash("0xcafe") + input0 := repotest.NewInputBuilder(). + WithIndex(0). + WithRawData(emptyInput()). + WithTransactionHash(txHash). + WithLogIndex(10). + Build() + input1 := repotest.NewInputBuilder(). + WithIndex(1). + WithRawData(emptyInput()). + WithTransactionHash(repotest.UniqueHash()). + WithLogIndex(11). + Build() + input2 := repotest.NewInputBuilder(). + WithIndex(2). + WithRawData(emptyInput()). + WithTransactionHash(txHash). + WithLogIndex(12). + Build() + err := s.repository.CreateEpochsAndInputs(ctx, numberToName(app), + map[*model.Epoch][]*model.Input{epoch: {input0, input1, input2}}, 30) + require.NoError(t, err) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listInputs", + "params": { + "application": "%v", + "transaction_hash": "%v" + }, + "id": 0 + }`, numberToName(app), txHash.Hex())) + + resp := testRPCResponse[[]map[string]json.RawMessage]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Nil(t, resp.Error) + require.Len(t, resp.Result.Data, 2) + + var index0, index1 hex64 + require.NoError(t, json.Unmarshal(resp.Result.Data[0]["index"], &index0)) + require.NoError(t, json.Unmarshal(resp.Result.Data[1]["index"], &index1)) + assert.Equal(t, uint64(0), uint64(index0)) + assert.Equal(t, uint64(2), uint64(index1)) + + // log_index must carry the seeded values, hex-encoded like the + // other uint64 fields. + assert.JSONEq(t, `"0xa"`, string(resp.Result.Data[0]["log_index"])) + assert.JSONEq(t, `"0xc"`, string(resp.Result.Data[1]["log_index"])) + + for _, input := range resp.Result.Data { + assert.JSONEq(t, fmt.Sprintf("%q", txHash.Hex()), string(input["transaction_hash"])) + assert.NotContains(t, input, "transaction_reference") + } + + body = s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listInputs", + "params": {"application": "%v", "from": "0x1", "to": "0x2"}, + "id": 0 + }`, numberToName(app))) + resp = testRPCResponse[[]map[string]json.RawMessage]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 2) + assert.JSONEq(t, `"0x1"`, string(resp.Result.Data[0]["index"])) + assert.JSONEq(t, `"0x2"`, string(resp.Result.Data[1]["index"])) + }) + + // failure: malformed transaction hash -> invalid params, not an + // empty result from a silently padded/truncated hash. + t.Run("invalidTransactionHash", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + for _, malformed := range []string{"0xcafe", "not-a-hash", "0x" + strings.Repeat("ab", 33)} { + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listInputs", + "params": { + "application": "%v", + "transaction_hash": "%v" + }, + "id": 0 + }`, numberToName(nr), malformed)) + + resp := testRPCResponse[[]model.Input]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error, "expected error for %q", malformed) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + } + }) + }) + + //////////////////////////////////////////////////////////////////////// + // listOutputs + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listOutputs", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no epoch in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no epoch in the database -> 0 + t.Run("absentOutput", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // success: many outputs in the database ordered correctly + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + enr := uint64(1) + inr := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + input := repotest.NewInputBuilder(). + WithIndex(inr). + WithRawData(emptyInput()). + Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + + many := uint64(100) + limit := uint64(many / 2) + outputData := make([][]byte, many) + for i := range many { + outputData[i] = emptyVoucher() + } + s.advanceInput(ctx, t, appID, enr, inr, outputData, nil) + + type Result struct { + EpochIndex hex64 `json:"epoch_index"` + InputIndex hex64 `json:"input_index"` + Index hex64 `json:"index"` + RawData string `json:"raw_data"` // hex encoded + + DecodedData *api.DecodedData `json:"decoded_data,omitempty"` + + // ... (ignore the rest of fields for test + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, true)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, true)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // inclusive index range + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": {"application": "%v", "from": "0x2", "to": "0x4"}, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[[]Result]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 3) + for i, expected := range []uint64{2, 3, 4} { + assert.Equal(t, expected, uint64(resp.Result.Data[i].Index)) + } + } + }) + + t.Run("executedWithOutputTypeList", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + app := uint64(4) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID).WithStatus(model.EpochStatus_ClaimAccepted).Build() + input := repotest.NewInputBuilder().WithRawData(emptyInput()).Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + s.advanceInput(ctx, t, appID, 0, 0, [][]byte{ + emptyVoucher(), + {0x10, 0x32, 0x1e, 0x8b}, + {0xc2, 0x58, 0xd6, 0xe5}, + emptyVoucher(), + }, nil) + + txHash := common.HexToHash("0x1") + err := s.repository.UpdateOutputsExecution(ctx, numberToName(app), []*model.Output{{ + InputEpochApplicationID: appID, Index: 3, ExecutionTransactionHash: &txHash, + }}, 10) + require.NoError(t, err) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { + "application": "%v", + "executed": true, + "output_type": ["0x237a816f", "0x10321e8b"] + }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[[]model.Output]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Nil(t, resp.Error) + require.Len(t, resp.Result.Data, 1) + assert.Equal(t, uint64(3), resp.Result.Data[0].Index) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // listReports + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listReports", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no epoch in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no epoch in the database -> 0 + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // success: many reports + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + enr := uint64(1) + inr := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + input := repotest.NewInputBuilder(). + WithIndex(inr). + WithRawData(emptyInput()). + Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + + many := uint64(100) + limit := uint64(many / 2) + reportData := make([][]byte, many) + for i := range many { + reportData[i] = emptyVoucher() + } + s.advanceInput(ctx, t, appID, enr, inr, nil, reportData) + + type Result struct { + EpochIndex hex64 `json:"epoch_index"` + InputIndex hex64 `json:"input_index"` + Index hex64 `json:"index"` + RawData string `json:"raw_data"` // hex encoded + + // ... (ignore the rest of fields for test + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, true)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, true)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) + } + } + + { // inclusive index range + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": {"application": "%v", "from": "0x2", "to": "0x4"}, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[[]Result]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 3) + for i, expected := range []uint64{2, 3, 4} { + assert.Equal(t, expected, uint64(resp.Result.Data[i].Index)) + } + } + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getTournament + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getTournament", func(t *testing.T) { + method := getName(t.Name()) + + // failure: tournament address not hex encoded -> invalid param + t.Run("malformedAddressParameter", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + address := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getTournament", + "params": { + "application": "%v", + "address": "%v" + }, + "id": 0 + }`, numberToName(app), address)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid tournament address: invalid address '0'", resp.Error.Message) + }) + + // failure: tournament address not in database -> absent tournament + t.Run("absent", func(t *testing.T) { + var err error + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + enr := uint64(1) + tnr := uint64(1) // correct (register) + wrong := uint64(2) // incorrect (query) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + err = s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(enr). + WithAddress(common.HexToAddress(hexutil.EncodeUint64(tnr))). + Build()) + require.NoError(t, err, "on test case: %v, application: %v, epoch_index: %v", 0, appID, enr) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getTournament", + "params": { + "application": "%v", + "address": "0x%040x" + }, + "id": 0 + }`, numberToName(app), wrong)) + + resp := testRPCResponse[*model.Tournament]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Tournament not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getTournament", + "params": { + "application": "%v", + "address": "0x%040x" + }, + "id": 0 + }`, numberToName(nr), 0)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: tournament present in the database -> retrieve tournament + t.Run("success", func(t *testing.T) { + var err error + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + enr := uint64(1) + tnr := uint64(1) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + err = s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(enr). + WithAddress(common.HexToAddress(hexutil.EncodeUint64(tnr))). + Build()) + require.NoError(t, err, "on test case: %v, application: %v, epoch_index: %v", 0, appID, enr) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getTournament", + "params": { + "application": "%v", + "address": "0x%040x" + }, + "id": 0 + }`, numberToName(app), tnr)) + + type Result struct { + EpochIndex hex64 `json:"epoch_index"` + Address common.Address `json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel hex64 `json:"max_level"` + Level hex64 `json:"level"` + Log2Step hex64 `json:"log2step"` + Height hex64 `json:"height"` + Snapshot model.TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + + resp := testRPCResponse[Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, hex64(enr), resp.Result.Data.EpochIndex) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // cartesi_listTournaments + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listTournaments", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no tournament in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Tournament]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no tournament in the database -> 0 + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Tournament]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // failure: parent_tournament_address not a valid address -> a + // single invalid params error. The Unmarshal nil-check doubles as + // a regression guard: the handler once kept going after writing + // the error, producing two JSON bodies in one response, which + // makes Unmarshal fail with a trailing-data error. + t.Run("invalidParentTournamentAddress", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { + "application": "%v", + "parent_tournament_address": "0xnothex" + }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Contains(t, resp.Error.Message, "Invalid parent tournament address") + }) + + // failure: parent_match_id_hash not a valid hash -> a single + // invalid params error (same regression guard as above). + t.Run("invalidParentMatchIdHash", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { + "application": "%v", + "parent_match_id_hash": "0xnothex" + }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Contains(t, resp.Error.Message, "Invalid parent match ID hash") + }) + + // success: many tournaments + // create one application with many (epoch, tournament) pairs. + // use their shared Index / EpochIndex to check pagination. + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + appID := s.newTestApplication(ctx, t, app) + + many := uint64(100) + limit := uint64(many / 2) + for tnr := range many { + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(tnr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + err := s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(tnr). + WithAddress(common.HexToAddress(hexutil.EncodeUint64(tnr))). + Build()) + require.NoError(t, err, "on test case: %v, application: %v, index: %v", 0, appID, tnr) + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, false)) + + resp := testRPCResponse[[]listTournamentsResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, false)) + + resp := testRPCResponse[[]listTournamentsResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, true)) + + resp := testRPCResponse[[]listTournamentsResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listTournaments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, true)) + + resp := testRPCResponse[[]listTournamentsResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getCommitment + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getCommitment", func(t *testing.T) { + method := getName(t.Name()) + + // failure: epoch_index not hex encoded -> invalid param + t.Run("malformedEpochIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + nr := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getCommitment", + "params": { + "application": "%v", + "epoch_index": "%v" + }, + "id": 0 + }`, numberToName(app), nr)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch index: expected hex encoded value", resp.Error.Message) + }) + + // failure: commitment not in the database -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(0) + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getCommitment", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "commitment": "0x%064x" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(nr+1), 0, 0)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Commitment not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getCommitment", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "commitment": "0x%064x" + }, + "id": 0 + }`, numberToName(nr), hexutil.EncodeUint64(0), 0, 0)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: commitment is in the database -> retrieve epoch + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(1) + address := common.HexToAddress("0x01") + commitment := common.HexToHash("0xdeadbeef") + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + err := s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(nr). + WithAddress(address). + Build()) + require.NoError(t, err, "failed to create tournament. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + err = s.repository.CreateCommitment(ctx, numberToName(app), + repotest.NewCommitmentBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + WithCommitmentHash(commitment). + Build()) + require.NoError(t, err, "failed to create commitment. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getCommitment", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "%v", + "commitment": "0x%064x" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(nr), address, commitment)) + + resp := testRPCResponse[getCommitmentResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, hex64(nr), resp.Result.Data.EpochIndex) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getMatch + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getMatch", func(t *testing.T) { + method := getName(t.Name()) + + // failure: epoch_index not hex encoded -> invalid param + t.Run("malformedEpochIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + nr := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatch", + "params": { + "application": "%v", + "epoch_index": "%v" + }, + "id": 0 + }`, numberToName(app), nr)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch index: expected hex encoded value", resp.Error.Message) + }) + + // failure: epoch not in the database -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(0) + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatch", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "id_hash": "0x%064x" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(nr+1), 0, 0)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Match not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatch", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "id_hash": "0x%064x" + }, + "id": 0 + }`, numberToName(nr), hexutil.EncodeUint64(0), 0, 0)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: commitment is in the database -> retrieve epoch + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(2) + address := common.HexToAddress("0x03") + idHash := common.HexToHash("0x04") + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + err := s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(nr). + WithAddress(address). + Build()) + require.NoError(t, err, "failed to create tournament. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + commitment := repotest.NewCommitmentBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + Build() + err = s.repository.CreateCommitment(ctx, numberToName(app), commitment) + require.NoError(t, err, "failed to create commitment. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + err = s.repository.CreateMatch(ctx, numberToName(app), + repotest.NewMatchBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + WithIDHash(idHash). + WithCommitmentOne(commitment.Commitment). + WithCommitmentTwo(commitment.Commitment). + WithWinner(model.WinnerCommitment_NONE). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). + Build()) + require.NoError(t, err, "failed to create match. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatch", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "id_hash": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(nr), address, idHash)) + + resp := testRPCResponse[getMatchResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, idHash, resp.Result.Data.IDHash) + assert.Equal(t, hex64(nr), resp.Result.Data.EpochIndex) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getMatchAdvance + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getMatchAdvance", func(t *testing.T) { + method := getName(t.Name()) + + // failure: log_index not hex encoded -> invalid param + t.Run("malformedLogIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + nr := uint64(0) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatchAdvance", + "params": { + "application": "%v", + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "%v" + }, + "id": 0 + }`, numberToName(app), 0, nr)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid log index: expected hex encoded value", resp.Error.Message) + }) + + // failure: event not in the database -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(0) + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatchAdvance", + "params": { + "application": "%v", + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "0x0" + }, + "id": 0 + }`, numberToName(app), 0)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Match advanced not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(0xdeadbeef) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatchAdvance", + "params": { + "application": "%v", + "epoch_index": "0x0", + "tournament_address": "0x0000000000000000000000000000000000000000", + "id_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "tx_hash": "0x%064x", + "log_index": "0x0" + }, + "id": 0 + }`, numberToName(nr), 0)) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: event is in the database -> retrieve its full identity + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + nr := uint64(2) + address := common.HexToAddress("0x03") + idHash := common.HexToHash("0x04") + parentHex := "0xAbCdEf0123456789aBcDeF0123456789AbCdEf0123456789aBcDeF0123456789" + parent := common.HexToHash(parentHex) + + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(nr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + err := s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(nr). + WithAddress(address). + Build()) + require.NoError(t, err, "failed to create tournament. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + commitment := repotest.NewCommitmentBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + Build() + err = s.repository.CreateCommitment(ctx, numberToName(app), commitment) + require.NoError(t, err, "failed to create commitment. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + err = s.repository.CreateMatch(ctx, numberToName(app), + repotest.NewMatchBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + WithIDHash(idHash). + WithCommitmentOne(commitment.Commitment). + WithCommitmentTwo(commitment.Commitment). + WithWinner(model.WinnerCommitment_NONE). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). + Build()) + require.NoError(t, err, "failed to create match. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + advance := repotest.NewMatchAdvancedBuilder(appID). + WithEpochIndex(nr). + WithTournamentAddress(address). + WithIDHash(idHash). + WithOtherParent(parent). + Build() + err = s.repository.CreateMatchAdvanced(ctx, numberToName(app), advance) + require.NoError(t, err, "failed to create match advanced. on test case: %v, application: %v, epoch_index: %v", 0, appID, nr) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getMatchAdvance", + "params": { + "application": "%v", + "epoch_index": "0x%x", + "tournament_address": "%s", + "id_hash": "%s", + "tx_hash": "%s", + "log_index": "0x%x" + }, + "id": 0 + }`, numberToName(app), nr, address.Hex(), idHash.Hex(), advance.TxHash.Hex(), advance.LogIndex)) + + resp := testRPCResponse[getMatchAdvancedResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, idHash, resp.Result.Data.IDHash) + assert.Equal(t, hex64(nr), resp.Result.Data.EpochIndex) + assert.Equal(t, advance.TxHash, resp.Result.Data.TxHash) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // cartesi_listMatches + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listMatches", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no match in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatches", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Match]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no match in the database -> 0 + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatches", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Match]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // success: many matches + // create one application with many (epoch, matches) pairs. + // use their shared EpochIndex to check pagination. + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + appID := s.newTestApplication(ctx, t, app) + + many := uint64(100) + limit := uint64(many / 2) + for tnr := range many { + // populate the database with stub data + address := common.HexToAddress(hexutil.EncodeUint64(tnr)) + idHash := common.HexToHash(hexutil.EncodeUint64(tnr)) + commitmentOne := common.HexToHash(hexutil.EncodeUint64(tnr)) + commitmentTwo := common.HexToHash(hexutil.EncodeUint64(tnr)) + + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(tnr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + err := s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(tnr). + WithAddress(common.HexToAddress(hexutil.EncodeUint64(tnr))). + Build()) + require.NoError(t, err, "on test case: %v, application: %v, epoch_index: %v", 0, appID, tnr) + + err = s.repository.CreateCommitment(ctx, numberToName(app), + repotest.NewCommitmentBuilder(appID). + WithEpochIndex(tnr). + WithTournamentAddress(address). + WithCommitmentHash(commitmentOne). + Build()) + require.NoError(t, err, "failed to create commitment. on test case: %v, application: %v, epoch_index: %v", 0, appID, tnr) + + err = s.repository.CreateMatch(ctx, numberToName(app), + repotest.NewMatchBuilder(appID). + WithEpochIndex(tnr). + WithTournamentAddress(address). + WithIDHash(idHash). + WithCommitmentOne(commitmentOne). + WithCommitmentTwo(commitmentTwo). + WithWinner(model.WinnerCommitment_NONE). + WithDeletion(model.MatchDeletionReason_TIMEOUT, matchDeletionBlock, repotest.UniqueHash()). + Build()) + require.NoError(t, err, "on test case: %v, application: %v, report_index: %v", 0, appID, tnr) + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatches", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, false)) + + resp := testRPCResponse[[]listMatchesResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatches", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, false)) + + resp := testRPCResponse[[]listMatchesResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatches", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, true)) + + resp := testRPCResponse[[]listMatchesResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatches", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, true)) + + resp := testRPCResponse[[]listMatchesResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, nr, uint64(resp.Result.Data[i].EpochIndex)) + } + } + }) + }) + + //////////////////////////////////////////////////////////////////////// + // cartesi_listMatchAdvances + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listMatchAdvances", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no match advance in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(0) + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatchAdvances", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "id_hash": "0x%064x" + }, + "id": 0 + }`, numberToName(app+1), hexutil.EncodeUint64(nr), 0, 0)) + + resp := testRPCResponse[[]listMatchesResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no tournament in the database -> 0 + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(0) + nr := uint64(1) + s.newTestApplication(ctx, t, app) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatchAdvances", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "id_hash": "0x%064x" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(nr), 0, 0)) + + resp := testRPCResponse[[]listMatchesResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // success: many match_advances + // create one tournament with many (match_advances) pairs. + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + enr := uint64(2) + tournamentAddress := common.HexToAddress(hexutil.EncodeUint64(enr)) + commitment := common.HexToHash(hexutil.EncodeUint64(enr)) + idHash := common.HexToHash(hexutil.EncodeUint64(enr)) + + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + err := s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(enr). + WithAddress(tournamentAddress). + Build()) + require.NoError(t, err) + + err = s.repository.CreateCommitment(ctx, numberToName(app), + repotest.NewCommitmentBuilder(appID). + WithEpochIndex(enr). + WithTournamentAddress(tournamentAddress). + WithCommitmentHash(commitment). + Build()) + require.NoError(t, err) + + err = s.repository.CreateMatch(ctx, numberToName(app), + repotest.NewMatchBuilder(appID). + WithEpochIndex(enr). + WithTournamentAddress(tournamentAddress). + WithIDHash(idHash). + WithCommitmentOne(commitment). + WithCommitmentTwo(commitment). + WithWinner(model.WinnerCommitment_NONE). + Build()) + require.NoError(t, err) + + many := uint64(100) + limit := uint64(many / 2) + for nr := range many { + otherParent := common.HexToHash(hexutil.EncodeUint64(nr)) + advance := repotest.NewMatchAdvancedBuilder(appID). + WithEpochIndex(enr). + WithTournamentAddress(tournamentAddress). + WithIDHash(idHash). + WithOtherParent(otherParent). + Build() + advance.CurrentHeight = many - nr + err = s.repository.CreateMatchAdvanced(ctx, numberToName(app), advance) + require.NoError(t, err, "failed to create match advance, app: %v, nr: %v", app, nr) + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatchAdvances", + "params": { + "application": "%v", + "epoch_index": "0x%020x", + "tournament_address": "0x%020x", + "id_hash": "0x%064x", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), enr, tournamentAddress, idHash, limit, 0, false)) + + resp := testRPCResponse[[]listMatchAdvancedResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].OtherParent) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatchAdvances", + "params": { + "application": "%v", + "epoch_index": "0x%020x", + "tournament_address": "0x%020x", + "id_hash": "0x%064x", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), enr, tournamentAddress, idHash, limit, 1, false)) + + resp := testRPCResponse[[]listMatchAdvancedResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].OtherParent) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatchAdvances", + "params": { + "application": "%v", + "epoch_index": "0x%020x", + "tournament_address": "0x%020x", + "id_hash": "0x%064x", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), enr, tournamentAddress, idHash, limit, 0, true)) + + resp := testRPCResponse[[]listMatchAdvancedResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].OtherParent) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listMatchAdvances", + "params": { + "application": "%v", + "epoch_index": "0x%020x", + "tournament_address": "0x%020x", + "id_hash": "0x%064x", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), enr, tournamentAddress, idHash, limit, 1, true)) + + resp := testRPCResponse[[]listMatchAdvancedResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].OtherParent) + } + } + }) + }) + + //////////////////////////////////////////////////////////////////////// + // cartesi_listCommitments + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listCommitments", func(t *testing.T) { + method := getName(t.Name()) + + // failure: no commitment in the database -> no application + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(0) + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listCommitments", + "params": { + "application": "%v", + "epoch_index": "%v", + "tournament_address": "0x%020x", + "id_hash": "0x%064x" + }, + "id": 0 + }`, numberToName(app+1), hexutil.EncodeUint64(nr), 0, 0)) + + resp := testRPCResponse[[]listCommitmentResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: no commitment in the database -> 0 + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listCommitments", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]listCommitmentResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // success: many commitments + // create one tournament with many commitments. + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + enr := uint64(2) + tournamentAddress := common.HexToAddress(hexutil.EncodeUint64(enr)) + + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(enr). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + err := s.repository.CreateTournament(ctx, numberToName(app), + repotest.NewTournamentBuilder(appID). + WithEpochIndex(enr). + WithAddress(tournamentAddress). + Build()) + require.NoError(t, err) + + many := uint64(100) + limit := uint64(many / 2) + for nr := range many { + commitment := common.HexToHash(hexutil.EncodeUint64(nr)) + + err = s.repository.CreateCommitment(ctx, numberToName(app), + repotest.NewCommitmentBuilder(appID). + WithEpochIndex(enr). + WithTournamentAddress(tournamentAddress). + WithCommitmentHash(commitment). + Build()) + require.NoError(t, err) + } + + { // offset == 0, descending = false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listCommitments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, false)) + + resp := testRPCResponse[[]listCommitmentResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].Commitment) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listCommitments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, false)) + + resp := testRPCResponse[[]listCommitmentResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := i + 1 + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].Commitment) + } + } + + { // offset == 0, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listCommitments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, true)) + + resp := testRPCResponse[[]listCommitmentResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 1 + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].Commitment) + } + } + + { // offset == 1, descending = true + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listCommitments", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, true)) + + resp := testRPCResponse[[]listCommitmentResult]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + nr := many - i - 2 + assert.Equal(t, common.HexToHash(hexutil.EncodeUint64(nr)), resp.Result.Data[i].Commitment) + } + } + }) + }) + + //////////////////////////////////////////////////////////////////////// + // getWithdrawal + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getWithdrawal", func(t *testing.T) { + method := getName(t.Name()) + + // failure: account_index not hex encoded -> invalid params + t.Run("malformed", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), "not-hex")) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + }) + + // failure: application missing -> application not found. + // GetWithdrawal's joined SELECT returns (nil, nil) for either + // missing application or missing account_index; the handler + // disambiguates by checking application existence, so an unknown + // application reports "Application not found". + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + app := uint64(2) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(0))) + + resp := testRPCResponse[*model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // failure: application exists but no matching account_index -> + // resource not found. + t.Run("absentAccountIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + s.newTestApplication(ctx, t, app) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(99))) + + resp := testRPCResponse[*model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Withdrawal not found", resp.Error.Message) + }) + + // success: account_index in DB -> return the row. + t.Run("success", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(4) + appID := s.newTestApplication(ctx, t, app) + w := &model.Withdrawal{ + ApplicationID: appID, + AccountIndex: 7, + Account: []byte{0xaa, 0xbb}, + Output: []byte{0xcc, 0xdd}, + BlockNumber: 1234, + TransactionHash: common.HexToHash("0xcafe"), + LogIndex: 2, + } + require.NoError(t, s.repository.InsertWithdrawal(ctx, w)) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getWithdrawal", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(w.AccountIndex))) + + type Result struct { + AccountIndex hex64 `json:"account_index"` + Account string `json:"account"` + Output string `json:"output"` + BlockNumber hex64 `json:"block_number"` + TransactionHash common.Hash `json:"transaction_hash"` + LogIndex hex64 `json:"log_index"` + } + resp := testRPCResponse[Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, w.AccountIndex, uint64(resp.Result.Data.AccountIndex)) + assert.Equal(t, "0x"+common.Bytes2Hex(w.Account), resp.Result.Data.Account) + assert.Equal(t, "0x"+common.Bytes2Hex(w.Output), resp.Result.Data.Output) + assert.Equal(t, w.BlockNumber, uint64(resp.Result.Data.BlockNumber)) + assert.Equal(t, w.TransactionHash, resp.Result.Data.TransactionHash) + assert.Equal(t, uint64(w.LogIndex), uint64(resp.Result.Data.LogIndex)) + }) + }) + + //////////////////////////////////////////////////////////////////////// + // listWithdrawals + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_listWithdrawals", func(t *testing.T) { + method := getName(t.Name()) + + // failure: application missing -> resource not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + nr := uint64(1) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: application present but no withdrawals -> empty list + t.Run("empty", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { "application": "%v" }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Withdrawal]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, 0, len(resp.Result.Data)) + }) + + // failure: malformed account_index filter -> invalid params + t.Run("malformedAccountIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(2) + s.newTestApplication(ctx, t, app) + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), "not-hex")) + + resp := testRPCResponse[any]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + }) + + // success: many withdrawals, ascending + descending + pagination + filter + t.Run("many", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(3) + appID := s.newTestApplication(ctx, t, app) + + const many = uint64(10) + const limit = uint64(many / 2) + for i := uint64(0); i < many; i++ { + require.NoError(t, s.repository.InsertWithdrawal(ctx, &model.Withdrawal{ + ApplicationID: appID, + AccountIndex: i, + Account: []byte{0xaa, byte(i)}, + Output: []byte{0xbb, byte(i)}, + BlockNumber: 1000 + i, + TransactionHash: common.HexToHash(hexutil.EncodeUint64(i)), + LogIndex: uint(i % 4), + })) + } + + type Result struct { + AccountIndex hex64 `json:"account_index"` + } + + { // offset == 0, descending = false → ascending account_index 0..limit-1 + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + assert.Equal(t, i, uint64(resp.Result.Data[i].AccountIndex)) + } + } + + { // offset == 1, descending == false + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 1, false)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + assert.Equal(t, i+1, uint64(resp.Result.Data[i].AccountIndex)) + } + } + + { // offset == 0, descending = true → last index first + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "limit": %v, + "offset": %v, + "descending": %v + }, + "id": 0 + }`, numberToName(app), limit, 0, true)) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, limit, uint64(len(resp.Result.Data))) + for i := range limit { + assert.Equal(t, many-i-1, uint64(resp.Result.Data[i].AccountIndex)) + } + } + + { // account_index filter → exactly one row + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listWithdrawals", + "params": { + "application": "%v", + "account_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(3))) + + resp := testRPCResponse[[]Result]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 1, len(resp.Result.Data)) + assert.Equal(t, uint64(3), uint64(resp.Result.Data[0].AccountIndex)) + } + }) + }) + + for _, method := range []string{"cartesi_getBondEvent", "cartesi_listBondEvents"} { + t.Run(method, func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := t.Context() + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + tournament := repotest.NewTournamentBuilder(appID).Build() + epoch := repotest.NewEpochBuilder(appID).WithStatus(model.EpochStatus_Closed).Build() + epoch.TournamentAddress = &tournament.Address + s.createTestEpoch(ctx, t, numberToName(app), epoch) + txHash := repotest.UniqueHash() + first := &model.BondEvent{ + ApplicationID: appID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Type: model.BondEventPartialRefund, BlockNumber: tournament.Snapshot.AsOfBlock, TxHash: txHash, + Refund: &model.PartialBondRefund{Recipient: repotest.UniqueAddress(), Success: false}, + } + second := *first + second.LogIndex = 1 + second.Refund = &model.PartialBondRefund{Recipient: repotest.UniqueAddress(), Success: true} + require.NoError(t, s.repository.StoreTournamentInterval(ctx, appID, nil, []*repository.TournamentEventBatch{{ + Tournament: tournament, BondEvents: []*model.BondEvent{first, &second}, + }})) + params := map[string]any{"application": numberToName(app)} + if method == "cartesi_getBondEvent" { + params["tx_hash"], params["log_index"] = txHash.Hex(), "0x1" + } else { + params["limit"], params["offset"], params["descending"] = 1, 1, false + } + request, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params, "id": 0}) + require.NoError(t, err) + body := s.doRequest(t, 0, request) + if method == "cartesi_getBondEvent" { + var response testRPCResponse[model.BondEvent] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Equal(t, second.LogIndex, response.Result.Data.LogIndex) + require.Equal(t, second.Refund, response.Result.Data.Refund) + } else { + var response testRPCResponse[[]model.BondEvent] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Len(t, response.Result.Data, 1) + require.Equal(t, second.LogIndex, response.Result.Data[0].LogIndex) + require.Equal(t, second.Refund, response.Result.Data[0].Refund) + } + }) + } + + for _, method := range []string{observerListStandingChangesMethod, observerGetStandingChangeMethod} { + t.Run(method, func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := t.Context() + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + tournament := repotest.NewTournamentBuilder(appID).Build() + epoch := repotest.NewEpochBuilder(appID).WithStatus(model.EpochStatus_Closed).Build() + epoch.TournamentAddress = &tournament.Address + epoch.LastBlock = tournament.StartInstant + s.createTestEpoch(ctx, t, numberToName(app), epoch) + txHash := repotest.UniqueHash() + first := &model.StandingChanged{ApplicationID: appID, EpochIndex: epoch.Index, + TournamentAddress: tournament.Address, MatchCount: model.Uint256{1}, + BlockNumber: tournament.Snapshot.AsOfBlock, TxHash: txHash} + second := *first + second.LogIndex, second.ResultAt, second.MatchCount = 1, tournament.StartInstant+tournament.Allowance, model.Uint256{} + require.NoError(t, s.repository.StoreTournamentInterval(ctx, appID, nil, []*repository.TournamentEventBatch{{ + Tournament: tournament, StandingChanges: []*model.StandingChanged{first, &second}, + }})) + params := map[string]any{observerApplicationKey: numberToName(app), observerEpochIndexKey: hexutil.EncodeUint64(epoch.Index), + observerTournamentAddressKey: tournament.Address.Hex()} + if method == observerGetStandingChangeMethod { + params[observerTxHashKey], params[observerLogIndexKey] = txHash.Hex(), "0x1" + } else { + params["limit"], params["offset"], params["descending"] = 1, 1, false + } + request, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params, "id": 0}) + require.NoError(t, err) + body := s.doRequest(t, 0, request) + if method == observerGetStandingChangeMethod { + var response testRPCResponse[model.StandingChanged] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Equal(t, uint64(1), response.Result.Data.LogIndex) + require.Equal(t, uint64(109), response.Result.Data.ResultAt) + } else { + var response testRPCResponse[[]model.StandingChanged] + require.NoError(t, json.Unmarshal(body, &response)) + require.Nil(t, response.Error) + require.Len(t, response.Result.Data, 1) + require.Equal(t, uint64(1), response.Result.Data[0].LogIndex) + require.Equal(t, uint64(109), response.Result.Data[0].ResultAt) + } + }) + } + + // tested methods, implemented methods and discover methods must match: + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + + var schema jsonrpcSchema + err = json.Unmarshal(data, &schema) + require.NoError(t, err) + + allMethods := make(map[string]bool) + tested := make(map[string]bool) + for k := range testHistogram { + allMethods[k] = true + tested[k] = true + } + + implemented := make(map[string]bool) + for k := range jsonrpcHandlers { + allMethods[k] = true + implemented[k] = true + } + + specified := make(map[string]bool) + for _, v := range schema.Methods { + allMethods[v.Name] = true + specified[v.Name] = true + } + + // Check each method + var errors []string + for method := range allMethods { + hasTest := tested[method] + hasImpl := implemented[method] + hasSpec := specified[method] + + // All methods must be tested and implemented + // rpc.discover is not discovered (not in schema), others must be + expectedInSpec := method != "rpc.discover" + + var missing []string + if !hasTest { + missing = append(missing, "tests") + } + if !hasImpl { + missing = append(missing, "implementation") + } + if hasSpec != expectedInSpec { + if expectedInSpec { + missing = append(missing, "specification") + } else { + missing = append(missing, "should not be in specification") + } + } + if len(missing) > 0 { + errors = append(errors, fmt.Sprintf("Method %s is missing: %v", method, missing)) + } + } + + if len(errors) > 0 { + t.Errorf("Method coverage issues:\n%s", strings.Join(errors, "\n")) + } +} + +func TestListIndexRangeValidation(t *testing.T) { + for _, method := range []string{ + "cartesi_listEpochs", + "cartesi_listInputs", + "cartesi_listOutputs", + "cartesi_listReports", + } { + t.Run(method, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`{ + "jsonrpc":"2.0", + "method":%q, + "params":{"application":"app","from":"0x2","to":"0x1"}, + "id":1 + }`, method)) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "invalid index range: from must be less than or equal to to", response.Error.Message) + }) + } +} + +func TestListOffsetValidation(t *testing.T) { + for _, method := range []string{ + "cartesi_listApplications", + "cartesi_listEpochs", + "cartesi_listInputs", + "cartesi_listOutputs", + "cartesi_listReports", + "cartesi_listWithdrawals", + "cartesi_listTournaments", + "cartesi_listCommitments", + "cartesi_listMatches", + "cartesi_listMatchAdvances", + "cartesi_listBondEvents", + observerListStandingChangesMethod, + } { + t.Run(method, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`{ + "jsonrpc":"2.0", + "method":%q, + "params":{"offset":9223372036854775808}, + "id":1 + }`, method)) + rr := serveRPC(t, s, body) + + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "Invalid offset", response.Error.Message) + }) + } + + t.Run("positional parameters", func(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`{ + "jsonrpc":"2.0", + "method":"cartesi_listApplications", + "params":[50,9223372036854775808], + "id":1 + }`)) + + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "Invalid offset", response.Error.Message) + }) +} + +func TestParseIndexRange(t *testing.T) { + from := "0x2" + to := "0x4" + indexRange, err := parseIndexRange(&from, &to) + require.NoError(t, err) + require.Equal(t, repository.Range{Start: 2, End: 4}, *indexRange) + + indexRange, err = parseIndexRange(&from, nil) + require.NoError(t, err) + require.Equal(t, uint64(2), indexRange.Start) + require.Equal(t, uint64(math.MaxUint64), indexRange.End) + + indexRange, err = parseIndexRange(nil, &to) + require.NoError(t, err) + require.Equal(t, uint64(0), indexRange.Start) + require.Equal(t, uint64(4), indexRange.End) + + invalid := "2" + _, err = parseIndexRange(&invalid, nil) + require.EqualError(t, err, "invalid from index: expected hex encoded value") + _, err = parseIndexRange(nil, &invalid) + require.EqualError(t, err, "invalid to index: expected hex encoded value") +} + +func TestRequestMethodIsInfoLogged(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) + + method := strings.Repeat("a", MAX_LOGGED_METHOD_LEN+32) + body := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","method":%q,"id":1}`, method)) + serveRPC(t, s, body) + + var methodLogs int + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + if record["method"] == truncatedMethod(method) { + require.Equal(t, "INFO", record["level"]) + methodLogs++ + } + require.NotEqual(t, method, record["method"]) + } + require.Positive(t, methodLogs) +} diff --git a/internal/jsonrpc/limitedwriter.go b/internal/jsonrpc/limitedwriter.go new file mode 100644 index 000000000..e5ae71569 --- /dev/null +++ b/internal/jsonrpc/limitedwriter.go @@ -0,0 +1,82 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "io" +) + +// budgetWriter tracks the response bytes available to one HTTP request. A +// batch shares this budget across all of its entries; a single request uses the +// same budget for its sole response. +// +// Each response is first encoded into a limitedWriter and only reaches writer +// when Flush succeeds. If any response exceeds the remaining budget, that +// response is discarded atomically and the budgetWriter is permanently +// closed. NewLimitedWriter then returns nil, causing every remaining batch +// entry to receive the response-size-limit error, even if that entry's response +// would fit in the unused budget. The response that causes closure does not +// consume any budget. +type budgetWriter struct { + writer io.Writer + budget int + closed bool +} + +// newBudgetWriter creates a response budget of exactly limit bytes. A response +// whose encoded size equals the remaining budget is allowed. +func newBudgetWriter(writer io.Writer, limit int) *budgetWriter { + return &budgetWriter{ + writer: writer, + budget: limit, + } +} + +// Write commits an already-buffered response and deducts successfully written +// bytes from the shared budget. +func (w *budgetWriter) Write(data []byte) (int, error) { + written, err := w.writer.Write(data) + if err == nil { + w.budget -= written + } + return written, err +} + +// NewLimitedWriter creates an atomic buffer for the next response. It returns +// nil after any response has exceeded the shared budget. +func (w *budgetWriter) NewLimitedWriter() *limitedWriter { + if w.closed { + return nil + } + return &limitedWriter{writer: w} +} + +// limitedWriter buffers one complete JSON-RPC response before committing it to +// its shared budgetWriter. +type limitedWriter struct { + writer *budgetWriter + buffer bytes.Buffer +} + +// Write appends data while the complete buffered response fits in the remaining +// budget. An overflowing write returns io.ErrShortBuffer, discards the response +// on Flush, and permanently closes the shared budget. +func (w *limitedWriter) Write(data []byte) (int, error) { + if w.buffer.Len()+len(data) > w.writer.budget { + w.writer.closed = true + return 0, io.ErrShortBuffer + } + return w.buffer.Write(data) +} + +// Flush atomically commits the buffered response unless an overflow has closed +// the shared budget. A flush after closure is intentionally a no-op. +func (w *limitedWriter) Flush() error { + if w.writer.closed { + return nil + } + _, err := w.writer.Write(w.buffer.Bytes()) + return err +} diff --git a/internal/jsonrpc/limitedwriter_test.go b/internal/jsonrpc/limitedwriter_test.go new file mode 100644 index 000000000..3bf654c5c --- /dev/null +++ b/internal/jsonrpc/limitedwriter_test.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimitedWriterFlushesWithinBudget(t *testing.T) { + var output bytes.Buffer + budget := newBudgetWriter(&output, 5) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + written, err := writer.Write([]byte("he")) + require.NoError(t, err) + assert.Equal(t, 2, written) + + written, err = writer.Write([]byte("llo")) + require.NoError(t, err) + assert.Equal(t, 3, written) + assert.Empty(t, output.String(), "writes should remain buffered until Flush") + assert.Equal(t, 5, budget.budget, "buffered writes should not consume the budget") + + require.NoError(t, writer.Flush()) + assert.Equal(t, "hello", output.String()) + assert.Zero(t, budget.budget) +} + +func TestLimitedWriterRejectsWriteBeyondBudget(t *testing.T) { + var output bytes.Buffer + budget := newBudgetWriter(&output, 4) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + written, err := writer.Write([]byte("abc")) + require.NoError(t, err) + assert.Equal(t, 3, written) + + written, err = writer.Write([]byte("de")) + assert.ErrorIs(t, err, io.ErrShortBuffer) + assert.Zero(t, written) + assert.Nil(t, budget.NewLimitedWriter(), "exceeding the budget should close the budget writer") + + require.NoError(t, writer.Flush()) + assert.Empty(t, output.String(), "a response that exceeded the budget should be discarded") + assert.Equal(t, 4, budget.budget) +} + +func TestLimitedWritersShareBudget(t *testing.T) { + var output bytes.Buffer + budget := newBudgetWriter(&output, 6) + + first := budget.NewLimitedWriter() + require.NotNil(t, first) + _, err := first.Write([]byte("one")) + require.NoError(t, err) + require.NoError(t, first.Flush()) + + second := budget.NewLimitedWriter() + require.NotNil(t, second) + _, err = second.Write([]byte("two")) + require.NoError(t, err) + require.NoError(t, second.Flush()) + + assert.Equal(t, "onetwo", output.String()) + assert.Zero(t, budget.budget) + + third := budget.NewLimitedWriter() + require.NotNil(t, third) + written, err := third.Write([]byte("x")) + assert.ErrorIs(t, err, io.ErrShortBuffer) + assert.Zero(t, written) + assert.Nil(t, budget.NewLimitedWriter()) +} + +func TestLimitedWriterAllowsEmptyWriteAtExhaustedBudget(t *testing.T) { + budget := newBudgetWriter(io.Discard, 0) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + written, err := writer.Write(nil) + require.NoError(t, err) + assert.Zero(t, written) + assert.False(t, budget.closed) + require.NoError(t, writer.Flush()) +} + +func TestLimitedWriterFlushPropagatesWriterError(t *testing.T) { + expectedErr := errors.New("write failed") + underlying := &stubWriter{written: 2, err: expectedErr} + budget := newBudgetWriter(underlying, 4) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + _, err := writer.Write([]byte("data")) + require.NoError(t, err) + + err = writer.Flush() + assert.ErrorIs(t, err, expectedErr) + assert.Equal(t, []byte("data"), underlying.data) + assert.Equal(t, 4, budget.budget, "a failed underlying write should not consume budget") +} + +type stubWriter struct { + written int + err error + data []byte +} + +func (w *stubWriter) Write(data []byte) (int, error) { + w.data = append(w.data, data...) + return w.written, w.err +} diff --git a/internal/jsonrpc/main_test.go b/internal/jsonrpc/main_test.go new file mode 100644 index 000000000..16a32219d --- /dev/null +++ b/internal/jsonrpc/main_test.go @@ -0,0 +1,28 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cartesi/rollups-node/test/tooling/db" +) + +func TestMain(m *testing.M) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + os.Exit(m.Run()) + } + release, err := db.LockTestPostgres(context.Background(), endpoint) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + code := m.Run() + release() + os.Exit(code) +} diff --git a/internal/jsonrpc/match_projection_test.go b/internal/jsonrpc/match_projection_test.go new file mode 100644 index 000000000..3815c974b --- /dev/null +++ b/internal/jsonrpc/match_projection_test.go @@ -0,0 +1,77 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type matchProjectionRepository struct { + repository.Repository + match *Match +} + +func (r *matchProjectionRepository) GetMatch(context.Context, string, uint64, string, string) (*Match, error) { + return r.match, nil +} + +func TestMatchDeletionTransactionProjection(t *testing.T) { + for _, deleted := range []bool{false, true} { + name := "not deleted" + if deleted { + name = "deleted with zero hash" + } + t.Run(name, func(t *testing.T) { + match := &Match{TournamentAddress: common.HexToAddress("0x1"), IDHash: common.HexToHash("0x2"), + Winner: WinnerCommitment_NONE, DeletionReason: MatchDeletionReason_NOT_DELETED} + if deleted { + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = 1 + match.DeletionTxHash = new(common.Hash) + } + params, err := json.Marshal(api.GetMatchParams{Application: "app", EpochIndex: "0x0", + TournamentAddress: match.TournamentAddress.Hex(), IDHash: match.IDHash.Hex()}) + require.NoError(t, err) + s := &Service{repository: &matchProjectionRepository{match: match}} + response, err := handleGetMatch(s, httptest.NewRequest(http.MethodPost, "/", nil), RPCRequest{Params: params}) + require.NoError(t, err) + data, err := json.Marshal(response) + require.NoError(t, err) + var decoded struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(data, &decoded)) + if deleted { + require.JSONEq(t, `"`+(common.Hash{}).Hex()+`"`, string(decoded.Data["deletion_tx_hash"])) + } else { + require.Equal(t, "null", string(decoded.Data["deletion_tx_hash"])) + } + }) + } +} + +func TestDiscoverySchemaNullableMatchDeletionTransaction(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + require.JSONEq(t, `{"oneOf":[{"$ref":"#/components/schemas/Hash"},{"type":"null"}]}`, + string(spec.Components.Schemas["Match"].Properties["deletion_tx_hash"])) +} diff --git a/internal/jsonrpc/prt_observer_contract_test.go b/internal/jsonrpc/prt_observer_contract_test.go new file mode 100644 index 000000000..8e387f05d --- /dev/null +++ b/internal/jsonrpc/prt_observer_contract_test.go @@ -0,0 +1,920 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "math/big" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/jsonrpc/api" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + observerListStandingChangesMethod = "cartesi_listStandingChanges" + observerGetStandingChangeMethod = "cartesi_getStandingChange" + observerGetBondEventMethod = "cartesi_getBondEvent" + observerListBondEventsMethod = "cartesi_listBondEvents" + observerGetMatchAdvanceMethod = "cartesi_getMatchAdvance" + observerListMatchAdvancesMethod = "cartesi_listMatchAdvances" + observerTestApplication = "app" + observerUint64Overflow = "0x10000000000000000" + observerApplicationKey = "application" + observerEpochIndexKey = "epoch_index" + observerTournamentAddressKey = "tournament_address" + observerMatchIDKey = "id_hash" + observerLogIndexKey = "log_index" + observerTxHashKey = "tx_hash" + obsUintMax = "0xffffffffffffffff" + obsTournament = "Tournament" + obsDescKey = "descending" + obsNullJSON = "null" + obsLimit = "limit" + obsOffset = "offset" +) + +type observerAPIRepository struct { + repository.Repository + standing *StandingChanged + standings []*StandingChanged + total uint64 + standingCalls int + appLookups int + appErr error + seenContext context.Context + respectContext bool + app *Application + bond *BondEvent + advance *MatchAdvanced + tournament *Tournament + commitment *Commitment + match *Match + err error + + application string + txHash common.Hash + logIndex uint64 + bondFilter repository.BondEventFilter + epochIndex uint64 + tournamentAddress string + idHash string + pagination repository.Pagination + descending bool +} + +func (r *observerAPIRepository) GetApplication(context.Context, string) (*Application, error) { + r.appLookups++ + if r.appErr != nil { + return nil, r.appErr + } + return r.app, r.err +} + +func (r *observerAPIRepository) GetBondEvent(_ context.Context, app string, tx common.Hash, index uint64) (*BondEvent, error) { + r.application, r.txHash, r.logIndex = app, tx, index + return r.bond, r.err +} + +func (r *observerAPIRepository) GetMatchAdvanced(_ context.Context, app string, epoch uint64, tournament, id string, + tx common.Hash, index uint64, +) (*MatchAdvanced, error) { + r.application, r.txHash, r.logIndex = app, tx, index + r.epochIndex, r.tournamentAddress, r.idHash = epoch, tournament, id + return r.advance, r.err +} + +func (r *observerAPIRepository) ListBondEvents(_ context.Context, app string, f repository.BondEventFilter, + p repository.Pagination, descending bool, +) ([]*BondEvent, uint64, error) { + r.application, r.bondFilter, r.pagination, r.descending = app, f, p, descending + if r.bond == nil || r.err != nil { + return nil, 0, r.err + } + return []*BondEvent{r.bond}, 1, nil +} + +func (r *observerAPIRepository) ListMatchAdvances(_ context.Context, app string, epoch uint64, tournament, id string, + p repository.Pagination, descending bool, +) ([]*MatchAdvanced, uint64, error) { + r.application, r.pagination, r.descending = app, p, descending + r.epochIndex, r.tournamentAddress, r.idHash = epoch, tournament, id + if r.advance == nil || r.err != nil { + return nil, 0, r.err + } + return []*MatchAdvanced{r.advance}, 1, nil +} + +func (r *observerAPIRepository) ListStandingChanges(ctx context.Context, app string, epoch uint64, tournament string, + p repository.Pagination, descending bool, +) ([]*StandingChanged, uint64, error) { + r.standingCalls++ + r.application, r.epochIndex, r.tournamentAddress = app, epoch, tournament + r.pagination, r.descending, r.seenContext = p, descending, ctx + if r.respectContext && ctx.Err() != nil { + return nil, 0, ctx.Err() + } + return r.standings, r.total, r.err +} + +func (r *observerAPIRepository) GetStandingChange(ctx context.Context, app string, epoch uint64, tournament string, + tx common.Hash, index uint64, +) (*StandingChanged, error) { + r.standingCalls++ + r.application, r.epochIndex, r.tournamentAddress = app, epoch, tournament + r.txHash, r.logIndex, r.seenContext = tx, index, ctx + if r.respectContext && ctx.Err() != nil { + return nil, ctx.Err() + } + return r.standing, r.err +} + +func (r *observerAPIRepository) GetTournament(context.Context, string, string) (*Tournament, error) { + return r.tournament, r.err +} + +func (r *observerAPIRepository) GetCommitment(context.Context, string, uint64, string, string) (*Commitment, error) { + return r.commitment, r.err +} + +func (r *observerAPIRepository) GetMatch(context.Context, string, uint64, string, string) (*Match, error) { + return r.match, r.err +} + +func observerAPIRequest(t *testing.T, repo *observerAPIRepository, method string, params any) (json.RawMessage, error) { + t.Helper() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + encoded, err := json.Marshal(params) + require.NoError(t, err) + result, err := jsonrpcHandlers[method](s, httptest.NewRequest(http.MethodPost, "/", nil), RPCRequest{Params: encoded}) + if err != nil { + return nil, err + } + data, err := json.Marshal(result) + require.NoError(t, err) + return data, nil +} + +func TestTournamentEventAPIIdentityAndErrors(t *testing.T) { + tx := common.HexToHash("0xabc") + tournament, id := common.HexToAddress("0x12"), common.HexToHash("0x34") + for _, method := range []string{observerGetBondEventMethod, observerGetMatchAdvanceMethod} { + t.Run(method, func(t *testing.T) { + repo := &observerAPIRepository{app: &Application{}, + bond: &BondEvent{TxHash: tx, Type: BondEventPartialRefund, Refund: &PartialBondRefund{}}, + advance: &MatchAdvanced{TxHash: tx}} + for _, index := range []string{"0x0", "0xffffffffffffffff"} { + params := map[string]string{ + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: index, + } + if method == observerGetMatchAdvanceMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + _, err := observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.Equal(t, observerTestApplication, repo.application) + require.Equal(t, tx, repo.txHash) + if method == observerGetMatchAdvanceMethod { + require.Equal(t, uint64(7), repo.epochIndex) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, id.Hex(), repo.idHash) + } + if index == "0x0" { + require.Zero(t, repo.logIndex) + } else { + require.Equal(t, ^uint64(0), repo.logIndex) + } + } + for name, params := range map[string]map[string]string{ + "missing hash": {observerApplicationKey: observerTestApplication, observerLogIndexKey: "0x0"}, + "short hash": {observerApplicationKey: observerTestApplication, observerTxHashKey: "0x01", observerLogIndexKey: "0x0"}, + "missing index": {observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex()}, + "decimal index": {observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: "1"}, + "overflow index": { + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), + observerLogIndexKey: observerUint64Overflow, + }, + "legacy payload key": {observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", "parent": tx.Hex()}, + } { + t.Run(name, func(t *testing.T) { + _, err := observerAPIRequest(t, &observerAPIRepository{}, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + }) + } + params := map[string]string{ + observerApplicationKey: observerTestApplication, observerTxHashKey: tx.Hex(), observerLogIndexKey: "0x0", + } + if method == observerGetMatchAdvanceMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + for _, test := range []struct { + name string + repo *observerAPIRepository + code int + }{ + {"unknown application", &observerAPIRepository{}, JSONRPC_APPLICATION_NOT_FOUND}, + {"unknown event", &observerAPIRepository{app: &Application{}}, JSONRPC_RESOURCE_NOT_FOUND}, + {"repository failure", &observerAPIRepository{err: errors.New("read failed")}, JSONRPC_INTERNAL_ERROR}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := observerAPIRequest(t, test.repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.code, rpcErr.Code) + }) + } + }) + } +} + +func TestTournamentEventAPIListFilters(t *testing.T) { + tournament, id := common.HexToAddress("0x12"), common.HexToHash("0x34") + for _, method := range []string{observerListBondEventsMethod, observerListMatchAdvancesMethod} { + t.Run(method, func(t *testing.T) { + repo := &observerAPIRepository{app: &Application{}} + params := map[string]any{observerApplicationKey: observerTestApplication} + if method == observerListMatchAdvancesMethod { + params[observerEpochIndexKey], params[observerTournamentAddressKey], params[observerMatchIDKey] = + "0x7", tournament.Hex(), id.Hex() + } + response, err := observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.JSONEq(t, `{"data":[],"pagination":{"total_count":0,"limit":50,"offset":0}}`, string(response)) + require.Equal(t, repository.BondEventFilter{}, repo.bondFilter) + params[observerEpochIndexKey], params[observerTournamentAddressKey] = "0x7", tournament.Hex() + params["limit"], params["offset"], params["descending"] = 20000, 3, true + _, err = observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + require.Equal(t, repository.Pagination{Limit: LIST_ITEM_LIMIT, Offset: 3}, repo.pagination) + require.True(t, repo.descending) + if method == observerListBondEventsMethod { + require.Equal(t, repository.BondEventFilter{EpochIndex: new(uint64(7)), TournamentAddress: &tournament}, repo.bondFilter) + } else { + require.Equal(t, uint64(7), repo.epochIndex) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, id.Hex(), repo.idHash) + } + }) + } +} + +func TestMatchAdvanceAPIRequiresMatchScope(t *testing.T) { + for _, method := range []string{observerListMatchAdvancesMethod, observerGetMatchAdvanceMethod} { + for field, invalidValues := range map[string][]any{ + observerApplicationKey: {nil, json.RawMessage("null"), "", "invalid/name"}, + observerEpochIndexKey: {nil, json.RawMessage("null"), "", "1", observerUint64Overflow}, + observerTournamentAddressKey: {nil, json.RawMessage("null"), "", "not-an-address"}, + observerMatchIDKey: {nil, json.RawMessage("null"), "", "0x01"}, + } { + for _, value := range invalidValues { + t.Run(fmt.Sprintf("%s/%s/%v", method, field, value), func(t *testing.T) { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", + observerTournamentAddressKey: (common.Address{}).Hex(), observerMatchIDKey: (common.Hash{}).Hex(), + observerTxHashKey: (common.Hash{}).Hex(), observerLogIndexKey: "0x0"} + if value == nil { + delete(params, field) + } else { + params[field] = value + } + repo := &observerAPIRepository{} + _, err := observerAPIRequest(t, repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + require.Empty(t, repo.application, "invalid scope must not reach the repository") + }) + } + } + } +} + +func TestBondEventAPIKeepsExactOutcomes(t *testing.T) { + maximum, err := Uint256FromBig(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))) + require.NoError(t, err) + for _, success := range []bool{false, true} { + repo := &observerAPIRepository{bond: &BondEvent{Type: BondEventPartialRefund, BlockNumber: 12, LogIndex: 0, + Refund: &PartialBondRefund{Recipient: common.HexToAddress("0x1"), Value: maximum, Success: success}}} + response, err := observerAPIRequest(t, repo, observerGetBondEventMethod, api.GetBondEventParams{ + Application: observerTestApplication, TxHash: repo.bond.TxHash.Hex(), LogIndex: "0x0", + }) + require.NoError(t, err) + var data struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &data)) + require.Equal(t, "null", string(data.Data["recovery"])) + require.JSONEq(t, fmt.Sprintf(`{"recipient":%q,"value":%q,"success":%t}`, + repo.bond.Refund.Recipient.Hex(), "0x"+strings.Repeat("f", 64), success), string(data.Data["refund"])) + require.Equal(t, `"0x0"`, string(data.Data[observerLogIndexKey])) + } + repo := &observerAPIRepository{bond: &BondEvent{Type: BondEventRecovered, + Recovery: &BondRecovered{Payment: Uint256{}, Burned: maximum}}} + response, err := observerAPIRequest(t, repo, observerGetBondEventMethod, api.GetBondEventParams{ + Application: observerTestApplication, TxHash: repo.bond.TxHash.Hex(), LogIndex: "0x0", + }) + require.NoError(t, err) + var result struct { + Data BondEvent `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Nil(t, result.Data.Refund) + require.Equal(t, repo.bond.Recovery, result.Data.Recovery) +} + +func TestTournamentAPISeparatesCurrentStateFromFacts(t *testing.T) { + address := common.HexToAddress("0x12") + candidate := common.HexToHash("0x34") + repo := &observerAPIRepository{tournament: &Tournament{Address: address, Level: 1, + Snapshot: TournamentSnapshot{AsOfBlock: 100, Standing: TournamentStandingInnerEliminableWinnerExpired, + Candidate: &candidate, FinishedAtBlock: 80, + InnerResult: &TournamentInnerResult{Disposition: InnerTournamentEliminable}, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}}}} + response, err := observerAPIRequest(t, repo, "cartesi_getTournament", + api.GetTournamentParams{Application: observerTestApplication, Address: address.Hex()}) + require.NoError(t, err) + var decoded struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &decoded)) + for _, field := range []string{"winner_commitment", "final_state_hash", "finished_at_block"} { + require.NotContains(t, decoded.Data, field, "current result must not have a duplicate top-level field") + } + var snapshot map[string]json.RawMessage + require.NoError(t, json.Unmarshal(decoded.Data["snapshot"], &snapshot)) + require.Equal(t, `"0x64"`, string(snapshot["as_of_block"])) + require.Equal(t, `"`+candidate.Hex()+`"`, string(snapshot["candidate"])) + require.Equal(t, "null", string(snapshot["winner_commitment"])) + require.Equal(t, "null", string(snapshot["final_state_hash"])) + require.Equal(t, "null", string(snapshot["parent_commitment"])) + require.Equal(t, `"0x0"`, string(snapshot["winner_expires_at"])) +} + +func TestMatchAPIPreservesPhasePayloads(t *testing.T) { + for _, phase := range []MatchPhase{MatchPhaseUninitialized, MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed} { + t.Run(string(phase), func(t *testing.T) { + match := &Match{Snapshot: MatchSnapshot{AsOfBlock: 200, Phase: phase, TimeoutOutcome: MatchTimeoutNone}} + switch phase { + case MatchPhaseBisecting, MatchPhaseReadyToSeal: + match.Snapshot.Bisection = &MatchBisectionSnapshot{Responder: CommitmentSideTwo} + if phase == MatchPhaseBisecting { + match.Snapshot.Bisection.CurrentHeight = new(uint64(4)) + } + case MatchPhaseSealed: + match.Snapshot.Sealed = &MatchSealedSnapshot{} + case MatchPhaseUninitialized: + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = 190 + match.DeletionTxHash, match.DeletionLogIndex = new(common.Hash), new(uint64) + } + response, err := observerAPIRequest(t, &observerAPIRepository{match: match}, "cartesi_getMatch", api.GetMatchParams{ + Application: observerTestApplication, EpochIndex: "0x0", + TournamentAddress: match.TournamentAddress.Hex(), IDHash: match.IDHash.Hex(), + }) + require.NoError(t, err) + var result struct { + Data struct { + Snapshot MatchSnapshot `json:"snapshot"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Equal(t, match.Snapshot, result.Data.Snapshot) + }) + } +} + +func TestCommitmentAPIKeepsOriginalSubmitterAfterRecovery(t *testing.T) { + commitment := &Commitment{SubmitterAddress: common.HexToAddress("0x12"), FinalStateHash: common.HexToHash("0x34"), + Snapshot: CommitmentSnapshot{AsOfBlock: 100, ClockAllowance: 50}} + response, err := observerAPIRequest(t, &observerAPIRepository{commitment: commitment}, "cartesi_getCommitment", + api.GetCommitmentParams{Application: observerTestApplication, EpochIndex: "0x0", + TournamentAddress: commitment.TournamentAddress.Hex(), Commitment: commitment.Commitment.Hex()}) + require.NoError(t, err) + var decoded struct { + Data Commitment `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &decoded)) + require.Equal(t, commitment.SubmitterAddress, decoded.Data.SubmitterAddress) + require.Equal(t, commitment.FinalStateHash, decoded.Data.FinalStateHash) + require.Equal(t, common.Address{}, decoded.Data.Snapshot.Claimer) + require.Equal(t, commitment.Snapshot, decoded.Data.Snapshot) +} + +func TestMatchAdvanceAPIKeepsFullWidthPosition(t *testing.T) { + position, err := Uint256FromBig(new(big.Int).Lsh(big.NewInt(1), 200)) + require.NoError(t, err) + advance := &MatchAdvanced{EpochIndex: 7, TournamentAddress: common.HexToAddress("0x12"), + IDHash: common.HexToHash("0x34"), OtherParent: common.HexToHash("0x56"), LeftNode: common.HexToHash("0x78"), + BlockNumber: 100, TxHash: common.HexToHash("0x90"), SegmentStartPosition: position, EliminableAt: 300, LogIndex: 4, + CurrentHeight: 1, ResponderDeadline: 211} + expected, err := json.Marshal(advance) + require.NoError(t, err) + for _, method := range []string{observerGetMatchAdvanceMethod, observerListMatchAdvancesMethod} { + t.Run(method, func(t *testing.T) { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x7", + observerTournamentAddressKey: advance.TournamentAddress.Hex(), observerMatchIDKey: advance.IDHash.Hex()} + if method == observerGetMatchAdvanceMethod { + params[observerTxHashKey], params[observerLogIndexKey] = advance.TxHash.Hex(), "0x4" + } + response, err := observerAPIRequest(t, &observerAPIRepository{advance: advance}, method, params) + require.NoError(t, err) + var result struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + if method == observerListMatchAdvancesMethod { + var records []json.RawMessage + require.NoError(t, json.Unmarshal(result.Data, &records)) + require.Len(t, records, 1) + result.Data = records[0] + } + require.JSONEq(t, string(expected), string(result.Data), "list and get must return the complete same record") + var decoded struct { + Position Uint256 `json:"segment_start_position"` + Height string `json:"current_height"` + ResponderDeadline string `json:"responder_deadline"` + EliminableAt string `json:"eliminable_at"` + LogIndex string `json:"log_index"` + TxHash common.Hash `json:"tx_hash"` + } + require.NoError(t, json.Unmarshal(result.Data, &decoded)) + require.Equal(t, position, decoded.Position) + require.Equal(t, "0x1", decoded.Height) + require.Equal(t, "0xd3", decoded.ResponderDeadline) + require.Equal(t, "0x12c", decoded.EliminableAt) + require.Equal(t, "0x4", decoded.LogIndex) + require.Equal(t, advance.TxHash, decoded.TxHash) + }) + } +} + +func TestDiscoverySchemaPassiveObserver(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Methods []struct { + Name string `json:"name"` + Params []struct { + Name string `json:"name"` + Required bool `json:"required"` + } `json:"params"` + } `json:"methods"` + Components struct { + Schemas map[string]json.RawMessage `json:"schemas"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + expected := map[string][]string{ + observerGetBondEventMethod: {observerApplicationKey, observerTxHashKey, observerLogIndexKey}, + observerListBondEventsMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, "limit", "offset", "descending", + }, + observerGetMatchAdvanceMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, observerMatchIDKey, + observerTxHashKey, observerLogIndexKey, + }, + observerListMatchAdvancesMethod: { + observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, observerMatchIDKey, + "limit", "offset", "descending", + }, + } + for _, method := range spec.Methods { + if want, ok := expected[method.Name]; ok { + names := make([]string, 0, len(method.Params)) + requiredCount := len(want) + switch method.Name { + case observerListMatchAdvancesMethod: + requiredCount = 4 + case observerListBondEventsMethod: + requiredCount = 1 + } + for index, param := range method.Params { + names = append(names, param.Name) + require.Equal(t, index < requiredCount, param.Required) + } + require.Equal(t, want, names) + delete(expected, method.Name) + } + } + require.Empty(t, expected, "all observer methods must be documented") + var wide struct { + Pattern string `json:"pattern"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas["Uint256"], &wide)) + pattern, err := regexp.Compile(wide.Pattern) + require.NoError(t, err) + for _, valid := range []string{"0x0", "0x1", observerUint64Overflow, "0x" + strings.Repeat("f", 64)} { + require.True(t, pattern.MatchString(valid), valid) + } + for _, invalid := range []string{"0x", "0x00", "-0x1", "0x" + strings.Repeat("f", 65), "1", "0x1.5"} { + require.False(t, pattern.MatchString(invalid), invalid) + } + for _, name := range []string{"Tournament", "Commitment", "Match"} { + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(spec.Components.Schemas[name], &schema)) + require.Contains(t, schema.Properties, "snapshot") + if name == "Tournament" { + require.NotContains(t, schema.Properties, "winner_commitment") + require.NotContains(t, schema.Properties, "finished_at_block") + } + } +} + +func TestStandingChangesScopeAndPagination(t *testing.T) { + tournament, tx := common.HexToAddress("0x12"), common.HexToHash("0x34") + for _, test := range []struct { + epoch, logIndex string + epochValue, logValue, limit, wantLimit uint64 + positional bool + }{ + {epoch: "0x0", logIndex: obsUintMax, logValue: math.MaxUint64, wantLimit: 50}, + {epoch: obsUintMax, logIndex: "0x0", epochValue: math.MaxUint64, limit: 10001, wantLimit: 10000, positional: true}, + {epoch: "0x10", logIndex: "0x1", epochValue: 16, logValue: 1, limit: 7, wantLimit: 7}, + } { + t.Run(test.epoch, func(t *testing.T) { + params := any(map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: test.epoch, + observerTournamentAddressKey: tournament.Hex(), obsLimit: test.limit, obsOffset: uint64(math.MaxInt64), obsDescKey: true}) + if test.positional { + params = []any{observerTestApplication, test.epoch, tournament.Hex(), test.limit, uint64(math.MaxInt64), true} + } + repo := &observerAPIRepository{standings: []*StandingChanged{{}}, total: 29} + response, err := observerAPIRequest(t, repo, observerListStandingChangesMethod, params) + require.NoError(t, err) + require.Equal(t, observerTestApplication, repo.application) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, test.epochValue, repo.epochIndex) + require.Equal(t, repository.Pagination{Limit: test.wantLimit, Offset: math.MaxInt64}, repo.pagination) + require.True(t, repo.descending) + require.Zero(t, repo.appLookups, "nonempty results need no application lookup") + var decoded api.ListResponse[*StandingChanged] + require.NoError(t, json.Unmarshal(response, &decoded)) + require.Equal(t, api.Pagination{TotalCount: 29, Limit: test.wantLimit, Offset: math.MaxInt64}, decoded.Pagination) + + params = map[string]string{observerApplicationKey: observerTestApplication, observerEpochIndexKey: test.epoch, + observerTournamentAddressKey: tournament.Hex(), observerTxHashKey: tx.Hex(), observerLogIndexKey: test.logIndex} + if test.positional { + params = []any{observerTestApplication, test.epoch, tournament.Hex(), tx.Hex(), test.logIndex} + } + repo = &observerAPIRepository{standing: &StandingChanged{}} + _, err = observerAPIRequest(t, repo, observerGetStandingChangeMethod, params) + require.NoError(t, err) + require.Equal(t, observerTestApplication, repo.application) + require.Equal(t, tournament.Hex(), repo.tournamentAddress) + require.Equal(t, tx, repo.txHash) + require.Equal(t, test.epochValue, repo.epochIndex) + require.Equal(t, test.logValue, repo.logIndex) + require.Zero(t, repo.appLookups) + }) + } +} + +func TestStandingChangesRejectInvalidParamsBeforeRepository(t *testing.T) { + for _, method := range []string{observerListStandingChangesMethod, observerGetStandingChangeMethod} { + valid := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", + observerTournamentAddressKey: common.HexToAddress("0x12").Hex()} + if method == observerGetStandingChangeMethod { + valid[observerTxHashKey], valid[observerLogIndexKey] = common.HexToHash("0x34").Hex(), "0x0" + } + invalid := map[string][]any{observerApplicationKey: {"", "!"}, observerEpochIndexKey: {"", "0", observerUint64Overflow}, + observerTournamentAddressKey: {"", "0xzz"}} + if method == observerGetStandingChangeMethod { + invalid[observerTxHashKey] = []any{"", "0x12", "0x" + strings.Repeat("f", 66)} + invalid[observerLogIndexKey] = []any{"", "0", observerUint64Overflow} + } else { + invalid[obsOffset] = []any{uint64(math.MaxInt64) + 1} + invalid[obsLimit] = []any{-1, "50"} + } + for key, values := range invalid { + for _, value := range values { + params := make(map[string]any, len(valid)) + for k, v := range valid { + params[k] = v + } + params[key] = value + repo := &observerAPIRepository{} + _, err := observerAPIRequest(t, repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr, "%s %s %v", method, key, value) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + require.Zero(t, repo.standingCalls) + require.Zero(t, repo.appLookups) + } + } + for _, params := range []any{nil, []any{}, []any{observerTestApplication}, []any{observerTestApplication, "0x0"}, "invalid"} { + repo := &observerAPIRepository{} + _, err := observerAPIRequest(t, repo, method, params) + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, JSONRPC_INVALID_PARAMS, rpcErr.Code) + require.Zero(t, repo.standingCalls) + } + } +} + +func TestStandingChangesMissingPagesAndErrors(t *testing.T) { + for _, method := range []string{observerListStandingChangesMethod, observerGetStandingChangeMethod} { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x0", + observerTournamentAddressKey: common.HexToAddress("0x12").Hex()} + if method == observerGetStandingChangeMethod { + params[observerTxHashKey], params[observerLogIndexKey] = common.HexToHash("0x34").Hex(), "0x0" + } + for _, test := range []struct { + name string + app *Application + err, appErr error + code int + }{ + {name: "missing application", code: JSONRPC_APPLICATION_NOT_FOUND}, + {name: "existing application", app: &Application{}, code: JSONRPC_RESOURCE_NOT_FOUND}, + {name: "repository failure", err: errors.New("private SQL detail"), code: JSONRPC_INTERNAL_ERROR}, + {name: "application lookup failure", appErr: errors.New("private lookup detail"), code: JSONRPC_INTERNAL_ERROR}, + } { + t.Run(method+"/"+test.name, func(t *testing.T) { + repo := &observerAPIRepository{app: test.app, err: test.err, appErr: test.appErr, total: 19} + response, err := observerAPIRequest(t, repo, method, params) + if test.app != nil && method == observerListStandingChangesMethod { + require.NoError(t, err) + require.JSONEq(t, `{"data":[],"pagination":{"total_count":19,"limit":50,"offset":0}}`, string(response)) + } else { + var rpcErr *RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.code, rpcErr.Code) + require.NotContains(t, rpcErr.Message, "private") + } + }) + } + for _, ctx := range []context.Context{func() context.Context { c, cancel := context.WithCancel(t.Context()); cancel(); return c }(), + func() context.Context { + c, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + t.Cleanup(cancel) + return c + }()} { + repo := &observerAPIRepository{respectContext: true} + s := newBatchTestService() + s.repository = repo + encoded, err := json.Marshal(params) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodPost, "/rpc", nil).WithContext(ctx) + _, err = jsonrpcHandlers[method](s, request, RPCRequest{Params: encoded}) + require.ErrorIs(t, err, ctx.Err()) + require.Same(t, ctx, repo.seenContext) + } + } +} + +//nolint:goconst // Keep literal wire values independent from model serialization. +func TestStandingChangesWireFacts(t *testing.T) { + count, err := Uint256FromBig(new(big.Int).Lsh(big.NewInt(1), 200)) + require.NoError(t, err) + zero := common.Hash{} + stamp := time.Date(2026, 10, 27, 1, 2, 3, 0, time.UTC) + event := &StandingChanged{ApplicationID: 99, EpochIndex: 7, TournamentAddress: common.HexToAddress("0x12"), MatchCount: count, + Dangling: &zero, ResultAt: 301, WinnerExpiresAt: 509, BlockNumber: 100, + TxHash: common.HexToHash("0x34"), LogIndex: 0, CreatedAt: stamp, UpdatedAt: stamp} + for _, method := range []string{observerListStandingChangesMethod, observerGetStandingChangeMethod} { + params := map[string]any{observerApplicationKey: observerTestApplication, observerEpochIndexKey: "0x7", + observerTournamentAddressKey: event.TournamentAddress.Hex()} + if method == observerGetStandingChangeMethod { + params[observerTxHashKey], params[observerLogIndexKey] = event.TxHash.Hex(), "0x0" + } + repo := &observerAPIRepository{standing: event, standings: []*StandingChanged{event}, total: 1} + response, err := observerAPIRequest(t, repo, method, params) + require.NoError(t, err) + var envelope struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &envelope)) + if method == observerListStandingChangesMethod { + var rows []json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Data, &rows)) + require.Len(t, rows, 1) + envelope.Data = rows[0] + } + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Data, &fields)) + require.Len(t, fields, 12) + for key, want := range map[string]string{ + observerEpochIndexKey: `"0x7"`, "match_count": `"0x100000000000000000000000000000000000000000000000000"`, + "dangling": `"0x0000000000000000000000000000000000000000000000000000000000000000"`, "parent_commitment": obsNullJSON, + "result_at": `"0x12d"`, "winner_expires_at": `"0x1fd"`, "block_number": `"0x64"`, observerLogIndexKey: `"0x0"`, + "created_at": `"2026-10-27T01:02:03Z"`, "updated_at": `"2026-10-27T01:02:03Z"`} { + require.Equal(t, want, string(fields[key]), key) + } + require.Equal(t, `"`+event.TournamentAddress.Hex()+`"`, string(fields[observerTournamentAddressKey])) + require.Equal(t, `"`+event.TxHash.Hex()+`"`, string(fields[observerTxHashKey])) + require.NotContains(t, fields, "application_id") + } +} + +func TestStandingChangesEmptyPageKeepsScopedTotal(t *testing.T) { + repo := &observerAPIRepository{app: &Application{}, total: 3} + response, err := observerAPIRequest(t, repo, observerListStandingChangesMethod, + []any{observerTestApplication, "0x0", common.HexToAddress("0x12").Hex(), 1, 100, true}) + require.NoError(t, err) + require.JSONEq(t, `{"data":[],"pagination":{"total_count":3,"limit":1,"offset":100}}`, string(response)) + require.Equal(t, repository.Pagination{Limit: 1, Offset: 100}, repo.pagination) + require.True(t, repo.descending) + require.Equal(t, 1, repo.appLookups) +} + +func TestAlpha6TournamentAndSealWireFacts(t *testing.T) { + wide, err := Uint256FromBig(new(big.Int).Lsh(big.NewInt(1), 200)) + require.NoError(t, err) + zero, other := common.Hash{}, common.HexToHash("0x22") + for _, child := range []bool{false, true} { + value := &Tournament{BondValue: wide} + if child { + value.Level = 1 + value.CreationEvent = &TournamentCreationEvent{BlockNumber: 7, LogIndex: 0} + value.ContestedFinalStateOne = &zero + value.ContestedFinalStateTwo = &other + } + response, err := observerAPIRequest(t, &observerAPIRepository{tournament: value}, "cartesi_getTournament", + api.GetTournamentParams{Application: observerTestApplication, Address: common.HexToAddress("0x12").Hex()}) + require.NoError(t, err) + var result struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Equal(t, `"0x100000000000000000000000000000000000000000000000000"`, string(result.Data["bond_value"])) + var creation map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result.Data["creation_event"], &creation)) + if child { + require.Equal(t, `"0x7"`, string(creation["block_number"])) + require.Equal(t, `"0x0"`, string(creation[observerLogIndexKey])) + require.Equal(t, `"`+zero.Hex()+`"`, string(result.Data["contested_final_state_one"])) + require.Equal(t, `"`+other.Hex()+`"`, string(result.Data["contested_final_state_two"])) + } else { + require.Equal(t, obsNullJSON, string(result.Data["creation_event"])) + require.Equal(t, obsNullJSON, string(result.Data["contested_final_state_one"])) + require.Equal(t, obsNullJSON, string(result.Data["contested_final_state_two"])) + } + } + match := &Match{ResponderDeadline: 211, LeafSeal: &LeafMatchSeal{AgreeState: common.HexToHash("0x11"), + DivergencePosition: wide, FinalStateOne: common.HexToHash("0x22"), FinalStateTwo: common.HexToHash("0x33"), + DeadlineOne: 301, DeadlineTwo: 509, EliminableAt: 509}, DeletionReason: MatchDeletionReason_TIMEOUT, + Snapshot: MatchSnapshot{Phase: MatchPhaseUninitialized}} + response, err := observerAPIRequest(t, &observerAPIRepository{match: match}, "cartesi_getMatch", + api.GetMatchParams{Application: observerTestApplication, EpochIndex: "0x0", + TournamentAddress: common.HexToAddress("0x12").Hex(), IDHash: common.HexToHash("0x44").Hex()}) + require.NoError(t, err) + var result struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response, &result)) + require.Equal(t, `"0xd3"`, string(result.Data["responder_deadline"])) + var seal map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result.Data["leaf_seal"], &seal)) + for key, want := range map[string]string{"agree_state": `"` + common.HexToHash("0x11").Hex() + `"`, + "divergence_position": `"0x100000000000000000000000000000000000000000000000000"`, + "final_state_one": `"` + common.HexToHash("0x22").Hex() + `"`, "final_state_two": `"` + common.HexToHash("0x33").Hex() + `"`, + "deadline_one": `"0x12d"`, "deadline_two": `"0x1fd"`, "eliminable_at": `"0x1fd"`, observerLogIndexKey: `"0x0"`} { + require.Equal(t, want, string(seal[key]), key) + } + var snapshot map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result.Data["snapshot"], &snapshot)) + require.Equal(t, obsNullJSON, string(snapshot["sealed"]), "deletion must not erase the durable leaf-seal facts") +} + +type alpha6DiscoverySchema struct { + Ref string `json:"$ref"` + Type any `json:"type"` + Format string `json:"format"` + Properties map[string]alpha6DiscoverySchema `json:"properties"` + Required []string `json:"required"` + OneOf []alpha6DiscoverySchema `json:"oneOf"` +} + +//nolint:goconst // Keep explicit schema names and field expectations independent from production metadata. +func TestAlpha6DiscoveryContract(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Methods []struct { + Name string `json:"name"` + Params []struct { + Name string `json:"name"` + Required bool `json:"required"` + Schema alpha6DiscoverySchema `json:"schema"` + } `json:"params"` + Errors []struct { + Ref string `json:"$ref"` + } `json:"errors"` + Result struct { + Schema alpha6DiscoverySchema `json:"schema"` + } `json:"result"` + } `json:"methods"` + Components struct { + Schemas map[string]alpha6DiscoverySchema `json:"schemas"` + Errors map[string]struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"errors"` + } `json:"components"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + for _, methodName := range []string{observerListStandingChangesMethod, observerGetStandingChangeMethod} { + found := false + for _, method := range spec.Methods { + if method.Name != methodName { + continue + } + found = true + want := []string{observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, obsLimit, obsOffset, obsDescKey} + wantRefs := []string{"NameOrAddress", "UnsignedInteger", "EthereumAddress", "", "", ""} + errors := []string{"InvalidParams", "ApplicationNotFound", "InternalError", "TimeoutError", "ResponseSizeLimitExceeded"} + result := "StandingChangedListResult" + required := 3 + if methodName == observerGetStandingChangeMethod { + want = []string{observerApplicationKey, observerEpochIndexKey, observerTournamentAddressKey, + observerTxHashKey, observerLogIndexKey} + wantRefs = []string{"NameOrAddress", "UnsignedInteger", "EthereumAddress", "Hash", "UnsignedInteger"} + required = 5 + result = "StandingChangedGetResult" + errors = append(errors, "StandingChangedNotFound") + } + require.Len(t, method.Params, len(want)) + for i, param := range method.Params { + require.Equal(t, want[i], param.Name) + require.Equal(t, i < required, param.Required) + if wantRefs[i] != "" { + require.Equal(t, "#/components/schemas/"+wantRefs[i], param.Schema.Ref) + } + } + refs := make([]string, 0, len(method.Errors)) + for _, e := range method.Errors { + refs = append(refs, strings.TrimPrefix(e.Ref, "#/components/errors/")) + } + require.ElementsMatch(t, errors, refs) + require.Equal(t, "#/components/schemas/"+result, method.Result.Schema.Ref) + } + require.True(t, found, methodName) + } + require.Equal(t, -31001, spec.Components.Errors["StandingChangedNotFound"].Code) + require.Equal(t, "Standing change not found", spec.Components.Errors["StandingChangedNotFound"].Message) + for name, fields := range map[string]map[string]string{ + obsTournament: {"bond_value": "Uint256"}, + "Match": {"responder_deadline": "UnsignedInteger"}, + "MatchAdvanced": {"current_height": "UnsignedInteger", "responder_deadline": "UnsignedInteger"}, + "LeafMatchSeal": {"agree_state": "Hash", "divergence_position": "Uint256", + "final_state_one": "Hash", "final_state_two": "Hash", "deadline_one": "UnsignedInteger", "deadline_two": "UnsignedInteger"}, + "StandingChanged": {observerEpochIndexKey: "UnsignedInteger", observerTournamentAddressKey: "EthereumAddress", + "match_count": "Uint256", "result_at": "UnsignedInteger", "winner_expires_at": "UnsignedInteger", + "block_number": "UnsignedInteger", observerTxHashKey: "Hash", observerLogIndexKey: "UnsignedInteger"}, + } { + schema := spec.Components.Schemas[name] + for field, ref := range fields { + require.Contains(t, schema.Required, field) + require.Equal(t, "#/components/schemas/"+ref, schema.Properties[field].Ref, name+"."+field) + } + } + creation := spec.Components.Schemas[obsTournament] + require.Contains(t, creation.Required, "creation_event") + choices := creation.Properties["creation_event"].OneOf + require.Len(t, choices, 2) + require.Equal(t, "#/components/schemas/TournamentCreationEvent", choices[0].Ref) + require.Equal(t, obsNullJSON, choices[1].Type) + for name, fields := range map[string][]string{ + obsTournament: {"contested_final_state_one", "contested_final_state_two"}, + "StandingChanged": {"dangling", "parent_commitment"}, + } { + for _, field := range fields { + schema := spec.Components.Schemas[name] + require.Contains(t, schema.Required, field) + choices := schema.Properties[field].OneOf + require.Len(t, choices, 2) + require.Equal(t, "#/components/schemas/Hash", choices[0].Ref) + require.Equal(t, obsNullJSON, choices[1].Type) + } + } + standing := spec.Components.Schemas["StandingChanged"] + require.ElementsMatch(t, []string{observerEpochIndexKey, observerTournamentAddressKey, "match_count", "dangling", "parent_commitment", + "result_at", "winner_expires_at", "block_number", observerTxHashKey, observerLogIndexKey, "created_at", "updated_at"}, + standing.Required) + require.Len(t, standing.Properties, 12) + for _, field := range []string{"created_at", "updated_at"} { + require.Equal(t, "string", standing.Properties[field].Type) + require.Equal(t, "date-time", standing.Properties[field].Format) + } +} diff --git a/internal/jsonrpc/request_log_test.go b/internal/jsonrpc/request_log_test.go new file mode 100644 index 000000000..e4198cfcb --- /dev/null +++ b/internal/jsonrpc/request_log_test.go @@ -0,0 +1,134 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +const logRequestID = "11111111-1111-4111-8111-111111111111" + +type failedApplicationRepository struct{ repository.Repository } + +func (*failedApplicationRepository) ListApplications( + context.Context, repository.ApplicationFilter, repository.Pagination, bool, +) ([]*model.Application, uint64, error) { + return nil, 0, errors.New("SAFE_REPOSITORY_FAILURE") +} + +func requireRequestLogs(t *testing.T, logs *bytes.Buffer, levels []slog.Level) { + t.Helper() + decoder := json.NewDecoder(logs) + var records []map[string]any + for { + var record map[string]any + err := decoder.Decode(&record) + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + records = append(records, record) + } + require.Len(t, records, len(levels), "adding the request ID must not add log events") + for i, record := range records { + require.Equal(t, levels[i].String(), record["level"]) + require.Equal(t, logRequestID, record["request_id"]) + } +} + +func serveWithLogRequestID(s *Service, w http.ResponseWriter, body string) { + r := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(body)) + r.Header.Set("X-Request-ID", logRequestID) + service.RequestIDMiddleware(http.HandlerFunc(s.handleRPC)).ServeHTTP(w, r) +} + +func TestRepositoryFailureLogsRequestID(t *testing.T) { + var logs bytes.Buffer + s := newBatchTestService() + s.repository = &failedApplicationRepository{} + s.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + recorder := httptest.NewRecorder() + serveWithLogRequestID(s, recorder, + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":1},"id":1}`) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, logRequestID, recorder.Header().Get("X-Request-ID")) + response := decodeRPCResponse(t, recorder.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INTERNAL_ERROR) + require.Equal(t, "Internal server error", response.Error.Message) + require.NotContains(t, recorder.Body.String(), "SAFE_REPOSITORY_FAILURE") + require.Contains(t, logs.String(), "SAFE_REPOSITORY_FAILURE", "the cause must stay in the server log") + requireRequestLogs(t, &logs, []slog.Level{slog.LevelInfo, slog.LevelError}) + + // The service logger itself must not retain a request's attributes. + s.Logger.Info("outside the request") + require.NotContains(t, logs.String(), "request_id") +} + +type failedResponseWriter struct{ *httptest.ResponseRecorder } + +func (failedResponseWriter) Write([]byte) (int, error) { + return 0, errors.New("SAFE_RESPONSE_WRITE_FAILURE") +} + +func TestDispatchAndWriteFailureLogsRequestID(t *testing.T) { + for _, test := range []struct { + name string + handler rpcHandler + levels []slog.Level + failedWrite bool + }{ + {name: "unexpected error", levels: []slog.Level{slog.LevelInfo, slog.LevelError}, + handler: func(*Service, *http.Request, RPCRequest) (any, error) { + return nil, errors.New("SAFE_DISPATCH_FAILURE") + }}, + {name: "panic", levels: []slog.Level{slog.LevelInfo, slog.LevelError}, + handler: func(*Service, *http.Request, RPCRequest) (any, error) { panic("SAFE_DISPATCH_PANIC") }}, + {name: "encode error", levels: []slog.Level{slog.LevelInfo, slog.LevelError}, + handler: func(*Service, *http.Request, RPCRequest) (any, error) { return make(chan int), nil }}, + {name: "timeout", levels: []slog.Level{slog.LevelInfo, slog.LevelWarn}, + handler: func(_ *Service, r *http.Request, _ RPCRequest) (any, error) { + <-r.Context().Done() + return nil, r.Context().Err() + }}, + {name: "expected domain error", levels: []slog.Level{slog.LevelInfo}, + handler: func(*Service, *http.Request, RPCRequest) (any, error) { + return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") + }}, + {name: "response write", levels: []slog.Level{slog.LevelInfo, slog.LevelWarn}, failedWrite: true, + handler: func(*Service, *http.Request, RPCRequest) (any, error) { return true, nil }}, + } { + t.Run(test.name, func(t *testing.T) { + var logs bytes.Buffer + s := newBatchTestService() + s.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + if test.name == "timeout" { + s.dispatchTimeout = 100 * time.Millisecond + } + withTestRPCHandler(t, s, "test_request_log", test.handler) + recorder := httptest.NewRecorder() + var writer http.ResponseWriter = recorder + if test.failedWrite { + writer = failedResponseWriter{recorder} + } + serveWithLogRequestID(s, writer, `{"jsonrpc":"2.0","method":"test_request_log","id":1}`) + requireRequestLogs(t, &logs, test.levels) + }) + } +} diff --git a/internal/jsonrpc/service.go b/internal/jsonrpc/service.go new file mode 100644 index 000000000..cb8606edc --- /dev/null +++ b/internal/jsonrpc/service.go @@ -0,0 +1,104 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" + "github.com/cartesi/rollups-node/pkg/contracts/outputs" + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +const ( + jsonrpcShutdownTimeout = 5 * time.Second + jsonrpcWriteHeadroom = 5 * time.Second +) + +// Service implements the IService interface. +type Service struct { + service.HTTPServiceTemplate + repository repository.Repository + inputABI *abi.ABI + outputABI *abi.ABI + // OpenAPI description for JSON-RPC API loaded from 'jsonrpc-discover.json' file + discoverSpec json.RawMessage + handlers dispatchTable + // dispatchTimeout expires requests early enough to serialize a complete + // timeout response before the HTTP server's write deadline. + dispatchTimeout time.Duration +} + +type CreateInfo struct { + Config config.JsonrpcConfig + Logger *slog.Logger + Repository repository.Repository +} + +func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { + var err error + if err = ctx.Err(); err != nil { + return nil, err // This returns context.Canceled or context.DeadlineExceeded. + } + + s := &Service{} + cfg := service.HTTPServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: config.ServiceJsonrpc, + Logger: c.Logger, + LogLevel: c.Config.LogLevel, + LogColor: c.Config.LogColor, + }, + HTTPServerOptions: service.DefaultJSONRPCOptions(), + Address: c.Config.JsonrpcApiAddress, + SafeRequestID: true, + CorsAllowedOrigins: c.Config.JsonrpcCorsAllowedOrigins, + MaxInflight: c.Config.JsonrpcMaxInflight, + ShutdownTimeout: jsonrpcShutdownTimeout, + } + + s.repository = c.Repository + s.handlers = cloneDispatchTable(jsonrpcHandlers) + if s.repository == nil { + return nil, fmt.Errorf("repository on validator service Create is nil") + } + + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + if err != nil { + return nil, fmt.Errorf("unable to read jsonrpc-discover content: %w", err) + } + if err := json.Unmarshal(data, &s.discoverSpec); err != nil { + return nil, fmt.Errorf("unable to unmarshal discovery spec JSON: %w", err) + } + + s.inputABI, err = inputs.InputsMetaData.GetAbi() + if err != nil { + return nil, err + } + + s.outputABI, err = outputs.OutputsMetaData.GetAbi() + if err != nil { + return nil, err + } + + mux := http.NewServeMux() + mux.HandleFunc("/rpc", s.handleRPC) + + service.InitHTTPServiceTemplate(&s.HTTPServiceTemplate, &cfg, mux) + + s.dispatchTimeout = cfg.HTTPServerOptions.WriteTimeout - jsonrpcWriteHeadroom + + s.Logger.Info("Created", "config", c.Config) + + return s, nil +} diff --git a/internal/jsonrpc/service_test.go b/internal/jsonrpc/service_test.go new file mode 100644 index 000000000..98a06b12d --- /dev/null +++ b/internal/jsonrpc/service_test.go @@ -0,0 +1,255 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/service" + + "github.com/stretchr/testify/require" +) + +// ensureSentinelRejects fires a request against s.server.Handler and +// expects the admission middleware to reject it with a 503. +func ensureSentinelRejects(t *testing.T, s *Service) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusServiceUnavailable, rr.Code) + retryAfter, err := strconv.Atoi(rr.Header().Get("Retry-After")) + require.NoError(t, err) + require.GreaterOrEqual(t, retryAfter, 1) + require.LessOrEqual(t, retryAfter, 3) + require.Contains(t, rr.Body.String(), "service at capacity") + require.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options")) +} + +// TestJSONRPC_HardenedServerOptions checks that Create wires the jsonrpc +// HTTP server through NewHTTPServer with DefaultJSONRPCOptions. If a future +// refactor reintroduces an inline &http.Server{} literal or drops a field, +// this test catches it. +func TestJSONRPC_HardenedServerOptions(t *testing.T) { + s := newTestService(t, "jsonrpc-server-options") + require.NotNil(t, s.Server) + + opts := service.DefaultJSONRPCOptions() + require.Equal(t, opts.ReadHeaderTimeout, s.Server.ReadHeaderTimeout) + require.Equal(t, opts.ReadTimeout, s.Server.ReadTimeout) + require.Equal(t, opts.WriteTimeout, s.Server.WriteTimeout) + require.Equal(t, opts.IdleTimeout, s.Server.IdleTimeout) + require.Equal(t, opts.MaxHeaderBytes, s.Server.MaxHeaderBytes) + require.NotNil(t, s.Server.ErrorLog) + require.Equal(t, opts.WriteTimeout-jsonrpcWriteHeadroom, s.dispatchTimeout) +} + +func TestJSONRPC_ServerHandlerAppliesBatchDispatchTimeout(t *testing.T) { + s := newTestService(t, "jsonrpc-dispatch-timeout") + s.dispatchTimeout = 10 * time.Millisecond + + const method = "test_server_dispatch_timeout" + withTestRPCHandler(t, s, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + <-r.Context().Done() + return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", + fmt.Errorf("repository query failed: %w", r.Context().Err())) + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2}, + {"jsonrpc":"2.0","method":%q,"id":3} + ]`, method, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + var responses []RPCResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &responses)) + require.Len(t, responses, 3) + requireRPCError(t, responses[0], float64(1), JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[1], float64(2), JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[2], float64(3), JSONRPC_TIMEOUT_ERROR) +} + +// TestJSONRPC_RequestIDPropagated verifies the middleware chain echoes a +// valid X-Request-ID back on the response. Runs directly against the +// Service.server handler so the whole chain (Recover -> RequestID -> CORS +// -> Admission -> mux) is exercised. +func TestJSONRPC_RequestIDPropagated(t *testing.T) { + s := newTestService(t, "jsonrpc-request-id") + + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("X-Request-ID", "pinned-xyz") + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + // handleRPC will reject the empty body as a bad request, but the + // middleware chain still runs and must echo the header. + require.Equal(t, "pinned-xyz", rr.Header().Get("X-Request-ID")) +} + +// ----------------------------------------------------------------------------- +// Oversized body handling +// ----------------------------------------------------------------------------- + +func TestJSONRPC_OversizedBodyReturns413(t *testing.T) { + s := newTestService(t, "jsonrpc-oversize") + + // Build a body that exceeds MAX_BODY_SIZE (1 MiB). + oversized := bytes.Repeat([]byte("x"), MAX_BODY_SIZE+1) + + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(oversized)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code) + require.Contains(t, rr.Body.String(), "Payload too large") +} + +// ----------------------------------------------------------------------------- +// Admission control +// ----------------------------------------------------------------------------- + +func TestJSONRPC_AdmissionDisabledWhenZero(t *testing.T) { + // JsonrpcMaxInflight=0 must leave s.admission == nil and make the + // middleware a passthrough. We can't assert "no 503" directly + // without coordinating a slow handler; instead verify the field + // and confirm a basic request reaches handleRPC (which rejects + // an empty body with 400, not 503). + s := newTestServiceWithInflight(t, "jsonrpc-adm-zero", 0) + require.Nil(t, s.Admission, "limit=0 must produce nil admission") + + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + require.NotEqual(t, http.StatusServiceUnavailable, rr.Code, + "disabled admission must not return 503") +} + +func TestJSONRPC_AdmissionWiredWhenPositive(t *testing.T) { + // JsonrpcMaxInflight>0 must construct a non-nil SemaphoreAdmission + // with the matching limit. + s := newTestServiceWithInflight(t, "jsonrpc-adm-wired", 7) + require.NotNil(t, s.Admission) + require.Equal(t, uint64(7), s.Admission.Limit()) +} + +func TestJSONRPC_AdmissionRejectsWhenExhausted(t *testing.T) { + // Replace the post-Create admission with a pre-filled semaphore + // and rebuild just the middleware stack onto a fresh mux. This is + // the same wiring Create() does — inlined here to exercise the + // rejection path without a blocking RPC handler. + s := newTestServiceWithInflight(t, "jsonrpc-adm-reject", 1) + + // Swap the admission underlying the server handler for a + // pre-filled one to force rejection on every request. + s.Admission.TryAcquire() // pre-fill the single permit + s.Server.Handler = rebuildHandlerWithAdmission(s) + + ensureSentinelRejects(t, s) + require.Equal(t, uint64(1), s.Admission.Rejected()) +} + +func TestJSONRPC_AdmissionPermitReleasedAfterRequest(t *testing.T) { + // With limit=1 a sequential burst must all succeed: each request + // releases its permit on return. + s := newTestServiceWithInflight(t, "jsonrpc-adm-release", 1) + require.NotNil(t, s.Admission) + + for range 5 { + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + // handleRPC returns 400 on empty body; the key assertion is + // that we never see 503 because the permit is released each + // time the handler returns. + require.NotEqual(t, http.StatusServiceUnavailable, rr.Code) + } + require.Equal(t, uint64(0), s.Admission.Rejected()) +} + +// rebuildHandlerWithAdmission rewraps the service's mux with only +// AdmissionMiddleware bound to the service's current admission field. +// Used by tests that swap the admission after construction to exercise +// the rejection path. The outer chain (Recover, RequestID, CORS) from +// Create() is deliberately omitted — admission is what these tests +// pin, and the other layers are inert for a plain POST without Origin. +func rebuildHandlerWithAdmission(s *Service) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/rpc", s.handleRPC) + + var handler http.Handler = mux + handler = service.AdmissionMiddleware(s.Admission)(handler) + return handler +} + +// ----------------------------------------------------------------------------- +// CORS integration +// ----------------------------------------------------------------------------- + +func TestJSONRPC_CORSDisabledByDefault(t *testing.T) { + s := newTestService(t, "jsonrpc-cors-off") + + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://evil.com") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Empty(t, rr.Header().Get("Vary")) +} + +func TestJSONRPC_CORSAllowedOriginEchoed(t *testing.T) { + s := newTestServiceWithCORS(t, "jsonrpc-cors-on", "http://trusted.example.com") + + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://trusted.example.com") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + require.Equal(t, "http://trusted.example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") +} + +func TestJSONRPC_CORSDisallowedOriginNoGrant(t *testing.T) { + s := newTestServiceWithCORS(t, "jsonrpc-cors-reject", "http://trusted.example.com") + + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://evil.com") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") +} + +func TestJSONRPC_CORSNoOriginPassthrough(t *testing.T) { + s := newTestServiceWithCORS(t, "jsonrpc-cors-no-origin", "http://trusted.example.com") + + req := httptest.NewRequest(http.MethodPost, "/rpc", http.NoBody) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Server.Handler.ServeHTTP(rr, req) + + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.NotEqual(t, http.StatusServiceUnavailable, rr.Code) +} diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go new file mode 100644 index 000000000..d9051b9c6 --- /dev/null +++ b/internal/jsonrpc/types.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "encoding/json" + "fmt" + "io" + "regexp" + + "github.com/cartesi/rollups-node/internal/config" +) + +// ----------------------------------------------------------------------------- +// JSON‑RPC message types +// ----------------------------------------------------------------------------- + +type RPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + ID json.RawMessage `json:"id"` +} + +type RPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result any `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` + ID json.RawMessage `json:"id"` +} + +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *RPCError) Error() string { + return e.Message +} + +func newRPCError(code int, message string) error { + return &RPCError{Code: code, Message: message} +} + +// writeRPCError sends a generic error response for internal errors. +func writeRPCError(w io.Writer, id json.RawMessage, code int, message string) error { + // Hide detailed error info for internal errors. + if code == JSONRPC_INTERNAL_ERROR { + message = "Internal server error" + } + resp := RPCResponse{ + JSONRPC: "2.0", + Error: &RPCError{ + Code: code, + Message: message, + }, + ID: id, + } + return json.NewEncoder(w).Encode(resp) +} + +func writeRPCResult(w io.Writer, id json.RawMessage, result any) error { + resp := RPCResponse{ + JSONRPC: "2.0", + Result: result, + ID: id, + } + return json.NewEncoder(w).Encode(resp) +} + +// ----------------------------------------------------------------------------- +// Validation helpers (server-only) +// ----------------------------------------------------------------------------- + +var hexAddressRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) +var appNameRegex = regexp.MustCompile(`^[a-z0-9_-]{3,}$`) + +func validateNameOrAddress(nameOrAddress string) error { + if hexAddressRegex.MatchString(nameOrAddress) { + _, err := config.ToAddressFromString(nameOrAddress) + return err + } + if appNameRegex.MatchString(nameOrAddress) { + return nil + } + return fmt.Errorf("invalid application name") +} diff --git a/internal/jsonrpc/util_test.go b/internal/jsonrpc/util_test.go new file mode 100644 index 000000000..54c8e5137 --- /dev/null +++ b/internal/jsonrpc/util_test.go @@ -0,0 +1,339 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" +) + +// histogram to gather statistics for each method +type hist map[string]int + +func (h hist) inc(k string) { + x, ok := h[k] + if ok { + h[k] = x + 1 + } else { + h[k] = 1 + } +} + +// auxiliary type to encode/decode hex strings in json +type hex64 uint64 + +func (x *hex64) MarshalJSON() ([]byte, error) { + return json.Marshal(hexutil.EncodeUint64(uint64(*x))) +} + +func (x *hex64) UnmarshalJSON(in []byte) error { + var hexString string + err := json.Unmarshal(in, &hexString) + if err != nil { + return err + } + hexValue, err := model.ParseHexUint64(hexString) + if err != nil { + return err + } + *x = hex64(hexValue) + return nil +} + +// RPC response type +type testRPCResponse[T any] struct { + JSONRPC string `json:"jsonrpc"` + Result struct { + Data T `json:"data"` + } `json:"result"` + Error *RPCError `json:"error,omitempty"` + ID any `json:"id"` +} + +func newTestService(t *testing.T, name string) *Service { + return newTestServiceWithInflight(t, name, 0) +} + +// newTestServiceWithInflight is like newTestService but lets the caller +// set CARTESI_JSONRPC_MAX_INFLIGHT for admission-control tests. A value +// of 0 disables admission (the Create-time nil-admission path). +func newTestServiceWithInflight(t *testing.T, name string, maxInflight uint64) *Service { + return newTestServiceFull(t, name, maxInflight, "") +} + +func newTestServiceWithCORS(t *testing.T, name string, origins string) *Service { + return newTestServiceFull(t, name, 0, origins) +} + +func newTestServiceFull(t *testing.T, name string, maxInflight uint64, corsOrigins string) *Service { + ctx := context.Background() + + dbTestEndpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + + err = db.SetupTestPostgres(dbTestEndpoint) + require.NoError(t, err) + + repo, err := factory.NewRepositoryFromConnectionString(ctx, dbTestEndpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + + logLevel, err := config.GetLogLevel() + require.NoError(t, err) + + ci := CreateInfo{ + Config: config.JsonrpcConfig{ + LogLevel: logLevel, + LogColor: true, + JsonrpcMaxInflight: maxInflight, + JsonrpcCorsAllowedOrigins: corsOrigins, + }, + Repository: repo, + } + s, err := Create(ctx, &ci) + require.NoError(t, err, "on new test service") + + return s.(*Service) +} + +func nameToNumber(in string) uint64 { + xs := hexutil.MustDecode(in) + return big.NewInt(0).SetBytes(xs).Uint64() +} + +func numberToName(x uint64) string { + return fmt.Sprintf("0x%016x", x) +} + +// create an application with mostly stub values. +func (s *Service) newTestApplication(ctx context.Context, t *testing.T, i uint64) int64 { + hex := numberToName(i) + app := repotest.NewApplicationBuilder(). + WithName(hex). + WithAddress(common.HexToAddress(hex)). + Create(ctx, t, s.repository) + return app.ID +} + +func (s *Service) doRequest(t *testing.T, i uint64, reqData []byte) []byte { + w := httptest.NewRecorder() + s.handleRPC( + w, + httptest.NewRequest( + http.MethodGet, + "/rpc", + bytes.NewReader(reqData), + ), + ) + + body := new(strings.Builder) + _, err := io.Copy(body, w.Result().Body) + require.NoError(t, err, "on test case: %v", i) + + return []byte(body.String()) +} + +// input from ./cartesi-rollups-cli send echo-dapp -y "" +func emptyInput() []byte { + raw, _ := hexutil.Decode("0x415bf363000000000000000000000000000000000000000000000000000000000000343a0000000000000000000000002e662c8a1a6c8008482a41ef6d3b333497e7f956000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000068c97fb45a1ab2f3478ee32e84c0a464f70d9da8d470868984ba5f00d9da757bbcee2098000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000") //nolint: lll + return raw +} + +// output from ./cartesi-rollups-cli send echo-dapp -y "" +func emptyVoucher() []byte { + raw, _ := hexutil.Decode("0x237a816f000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000deadbeef00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000") //nolint: lll + return raw +} + +// createTestEpoch creates an epoch using production CreateEpochsAndInputs. +func (s *Service) createTestEpoch(ctx context.Context, t *testing.T, appName string, epoch *model.Epoch) { + t.Helper() + targetStatus := epoch.Status + if requiresPublishedProof(targetStatus) { + epoch.Status = model.EpochStatus_Closed + } + err := s.repository.CreateEpochsAndInputs(ctx, appName, + map[*model.Epoch][]*model.Input{epoch: {}}, 10) + require.NoError(t, err) + if epoch.Status != targetStatus { + repotest.AdvanceEpochStatus(ctx, t, s.repository, appName, epoch, targetStatus) + } +} + +// createTestEpochWithInput creates an epoch with one input using production CreateEpochsAndInputs. +func (s *Service) createTestEpochWithInput( + ctx context.Context, t *testing.T, appName string, + epoch *model.Epoch, input *model.Input, +) { + t.Helper() + // Inputs and outputs must be stored before the final state proof is + // published. These JSON-RPC fixtures do not depend on the epoch's claim + // status, so keep it CLOSED while advanceInput populates its contents. + if requiresPublishedProof(epoch.Status) { + epoch.Status = model.EpochStatus_Closed + } + inputs := make([]*model.Input, 0, input.Index+1) + for index := uint64(0); index < input.Index; index++ { + inputs = append(inputs, repotest.NewInputBuilder(). + WithIndex(index). + WithEpochIndex(epoch.Index). + Build()) + } + inputs = append(inputs, input) + err := s.repository.CreateEpochsAndInputs(ctx, appName, + map[*model.Epoch][]*model.Input{epoch: inputs}, 10) + require.NoError(t, err) + for index := uint64(0); index < input.Index; index++ { + repotest.StoreAdvanceResult( + ctx, t, s.repository, epoch.ApplicationID, epoch.Index, index, + model.InputCompletionStatus_Accepted, nil, nil, + ) + } +} + +func requiresPublishedProof(status model.EpochStatus) bool { + switch status { + case model.EpochStatus_InputsProcessed, + model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimSubmitted, + model.EpochStatus_ClaimStaged, + model.EpochStatus_ClaimAccepted, + model.EpochStatus_ClaimRejected: + return true + case model.EpochStatus_Open, + model.EpochStatus_Closed, + model.EpochStatus_ClaimForeclosed: + return false + default: + return false + } +} + +// advanceInput stores an advance result (outputs/reports) for an input using production StoreAdvanceResult. +func (s *Service) advanceInput( + ctx context.Context, t *testing.T, appID int64, + epochIndex, inputIndex uint64, outputs [][]byte, reports [][]byte, +) { + t.Helper() + repotest.StoreAdvanceResult(ctx, t, s.repository, appID, epochIndex, inputIndex, + model.InputCompletionStatus_Accepted, outputs, reports) +} + +type listTournamentsResult struct { + EpochIndex hex64 `json:"epoch_index"` + Address common.Address `json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel hex64 `json:"max_level"` + Level hex64 `json:"level"` + Log2Step hex64 `json:"log2step"` + Height hex64 `json:"height"` + Snapshot model.TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type getCommitmentResult struct { + ApplicationID hex64 `sql:"primary_key" json:"-"` + EpochIndex hex64 `sql:"primary_key" json:"epoch_index"` + TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` + Commitment common.Hash `sql:"primary_key" json:"commitment"` + FinalStateHash common.Hash `json:"final_state_hash"` + SubmitterAddress common.Address `json:"submitter_address"` + BlockNumber hex64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type getMatchResult struct { + EpochIndex hex64 `json:"epoch_index"` + BlockNumber hex64 `json:"block_number"` + DeletionBlockNumber hex64 `json:"deletion_block_number"` + TournamentAddress common.Address `json:"tournament_address"` + IDHash common.Hash `json:"id_hash"` + CommitmentOne common.Hash `json:"commitment_one"` + CommitmentTwo common.Hash `json:"commitment_two"` + LeftOfTwo common.Hash `json:"left_of_two"` + TxHash common.Hash `json:"tx_hash"` + Winner model.WinnerCommitment `json:"winner_commitment"` + DeletionReason model.MatchDeletionReason `json:"deletion_reason"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type getMatchAdvancedResult struct { + ApplicationID hex64 `json:"-"` + EpochIndex hex64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + IDHash common.Hash `json:"id_hash"` + OtherParent common.Hash `json:"other_parent"` + LeftNode common.Hash `json:"left_node"` + BlockNumber hex64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type listMatchesResult struct { + EpochIndex hex64 `json:"epoch_index"` + BlockNumber hex64 `json:"block_number"` + DeletionBlockNumber hex64 `json:"deletion_block_number"` + TournamentAddress common.Address `json:"tournament_address"` + IDHash common.Hash `json:"id_hash"` + CommitmentOne common.Hash `json:"commitment_one"` + CommitmentTwo common.Hash `json:"commitment_two"` + LeftOfTwo common.Hash `json:"left_of_two"` + TxHash common.Hash `json:"tx_hash"` + Winner model.WinnerCommitment `json:"winner_commitment"` + DeletionReason model.MatchDeletionReason `json:"deletion_reason"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type listMatchAdvancedResult struct { + ApplicationID hex64 `json:"-"` + EpochIndex hex64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + IDHash common.Hash `json:"id_hash"` + OtherParent common.Hash `json:"other_parent"` + LeftNode common.Hash `json:"left_node"` + BlockNumber hex64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type listCommitmentResult struct { + ApplicationID hex64 `json:"-"` + EpochIndex hex64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + Commitment common.Hash `json:"commitment"` + FinalStateHash common.Hash `json:"final_state_hash"` + SubmitterAddress common.Address `json:"submitter_address"` + BlockNumber hex64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go new file mode 100644 index 000000000..aad5d5c52 --- /dev/null +++ b/internal/kms/signtx.go @@ -0,0 +1,223 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +/* Package signtx provides utilities for dealing with private keys stored + * remotely in a Amazon KMS secure server. + * + * Without local access to the private key, signing a message involves creating + * a digest for it, sending it to KMS server, signing it there, then retrieving + * the signature. */ +package kms + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/asn1" + "errors" + "fmt" + "math/big" + + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" +) + +/* This is the signature for a function that takes a transaction, passes it + * through a signer to obtain a message digest, creates a signature and embeds + * it into the transaction itself. */ +type SignTxFn = func(ctx context.Context, tx *types.Transaction, s types.Signer) (*types.Transaction, error) + +type Client interface { + GetPublicKey(context.Context, *kms.GetPublicKeyInput, ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) + Sign(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) +} + +const signatureComponentSize = 32 + +/* AWS sometimes reply with a `r` larger than 32bytes padded on the left with + * zeros. Trim it down to a total of 32bytes */ +func normalizeR(r []byte) ([]byte, error) { + if len(r) <= signatureComponentSize { + return r, nil + } + for i := 0; i < len(r)-signatureComponentSize; i++ { + if r[i] != 0 { // must be padding + return nil, errors.New("malformed `r` component") + } + } + return r[len(r)-signatureComponentSize:], nil +} + +/* normalize `s` to the lower half of N according to EIP-2 + * ref. https://eips.ethereum.org/EIPS/eip-2 */ +func normalizeS(s []byte) []byte { + n := crypto.S256().Params().N + halfN := new(big.Int).Div(n, big.NewInt(2)) //nolint:mnd + sBigInt := new(big.Int).SetBytes(s) + + if sBigInt.Cmp(halfN) > 0 { + s = new(big.Int).Sub(n, sBigInt).Bytes() + } + return s +} + +/* Compute the final component `v` of the ethereum signature, one KMS doesn't + * provide, and assemble `r`, `s`, and `v` into the signature byte array. + * Format is: [ R || S || V ], check signature_cgo.go for details. + * + * This `v` field is an ethereum extension to the ECDSA `r` and `s` signature + * values used to facilitate the retrieval of the public key from the hash + + * signature pair. We use this property to compute `v` by trial and error. One + * of the values of `v` will hold ecrecover(hash, sig) == publicKey, and that + * is the one ethereum wants. */ +func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, error) { + if len(r) > signatureComponentSize || len(s) > signatureComponentSize { + return nil, fmt.Errorf("malformed signature: len(r)=%d len(s)=%d", len(r), len(s)) + } + + sig := make([]byte, 65) + + // align `s` and `r` in case they have less then 32bytes in size + copy(sig[signatureComponentSize-len(r):], r) + copy(sig[64-len(s):], s) + + for i := byte(0); i < 2; i++ { + sig[64] = i + pub, err := crypto.Ecrecover(hash, sig) + if err != nil { + continue + } + if bytes.Equal(pub, key) { + return sig, nil + } + } + return nil, errors.New("failed to compute signature") +} + +/* Create a SignTxFn that uses the KMS infrastructure from AWS for signing. + * The function wraps a context ctx, KMS client and arn to access and identify + * the signing key. */ +func CreateAWSSignTxFn( + ctx context.Context, + client Client, + arn *string, +) (SignTxFn, *ecdsa.PublicKey, common.Address, error) { + publicKeyBytes, err := GetPublicKeyBytes(ctx, client, arn) + if err != nil { + return nil, nil, common.Address{}, err + } + publicKey, err := crypto.UnmarshalPubkey(publicKeyBytes) + if err != nil { + return nil, nil, common.Address{}, err + } + return func(ctx context.Context, tx *types.Transaction, signer types.Signer) (*types.Transaction, error) { + hash := signer.Hash(tx).Bytes() + signOutput, err := client.Sign(ctx, &kms.SignInput{ + KeyId: arn, + Message: hash, + SigningAlgorithm: "ECDSA_SHA_256", + MessageType: "DIGEST", + }) + if err != nil { + return nil, err + } + + /* AWS returns the signature wrapped in a DER-encoded object. + * Try to unwrap it before continuing. */ + type ecdsaSigValue struct { + R asn1.RawValue + S asn1.RawValue + } + var asn1sig ecdsaSigValue + _, err = asn1.Unmarshal(signOutput.Signature, &asn1sig) + if err != nil { + return nil, err + } + + S := normalizeS(asn1sig.S.Bytes) + R, err := normalizeR(asn1sig.R.Bytes) + if err != nil { + return nil, err + } + signature, err := assembleSignature(R, S, hash, publicKeyBytes) + if err != nil { + return nil, err + } + return tx.WithSignature(signer, signature) + }, publicKey, crypto.PubkeyToAddress(*publicKey), nil +} + +func GetPublicKeyBytes(ctx context.Context, client Client, arn *string) ([]byte, error) { + publicKeyOutput, err := client.GetPublicKey(ctx, &kms.GetPublicKeyInput{ + KeyId: arn, + }) + if err != nil { + return nil, err + } + + /* AWS returns the public key wrapped in a DER-encoded object. Unwrap + * it before returning + * ref: + * https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/kms#GetPublicKeyOutput + * https://datatracker.ietf.org/doc/html/rfc5280 (p.16-17) */ + type algorithmIdentifier struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier // Optional by the spec + } + type subjectPublicKeyInfo struct { + Algorithm algorithmIdentifier + SubjectPublicKey asn1.BitString + } + var asn1key subjectPublicKeyInfo + _, err = asn1.Unmarshal(publicKeyOutput.PublicKey, &asn1key) + if err != nil { + return nil, err + } + return asn1key.SubjectPublicKey.Bytes, nil +} + +func CreateAWSTransactOptsFactory( + ctx context.Context, + client Client, + arn *string, + signer types.Signer, +) (*AWSTransactOptsFactory, error) { + signTxFn, _, keyAddress, err := CreateAWSSignTxFn(ctx, client, arn) + if err != nil { + return nil, err + } + return &AWSTransactOptsFactory{ + signTxFn: signTxFn, + address: keyAddress, + signer: signer, + }, nil +} + +type AWSTransactOptsFactory struct { + signTxFn SignTxFn + address common.Address + signer types.Signer +} + +func (f *AWSTransactOptsFactory) From() common.Address { + return f.address +} + +func (f *AWSTransactOptsFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + return &bind.TransactOpts{ + From: f.address, + Signer: func( + address common.Address, + tx *types.Transaction, + ) (*types.Transaction, error) { + if address != f.address { + return nil, bind.ErrNotAuthorized + } + return f.signTxFn(ctx, tx, f.signer) + }, + Context: ctx, + }, nil +} diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go new file mode 100644 index 000000000..094a117d0 --- /dev/null +++ b/internal/kms/signtx_test.go @@ -0,0 +1,488 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package kms + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "encoding/asn1" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + + awscfg "github.com/aws/aws-sdk-go-v2/config" + awskms "github.com/aws/aws-sdk-go-v2/service/kms" + kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/stretchr/testify/require" +) + +const testKeyID = "alias/test-key" + +var ARN = "" + +/* Create a SignTxFn from a private key. Useful for testing */ +func CreateSignTxFnFromPrivateKey(privateKey *ecdsa.PrivateKey) SignTxFn { + return func(_ context.Context, tx *ethtypes.Transaction, s ethtypes.Signer) (*ethtypes.Transaction, error) { + return ethtypes.SignTx(tx, s, privateKey) + } +} + +func TestAssembleSignatureRejectsOverlongComponents(t *testing.T) { + tests := []struct { + name string + r []byte + s []byte + expected string + }{ + { + name: "r", r: make([]byte, 33), s: make([]byte, 32), + expected: "malformed signature: len(r)=33 len(s)=32", + }, + { + name: "s", r: make([]byte, 32), s: make([]byte, 33), + expected: "malformed signature: len(r)=32 len(s)=33", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + signature, err := assembleSignature(test.r, test.s, nil, nil) + require.Nil(t, signature) + require.EqualError(t, err, test.expected) + }) + } +} + +func TestNormalizeR(t *testing.T) { + t.Run("keeps components up to 32 bytes", func(t *testing.T) { + input := []byte{1, 2, 3} + + r, err := normalizeR(input) + require.NoError(t, err) + require.Equal(t, input, r) + }) + + t.Run("trims leading zero padding", func(t *testing.T) { + padded := append([]byte{0}, make([]byte, 32)...) + padded[len(padded)-1] = 1 + + r, err := normalizeR(padded) + require.NoError(t, err) + require.Len(t, r, 32) + require.Equal(t, byte(1), r[len(r)-1]) + }) + + t.Run("rejects non-padding bytes", func(t *testing.T) { + malformed := append([]byte{1}, make([]byte, 32)...) + + r, err := normalizeR(malformed) + require.Nil(t, r) + require.EqualError(t, err, "malformed `r` component") + }) +} + +func TestNormalizeSConvertsHighSToLowS(t *testing.T) { + n := crypto.S256().Params().N + halfN := new(big.Int).Div(new(big.Int).Set(n), big.NewInt(2)) + highS := new(big.Int).Add(halfN, big.NewInt(1)) + expected := new(big.Int).Sub(n, highS).Bytes() + + require.Equal(t, expected, normalizeS(highS.Bytes())) +} + +func TestAssembleSignatureRejectsUnrecoverableKey(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + otherKey, err := crypto.GenerateKey() + require.NoError(t, err) + hash := crypto.Keccak256([]byte("test transaction")) + signature, err := crypto.Sign(hash, privateKey) + require.NoError(t, err) + + assembled, err := assembleSignature( + signature[:32], signature[32:64], hash, crypto.FromECDSAPub(&otherKey.PublicKey), + ) + require.EqualError(t, err, "failed to compute signature") + require.Nil(t, assembled) +} + +func TestAssembleSignatureTriesBothRecoveryIDs(t *testing.T) { + assembled, err := assembleSignature(make([]byte, 32), make([]byte, 32), make([]byte, 32), nil) + require.Nil(t, assembled) + require.EqualError(t, err, "failed to compute signature") +} + +func sendFunds( + ctx context.Context, + client *ethclient.Client, + value *big.Int, + signTx SignTxFn, + sender common.Address, + recipient common.Address, +) { + nonce, err := client.PendingNonceAt(context.Background(), sender) + if err != nil { + panic(err) + } + gasLimit := uint64(21000) + gasPrice, err := client.SuggestGasPrice(ctx) + if err != nil { + panic(err) + } + var data []byte + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: nonce, To: &recipient, Value: value, Gas: gasLimit, GasPrice: gasPrice, Data: data, + }) + chainID, err := client.NetworkID(context.Background()) + if err != nil { + panic(err) + } + signedTx, err := signTx(ctx, tx, ethtypes.LatestSignerForChainID(chainID)) + if err != nil { + panic(err) + } + err = client.SendTransaction(context.Background(), signedTx) + if err != nil { + panic(err) + } +} + +func TestSignTx(t *testing.T) { + if len(ARN) == 0 { + t.Skip("Skipping test, ARN for KMS key is unset") + } + value20 := big.NewInt(2000000000000000000) // in wei (2 eth) + value10 := big.NewInt(1000000000000000000) // in wei (1 eth) + client, err := ethclient.Dial("http://127.0.0.1:8545") // anvil + if err != nil { + panic(err) + } + defer client.Close() + + anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + if err != nil { + panic(err) + } + anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) + anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) + + config, err := awscfg.LoadDefaultConfig(context.Background()) + if err != nil { + panic(err) + } + kms := awskms.NewFromConfig(config) + SignTx, _, KMSAddress, err := CreateAWSSignTxFn(context.Background(), kms, &ARN) + if err != nil { + panic(err) + } + + sendFunds(context.Background(), client, value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), + anvilAddress, KMSAddress) + sendFunds(context.Background(), client, value10, SignTx, + KMSAddress, anvilAddress) +} + +func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + client := newFakeKMSClient(t, privateKey) + arn := testKeyID + startupCtx, cancelStartup := context.WithCancel(context.Background()) + factory, err := CreateAWSTransactOptsFactory( + startupCtx, + client, + &arn, + ethtypes.LatestSignerForChainID(big.NewInt(1)), + ) + require.NoError(t, err) + cancelStartup() + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), factory.From()) + + type contextKey string + submitCtx := context.WithValue(context.Background(), contextKey("phase"), "submit") + opts, err := factory.NewTransactOpts(submitCtx) + require.NoError(t, err) + + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + _, err = opts.Signer(opts.From, tx) + require.NoError(t, err) + require.Equal(t, "submit", client.signContext.Value(contextKey("phase"))) + require.NoError(t, client.signContext.Err()) +} + +func TestAWSTransactOptsFactorySignsDynamicFeeTransaction(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + chainID := big.NewInt(31337) + client := newFakeKMSClient(t, privateKey) + keyID := "alias/test-key" + factory, err := CreateAWSTransactOptsFactory( + context.Background(), client, &keyID, ethtypes.LatestSignerForChainID(chainID), + ) + require.NoError(t, err) + + opts, err := factory.NewTransactOpts(context.Background()) + require.NoError(t, err) + tx := ethtypes.NewTx(ðtypes.DynamicFeeTx{ + ChainID: chainID, + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 21000, + To: &common.Address{0x01}, + Value: big.NewInt(3), + }) + signed, err := opts.Signer(opts.From, tx) + require.NoError(t, err) + + sender, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), sender) +} + +func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + arn := testKeyID + factory, err := CreateAWSTransactOptsFactory( + context.Background(), client, &arn, ethtypes.LatestSignerForChainID(big.NewInt(1)), + ) + require.NoError(t, err) + opts, err := factory.NewTransactOpts(context.Background()) + require.NoError(t, err) + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + + signed, err := opts.Signer(common.Address{0xff}, tx) + require.Nil(t, signed) + require.ErrorIs(t, err, bind.ErrNotAuthorized) + require.Zero(t, client.signCalls) +} + +func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + arn := testKeyID + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + signer := ethtypes.LatestSignerForChainID(big.NewInt(1)) + + tests := []struct { + name string + r []byte + s []byte + expected string + }{ + { + name: "non-padding byte in overlong r", r: append([]byte{1}, make([]byte, 32)...), s: []byte{1}, + expected: "malformed `r` component", + }, + { + name: "non-minimal overlong s", r: []byte{1}, s: append([]byte{0}, make([]byte, 32)...), + expected: "malformed signature: len(r)=1 len(s)=33", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := newFakeKMSClient(t, privateKey) + client.signature = marshalRawECDSASignature(test.r, test.s) + signTx, _, _, err := CreateAWSSignTxFn(context.Background(), client, &arn) + require.NoError(t, err) + + signed, err := signTx(context.Background(), tx, signer) + require.Nil(t, signed) + require.EqualError(t, err, test.expected) + }) + } +} + +func TestAWSSignTxPropagatesKMSSignFailure(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + signErr := errors.New("KMSInternalException") + client.signErr = signErr + keyID := testKeyID + signTx, _, _, err := CreateAWSSignTxFn(t.Context(), client, &keyID) + require.NoError(t, err) + + chainID := big.NewInt(31337) + tx := ethtypes.NewTx(ðtypes.DynamicFeeTx{ + ChainID: chainID, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), Gas: 21000, + }) + signed, err := signTx(t.Context(), tx, ethtypes.LatestSignerForChainID(chainID)) + + require.Nil(t, signed) + require.ErrorIs(t, err, signErr) +} + +func TestAWSSignTxRejectsMalformedKMSSignature(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + keyID := testKeyID + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + signer := ethtypes.LatestSignerForChainID(big.NewInt(1)) + + for _, test := range []struct { + name string + signature []byte + }{ + {name: "not DER", signature: []byte{0xff, 0xff, 0xff}}, + {name: "truncated sequence", signature: []byte{0x30, 0x03, 0x02, 0x01, 0x01}}, + } { + t.Run(test.name, func(t *testing.T) { + client := newFakeKMSClient(t, privateKey) + client.signature = test.signature + signTx, _, _, err := CreateAWSSignTxFn(t.Context(), client, &keyID) + require.NoError(t, err) + + signed, err := signTx(t.Context(), tx, signer) + require.Nil(t, signed) + require.Error(t, err) + }) + } +} + +func TestCreateAWSTransactOptsFactoryPropagatesGetPublicKeyFailure(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + getPublicKeyErr := errors.New("KeyUnavailableException") + client.getPublicKeyErr = getPublicKeyErr + keyID := testKeyID + + factory, err := CreateAWSTransactOptsFactory( + t.Context(), client, &keyID, ethtypes.LatestSignerForChainID(big.NewInt(1)), + ) + + require.Nil(t, factory) + require.ErrorIs(t, err, getPublicKeyErr) +} + +func TestCreateAWSTransactOptsFactoryRejectsMalformedPublicKeys(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + keyID := testKeyID + tests := []struct { + name string + publicKey []byte + }{ + {name: "malformed DER", publicKey: []byte{0xff, 0xff}}, + {name: "invalid secp256k1 point", publicKey: marshalSPKI(t, []byte{0x04, 0x01, 0x02})}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := newFakeKMSClient(t, privateKey) + client.publicKey = test.publicKey + factory, err := CreateAWSTransactOptsFactory( + t.Context(), client, &keyID, ethtypes.LatestSignerForChainID(big.NewInt(1)), + ) + require.Nil(t, factory) + require.Error(t, err) + }) + } +} + +func marshalRawECDSASignature(r, s []byte) []byte { + const maxDERLength = 255 + if len(r) > maxDERLength || len(s) > maxDERLength || len(r)+len(s)+4 > maxDERLength { + panic("test DER signature is too large for single-byte length encoding") + } + + content := make([]byte, 0, len(r)+len(s)+4) + content = append(content, 0x02, byte(len(r))) //nolint:gosec // Length is bounded above. + content = append(content, r...) + content = append(content, 0x02, byte(len(s))) //nolint:gosec // Length is bounded above. + content = append(content, s...) + return append([]byte{0x30, byte(len(content))}, content...) //nolint:gosec // Length is bounded above. +} + +type fakeKMSClient struct { + t *testing.T + privateKey *ecdsa.PrivateKey + publicKey []byte + signContext context.Context + signCalls int + signature []byte + getPublicKeyErr error + signErr error +} + +func newFakeKMSClient(t *testing.T, privateKey *ecdsa.PrivateKey) *fakeKMSClient { + t.Helper() + return &fakeKMSClient{ + t: t, privateKey: privateKey, publicKey: marshalSPKI(t, crypto.FromECDSAPub(&privateKey.PublicKey)), + } +} + +func marshalSPKI(t *testing.T, publicKeyBytes []byte) []byte { + t.Helper() + publicKey, err := asn1.Marshal(struct { + Algorithm struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + } + SubjectPublicKey asn1.BitString + }{ + Algorithm: struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + }{ + Algorithm: asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1}, + Parameters: asn1.ObjectIdentifier{1, 3, 132, 0, 10}, + }, + SubjectPublicKey: asn1.BitString{Bytes: publicKeyBytes}, + }) + require.NoError(t, err) + return publicKey +} + +func (f *fakeKMSClient) GetPublicKey( + context.Context, + *awskms.GetPublicKeyInput, + ...func(*awskms.Options), +) (*awskms.GetPublicKeyOutput, error) { + if f.getPublicKeyErr != nil { + return nil, f.getPublicKeyErr + } + return &awskms.GetPublicKeyOutput{PublicKey: f.publicKey}, nil +} + +func (f *fakeKMSClient) Sign( + ctx context.Context, + input *awskms.SignInput, + _ ...func(*awskms.Options), +) (*awskms.SignOutput, error) { + f.signCalls++ + f.signContext = ctx + if f.signErr != nil { + return nil, f.signErr + } + if f.signature != nil { + return &awskms.SignOutput{Signature: f.signature}, nil + } + r, s, err := ecdsa.Sign(rand.Reader, f.privateKey, input.Message) + require.NoError(f.t, err) + signature, err := asn1.Marshal(struct { + R *big.Int + S *big.Int + }{R: r, S: s}) + require.NoError(f.t, err) + return &awskms.SignOutput{ + KeyId: input.KeyId, + SigningAlgorithm: kmstypes.SigningAlgorithmSpecEcdsaSha256, + Signature: signature, + }, nil +} diff --git a/internal/machine/snapshot.go b/internal/machine/snapshot.go deleted file mode 100644 index 675743632..000000000 --- a/internal/machine/snapshot.go +++ /dev/null @@ -1,75 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// Package machine provides mechanisms to handle Cartesi Machine Snapshots -// to run Node locally for development and tests -package machine - -import ( - "errors" - "fmt" - "io/fs" - "log/slog" - "os" - "os/exec" - "strings" -) - -const SNAPSHOT_CONTAINER_PATH = "/usr/share/cartesi/snapshot" - -func runCommand(name string, args ...string) error { - slog.Debug("Running command", "name", name, "args", strings.Join(args, " ")) - cmd := exec.Command(name, args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("'%v %v' failed with %w: %v", - name, strings.Join(args, " "), err, string(output), - ) - } - return nil -} - -func fileExists(filePath string) bool { - _, err := os.Stat(filePath) - - // Check if the file exists by examining the error returned by os.Stat - return !errors.Is(err, fs.ErrNotExist) -} - -func Save(sourceDockerImage string, destDir string, tempContainerName string) error { - - // Remove previous snapshot dir - if fileExists(destDir) { - slog.Info("Removing previous snapshot") - err := os.RemoveAll(destDir) - if err != nil { - return err - } - } - - // Copy machine snapshot from Docker Container - err := runCommand("docker", "create", "--name", tempContainerName, sourceDockerImage) - if err != nil { - return err - } - - defer func() { - err := runCommand("docker", "rm", tempContainerName) - if err != nil { - slog.Warn("Error trying to delete container", - "container", tempContainerName, - "error", err) - } - }() - - fromDir := fmt.Sprintf("%v:%v", tempContainerName, SNAPSHOT_CONTAINER_PATH) - err = runCommand("docker", "cp", fromDir, destDir) - if err != nil { - return err - } - - slog.Info("Cartesi machine snapshot saved", - "docker-image", sourceDockerImage, - "destination-dir", destDir) - return nil -} diff --git a/internal/manager/errors.go b/internal/manager/errors.go new file mode 100644 index 000000000..e94adc7a0 --- /dev/null +++ b/internal/manager/errors.go @@ -0,0 +1,75 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package manager + +import ( + "errors" + "fmt" +) + +var ErrApplicationFailureNotDurable = errors.New("application failure status is not durably confirmed") + +// ApplicationFailurePersistenceError means local machine work is fenced but +// the repository has not confirmed the corresponding FAILED (or a stronger +// terminal/deleted) application state. +type ApplicationFailurePersistenceError struct { + ApplicationID int64 + WriteErr error + ReadErr error +} + +func (e *ApplicationFailurePersistenceError) Error() string { + if e.ReadErr != nil { + return fmt.Sprintf( + "%v: application=%d write_error=%v read_error=%v", + ErrApplicationFailureNotDurable, e.ApplicationID, e.WriteErr, e.ReadErr, + ) + } + return fmt.Sprintf( + "%v: application=%d write_error=%v durable status remains unconfirmed", + ErrApplicationFailureNotDurable, e.ApplicationID, e.WriteErr, + ) +} + +func (e *ApplicationFailurePersistenceError) Unwrap() []error { + errList := []error{ErrApplicationFailureNotDurable} + if e.WriteErr != nil { + errList = append(errList, e.WriteErr) + } + if e.ReadErr != nil { + errList = append(errList, e.ReadErr) + } + return errList +} + +// IsOnlyApplicationFailurePersistenceErrors reports whether err is one or +// more application-local durability failures and contains no global failure. +// It deliberately examines joined top-level errors without descending into a +// persistence error's write/read causes. +func IsOnlyApplicationFailurePersistenceErrors(err error) bool { + if err == nil { + return false + } + if _, ok := err.(*ApplicationFailurePersistenceError); ok { + return true + } + + switch wrapped := err.(type) { + case interface{ Unwrap() []error }: + children := wrapped.Unwrap() + if len(children) == 0 { + return false + } + for _, child := range children { + if !IsOnlyApplicationFailurePersistenceErrors(child) { + return false + } + } + return true + case interface{ Unwrap() error }: + return IsOnlyApplicationFailurePersistenceErrors(wrapped.Unwrap()) + default: + return false + } +} diff --git a/internal/manager/final_state_entry_native_test.go b/internal/manager/final_state_entry_native_test.go new file mode 100644 index 000000000..e7fee14a9 --- /dev/null +++ b/internal/manager/final_state_entry_native_test.go @@ -0,0 +1,98 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package manager + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/emulator" + "github.com/cartesi/rollups-node/pkg/machine" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func managerNativeYieldGuest(t *testing.T, reason emulator.CmioYieldReason) machine.Machine { + t.Helper() + const ramStart = uint64(0x80000000) + config, err := json.Marshal(map[string]any{ + "processor": map[string]any{"registers": map[string]any{"pc": ramStart}}, + "ram": map[string]any{"length": 4096}, + "cmio": map[string]any{"rx_buffer": map[string]any{}, "tx_buffer": map[string]any{}}, + }) + require.NoError(t, err) + local, err := emulator.CreateMachine(string(config), "", "") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, local.Destroy()); local.Delete() }) + instructions := []uint32{0x400082b7, 0x0002b423, 0x0132b023} + program := make([]byte, 4*len(instructions)) + for index, instruction := range instructions { + binary.LittleEndian.PutUint32(program[4*index:], instruction) + } + require.NoError(t, local.WriteMemory(ramStart, program)) + length := uint64(0) + if reason == emulator.ManualYieldReasonAccepted { + length = machine.HashSize + } + yield := emulator.HtifDeviceYield< app.ProcessedInputs { + return 0, fmt.Errorf( + "%w: snapshot represents %d processed inputs, application has %d", + ErrInvalidSnapshotPoint, processedInputs, app.ProcessedInputs, + ) + } + return processedInputs, nil +} + +func closeMachineCandidate( + logger *slog.Logger, + candidate MachineInstance, + failureMessage string, +) { + if candidate == nil { + return + } + if err := candidate.Close(); err != nil { + logger.Warn(failureMessage, "error", err) + } +} + +func (m *MachineManager) tryLoadSnapshotInstance( + ctx context.Context, + app *model.Application, + logger *slog.Logger, +) MachineInstance { + snapshot, err := m.repository.GetLastSnapshot(ctx, app.IApplicationAddress.String()) + if err != nil { + // Shutdown cancels the query mid-flight; deadlines and repository + // failures still require operator attention. + if errors.Is(err, context.Canceled) { + logger.Debug("GetLastSnapshot canceled during shutdown", "error", err) + } else { + logger.Error("Failed to find latest snapshot", "error", err) + } + return nil + } + if snapshot == nil || snapshot.SnapshotURI == nil { + return nil + } + + snapshotLogger := logger.With( + "snapshot", *snapshot.SnapshotURI, + "input_index", snapshot.Index, + ) + if snapshot.MachineHash == nil { + snapshotLogger.Warn("Snapshot input has no persisted machine hash; falling back to template") + return nil + } + + expectedProcessedInputs, err := snapshotProcessedInputs(app, snapshot) + if err != nil { + snapshotLogger.Warn( + "Snapshot input is not a valid application starting point; falling back to template", + "app_processed_inputs", app.ProcessedInputs, + "error", err, + ) + return nil + } + + if _, err := os.Stat(*snapshot.SnapshotURI); err != nil { + if errors.Is(err, os.ErrNotExist) { + snapshotLogger.Warn("Snapshot path does not exist") + } else { + snapshotLogger.Error("Failed to access snapshot path", "error", err) + } + return nil + } + + snapshotLogger.Info("Creating machine instance from snapshot") + candidate, err := m.instanceFactory.NewFromSnapshot( + ctx, app, m.logger, + *snapshot.SnapshotURI, *snapshot.MachineHash, snapshot.Index, + ) + if err != nil { + snapshotLogger.Error("Failed to create machine instance from snapshot", "error", err) + closeMachineCandidate(snapshotLogger, candidate, "Failed to close partial snapshot machine") + return nil + } + if candidate == nil { + snapshotLogger.Error("Snapshot factory returned no machine instance") + return nil + } + if candidate.ProcessedInputs() != expectedProcessedInputs { + snapshotLogger.Error("Snapshot machine has an invalid processed-input count", + "expected_processed_inputs", expectedProcessedInputs, + "actual_processed_inputs", candidate.ProcessedInputs(), + ) + closeMachineCandidate(snapshotLogger, candidate, "Failed to close invalid snapshot machine") + return nil + } + + // The default snapshot factory verifies this hash while loading. Verify it + // again at the manager boundary because tests and alternative factories are + // explicit injection seams and are not required to duplicate that policy. + actualHash, err := candidate.Hash(ctx) + if err != nil { + snapshotLogger.Error("Failed to verify snapshot machine hash", "error", err) + closeMachineCandidate(snapshotLogger, candidate, "Failed to close unverifiable snapshot machine") + return nil + } + if common.Hash(actualHash) != *snapshot.MachineHash { + snapshotLogger.Error("Snapshot machine hash mismatch", + "expected_hash", snapshot.MachineHash.Hex(), + "actual_hash", common.Hash(actualHash).Hex(), + ) + closeMachineCandidate(snapshotLogger, candidate, "Failed to close mismatched snapshot machine") + return nil + } + return candidate +} + +func (m *MachineManager) tryLoadTemplateInstance( + ctx context.Context, + app *model.Application, + logger *slog.Logger, +) (MachineInstance, error) { + candidate, err := m.instanceFactory.NewFromTemplate(ctx, app, m.logger, m.checkTemplateHash) + if err != nil { + closeMachineCandidate(logger, candidate, "Failed to close partial template machine") + if ctx.Err() == context.Canceled && errutil.IsOnlyCancellation(err) { + logger.Debug("NewFromTemplate canceled during shutdown", "error", err) + return nil, nil + } + logger.Error("Failed to create machine instance", "error", err) + return nil, err + } + if candidate == nil { + logger.Error("Template factory returned no machine instance") + return nil, nil + } + return candidate, nil +} + +func replayContradictionReason(err error) string { + reason := replay.ErrContradiction.Error() + var detail *replay.ContradictionError + if errors.As(err, &detail) { + // ContradictionError represents byte values only as lengths and + // digests, so its text is safe to persist for operator diagnostics. + reason = detail.Error() + } + return reason +} + +func classifyReplayFailure(err error) (reason, logMessage string, shouldFence bool) { + switch { + case errors.Is(err, replay.ErrContradiction): + // A contradiction proves that this local reconstruction is incompatible + // with the stored result, but not that the database itself is corrupt. A + // repaired template or runtime may recover it, so fence it as FAILED. An + // unresolved contradiction is detected and fenced again on re-enable. + return replayContradictionReason(err), + "Machine replay contradicts stored input result; marking application failed", true + case machine.IsExecutionLimitError(err): + return fmt.Sprintf("machine replay reached execution limit: %v", err), + "Machine replay reached an execution limit; marking application failed", true + case errors.Is(err, machine.ErrDeadlineExceeded): + return fmt.Sprintf("machine replay exceeded execution deadline: %v", err), + "Machine replay exceeded its execution deadline; marking application failed", true + case errors.Is(err, ErrIncompleteAdvance): + return fmt.Sprintf("machine replay returned an incomplete advance result: %v", err), + "Machine replay returned an incomplete advance result; marking application failed", true + case errors.Is(err, machine.ErrMachineInternal): + return fmt.Sprintf("machine replay failed internally: %v", err), + "Machine replay failed internally; marking application failed", true + default: + return "", "Failed to replay machine", false + } +} + +// NewMachineManager creates a new machine manager. +func NewMachineManager( + repo MachineRepository, + logger *slog.Logger, + checkTemplateHash bool, + inputBatchSize uint64, + opts ...Option, +) *MachineManager { + m := &MachineManager{ + machines: map[int64]MachineInstance{}, + pendingApplicationFailures: map[int64]*pendingApplicationFailure{}, + repository: repo, + checkTemplateHash: checkTemplateHash, + inputBatchSize: inputBatchSize, + logger: logger, + instanceFactory: &DefaultMachineInstanceFactory{}, + replayRun: replay.Run, + } + for _, opt := range opts { + opt(m) + } + return m +} + +// UpdateMachines refreshes the list of machines based on applications that +// still need local machine work. +func (m *MachineManager) UpdateMachines(ctx context.Context) error { + // Retry failed status writes before doing any more machine work. Every app + // returned here remains fenced for this entire update, including when the + // retry succeeds but a repository read observes a stale executable row. + fenced, persistenceErr := m.persistPendingApplicationFailures(ctx) + if ctxErr := ctx.Err(); ctxErr != nil { + return errors.Join(persistenceErr, ctxErr) + } + if persistenceErr != nil && !IsOnlyApplicationFailurePersistenceErrors(persistenceErr) { + return persistenceErr + } + + apps, err := getMachineApplications(ctx, m.repository) + if err != nil { + return errors.Join(persistenceErr, err) + } + + // Create machines for new applications + for _, app := range apps { + if m.HasMachine(app.ID) { + continue + } + if _, pending := fenced[app.ID]; pending { + continue + } + + appLogger := m.logger.With( + "application", app.Name, + "address", app.IApplicationAddress, + ) + appLogger.Info("Creating new machine instance") + + // Prefer a verified snapshot and fall back to the template whenever the + // snapshot is absent, stale, inaccessible, or invalid. + instance := m.tryLoadSnapshotInstance(ctx, app, appLogger) + if instance == nil { + instance, err = m.tryLoadTemplateInstance(ctx, app, appLogger) + if err != nil { + reason := fmt.Sprintf("failed to create machine instance: %v", err) + m.FenceApplicationFailure(app, reason) + m.ReportProtectionStop(ctx, app, err) + fenced[app.ID] = struct{}{} + persistenceErr = errors.Join(persistenceErr, m.persistApplicationFailure(ctx, app.ID)) + } + } + if instance == nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return errors.Join(persistenceErr, ctxErr) + } + continue + } + + // Canonical verification is the normal reconstruction policy: replay all + // completed inputs after the candidate's starting point and compare each + // status, exception payload, machine root, and cumulative outputs root. + _, err = m.replayRun(ctx, m.repository, instance, replay.Options{ + Application: app, + FromInput: instance.ProcessedInputs(), + ToInputExclusive: app.ProcessedInputs, + BatchSize: m.inputBatchSize, + Verification: repository.ReplayVerificationCanonical, + }) + if err != nil { + var appPersistenceErr error + reason, logMessage, shouldFence := classifyReplayFailure(err) + appLogger.Error(logMessage, "error", err) + if shouldFence { + m.FenceApplicationFailure(app, reason) + m.ReportProtectionStop(ctx, app, errors.New(reason)) + fenced[app.ID] = struct{}{} + appPersistenceErr = m.persistApplicationFailure(ctx, app.ID) + } + if err := instance.Close(); err != nil { + appLogger.Warn("Failed to close machine after replay failure", "error", err) + } + if appPersistenceErr != nil { + persistenceErr = errors.Join(persistenceErr, appPersistenceErr) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return errors.Join(persistenceErr, ctxErr) + } + continue + } + + // Add the machine to the manager; close if it fails + if !m.addMachine(app.ID, instance) { + if err := instance.Close(); err != nil { + appLogger.Warn("Failed to close duplicate machine instance", "error", err) + } + } + } + + // Remove machines for non-enabled applications (disabled, failed, etc.) + m.removeMachines(excludeFencedApplications(apps, fenced)) + + return persistenceErr +} + +func excludeFencedApplications( + apps []*model.Application, + fenced map[int64]struct{}, +) []*model.Application { + if len(fenced) == 0 { + return apps + } + active := make([]*model.Application, 0, len(apps)) + for _, app := range apps { + if _, excluded := fenced[app.ID]; !excluded { + active = append(active, app) + } + } + return active +} + +// FenceApplicationFailure immediately fences app and queues the exact +// normalized FAILED reason until durability is confirmed. This method does not +// write the repository itself. +func (m *MachineManager) FenceApplicationFailure(app *model.Application, reason string) { + reason = appstatus.NormalizeReason(reason) + pending := &pendingApplicationFailure{ + application: app, + reason: reason, + } + m.mutex.Lock() + defer m.mutex.Unlock() + if _, exists := m.pendingApplicationFailures[app.ID]; !exists { + m.pendingApplicationFailures[app.ID] = pending + } +} + +// persistPendingApplicationFailures retries every pending FAILED write and +// returns the application IDs that must remain fenced for this update. +func (m *MachineManager) persistPendingApplicationFailures( + ctx context.Context, +) (map[int64]struct{}, error) { + m.mutex.RLock() + appIDs := make([]int64, 0, len(m.pendingApplicationFailures)) + for appID := range m.pendingApplicationFailures { + appIDs = append(appIDs, appID) + } + m.mutex.RUnlock() + sort.Slice(appIDs, func(i, j int) bool { return appIDs[i] < appIDs[j] }) + + fenced := make(map[int64]struct{}, len(appIDs)) + var persistenceErrors []error + for _, appID := range appIDs { + fenced[appID] = struct{}{} + if ctxErr := ctx.Err(); ctxErr != nil { + return fenced, errors.Join(errors.Join(persistenceErrors...), ctxErr) + } + if err := m.persistApplicationFailure(ctx, appID); err != nil { + persistenceErrors = append(persistenceErrors, err) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return fenced, errors.Join(errors.Join(persistenceErrors...), ctxErr) + } + } + return fenced, errors.Join(persistenceErrors...) +} + +func (m *MachineManager) persistApplicationFailure(ctx context.Context, appID int64) error { + m.mutex.RLock() + pending := m.pendingApplicationFailures[appID] + m.mutex.RUnlock() + if pending == nil { + return nil + } + + if writeErr := appstatus.SetFailed(ctx, m.logger, m.repository, pending.application, pending.reason); writeErr != nil { + if errors.Is(writeErr, repository.ErrNotFound) { + m.deletePendingApplicationFailure(appID, pending) + return nil + } + // Another process may have installed a stronger terminal status, or the + // write may have committed even though its result was lost. Retire the + // fence only when the durable row proves one of those outcomes. An + // unrelated FAILED reason remains fenced: it does not prove that this + // application failure was recorded. Unknown/read failures and OK + // (including disabled+OK) also remain pending. + current, readErr := m.repository.GetApplication( + ctx, pending.application.IApplicationAddress.String(), + ) + retire := errors.Is(readErr, repository.ErrNotFound) + if readErr == nil { + retire = current == nil || + current.ID != pending.application.ID || + applicationFailureAlreadyResolved(current, pending.reason) + } + if retire { + m.deletePendingApplicationFailure(appID, pending) + return nil + } + return &ApplicationFailurePersistenceError{ + ApplicationID: appID, + WriteErr: writeErr, + ReadErr: readErr, + } + } + + m.deletePendingApplicationFailure(appID, pending) + return nil +} + +func applicationFailureAlreadyResolved(app *model.Application, reason string) bool { + if app == nil { + return false + } + if app.Status.IsTerminal() { + return true + } + if app.Status == model.ApplicationStatus_Failed { + return app.Reason != nil && *app.Reason == reason + } + return false +} + +func (m *MachineManager) deletePendingApplicationFailure( + appID int64, + pending *pendingApplicationFailure, +) { + m.mutex.Lock() + // Remove only the pending record that this persistence attempt observed. + if m.pendingApplicationFailures[appID] == pending { + delete(m.pendingApplicationFailures, appID) + } + m.mutex.Unlock() +} + +// GetMachine retrieves a machine instance for an application +func (m *MachineManager) GetMachine(appID int64) (MachineInstance, bool) { + m.mutex.RLock() + defer m.mutex.RUnlock() + machine, exists := m.machines[appID] + return machine, exists +} + +// HasMachine checks if a machine exists for an application +func (m *MachineManager) HasMachine(appID int64) bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + _, exists := m.machines[appID] + return exists +} + +// HasPendingApplicationFailures reports whether any local machine work is +// fenced while its durable application status remains unconfirmed. +func (m *MachineManager) HasPendingApplicationFailures() bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + return len(m.pendingApplicationFailures) != 0 +} + +// addMachine adds a machine to the manager. +// Returns false if the manager is closed or the appID already exists. +func (m *MachineManager) addMachine(appID int64, machine MachineInstance) bool { + m.mutex.Lock() + defer m.mutex.Unlock() + + if m.closed { + return false + } + + if _, exists := m.machines[appID]; exists { + return false + } + + m.machines[appID] = machine + return true +} + +// RemoveMachines removes machines for applications not in the provided list +func (m *MachineManager) removeMachines(apps []*model.Application) { + m.mutex.Lock() + defer m.mutex.Unlock() + + // Create a map of active application IDs + activeApps := make(map[int64]struct{}) + for _, app := range apps { + activeApps[app.ID] = struct{}{} + } + + // Remove machines for applications not in the active list + for id, machine := range m.machines { + if _, present := activeApps[id]; !present { + if m.logger != nil { + m.logger.Info("Application is no longer executable, shutting down machine", + "application", machine.Application().Name) + } + if err := machine.Close(); err != nil && m.logger != nil { + m.logger.Warn("Failed to close machine for non-executable application", + "application", machine.Application().Name, "error", err) + } + delete(m.machines, id) + } + } +} + +// Applications returns the list of applications with active machines, +// sorted by ID for deterministic iteration order. +func (m *MachineManager) Applications() []*model.Application { + m.mutex.RLock() + defer m.mutex.RUnlock() + + apps := make([]*model.Application, 0, len(m.machines)) + for _, machine := range m.machines { + apps = append(apps, machine.Application()) + } + sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID }) + return apps +} + +// Close shuts down all machine instances in parallel. +// After Close returns, no new machines can be added. +func (m *MachineManager) Close() { + m.logger.Info("Closing machine manager") + + // Mark as closed and take ownership of the machines map under the lock, + // then release it so readers (GetMachine, HasMachine, Applications) + // aren't blocked during the potentially slow parallel shutdown. + m.mutex.Lock() + m.closed = true + machines := m.machines + m.machines = make(map[int64]MachineInstance) + m.mutex.Unlock() + + type closeResult struct { + id int64 + err error + } + + var wg sync.WaitGroup + results := make(chan closeResult, len(machines)) + + for id, machine := range machines { + wg.Go(func() { + results <- closeResult{id: id, err: machine.Close()} + }) + } + + wg.Wait() + close(results) + + for r := range results { + if r.err != nil { + m.logger.Error("failed to close machine for app", "app", r.id, "error", r.err) + } + } +} + +func getMachineApplications(ctx context.Context, repo MachineRepository) ([]*model.Application, error) { + apps, _, err := repo.ListApplications(ctx, repository.ExecutableApplicationsFilter(), repository.Pagination{}, false) + if err != nil { + return nil, err + } + + foreclosedApps, _, err := repo.ListApplications(ctx, foreclosedMachineDrainFilter(), repository.Pagination{}, false) + if err != nil { + return nil, err + } + for _, app := range foreclosedApps { + if app.ForecloseBlock == 0 { + continue + } + // Bootstrap-readiness guard, shared with the claimer and PRT. Until the + // historical L1 scan reaches foreclose_block the inputs/epochs table is + // incomplete, so HasUndrainedEpochsBeforeBlock would return false on an + // empty table and the machine would be torn down before the pre- + // foreclosure inputs it still needs to advance are even ingested. Keep + // the machine until the scan has caught up. + if !app.ForeclosureScanCaughtUp() { + apps = append(apps, app) + continue + } + undrained, err := repo.HasUndrainedEpochsBeforeBlock(ctx, app.ID, app.ForecloseBlock) + if err != nil { + return nil, err + } + if undrained { + apps = append(apps, app) + continue + } + if app.ConsensusType == model.Consensus_Authority || app.ConsensusType == model.Consensus_Quorum { + // Accepted inputs can precede reader closure or a failed IP write. + // Keep the runtime only through proof publication; IP needs no VM. + unpublished, err := repo.HasUnpublishedEpochBeforeBlock(ctx, app.ID, app.ForecloseBlock) + if err != nil { + return nil, err + } + if unpublished { + apps = append(apps, app) + } + } + } + return apps, nil +} + +func foreclosedMachineDrainFilter() repository.ApplicationFilter { + return repository.ApplicationFilter{ + Enabled: new(true), + Status: new(model.ApplicationStatus_OK), + ForeclosureRecorded: new(true), + } +} + +// ReportProtectionStop reports open PRT obligations after a local failure fence. +// Callers must release the manager lock before reading repository metadata. +func (m *MachineManager) ReportProtectionStop(ctx context.Context, app *model.Application, cause error) { + prtprotection.ReportStop(ctx, m.logger, m.repository, app, 0, cause) +} diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go new file mode 100644 index 000000000..cf6332ac6 --- /dev/null +++ b/internal/manager/manager_test.go @@ -0,0 +1,2005 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package manager + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "math" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/replay" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/machine" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +func TestMachineManager(t *testing.T) { + suite.Run(t, new(MachineManagerSuite)) +} + +type MachineManagerSuite struct { + suite.Suite +} + +const ( + replayMachineHashField = "machine_hash" + replayTxBufferDataBlockField = "tx_buffer_data_block" + replayExecutionLimitReason = "execution limit" + testApplicationName = "App1" +) + +func newForkableMock() *MockRollupsMachine { + runtime := &MockRollupsMachine{ + CompletionStatusReturn: machine.CompletionStatusAccepted, + HashReturn: newHash(1), + StateProofReturn: acceptedStateProof(newHash(1), newHash(2)), + } + runtime.ForkFunc = func(context.Context) (machine.Machine, error) { + return newForkableMock(), nil + } + return runtime +} + +func newTestMachineManager( + repo MachineRepository, + logger *slog.Logger, + checkTemplateHash bool, + inputBatchSize uint64, + opts ...Option, +) *MachineManager { + testRun := withReplayRun(func( + _ context.Context, + _ repository.ReplayRepository, + executor replay.Executor, + _ replay.Options, + ) (replay.Result, error) { + if instance, ok := executor.(*DummyMachineInstanceMock); ok { + instance.replayCalls++ + return replay.Result{}, instance.replayErr + } + return replay.Result{}, nil + }) + return NewMachineManager( + repo, logger, checkTemplateHash, inputBatchSize, append(opts, testRun)..., + ) +} + +func withReplayRun( + run func(context.Context, repository.ReplayRepository, replay.Executor, replay.Options) (replay.Result, error), +) Option { + return func(m *MachineManager) { m.replayRun = run } +} + +type nilSingleUnwrapperError struct{} + +func (nilSingleUnwrapperError) Error() string { return "nil single unwrapper" } +func (nilSingleUnwrapperError) Unwrap() error { return nil } + +type emptyMultiUnwrapperError struct{} + +func (emptyMultiUnwrapperError) Error() string { return "empty multi unwrapper" } +func (emptyMultiUnwrapperError) Unwrap() []error { return []error{} } + +func (s *MachineManagerSuite) TestNewMachineManager() { + require := s.Require() + repo := &MockMachineRepository{} + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + manager := newTestMachineManager(repo, testLogger, false, 500) + require.NotNil(manager) + require.Empty(manager.machines) + require.Equal(repo, manager.repository) +} + +func (s *MachineManagerSuite) TestUpdateMachinesUsesCanonicalReplayPolicy() { + require := s.Require() + app := &model.Application{ + ID: 47, + Name: "ReplayPolicy", + IApplicationAddress: common.HexToAddress("0x47"), + Status: model.ApplicationStatus_OK, + ProcessedInputs: 7, + } + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()).Return(nil, nil).Once() + + instance := &DummyMachineInstanceMock{application: app, processedInputs: 3} + factory := &MockMachineInstanceFactory{Instance: instance} + var capturedSource repository.ReplayRepository + var capturedExecutor replay.Executor + var capturedOptions replay.Options + run := func( + _ context.Context, + source repository.ReplayRepository, + executor replay.Executor, + options replay.Options, + ) (replay.Result, error) { + capturedSource = source + capturedExecutor = executor + capturedOptions = options + return replay.Result{ReplayedInputs: options.ToInputExclusive - options.FromInput}, nil + } + manager := NewMachineManager( + repo, + slog.New(slog.NewTextHandler(io.Discard, nil)), + false, + 23, + WithInstanceFactory(factory), + withReplayRun(run), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Same(repo, capturedSource) + require.Same(instance, capturedExecutor) + require.Same(app, capturedOptions.Application) + require.Equal(uint64(3), capturedOptions.FromInput) + require.Equal(uint64(7), capturedOptions.ToInputExclusive) + require.Equal(uint64(23), capturedOptions.BatchSize) + require.Equal(repository.ReplayVerificationCanonical, capturedOptions.Verification) + require.True(manager.HasMachine(app.ID)) + repo.AssertExpectations(s.T()) + manager.Close() +} + +func (s *MachineManagerSuite) TestIsOnlyApplicationFailurePersistenceErrors() { + localFailure := func(applicationID int64) error { + return &ApplicationFailurePersistenceError{ + ApplicationID: applicationID, + WriteErr: errors.New("repository unavailable"), + } + } + wrap := func(message string, err error) error { + return fmt.Errorf("%s: %w", message, err) + } + + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil", + err: nil, + want: false, + }, + { + name: "plain non-wrapping error", + err: errors.New("global repository failure"), + want: false, + }, + { + name: "single unwrapper returning nil", + err: nilSingleUnwrapperError{}, + want: false, + }, + { + name: "multi unwrapper returning empty slice", + err: emptyMultiUnwrapperError{}, + want: false, + }, + { + name: "single wrapper", + err: wrap("update machines", localFailure(1)), + want: true, + }, + { + name: "multiple wrappers", + err: wrap("advancer step", wrap("update machines", localFailure(2))), + want: true, + }, + { + name: "nested joins of wrapped local failures", + err: errors.Join( + wrap("first application", localFailure(3)), + errors.Join( + wrap("second application", wrap("status persistence", localFailure(4))), + wrap("third application", localFailure(5)), + ), + ), + want: true, + }, + { + name: "mixed join with global failure", + err: errors.Join( + wrap("application", localFailure(6)), + errors.Join( + wrap("another application", localFailure(7)), + errors.New("global repository failure"), + ), + ), + want: false, + }, + } + + for _, test := range tests { + s.Run(test.name, func() { + s.Equal(test.want, IsOnlyApplicationFailurePersistenceErrors(test.err)) + }) + } +} + +func (s *MachineManagerSuite) TestUpdateMachines() { + s.Run("ReplayContradictionClosesAndDoesNotRegisterMachine", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 71, + Name: "ReplayMismatch", + IApplicationAddress: common.HexToAddress("0x71"), + Enabled: true, + Status: model.ApplicationStatus_OK, + } + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()).Return(nil, nil).Once() + const expectedReason = "replay contradicts persisted canonical result: " + + "app=\"ReplayMismatch\" epoch= input=0 " + + "field=machine_hash expected= actual=" + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && *reason == expectedReason + })).Return(nil).Once() + + instance := &DummyMachineInstanceMock{ + application: app, + replayErr: &replay.ContradictionError{ + Application: app.Name, + Field: replayMachineHashField, + }, + } + factory := &MockMachineInstanceFactory{Instance: instance} + manager := newTestMachineManager( + repo, + slog.New(slog.NewTextHandler(io.Discard, nil)), + false, + 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Equal(1, instance.closeCalls) + require.Equal(1, factory.TemplateCalls) + require.False(manager.HasMachine(app.ID)) + require.Empty(manager.Applications()) + require.Equal(model.ApplicationStatus_Failed, app.Status) + require.NotNil(app.Reason) + require.Empty(manager.pendingApplicationFailures) + require.False(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + + // A fresh manager sees no executable row once FAILED is durable, so a + // process restart cannot replay the contradictory history again. + restartRepo := &MockMachineRepository{} + restartRepo.On("ListApplications", mock.Anything, mock.Anything, repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), nil).Twice() + restartFactory := &MockMachineInstanceFactory{} + restarted := newTestMachineManager( + restartRepo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(restartFactory), + ) + require.NoError(restarted.UpdateMachines(context.Background())) + require.Zero(restartFactory.TemplateCalls) + restartRepo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureStatusWriteRetriesWithoutReplay", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 75, Name: "RetryFailedStatus", IApplicationAddress: common.HexToAddress("0x75"), + Enabled: true, Status: model.ApplicationStatus_OK, ConsensusType: model.Consensus_PRT, + } + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{app}, uint64(1), nil).Twice() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Twice() + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()).Return(nil, nil).Once() + root := common.HexToAddress("0x100") + epoch := &model.Epoch{ApplicationID: app.ID, Index: 2, LastBlock: 100, TournamentAddress: &root, Status: model.EpochStatus_Closed} + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: model.NonTerminalEpochStatuses(), + }, repository.Pagination{}, false).Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), root.Hex()).Return(&model.Tournament{ + ApplicationID: app.ID, EpochIndex: epoch.Index, Address: root, StartInstant: 100, Allowance: 50, + }, nil).Once() + var output bytes.Buffer + writeErr := errors.New("temporary status write failure") + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(writeErr).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.String()).Return(app, nil).Once() + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(nil).Once() + + instance := &DummyMachineInstanceMock{ + application: app, + replayErr: &replay.ContradictionError{ + Application: app.Name, InputIndex: 9, Field: replayTxBufferDataBlockField, + Expected: "0x01", Actual: "0x02", + }, + } + factory := &MockMachineInstanceFactory{Instance: instance} + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(&output, nil)), false, 10, + WithInstanceFactory(factory), + ) + + err := manager.UpdateMachines(context.Background()) + require.ErrorIs(err, ErrApplicationFailureNotDurable) + var persistenceErr *ApplicationFailurePersistenceError + require.ErrorAs(err, &persistenceErr) + require.ErrorIs(err, writeErr) + require.Equal(app.ID, persistenceErr.ApplicationID) + require.Equal(model.ApplicationStatus_OK, app.Status) + require.Len(manager.pendingApplicationFailures, 1) + require.True(manager.HasPendingApplicationFailures()) + require.Equal(1, factory.TemplateCalls) + require.Equal(1, instance.closeCalls) + require.False(manager.HasMachine(app.ID)) + + // The next update retries only the FAILED write. The app remains fenced + // even though this mock returns its stale executable row. + require.NoError(manager.UpdateMachines(context.Background())) + require.Equal(model.ApplicationStatus_Failed, app.Status) + require.Empty(manager.pendingApplicationFailures) + require.False(manager.HasPendingApplicationFailures()) + require.Equal(1, factory.TemplateCalls) + require.Equal(1, instance.closeCalls) + require.False(manager.HasMachine(app.ID)) + require.Equal(1, strings.Count(output.String(), "PRT protection stopped")) + require.Contains(output.String(), "join_close_block=150") + require.Contains(output.String(), "epoch_index=2") + repo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureFenceClearsWhenTerminalStatusWins", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 76, Name: "TerminalStatusWins", IApplicationAddress: common.HexToAddress("0x76"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + terminalApp := *app + terminalApp.Status = model.ApplicationStatus_Corrupted + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()).Return(nil, nil).Once() + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(errors.New("terminal status transition rejected")).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.String()). + Return(&terminalApp, nil).Once() + + instance := &DummyMachineInstanceMock{ + application: app, + replayErr: &replay.ContradictionError{ + Application: app.Name, + Field: replayMachineHashField, + }, + } + factory := &MockMachineInstanceFactory{Instance: instance} + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Empty(manager.pendingApplicationFailures) + require.False(manager.HasPendingApplicationFailures()) + require.Equal(1, factory.TemplateCalls) + require.Equal(1, instance.closeCalls) + require.False(manager.HasMachine(app.ID)) + repo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureFenceRetainsUnrelatedFailedStatus", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 77, Name: "UnrelatedFailure", IApplicationAddress: common.HexToAddress("0x77"), + Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + ) + manager.FenceApplicationFailure(app, replayContradictionReason(&replay.ContradictionError{ + Application: app.Name, Field: replayMachineHashField, + })) + + unrelatedReason := "machine process crashed" + durableApp := *app + durableApp.Status = model.ApplicationStatus_Failed + durableApp.Reason = &unrelatedReason + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(errors.New("status transition rejected")).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.String()). + Return(&durableApp, nil).Once() + + err := manager.persistApplicationFailure(context.Background(), app.ID) + + require.ErrorIs(err, ErrApplicationFailureNotDurable) + require.Len(manager.pendingApplicationFailures, 1) + require.True(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + + s.Run("AmbiguousWriteClearsFenceWhenExactFailedStatusIsDurable", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 78, Name: "MatchingFailure", IApplicationAddress: common.HexToAddress("0x78"), + Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + ) + manager.FenceApplicationFailure(app, replayContradictionReason(&replay.ContradictionError{ + Application: app.Name, Field: replayMachineHashField, + })) + pending := manager.pendingApplicationFailures[app.ID] + + durableApp := *app + durableApp.Status = model.ApplicationStatus_Failed + durableApp.Reason = &pending.reason + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(errors.New("write result lost")).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.String()). + Return(&durableApp, nil).Once() + + require.NoError(manager.persistApplicationFailure(context.Background(), app.ID)) + + require.Empty(manager.pendingApplicationFailures) + require.False(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + + s.Run("AmbiguousWriteClearsFenceForNormalizedLongReason", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 81, Name: "LongReason", IApplicationAddress: common.HexToAddress("0x81"), + Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + ) + detail := &replay.ContradictionError{ + Application: app.Name, + Field: replayMachineHashField, + Expected: strings.Repeat("e", 5000), + Actual: "different", + } + expectedReason := appstatus.NormalizeReason(detail.Error()) + manager.FenceApplicationFailure(app, replayContradictionReason(detail)) + pending := manager.pendingApplicationFailures[app.ID] + require.Equal(expectedReason, pending.reason) + + durableApp := *app + durableApp.Status = model.ApplicationStatus_Failed + durableApp.Reason = &expectedReason + repo.On( + "UpdateApplicationStatus", + mock.Anything, + app.ID, + model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && *reason == expectedReason + }), + ).Return(errors.New("write result lost")).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.String()). + Return(&durableApp, nil).Once() + + require.NoError(manager.persistApplicationFailure(context.Background(), app.ID)) + require.False(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureFenceClearsWhenStatusWriteFindsDeletedApplication", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 79, Name: "DeletedApplication", IApplicationAddress: common.HexToAddress("0x79"), + Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + ) + manager.FenceApplicationFailure(app, replayContradictionReason(&replay.ContradictionError{ + Application: app.Name, Field: replayMachineHashField, + })) + + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(repository.ErrNotFound).Once() + + require.NoError(manager.persistApplicationFailure(context.Background(), app.ID)) + + require.Empty(manager.pendingApplicationFailures) + require.False(manager.HasPendingApplicationFailures()) + repo.AssertNotCalled(s.T(), "GetApplication", mock.Anything, mock.Anything) + repo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureFenceClearsWhenReadFindsDeletedApplication", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 82, Name: "DeletedApplicationReadback", IApplicationAddress: common.HexToAddress("0x82"), + Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + ) + manager.FenceApplicationFailure(app, "machine execution failed") + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(errors.New("write result unavailable")).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.String()). + Return(nil, nil).Once() + + require.NoError(manager.persistApplicationFailure(context.Background(), app.ID)) + require.False(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureFenceClearsWhenAddressBelongsToReplacement", func() { + require := s.Require() + repo := &MockMachineRepository{} + oldApp := &model.Application{ + ID: 82, Name: "OldApplication", IApplicationAddress: common.HexToAddress("0x82"), + Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + ) + manager.FenceApplicationFailure(oldApp, replayContradictionReason(&replay.ContradictionError{ + Application: oldApp.Name, Field: replayMachineHashField, + })) + replacement := *oldApp + replacement.ID = 83 + replacement.Name = "ReplacementApplication" + + repo.On( + "UpdateApplicationStatus", + mock.Anything, + oldApp.ID, + model.ApplicationStatus_Failed, + mock.Anything, + ).Return(errors.New("write result unavailable")).Once() + repo.On("GetApplication", mock.Anything, oldApp.IApplicationAddress.String()). + Return(&replacement, nil).Once() + + require.NoError(manager.persistApplicationFailure(context.Background(), oldApp.ID)) + require.False(manager.HasPendingApplicationFailures()) + require.Equal(model.ApplicationStatus_OK, replacement.Status) + require.Nil(replacement.Reason) + repo.AssertNotCalled( + s.T(), + "UpdateApplicationStatus", + mock.Anything, + replacement.ID, + mock.Anything, + mock.Anything, + ) + repo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureFencePropagatesWriteAndReadFailure", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 80, Name: "UnconfirmedFailure", IApplicationAddress: common.HexToAddress("0x80"), + Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + ) + manager.FenceApplicationFailure(app, replayContradictionReason(&replay.ContradictionError{ + Application: app.Name, Field: replayMachineHashField, + })) + writeErr := errors.New("status write unavailable") + readErr := errors.New("status read unavailable") + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(writeErr).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.String()). + Return(nil, readErr).Once() + + err := manager.persistApplicationFailure(context.Background(), app.ID) + + require.ErrorIs(err, ErrApplicationFailureNotDurable) + require.ErrorIs(err, writeErr) + require.ErrorIs(err, readErr) + require.True(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + + s.Run("ApplicationFailureLocalDeadlineDoesNotBlockHealthySibling", func() { + require := s.Require() + repo := &MockMachineRepository{} + failed := &model.Application{ + ID: 91, Name: "LocalDeadline", IApplicationAddress: common.HexToAddress("0x91"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + healthy := &model.Application{ + ID: 92, Name: "HealthyAfterDeadline", IApplicationAddress: common.HexToAddress("0x92"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(&MockMachineInstanceFactory{TemplateFn: func(app *model.Application) (MachineInstance, error) { + return &DummyMachineInstanceMock{application: app}, nil + }}), + ) + manager.FenceApplicationFailure(failed, "machine execution failed") + localDeadline := fmt.Errorf("repository statement timeout: %w", context.DeadlineExceeded) + repo.On("UpdateApplicationStatus", mock.Anything, failed.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(localDeadline).Once() + repo.On("GetApplication", mock.Anything, failed.IApplicationAddress.String()). + Return(failed, nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{failed, healthy}, uint64(2), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, healthy.IApplicationAddress.String()).Return(nil, nil).Once() + + err := manager.UpdateMachines(context.Background()) + + require.ErrorIs(err, context.DeadlineExceeded) + require.True(IsOnlyApplicationFailurePersistenceErrors(err)) + require.True(manager.HasPendingApplicationFailures()) + require.False(manager.HasMachine(failed.ID)) + require.True(manager.HasMachine(healthy.ID), "a repository-local timeout must not block the sibling") + repo.AssertExpectations(s.T()) + }) + + s.Run("PendingApplicationFailureCancellationPreservesErrorChainAndStops", func() { + require := s.Require() + repo := &MockMachineRepository{} + manager := newTestMachineManager(repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10) + apps := []*model.Application{ + {ID: 101, Name: "First", IApplicationAddress: common.HexToAddress("0x101")}, + {ID: 102, Name: "Canceled", IApplicationAddress: common.HexToAddress("0x102")}, + {ID: 103, Name: "MustNotRun", IApplicationAddress: common.HexToAddress("0x103")}, + } + for _, app := range apps { + manager.FenceApplicationFailure(app, "machine execution failed") + } + firstWriteErr := errors.New("first write unavailable") + firstReadErr := errors.New("first read unavailable") + repo.On("UpdateApplicationStatus", mock.Anything, apps[0].ID, model.ApplicationStatus_Failed, mock.Anything). + Return(firstWriteErr).Once() + repo.On("GetApplication", mock.Anything, apps[0].IApplicationAddress.String()). + Return(nil, firstReadErr).Once() + ctx, cancel := context.WithCancel(context.Background()) + repo.On("UpdateApplicationStatus", mock.Anything, apps[1].ID, model.ApplicationStatus_Failed, mock.Anything). + Run(func(mock.Arguments) { cancel() }).Return(context.Canceled).Once() + repo.On("GetApplication", mock.Anything, apps[1].IApplicationAddress.String()). + Return(nil, context.Canceled).Once() + + _, err := manager.persistPendingApplicationFailures(ctx) + + require.ErrorIs(err, firstWriteErr) + require.ErrorIs(err, firstReadErr) + require.ErrorIs(err, context.Canceled) + var persistenceIDs []int64 + var collect func(error) + collect = func(current error) { + if current == nil { + return + } + if typed, ok := current.(*ApplicationFailurePersistenceError); ok { + persistenceIDs = append(persistenceIDs, typed.ApplicationID) + return + } + if joined, ok := current.(interface{ Unwrap() []error }); ok { + for _, child := range joined.Unwrap() { + collect(child) + } + } + } + collect(err) + require.ElementsMatch([]int64{apps[0].ID, apps[1].ID}, persistenceIDs) + repo.AssertNotCalled(s.T(), "UpdateApplicationStatus", mock.Anything, apps[2].ID, mock.Anything, mock.Anything) + require.Len(manager.pendingApplicationFailures, 3) + repo.AssertExpectations(s.T()) + }) + + s.Run("QueuedLiveFailureRetryClearsFenceAndRemovesOnlyAffectedMachine", func() { + require := s.Require() + repo := &MockMachineRepository{} + failed := &model.Application{ + ID: 111, Name: "LiveFailure", IApplicationAddress: common.HexToAddress("0x111"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + healthy := &model.Application{ + ID: 112, Name: "Healthy", IApplicationAddress: common.HexToAddress("0x112"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + manager := newTestMachineManager(repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10) + failedMachine := &DummyMachineInstanceMock{application: failed} + healthyMachine := &DummyMachineInstanceMock{application: healthy} + require.True(manager.addMachine(failed.ID, failedMachine)) + require.True(manager.addMachine(healthy.ID, healthyMachine)) + const reason = "advance execution reached configured cycle limit" + manager.FenceApplicationFailure(failed, reason) + repo.On("UpdateApplicationStatus", mock.Anything, failed.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(got *string) bool { return got != nil && *got == reason })).Return(nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{failed, healthy}, uint64(2), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + + require.NoError(manager.UpdateMachines(context.Background())) + + require.False(manager.HasPendingApplicationFailures()) + require.False(manager.HasMachine(failed.ID)) + require.True(manager.HasMachine(healthy.ID)) + require.Equal(1, failedMachine.closeCalls) + require.Zero(healthyMachine.closeCalls) + repo.AssertExpectations(s.T()) + }) + + s.Run("TransientReplayErrorRetries", func() { + require := s.Require() + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 72, Name: "TransientReplay", IApplicationAddress: common.HexToAddress("0x72"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{app}, uint64(1), nil).Twice() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Twice() + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()).Return(nil, nil).Twice() + + var instances []*DummyMachineInstanceMock + factory := &MockMachineInstanceFactory{TemplateFn: func(got *model.Application) (MachineInstance, error) { + instance := &DummyMachineInstanceMock{application: got} + if len(instances) == 0 { + instance.replayErr = errors.New("temporary repository outage") + } + instances = append(instances, instance) + return instance, nil + }} + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Len(instances, 1) + require.Equal(1, instances[0].closeCalls) + require.Empty(manager.pendingApplicationFailures) + require.False(manager.HasMachine(app.ID)) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Len(instances, 2) + require.Equal(2, factory.TemplateCalls) + require.True(manager.HasMachine(app.ID)) + require.Empty(manager.pendingApplicationFailures) + repo.AssertExpectations(s.T()) + }) + + s.Run("ReplayContradictionDoesNotBlockHealthySibling", func() { + require := s.Require() + repo := &MockMachineRepository{} + badApp := &model.Application{ + ID: 73, Name: "BadReplay", IApplicationAddress: common.HexToAddress("0x73"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + goodApp := &model.Application{ + ID: 74, Name: "GoodReplay", IApplicationAddress: common.HexToAddress("0x74"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false). + Return([]*model.Application{badApp, goodApp}, uint64(2), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, mock.Anything).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", mock.Anything, badApp.ID, model.ApplicationStatus_Failed, + mock.Anything).Return(nil).Once() + + instances := map[int64]*DummyMachineInstanceMock{} + factory := &MockMachineInstanceFactory{TemplateFn: func(app *model.Application) (MachineInstance, error) { + instance := &DummyMachineInstanceMock{application: app} + if app.ID == badApp.ID { + instance.replayErr = &replay.ContradictionError{ + Application: app.Name, InputIndex: 4, Field: replayTxBufferDataBlockField, + Expected: "0x01", Actual: "0x02", + } + } + instances[app.ID] = instance + return instance, nil + }} + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.False(manager.HasMachine(badApp.ID)) + require.True(manager.HasMachine(goodApp.ID)) + require.Equal(1, instances[badApp.ID].closeCalls) + require.Zero(instances[goodApp.ID].closeCalls) + require.Equal(model.ApplicationStatus_Failed, badApp.Status) + require.Equal(model.ApplicationStatus_OK, goodApp.Status) + require.Empty(manager.pendingApplicationFailures) + require.False(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + + s.Run("UnconfirmedApplicationFailureDoesNotBlockHealthySibling", func() { + require := s.Require() + repo := &MockMachineRepository{} + badApp := &model.Application{ + ID: 83, Name: "BadUndurableReplay", IApplicationAddress: common.HexToAddress("0x83"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + goodApp := &model.Application{ + ID: 84, Name: "HealthyReplay", IApplicationAddress: common.HexToAddress("0x84"), + Enabled: true, Status: model.ApplicationStatus_OK, + } + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false). + Return([]*model.Application{badApp, goodApp}, uint64(2), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, mock.Anything).Return(nil, nil).Twice() + writeErr := errors.New("status write unavailable") + readErr := errors.New("status read unavailable") + repo.On("UpdateApplicationStatus", mock.Anything, badApp.ID, model.ApplicationStatus_Failed, mock.Anything). + Return(writeErr).Once() + repo.On("GetApplication", mock.Anything, badApp.IApplicationAddress.String()). + Return(nil, readErr).Once() + + instances := map[int64]*DummyMachineInstanceMock{} + factory := &MockMachineInstanceFactory{TemplateFn: func(app *model.Application) (MachineInstance, error) { + instance := &DummyMachineInstanceMock{application: app} + if app.ID == badApp.ID { + instance.replayErr = &replay.ContradictionError{ + Application: app.Name, InputIndex: 4, Field: replayTxBufferDataBlockField, + } + } + instances[app.ID] = instance + return instance, nil + }} + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + err := manager.UpdateMachines(context.Background()) + require.ErrorIs(err, ErrApplicationFailureNotDurable) + require.ErrorIs(err, writeErr) + require.ErrorIs(err, readErr) + require.False(manager.HasMachine(badApp.ID)) + require.True(manager.HasMachine(goodApp.ID)) + require.Equal(1, instances[badApp.ID].closeCalls) + require.Zero(instances[goodApp.ID].closeCalls) + require.True(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + + replayFailures := []struct { + name string + err error + reasonContains string + }{ + {"PayloadLengthLimit", machine.ErrPayloadLengthLimitExceeded, replayExecutionLimitReason}, + {"OutputsLimit", machine.ErrOutputsLimitExceeded, replayExecutionLimitReason}, + {"ReportsLimit", machine.ErrReportsLimitExceeded, replayExecutionLimitReason}, + {"McycleLimit", machine.ErrReachedLimitMcycle, replayExecutionLimitReason}, + {"Deadline", machine.ErrDeadlineExceeded, "execution deadline"}, + {"IncompleteAdvance", ErrIncompleteAdvance, "incomplete advance result"}, + {"MachineInternal", machine.ErrMachineInternal, "failed internally"}, + } + for index, failure := range replayFailures { + s.Run("ReplayFailure/"+failure.name, func() { + require := s.Require() + repo := &MockMachineRepository{} + failedID := int64(85 + index*2) + failed := &model.Application{ + ID: failedID, Name: "FailedReplay", + IApplicationAddress: common.BigToAddress(big.NewInt(failedID)), + Enabled: true, Status: model.ApplicationStatus_OK, + } + healthy := &model.Application{ + ID: failedID + 1, Name: "HealthyReplay", + IApplicationAddress: common.BigToAddress(big.NewInt(failedID + 1)), + Enabled: true, Status: model.ApplicationStatus_OK, + } + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false). + Return([]*model.Application{failed, healthy}, uint64(2), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, mock.Anything).Return(nil, nil).Twice() + repo.On("UpdateApplicationStatus", mock.Anything, failed.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, failure.reasonContains) + })).Return(nil).Once() + + instances := map[int64]*DummyMachineInstanceMock{} + factory := &MockMachineInstanceFactory{TemplateFn: func(app *model.Application) (MachineInstance, error) { + instance := &DummyMachineInstanceMock{application: app} + if app.ID == failed.ID { + instance.replayErr = fmt.Errorf("replay input: %w", failure.err) + } + instances[app.ID] = instance + return instance, nil + }} + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.False(manager.HasMachine(failed.ID)) + require.True(manager.HasMachine(healthy.ID)) + require.Equal(model.ApplicationStatus_Failed, failed.Status) + require.Equal(model.ApplicationStatus_OK, healthy.Status) + require.Equal(1, instances[failed.ID].closeCalls) + require.Zero(instances[healthy.ID].closeCalls) + require.False(manager.HasPendingApplicationFailures()) + repo.AssertExpectations(s.T()) + }) + } + + s.Run("AddNewMachines", func() { + require := s.Require() + + // Setup repository with enabled applications + repo := &MockMachineRepository{} + app1 := &model.Application{ + ID: 1, + Name: testApplicationName, + IApplicationAddress: common.HexToAddress("0x1"), + Status: model.ApplicationStatus_OK, + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: 100, + InspectMaxDeadline: 100, + MaxConcurrentInspects: 3, + }, + } + + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app1}, uint64(1), nil) + + // Mock GetLastSnapshot to return nil (no snapshot available) + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + + // Create manager with a mock instance factory + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + mockInstance := &DummyMachineInstanceMock{application: app1} + factory := &MockMachineInstanceFactory{Instance: mockInstance} + manager := newTestMachineManager(repo, testLogger, false, 500, WithInstanceFactory(factory)) + + err := manager.UpdateMachines(context.Background()) + require.NoError(err) + require.True(manager.HasMachine(1)) + + repo.AssertCalled(s.T(), "ListApplications", mock.Anything, mock.Anything, mock.Anything, false) + }) + + s.Run("AddsMachineForForeclosedAppWithUndrainedInputs", func() { + require := s.Require() + + repo := &MockMachineRepository{} + app := &model.Application{ + ID: 1, + Name: "ForeclosedApp", + IApplicationAddress: common.HexToAddress("0x1"), + Enabled: true, + Status: model.ApplicationStatus_OK, + ForecloseBlock: 100, + // Caught up (last_input_check_block >= foreclose_block), so the + // drain query below is trustworthy and gets consulted. + LastInputCheckBlock: 100, + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: 100, + InspectMaxDeadline: 100, + MaxConcurrentInspects: 3, + }, + } + + // The executable filter selects OK apps that have not foreclosed; the + // drain filter selects OK apps that have. They are distinguished by + // ForeclosureRecorded, since a foreclosed app keeps status OK. + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.Status != nil && *f.Status == model.ApplicationStatus_OK && + f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.Status != nil && *f.Status == model.ApplicationStatus_OK && + f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock). + Return(true, nil).Once() + repo.On("GetLastSnapshot", mock.Anything, mock.Anything).Return(nil, nil) + + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + mockInstance := &DummyMachineInstanceMock{application: app} + factory := &MockMachineInstanceFactory{Instance: mockInstance} + manager := newTestMachineManager(repo, testLogger, false, 500, WithInstanceFactory(factory)) + + err := manager.UpdateMachines(context.Background()) + require.NoError(err) + require.True(manager.HasMachine(app.ID)) + repo.AssertExpectations(s.T()) + }) + + s.Run("KeepsMachineForForeclosedAppBeforeScanCaughtUp", func() { + require := s.Require() + + repo := &MockMachineRepository{} + // Foreclosed app whose historical scan has NOT yet reached + // foreclose_block (last_input_check_block < foreclose_block). The + // inputs/epochs table is still incomplete, so the drain query would + // answer "nothing to drain" on an empty table. The manager must keep + // the machine and must NOT consult HasUndrainedEpochsBeforeBlock yet. + app := &model.Application{ + ID: 1, + Name: "ForeclosedAppBootstrapping", + IApplicationAddress: common.HexToAddress("0x1"), + Enabled: true, + Status: model.ApplicationStatus_OK, + ForecloseBlock: 100, + LastInputCheckBlock: 99, + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: 100, + InspectMaxDeadline: 100, + MaxConcurrentInspects: 3, + }, + } + + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.Status != nil && *f.Status == model.ApplicationStatus_OK && + f.ForeclosureRecorded != nil && !*f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("ListApplications", mock.Anything, mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.Status != nil && *f.Status == model.ApplicationStatus_OK && + f.ForeclosureRecorded != nil && *f.ForeclosureRecorded + }), repository.Pagination{}, false).Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, mock.Anything).Return(nil, nil) + + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + mockInstance := &DummyMachineInstanceMock{application: app} + factory := &MockMachineInstanceFactory{Instance: mockInstance} + manager := newTestMachineManager(repo, testLogger, false, 500, WithInstanceFactory(factory)) + + err := manager.UpdateMachines(context.Background()) + require.NoError(err) + require.True(manager.HasMachine(app.ID), "machine kept while the scan catches up") + repo.AssertExpectations(s.T()) + repo.AssertNotCalled(s.T(), "HasUndrainedEpochsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + }) + + s.Run("RemoveDisabledMachines", func() { + require := s.Require() + + // Create a mock repository + repo := &MockMachineRepository{} + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + + // Create a test logger + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + manager := newTestMachineManager(repo, testLogger, false, 500) + + // Add mock machines + app1 := &model.Application{ID: 1, Name: testApplicationName} + app2 := &model.Application{ID: 2, Name: "App2"} + app3 := &model.Application{ID: 3, Name: "App3"} + + mockMachine1 := &DummyMachineInstanceMock{application: app1} + mockMachine2 := &DummyMachineInstanceMock{application: app2} + mockMachine3 := &DummyMachineInstanceMock{application: app3} + + manager.addMachine(1, mockMachine1) + manager.addMachine(2, mockMachine2) + manager.addMachine(3, mockMachine3) + + // Remove machines not in the active list + manager.removeMachines([]*model.Application{app1, app3}) + + // Verify machine2 was removed + require.Len(manager.machines, 2) + require.True(manager.HasMachine(1)) + require.False(manager.HasMachine(2)) + require.True(manager.HasMachine(3)) + }) +} + +func (s *MachineManagerSuite) TestSnapshotStartingStateVerification() { + newApp := func(id int64, processed uint64) *model.Application { + return &model.Application{ + ID: id, + Name: "SnapshotApp", + IApplicationAddress: common.BigToAddress(new(big.Int).SetInt64(id)), + Enabled: true, + Status: model.ApplicationStatus_OK, + ProcessedInputs: processed, + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: time.Second, + InspectMaxDeadline: time.Second, + LoadDeadline: time.Second, + MaxConcurrentInspects: 3, + }, + } + } + prepareRepo := func( + app *model.Application, + snapshot *model.Input, + snapshotErr error, + ) *MockMachineRepository { + repo := &MockMachineRepository{ + replayApplicationID: app.ID, + replayConsensus: app.ConsensusType, + } + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()). + Return(snapshot, snapshotErr).Once() + return repo + } + configureReplay := func(repo *MockMachineRepository, app *model.Application) common.Hash { + machineHash := newHash(1) + outputsHash := newHash(2) + repo.replayCount = app.ProcessedInputs + repo.replayRecords = []*model.ReplayRecord{{ + Input: model.ReplayInput{ + ApplicationID: app.ID, + EpochIndex: 0, + InputIndex: 0, + RawData: []byte("replayed input"), + Status: model.InputCompletionStatus_Accepted, + MachineHash: &machineHash, + TxBufferDataBlock: &outputsHash, + }, + }} + return machineHash + } + newReplayTemplate := func(app *model.Application) MachineInstance { + base := &MockRollupsMachine{ + HashReturn: newHash(0), + } + base.ForkReturn = newForkableMock() + instance, err := NewMachineInstanceWithFactory( + context.Background(), app, 0, + slog.New(slog.NewTextHandler(io.Discard, nil)), + &MockMachineRuntimeFactory{RuntimeToReturn: base}, + ) + s.Require().NoError(err) + return instance + } + assertTemplateReplay := func( + require *require.Assertions, + manager *MachineManager, + repo *MockMachineRepository, + app *model.Application, + expectedHash common.Hash, + ) { + machineInstance, exists := manager.GetMachine(app.ID) + require.True(exists) + require.Equal(app.ProcessedInputs, machineInstance.ProcessedInputs()) + actualHash, err := machineInstance.Hash(context.Background()) + require.NoError(err) + require.Equal([32]byte(expectedHash), actualHash) + require.Positive(repo.replayListCalls, "template fallback must replay canonical inputs") + } + + s.Run("MatchingCaughtUpSnapshotIsAccepted", func() { + require := s.Require() + app := newApp(81, 3) + path := s.T().TempDir() + hash := common.HexToHash("0x81") + snapshot := &model.Input{Index: 2, SnapshotURI: &path, MachineHash: &hash} + repo := prepareRepo(app, snapshot, nil) + repo.replayCount = 3 + snapshotInstance := &DummyMachineInstanceMock{ + application: app, processedInputs: 3, hashReturn: [32]byte(hash), + } + factory := &MockMachineInstanceFactory{SnapshotInstance: snapshotInstance} + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Equal(1, factory.SnapshotCalls) + require.Zero(factory.TemplateCalls) + require.Equal(1, snapshotInstance.hashCalls) + require.Equal(1, snapshotInstance.replayCalls) + require.True(manager.HasMachine(app.ID)) + manager.Close() + }) + + fallbackTests := []struct { + name string + snapshot func(path string, expectedHash common.Hash) *model.Input + repositoryErr error + candidate func(app *model.Application, expectedHash common.Hash) *DummyMachineInstanceMock + factoryErr error + wantCalls int + wantHashCalls int + }{ + { + name: "missing persisted hash", + snapshot: func(path string, _ common.Hash) *model.Input { + return &model.Input{Index: 0, SnapshotURI: &path} + }, + wantCalls: 0, + }, + { + name: "repository result accompanied by error", + snapshot: func(path string, expectedHash common.Hash) *model.Input { + return &model.Input{Index: 0, SnapshotURI: &path, MachineHash: &expectedHash} + }, + repositoryErr: errors.New("snapshot query failed"), + wantCalls: 0, + }, + { + name: "snapshot input index cannot be incremented", + snapshot: func(path string, expectedHash common.Hash) *model.Input { + return &model.Input{Index: math.MaxUint64, SnapshotURI: &path, MachineHash: &expectedHash} + }, + wantCalls: 0, + }, + { + name: "snapshot is beyond application progress", + snapshot: func(path string, expectedHash common.Hash) *model.Input { + return &model.Input{Index: 1, SnapshotURI: &path, MachineHash: &expectedHash} + }, + wantCalls: 0, + }, + { + name: "matching hash with wrong processed input count", + snapshot: func(path string, expectedHash common.Hash) *model.Input { + return &model.Input{Index: 0, SnapshotURI: &path, MachineHash: &expectedHash} + }, + candidate: func(app *model.Application, expectedHash common.Hash) *DummyMachineInstanceMock { + return &DummyMachineInstanceMock{ + application: app, processedInputs: 0, hashReturn: [32]byte(expectedHash), + } + }, + wantCalls: 1, + }, + { + name: "manager boundary hash mismatch", + snapshot: func(path string, expectedHash common.Hash) *model.Input { + return &model.Input{Index: 0, SnapshotURI: &path, MachineHash: &expectedHash} + }, + candidate: func(app *model.Application, _ common.Hash) *DummyMachineInstanceMock { + return &DummyMachineInstanceMock{application: app, processedInputs: 1, hashReturn: newHash(99)} + }, + wantCalls: 1, + wantHashCalls: 1, + }, + { + name: "manager boundary hash read error", + snapshot: func(path string, expectedHash common.Hash) *model.Input { + return &model.Input{Index: 0, SnapshotURI: &path, MachineHash: &expectedHash} + }, + candidate: func(app *model.Application, expectedHash common.Hash) *DummyMachineInstanceMock { + return &DummyMachineInstanceMock{ + application: app, processedInputs: 1, hashReturn: [32]byte(expectedHash), + hashError: errors.New("snapshot hash unavailable"), + } + }, + wantCalls: 1, + wantHashCalls: 1, + }, + { + name: "factory returns partial candidate with error", + snapshot: func(path string, expectedHash common.Hash) *model.Input { + return &model.Input{Index: 0, SnapshotURI: &path, MachineHash: &expectedHash} + }, + candidate: func(app *model.Application, expectedHash common.Hash) *DummyMachineInstanceMock { + return &DummyMachineInstanceMock{ + application: app, processedInputs: 1, hashReturn: [32]byte(expectedHash), + } + }, + factoryErr: errors.New("factory failed after creating candidate"), + wantCalls: 1, + }, + } + + for i, tt := range fallbackTests { + s.Run(tt.name, func() { + require := s.Require() + app := newApp(82+int64(i), 1) + path := s.T().TempDir() + expectedHash := newHash(1) + snapshot := tt.snapshot(path, expectedHash) + repo := prepareRepo(app, snapshot, tt.repositoryErr) + require.Equal(expectedHash, configureReplay(repo, app)) + candidate := (*DummyMachineInstanceMock)(nil) + if tt.candidate != nil { + candidate = tt.candidate(app, expectedHash) + } + template := newReplayTemplate(app) + factory := &MockMachineInstanceFactory{ + Instance: template, SnapshotInstance: candidate, SnapshotErr: tt.factoryErr, + } + manager := NewMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Equal(tt.wantCalls, factory.SnapshotCalls) + require.Equal(1, factory.TemplateCalls) + require.Equal([]bool{false}, factory.TemplateChecks) + if candidate != nil { + require.Equal(1, candidate.closeCalls) + require.Equal(tt.wantHashCalls, candidate.hashCalls) + require.Zero(candidate.replayCalls) + } + assertTemplateReplay(require, manager, repo, app, expectedHash) + manager.Close() + }) + } + + s.Run("SnapshotFactoryNilResultFallsBackToTemplate", func() { + require := s.Require() + app := newApp(90, 1) + path := s.T().TempDir() + expectedHash := common.Hash{} + snapshot := &model.Input{Index: 0, SnapshotURI: &path, MachineHash: &expectedHash} + repo := prepareRepo(app, snapshot, nil) + expectedHash = configureReplay(repo, app) + snapshot.MachineHash = &expectedHash + template := newReplayTemplate(app) + factory := &MockMachineInstanceFactory{Instance: template, ReturnNilSnapshot: true} + manager := NewMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Equal(1, factory.SnapshotCalls) + require.Equal(1, factory.TemplateCalls) + assertTemplateReplay(require, manager, repo, app, expectedHash) + manager.Close() + }) +} + +func (s *MachineManagerSuite) TestTemplateFactoryResultHandling() { + tests := []struct { + name string + candidate *DummyMachineInstanceMock + factoryErr error + }{ + {name: "nil instance without error"}, + { + name: "partial instance with error", + candidate: &DummyMachineInstanceMock{ + closeError: errors.New("partial template close failed"), + }, + factoryErr: errors.New("template factory failed"), + }, + } + + for i, tt := range tests { + s.Run(tt.name, func() { + require := s.Require() + app := &model.Application{ + ID: 91 + int64(i), + Name: "TemplateFactoryResultApp", + IApplicationAddress: common.BigToAddress(big.NewInt(91 + int64(i))), + Enabled: true, + Status: model.ApplicationStatus_OK, + } + if tt.candidate != nil { + tt.candidate.application = app + } + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()). + Return(nil, nil).Once() + factory := &MockMachineInstanceFactory{Err: tt.factoryErr} + if tt.candidate != nil { + factory.Instance = tt.candidate + } + if tt.factoryErr != nil { + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, tt.factoryErr.Error()) + })).Return(nil).Once() + } + manager := newTestMachineManager( + repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 10, + WithInstanceFactory(factory), + ) + + require.NoError(manager.UpdateMachines(context.Background())) + require.Equal(1, factory.TemplateCalls) + require.False(manager.HasMachine(app.ID)) + if tt.factoryErr != nil { + require.Equal(model.ApplicationStatus_Failed, app.Status) + } + if tt.candidate != nil { + require.Equal(1, tt.candidate.closeCalls) + require.Zero(tt.candidate.replayCalls) + } + repo.AssertExpectations(s.T()) + manager.Close() + }) + } +} + +func (s *MachineManagerSuite) TestGetMachine() { + require := s.Require() + + repo := &MockMachineRepository{} + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + + manager := newTestMachineManager(repo, nil, false, 500) + machine := &DummyMachineInstanceMock{application: &model.Application{ID: 1}} + + // Add a machine + manager.addMachine(1, machine) + + // Test retrieval + retrieved, exists := manager.GetMachine(1) + require.True(exists) + require.Same(machine, retrieved) + + // Test non-existent machine + _, exists = manager.GetMachine(2) + require.False(exists) +} + +func (s *MachineManagerSuite) TestHasMachine() { + require := s.Require() + + repo := &MockMachineRepository{} + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + + manager := newTestMachineManager(repo, nil, false, 500) + machine := &DummyMachineInstanceMock{application: &model.Application{ID: 1}} + + // Add a machine + manager.addMachine(1, machine) + + // Test has machine + require.True(manager.HasMachine(1)) + + // Test doesn't have machine + require.False(manager.HasMachine(2)) +} + +func (s *MachineManagerSuite) TestAddMachine() { + require := s.Require() + + repo := &MockMachineRepository{} + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + + manager := newTestMachineManager(repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 500) + machine1 := &DummyMachineInstanceMock{application: &model.Application{ID: 1}} + machine2 := &DummyMachineInstanceMock{application: &model.Application{ID: 2}} + + // Add first machine + added := manager.addMachine(1, machine1) + require.True(added) + require.Len(manager.machines, 1) + + // Add second machine + added = manager.addMachine(2, machine2) + require.True(added) + require.Len(manager.machines, 2) + + // Try to add duplicate + added = manager.addMachine(1, machine1) + require.False(added) + require.Len(manager.machines, 2) + + // Close the manager and try to add a new machine + manager.Close() + + machine3 := &DummyMachineInstanceMock{application: &model.Application{ID: 3}} + added = manager.addMachine(3, machine3) + require.False(added, "addMachine must reject additions after Close") +} + +func (s *MachineManagerSuite) TestRemoveDisabledMachines() { + require := s.Require() + + manager := newTestMachineManager(nil, nil, false, 500) + + // Add machines + app1 := &model.Application{ID: 1} + app2 := &model.Application{ID: 2} + app3 := &model.Application{ID: 3} + + machine1 := &DummyMachineInstanceMock{application: app1} + machine2 := &DummyMachineInstanceMock{application: app2} + machine3 := &DummyMachineInstanceMock{application: app3} + + manager.addMachine(1, machine1) + manager.addMachine(2, machine2) + manager.addMachine(3, machine3) + + // Remove machines not in the active list + manager.removeMachines([]*model.Application{app1, app3}) + + // Verify machine2 was removed + require.Len(manager.machines, 2) + require.True(manager.HasMachine(1)) + require.False(manager.HasMachine(2)) + require.True(manager.HasMachine(3)) +} + +func (s *MachineManagerSuite) TestUpdateMachinesErrors() { + s.Run("GetExecutableApplicationsError", func() { + require := s.Require() + + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return(([]*model.Application)(nil), uint64(0), errors.New("db error")) + + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + manager := newTestMachineManager(repo, testLogger, false, 500) + + err := manager.UpdateMachines(context.Background()) + require.Error(err) + require.Contains(err.Error(), "db error") + }) + + s.Run("SnapshotCreationFailureFallsBackToTemplate", func() { + require := s.Require() + + app := &model.Application{ + ID: 1, + Name: testApplicationName, + IApplicationAddress: common.HexToAddress("0x1"), + Status: model.ApplicationStatus_OK, + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: 100, + InspectMaxDeadline: 100, + MaxConcurrentInspects: 3, + }, + } + + snapshotPath := "/fake/snapshot/path" + snapshotInput := &model.Input{ + Index: 2, + SnapshotURI: &snapshotPath, + } + + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(snapshotInput, nil) + // The snapshot path doesn't exist, so it should fall back to template + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + mockInstance := &DummyMachineInstanceMock{application: app} + factory := &MockMachineInstanceFactory{Instance: mockInstance} + manager := newTestMachineManager(repo, testLogger, false, 500, WithInstanceFactory(factory)) + + err := manager.UpdateMachines(context.Background()) + require.NoError(err) + require.True(manager.HasMachine(1)) + }) + + s.Run("TemplateCreationFailureMarksAppFailed", func() { + require := s.Require() + + app := &model.Application{ + ID: 1, + Name: testApplicationName, + IApplicationAddress: common.HexToAddress("0x1"), + Status: model.ApplicationStatus_OK, + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: 100, + InspectMaxDeadline: 100, + MaxConcurrentInspects: 3, + }, + } + + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + factory := &MockMachineInstanceFactory{Err: errors.New("machine creation failed")} + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "machine creation failed") + })).Return(nil).Once() + manager := newTestMachineManager(repo, testLogger, false, 500, WithInstanceFactory(factory)) + + err := manager.UpdateMachines(context.Background()) + require.NoError(err) + require.False(manager.HasMachine(1)) + require.Equal(model.ApplicationStatus_Failed, app.Status) + repo.AssertExpectations(s.T()) + }) + + s.Run("SynchronizeFailureClosesAndSkipsApp", func() { + require := s.Require() + + app := &model.Application{ + ID: 1, + Name: testApplicationName, + IApplicationAddress: common.HexToAddress("0x1"), + Status: model.ApplicationStatus_OK, + ProcessedInputs: 3, + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: 100, + InspectMaxDeadline: 100, + MaxConcurrentInspects: 3, + }, + } + + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, mock.Anything, mock.Anything, false). + Return([]*model.Application{app}, uint64(1), nil) + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + repo.replayCountError = errors.New("db connection lost") + + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + // Use a factory that builds a real MachineInstanceImpl (with a mock runtime) + // so that Synchronize actually runs and hits the repo. + mockRuntime := &MockRollupsMachine{} + runtimeFactory := &MockMachineRuntimeFactory{ + RuntimeToReturn: mockRuntime, + ErrorToReturn: nil, + } + realFactory := &realMachineInstanceFactory{runtimeFactory: runtimeFactory} + manager := NewMachineManager(repo, testLogger, false, 500, WithInstanceFactory(realFactory)) + + err := manager.UpdateMachines(context.Background()) + require.NoError(err) + // Machine should NOT have been added due to sync failure + require.False(manager.HasMachine(1)) + }) +} + +// captureLogger returns a logger whose output is written to buf. Level is set +// to debug so every call is recorded. +func captureLogger(buf *bytes.Buffer) *slog.Logger { + return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +func (s *MachineManagerSuite) TestCloseAggregatesErrors() { + require := s.Require() + + var buf bytes.Buffer + manager := newTestMachineManager(nil, captureLogger(&buf), false, 500) + + machine1 := &DummyMachineInstanceMock{application: &model.Application{ID: 1}} + machine2 := &DummyMachineInstanceMock{ + application: &model.Application{ID: 2}, + closeError: errors.New("close error 2"), + } + machine3 := &DummyMachineInstanceMock{ + application: &model.Application{ID: 3}, + closeError: errors.New("close error 3"), + } + + manager.addMachine(1, machine1) + manager.addMachine(2, machine2) + manager.addMachine(3, machine3) + + manager.Close() + + logContents := buf.String() + require.Contains(logContents, "close error 2") + require.Contains(logContents, "close error 3") + require.Empty(manager.machines) +} + +func (s *MachineManagerSuite) TestApplications() { + require := s.Require() + + repo := &MockMachineRepository{} + repo.On("GetLastSnapshot", mock.Anything, mock.Anything). + Return(nil, nil) + + manager := newTestMachineManager(repo, nil, false, 500) + + // Add machines + app1 := &model.Application{ID: 1, Name: testApplicationName} + app2 := &model.Application{ID: 2, Name: "App2"} + + machine1 := &DummyMachineInstanceMock{application: app1} + machine2 := &DummyMachineInstanceMock{application: app2} + + manager.addMachine(1, machine1) + manager.addMachine(2, machine2) + + // Get applications + apps := manager.Applications() + require.Len(apps, 2) + + // Verify apps are in the list (order not guaranteed) + appMap := make(map[int64]*model.Application) + for _, app := range apps { + appMap[app.ID] = app + } + + require.Contains(appMap, int64(1)) + require.Contains(appMap, int64(2)) + require.Equal(testApplicationName, appMap[1].Name) + require.Equal("App2", appMap[2].Name) +} + +// Mock repository for testing +type MockMachineRepository struct { + mock.Mock + replayApplicationID int64 + replayConsensus model.Consensus + replayCount uint64 + replayCountError error + replayRecords []*model.ReplayRecord + replayListCalls int +} + +func (m *MockMachineRepository) ReplaySummary( + _ context.Context, + _ common.Address, + _ repository.ReplayVerificationLevel, +) (model.ReplaySummary, error) { + return model.ReplaySummary{ + ApplicationID: m.replayApplicationID, + ProcessedInputs: m.replayCount, + Consensus: m.replayConsensus, + }, m.replayCountError +} + +func (m *MockMachineRepository) ReplayPage( + _ context.Context, + request repository.ReplayPageRequest, +) ([]*model.ReplayRecord, error) { + m.replayListCalls++ + records := make([]*model.ReplayRecord, 0, min(request.Limit, uint64(len(m.replayRecords)))) + for _, record := range m.replayRecords { + index := record.Input.InputIndex + if index < request.FromInput || index >= request.ToInputExclusive { + continue + } + records = append(records, record) + if uint64(len(records)) == request.Limit { + break + } + } + return records, nil +} + +func (m *MockMachineRepository) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, +) ([]*model.Application, uint64, error) { + args := m.Called(ctx, f, p, descending) + return args.Get(0).([]*model.Application), args.Get(1).(uint64), args.Error(2) +} + +func (m *MockMachineRepository) HasUndrainedEpochsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *MockMachineRepository) HasUnpublishedEpochBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *MockMachineRepository) GetLastSnapshot( + ctx context.Context, + nameOrAddress string) (*model.Input, error) { + args := m.Called(ctx, nameOrAddress) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Input), args.Error(1) +} + +func (m *MockMachineRepository) GetApplication( + ctx context.Context, + nameOrAddress string, +) (*model.Application, error) { + args := m.Called(ctx, nameOrAddress) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Application), args.Error(1) +} + +func (m *MockMachineRepository) UpdateApplicationStatus( + ctx context.Context, + appID int64, + status model.ApplicationStatus, + reason *string, +) error { + return m.Called(ctx, appID, status, reason).Error(0) +} + +// ------------------------------------------------------------------------------------------------ + +// MockMachineInstanceFactory implements MachineInstanceFactory for testing. +// It returns the same instance for every call, ignoring the app/path arguments. +type MockMachineInstanceFactory struct { + Instance MachineInstance + SnapshotInstance MachineInstance + Err error + SnapshotErr error + TemplateFn func(*model.Application) (MachineInstance, error) + ReturnNilSnapshot bool + TemplateCalls int + SnapshotCalls int + TemplateChecks []bool +} + +func (f *MockMachineInstanceFactory) NewFromTemplate( + _ context.Context, app *model.Application, _ *slog.Logger, checkTemplateHash bool, +) (MachineInstance, error) { + f.TemplateCalls++ + f.TemplateChecks = append(f.TemplateChecks, checkTemplateHash) + if f.TemplateFn != nil { + return f.TemplateFn(app) + } + return f.Instance, f.Err +} + +func (f *MockMachineInstanceFactory) NewFromSnapshot( + _ context.Context, _ *model.Application, _ *slog.Logger, + _ string, _ common.Hash, _ uint64, +) (MachineInstance, error) { + f.SnapshotCalls++ + if f.ReturnNilSnapshot { + return nil, nil + } + if f.SnapshotErr != nil { + return f.SnapshotInstance, f.SnapshotErr + } + if f.SnapshotInstance != nil { + return f.SnapshotInstance, nil + } + return f.Instance, f.Err +} + +// realMachineInstanceFactory builds real MachineInstanceImpl values using the +// provided MachineRuntimeFactory. This lets tests exercise the real Synchronize +// path while still mocking the machine runtime. Unlike MockMachineInstanceFactory, +// snapshot path and hash are ignored — it always creates from the runtime factory. +type realMachineInstanceFactory struct { + runtimeFactory MachineRuntimeFactory +} + +func (f *realMachineInstanceFactory) NewFromTemplate( + ctx context.Context, app *model.Application, logger *slog.Logger, _ bool, +) (MachineInstance, error) { + return NewMachineInstanceWithFactory(ctx, app, 0, logger, f.runtimeFactory) +} + +func (f *realMachineInstanceFactory) NewFromSnapshot( + ctx context.Context, app *model.Application, logger *slog.Logger, + _ string, _ common.Hash, inputIndex uint64, +) (MachineInstance, error) { + if inputIndex == math.MaxUint64 { + return nil, ErrInvalidSnapshotPoint + } + return NewMachineInstanceWithFactory(ctx, app, inputIndex+1, logger, f.runtimeFactory) +} + +// ------------------------------------------------------------------------------------------------ + +// DummyMachineInstanceMock implements the MachineInstance interface for testing +type DummyMachineInstanceMock struct { + application *model.Application + closeError error + hashError error + hashReturn [32]byte + replayErr error + closeCalls int + hashCalls int + processedInputs uint64 + replayCalls int +} + +func (m *DummyMachineInstanceMock) Application() *model.Application { + return m.application +} + +func (m *DummyMachineInstanceMock) ProcessedInputs() uint64 { + return m.processedInputs +} + +func (m *DummyMachineInstanceMock) StateProof(_ context.Context) (*model.StateProof, error) { + return nil, nil +} + +func (m *DummyMachineInstanceMock) Advance(_ context.Context, _ []byte, _ uint64, _ uint64, _ bool) (*model.AdvanceResult, error) { + return nil, nil +} + +func (m *DummyMachineInstanceMock) Inspect(_ context.Context, _ []byte) (*InspectResult, error) { + return nil, nil +} + +func (m *DummyMachineInstanceMock) CreateSnapshot(_ context.Context, _ uint64, _ string) error { + return nil +} + +func (m *DummyMachineInstanceMock) Hash(_ context.Context) ([32]byte, error) { + m.hashCalls++ + return m.hashReturn, m.hashError +} + +func (m *DummyMachineInstanceMock) Close() error { + m.closeCalls++ + return m.closeError +} + +func (m *MockMachineRepository) ListEpochs( + ctx context.Context, name string, filter repository.EpochFilter, pagination repository.Pagination, descending bool, +) ([]*model.Epoch, uint64, error) { + args := m.Called(ctx, name, filter, pagination, descending) + return args.Get(0).([]*model.Epoch), args.Get(1).(uint64), args.Error(2) +} +func (m *MockMachineRepository) GetTournament(ctx context.Context, name, address string) (*model.Tournament, error) { + args := m.Called(ctx, name, address) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Tournament), args.Error(1) +} diff --git a/internal/manager/pmutex/pmutex.go b/internal/manager/pmutex/pmutex.go new file mode 100644 index 000000000..85cd39d04 --- /dev/null +++ b/internal/manager/pmutex/pmutex.go @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package pmutex provides a mutual exclusion lock with priority capabilities. +package pmutex + +import ( + "sync" + "sync/atomic" +) + +// A PMutex is a mutual exclusion lock with priority capabilities. +// When contested, a call to HLock always acquires the mutex before LLock. +type PMutex struct { + // Main mutex. + mutex *sync.Mutex + + // Condition variable for the waiting low-priority threads. + waitingLow *sync.Cond + + // Quantity of high-priority threads waiting to acquire the lock. + waitingHigh *atomic.Int32 +} + +// New creates a new PMutex. +func New() *PMutex { + mutex := &sync.Mutex{} + return &PMutex{ + mutex: mutex, + waitingLow: sync.NewCond(mutex), + waitingHigh: &atomic.Int32{}, + } +} + +// HLock acquires the mutex for high-priority threads. +func (pmutex *PMutex) HLock() { + pmutex.waitingHigh.Add(1) + pmutex.mutex.Lock() + pmutex.waitingHigh.Add(-1) +} + +// LLock acquires the mutex for low-priority threads. +// (It waits until there are no high-priority threads trying to acquire the lock.) +func (pmutex *PMutex) LLock() { + pmutex.mutex.Lock() + + // Even after acquiring the lock, a low-priority thread releases it if there are any + // high-priority threads waiting. + for pmutex.waitingHigh.Load() != 0 { + // NOTE: a cond.Wait() releases the lock upon being called + // and tries to acquire it after being awakened. + pmutex.waitingLow.Wait() + } +} + +// Unlock releases the mutex for both types of threads. +func (pmutex *PMutex) Unlock() { + pmutex.waitingLow.Broadcast() + pmutex.mutex.Unlock() +} diff --git a/internal/manager/pmutex/pmutex_test.go b/internal/manager/pmutex/pmutex_test.go new file mode 100644 index 000000000..4305ac085 --- /dev/null +++ b/internal/manager/pmutex/pmutex_test.go @@ -0,0 +1,131 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package pmutex + +import ( + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +func TestPMutex(t *testing.T) { + suite.Run(t, new(PMutexSuite)) +} + +type PMutexSuite struct { + suite.Suite + mutex *PMutex +} + +func (s *PMutexSuite) SetupTest() { + require := s.Require() + s.mutex = New() + require.NotNil(s.mutex) +} + +func (s *PMutexSuite) TestNew() { + // This test is inside SetupTest. +} + +func (s *PMutexSuite) TestSingleHLock() { + s.mutex.HLock() +} + +func (s *PMutexSuite) TestSingleLLock() { + s.mutex.LLock() +} + +func (s *PMutexSuite) TestContestedHLock() { + s.mutex.LLock() + acquired := make(chan struct{}) + go func() { + s.mutex.HLock() + close(acquired) + }() + select { + case <-acquired: + s.Fail("HLock should not be acquired while LLock is held") + case <-time.After(decisecond): + // Expected: HLock is blocked. + } + // Clean up: unlock so the goroutine can finish. + s.mutex.Unlock() + <-acquired +} + +func (s *PMutexSuite) TestContestedLLock() { + s.mutex.HLock() + acquired := make(chan struct{}) + go func() { + s.mutex.LLock() + close(acquired) + }() + select { + case <-acquired: + s.Fail("LLock should not be acquired while HLock is held") + case <-time.After(decisecond): + // Expected: LLock is blocked. + } + // Clean up: unlock so the goroutine can finish. + s.mutex.Unlock() + <-acquired +} + +func (s *PMutexSuite) TestPriority() { + require := s.Require() + release := make(chan struct{}) + + low, high := 500, 5 + actual, expected := "", strings.Repeat("H", high)+strings.Repeat("L", low) + var wg sync.WaitGroup + wg.Add(low + high) + + // Creates a thread that holds the lock until we signalize its release. + go func() { + s.mutex.HLock() + <-release + s.mutex.Unlock() + }() + + // Gives some time for the thread to hang on <-release. + time.Sleep(decisecond) + + // Creates a lot of low-priority threads. + for i := 0; i < low; i++ { + go func() { + s.mutex.LLock() + actual += "L" + s.mutex.Unlock() + wg.Done() + }() + } + + // Creates a few high-priority threads. + for i := 0; i < high; i++ { + go func() { + s.mutex.HLock() + actual += "H" + s.mutex.Unlock() + wg.Done() + }() + } + + // Gives some time for the new threads to hang on their calls to LLock and HLock. + time.Sleep(decisecond) + + // Releases the lock from the first thread and waits for all threads to finish. + release <- struct{}{} + wg.Wait() + + // Asserts that all the high-priority threads acquired the lock + // before any of the low-priority threads. + require.Equal(expected, actual) +} + +// ------------------------------------------------------------------------------------------------ + +const decisecond = 100 * time.Millisecond diff --git a/internal/manager/prt_identity_config_test.go b/internal/manager/prt_identity_config_test.go new file mode 100644 index 000000000..df3c9ceba --- /dev/null +++ b/internal/manager/prt_identity_config_test.go @@ -0,0 +1,150 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package manager + +import ( + "context" + "fmt" + "io" + "log/slog" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/replay" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/machine" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRuntimeFactoriesSelectPrtMachineIdentity(t *testing.T) { + for _, tc := range []struct { + name string + consensus model.Consensus + want bool + }{ + {"authority", model.Consensus_Authority, false}, + {"quorum", model.Consensus_Quorum, false}, + {"prt", model.Consensus_PRT, true}, + } { + for _, snapshot := range []bool{false, true} { + kind := "template" + if snapshot { + kind = "snapshot" + } + t.Run(tc.name+"/"+kind, func(t *testing.T) { + expectedHash := common.HexToHash("0x123") + app := &model.Application{ + ConsensusType: tc.consensus, TemplateURI: "identity-template", TemplateHash: expectedHash, + ExecutionParameters: model.ExecutionParameters{AdvanceMaxDeadline: time.Second}, + } + runtime := &MockRollupsMachine{HashReturn: machine.Hash(expectedHash)} + calls := 0 + loader := func(_ context.Context, _ *slog.Logger, cfg *machine.MachineConfig) (machine.Machine, error) { + calls++ + require.Equal(t, tc.want, cfg.RequirePrtMachineIdentity) + require.Equal(t, app.ExecutionParameters, cfg.ExecutionParameters) + if snapshot { + require.Equal(t, "identity-snapshot", cfg.Path) + } else { + require.Equal(t, app.TemplateURI, cfg.Path) + } + return runtime, nil + } + var factory MachineRuntimeFactory = &DefaultMachineRuntimeFactory{CheckTemplateHash: true, loader: loader} + if snapshot { + factory = &SnapshotMachineRuntimeFactory{ + SnapshotPath: "identity-snapshot", ExpectedHash: expectedHash, loader: loader, + } + } + loaded, err := factory.CreateMachineRuntime(t.Context(), app, slog.New(slog.NewTextHandler(io.Discard, nil))) + require.NoError(t, err) + require.Same(t, runtime, loaded) + require.Equal(t, 1, calls) + require.EqualValues(t, 1, runtime.HashCalls.Load()) + require.NoError(t, loaded.Close()) + }) + } + } +} + +type identityWiringInstanceFactory struct { + loader machineLoader +} + +func (f *identityWiringInstanceFactory) NewFromTemplate( + ctx context.Context, app *model.Application, logger *slog.Logger, check bool, +) (MachineInstance, error) { + return NewMachineInstanceWithFactory(ctx, app, 0, logger, + &DefaultMachineRuntimeFactory{CheckTemplateHash: check, loader: f.loader}) +} + +func (f *identityWiringInstanceFactory) NewFromSnapshot( + ctx context.Context, app *model.Application, logger *slog.Logger, + path string, expected common.Hash, index uint64, +) (MachineInstance, error) { + return newMachineInstanceFromSnapshot(ctx, app, logger, path, expected, index, f.loader) +} + +func TestPrtSnapshotIdentityFailureRetainsTemplateFallback(t *testing.T) { + app := &model.Application{ + ID: 1, Name: "identity-fallback", ConsensusType: model.Consensus_PRT, + Enabled: true, Status: model.ApplicationStatus_OK, ProcessedInputs: 1, + TemplateURI: "identity-template", TemplateHash: common.Hash(newHash(1)), + ExecutionParameters: model.ExecutionParameters{ + AdvanceMaxDeadline: time.Second, InspectMaxDeadline: time.Second, MaxConcurrentInspects: 1, + }, + } + path := t.TempDir() + snapshotHash := common.HexToHash("0x321") + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, repository.ExecutableApplicationsFilter(), repository.Pagination{}, false). + Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("ListApplications", mock.Anything, foreclosedMachineDrainFilter(), repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.String()).Return(&model.Input{ + Index: 0, SnapshotURI: &path, MachineHash: &snapshotHash, + }, nil).Once() + fork := newForkableMock() + fork.AdvanceRemainingReturn = machine.InputEntryCapacity + runtime := &MockRollupsMachine{HashReturn: newHash(1), ForkReturn: fork} + loadedPaths := []string{} + factory := &identityWiringInstanceFactory{loader: func( + _ context.Context, _ *slog.Logger, cfg *machine.MachineConfig, + ) (machine.Machine, error) { + require.True(t, cfg.RequirePrtMachineIdentity) + loadedPaths = append(loadedPaths, cfg.Path) + if cfg.Path == path { + return nil, fmt.Errorf("snapshot identity mismatch: %w", model.ErrIncompatibleStateTransition) + } + require.Equal(t, app.TemplateURI, cfg.Path) + return runtime, nil + }} + replayCalls := 0 + manager := NewMachineManager(repo, slog.New(slog.NewTextHandler(io.Discard, nil)), true, 1, + WithInstanceFactory(factory), withReplayRun(func( + ctx context.Context, _ repository.ReplayRepository, executor replay.Executor, opts replay.Options, + ) (replay.Result, error) { + replayCalls++ + require.Equal(t, repository.ReplayVerificationCanonical, opts.Verification) + require.Zero(t, opts.FromInput) + require.Equal(t, app.ProcessedInputs, opts.ToInputExclusive) + result, err := executor.Advance(ctx, []byte("identity replay input"), 0, 0, true) + require.NoError(t, err) + require.Equal(t, model.InputCompletionStatus_Accepted, result.Status) + require.Equal(t, app.ProcessedInputs, executor.ProcessedInputs()) + return replay.Result{ReplayedInputs: 1}, nil + })) + t.Cleanup(manager.Close) + require.NoError(t, manager.UpdateMachines(t.Context())) + require.Equal(t, []string{path, app.TemplateURI}, loadedPaths) + require.Equal(t, 1, replayCalls) + require.True(t, manager.HasMachine(app.ID)) + require.False(t, manager.HasPendingApplicationFailures()) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} diff --git a/internal/manager/publication_lifetime_test.go b/internal/manager/publication_lifetime_test.go new file mode 100644 index 000000000..3fb4f5a2d --- /dev/null +++ b/internal/manager/publication_lifetime_test.go @@ -0,0 +1,92 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package manager + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func foreclosedPublicationManager(t *testing.T, consensus model.Consensus, undrained bool) ( + *MachineManager, *MockMachineRepository, *DummyMachineInstanceMock, +) { + t.Helper() + app := &model.Application{ID: 17, Name: "closed-before-foreclosure", Enabled: true, + Status: model.ApplicationStatus_OK, ConsensusType: consensus, + ForecloseBlock: 80, LastInputCheckBlock: 80, LastEpochCheckBlock: 80, ProcessedInputs: 2} + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, repository.ExecutableApplicationsFilter(), repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), nil) + repo.On("ListApplications", mock.Anything, foreclosedMachineDrainFilter(), repository.Pagination{}, false). + Return([]*model.Application{app}, uint64(1), nil) + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(undrained, nil) + m := NewMachineManager(repo, slog.New(slog.NewTextHandler(io.Discard, nil)), false, 1) + instance := &DummyMachineInstanceMock{application: app, processedInputs: 2} + m.addMachine(app.ID, instance) + t.Cleanup(m.Close) + return m, repo, instance +} + +func TestForeclosedMachineWaitsForProofPublication(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + t.Run(string(consensus), func(t *testing.T) { + m, repo, instance := foreclosedPublicationManager(t, consensus, false) + app := instance.Application() + // The reader closed a pre-F epoch after its inputs already committed. + // Only proof publication needs the resident machine now. + repo.On("HasUnpublishedEpochBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + repo.On("HasUnpublishedEpochBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + require.NoError(t, m.UpdateMachines(t.Context())) + require.True(t, m.HasMachine(app.ID), "CLOSED with accepted inputs still needs its publication runtime") + require.Zero(t, instance.closeCalls) + // CLOSED -> INPUTS_PROCESSED discharges machine ownership. Validator + // and claim reconciliation may still have work, entirely in the DB. + require.NoError(t, m.UpdateMachines(t.Context())) + require.False(t, m.HasMachine(app.ID), "do not retain the runtime until CLAIM_COMPUTED or claim drain") + require.Equal(t, 1, instance.closeCalls) + repo.AssertExpectations(t) + }) + } +} + +func TestForeclosedMachinePublicationReadErrorKeepsRuntime(t *testing.T) { + m, repo, instance := foreclosedPublicationManager(t, model.Consensus_Authority, false) + app := instance.Application() + failure := errors.New("publication progress unavailable") + repo.On("HasUnpublishedEpochBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, failure).Once() + repo.On("HasUnpublishedEpochBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + require.ErrorIs(t, m.UpdateMachines(t.Context()), failure) + require.True(t, m.HasMachine(app.ID), "an unavailable progress read cannot authorize teardown") + require.Zero(t, instance.closeCalls) + require.NoError(t, m.UpdateMachines(t.Context())) + require.False(t, m.HasMachine(app.ID)) + repo.AssertExpectations(t) +} + +func TestForeclosedMachinePublicationDoesNotExpandPRTOrUndrainedPath(t *testing.T) { + for _, test := range []struct { + name string + consensus model.Consensus + undrained bool + }{ + {"PRT remains input-drain only", model.Consensus_PRT, false}, + {"pending A/Q inputs already retain runtime", model.Consensus_Authority, true}, + } { + t.Run(test.name, func(t *testing.T) { + m, repo, instance := foreclosedPublicationManager(t, test.consensus, test.undrained) + require.NoError(t, m.UpdateMachines(context.Background())) + require.Equal(t, test.undrained, m.HasMachine(instance.Application().ID)) + repo.AssertNotCalled(t, "HasUnpublishedEpochBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/manager/template_failure_test.go b/internal/manager/template_failure_test.go new file mode 100644 index 000000000..30cc315b3 --- /dev/null +++ b/internal/manager/template_failure_test.go @@ -0,0 +1,183 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package manager + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTemplateFailurePersistsWithRetryAndSibling(t *testing.T) { + app := &model.Application{ + ID: 1, Name: "failed-template", IApplicationAddress: common.HexToAddress("0x123"), + Enabled: true, Status: model.ApplicationStatus_OK, ConsensusType: model.Consensus_PRT, + } + sibling := &model.Application{ + ID: 2, Name: "healthy-template", IApplicationAddress: common.HexToAddress("0x456"), + Enabled: true, Status: model.ApplicationStatus_OK, ConsensusType: model.Consensus_Authority, + } + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, repository.ExecutableApplicationsFilter(), repository.Pagination{}, false). + Return([]*model.Application{app, sibling}, uint64(2), nil).Twice() + repo.On("ListApplications", mock.Anything, foreclosedMachineDrainFilter(), repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), nil).Twice() + for _, current := range []*model.Application{app, sibling} { + repo.On("GetLastSnapshot", mock.Anything, current.IApplicationAddress.Hex()).Return(nil, nil).Once() + } + root := common.HexToAddress("0x789") + epoch := &model.Epoch{ + ApplicationID: app.ID, Index: 3, LastBlock: 100, + TournamentAddress: &root, Status: model.EpochStatus_Closed, + } + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: model.NonTerminalEpochStatuses(), + }, repository.Pagination{}, false).Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), root.Hex()).Return(&model.Tournament{ + ApplicationID: app.ID, EpochIndex: epoch.Index, Address: root, StartInstant: 100, Allowance: 50, + }, nil).Once() + + factoryErr := errors.New("template configuration is incompatible") + writeErr, readErr := errors.New("status write unavailable"), errors.New("status read unavailable") + partial := &DummyMachineInstanceMock{application: app, closeError: errors.New("partial close failed")} + healthy := &DummyMachineInstanceMock{application: sibling} + factory := &MockMachineInstanceFactory{TemplateFn: func(current *model.Application) (MachineInstance, error) { + if current.ID == app.ID { + return partial, factoryErr + } + return healthy, nil + }} + var output bytes.Buffer + manager := newTestMachineManager(repo, captureLogger(&output), false, 10, WithInstanceFactory(factory)) + t.Cleanup(manager.Close) + wantReason := "failed to create machine instance: template configuration is incompatible" + statusWrite := func() *mock.Call { + return repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { return reason != nil && *reason == wantReason })). + Run(func(mock.Arguments) { + require.Equal(t, 1, partial.closeCalls) + require.True(t, manager.HasPendingApplicationFailures()) + }) + } + statusWrite().Return(writeErr).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.Hex()).Return(nil, readErr).Once() + statusWrite().Return(nil).Once() + + err := manager.UpdateMachines(t.Context()) + require.ErrorIs(t, err, ErrApplicationFailureNotDurable) + require.ErrorIs(t, err, writeErr) + require.ErrorIs(t, err, readErr) + require.True(t, manager.HasPendingApplicationFailures()) + require.False(t, manager.HasMachine(app.ID)) + require.True(t, manager.HasMachine(sibling.ID)) + require.Equal(t, 1, partial.closeCalls) + require.Zero(t, partial.replayCalls) + require.Equal(t, 1, healthy.replayCalls) + require.Equal(t, 2, factory.TemplateCalls) + require.Contains(t, output.String(), "level=ERROR") + require.Contains(t, output.String(), "application=failed-template") + require.Contains(t, output.String(), app.IApplicationAddress.Hex()) + require.Contains(t, output.String(), factoryErr.Error()) + require.Contains(t, output.String(), "join_close_block=150") + require.Contains(t, output.String(), "epoch_index=3") + + require.NoError(t, manager.UpdateMachines(t.Context())) + require.False(t, manager.HasPendingApplicationFailures()) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + require.NotNil(t, app.Reason) + require.Equal(t, wantReason, *app.Reason) + require.False(t, manager.HasMachine(app.ID)) + require.True(t, manager.HasMachine(sibling.ID)) + require.Equal(t, 2, factory.TemplateCalls) + require.Equal(t, 1, partial.closeCalls) + repo.AssertExpectations(t) +} + +func TestTemplateFailureCancellation(t *testing.T) { + dependencyErr := errors.New("template read failed") + for _, tc := range []struct { + name string + shutdown bool + cause error + quiet bool + }{ + {"active cancellation", false, context.Canceled, false}, + {"shutdown cancellation", true, fmt.Errorf("load: %w", context.Canceled), true}, + {"shutdown joined cancellations", true, errors.Join(context.Canceled, context.Canceled), true}, + {"shutdown independent failure", true, errors.Join(context.Canceled, dependencyErr), false}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + app := &model.Application{ + ID: 5, Name: "cancellation-template", IApplicationAddress: common.HexToAddress("0x555"), + Enabled: true, Status: model.ApplicationStatus_OK, ConsensusType: model.Consensus_Authority, + } + repo := &MockMachineRepository{} + repo.On("ListApplications", mock.Anything, repository.ExecutableApplicationsFilter(), repository.Pagination{}, false). + Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("ListApplications", mock.Anything, foreclosedMachineDrainFilter(), repository.Pagination{}, false). + Return([]*model.Application{}, uint64(0), nil).Once() + repo.On("GetLastSnapshot", mock.Anything, app.IApplicationAddress.Hex()).Return(nil, nil).Once() + partial := &DummyMachineInstanceMock{application: app} + factory := &MockMachineInstanceFactory{TemplateFn: func(*model.Application) (MachineInstance, error) { + if tc.shutdown { + cancel() + } + return partial, tc.cause + }} + if !tc.quiet { + write := repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && *reason == "failed to create machine instance: "+tc.cause.Error() + })) + if tc.shutdown { + write.Return(context.Canceled).Once() + repo.On("GetApplication", mock.Anything, app.IApplicationAddress.Hex()).Return(nil, context.Canceled).Once() + } else { + write.Return(nil).Once() + } + } + var output bytes.Buffer + manager := newTestMachineManager(repo, captureLogger(&output), false, 10, WithInstanceFactory(factory)) + t.Cleanup(manager.Close) + err := manager.UpdateMachines(ctx) + if tc.shutdown { + require.ErrorIs(t, err, context.Canceled) + } else { + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + } + require.Equal(t, 1, partial.closeCalls) + require.Zero(t, partial.replayCalls) + require.Equal(t, 1, factory.TemplateCalls) + require.False(t, manager.HasMachine(app.ID)) + if tc.quiet { + require.NotContains(t, output.String(), "level=ERROR") + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.False(t, manager.HasPendingApplicationFailures()) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + require.Contains(t, output.String(), "level=ERROR") + require.Contains(t, output.String(), context.Canceled.Error()) + if errors.Is(tc.cause, dependencyErr) { + require.Contains(t, output.String(), dependencyErr.Error()) + } + if tc.shutdown { + require.True(t, manager.HasPendingApplicationFailures()) + require.ErrorIs(t, err, ErrApplicationFailureNotDurable) + } + } + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/manager/types.go b/internal/manager/types.go new file mode 100644 index 000000000..2acf2d72b --- /dev/null +++ b/internal/manager/types.go @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package manager + +import ( + "context" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/machine" +) + +// InspectResult carries a typed machine completion or an incomplete execution +// failure. Error is non-nil only when Status is machine.CompletionStatusUnknown; +// Reports then contains any reports emitted before the failure. +type InspectResult struct { + ProcessedInputs uint64 + Status machine.CompletionStatus + ExceptionData []byte + Reports [][]byte + Error error +} + +// MachineInstance defines the interface for a machine instance +type MachineInstance interface { + Application() *model.Application + Advance( + ctx context.Context, + input []byte, + epochIndex uint64, + inputIndex uint64, + computeHashes bool, + ) (*model.AdvanceResult, error) + Inspect(ctx context.Context, query []byte) (*InspectResult, error) + CreateSnapshot(ctx context.Context, processedInputs uint64, path string) error + ProcessedInputs() uint64 + Hash(ctx context.Context) ([32]byte, error) + StateProof(ctx context.Context) (*model.StateProof, error) + Close() error +} + +// MachineProvider defines the interface for accessing machines +type MachineProvider interface { + // GetMachine retrieves a machine instance for an application + GetMachine(appID int64) (MachineInstance, bool) + + // Applications returns the list of applications with active machines + Applications() []*model.Application + + // UpdateMachines refreshes the list of machines + UpdateMachines(ctx context.Context) error + + // FenceApplicationFailure fences an application whose initial FAILED + // status write could not be confirmed. It queues a later durability retry + // without duplicating the initial repository write. + FenceApplicationFailure(app *model.Application, reason string) + + // HasMachine checks if a machine exists for the given application ID + HasMachine(appID int64) bool + + // HasPendingApplicationFailures reports an unresolved durable-status fence. + HasPendingApplicationFailures() bool + + // Close shuts down all machine instances and releases resources + Close() +} diff --git a/internal/merkle/builder.go b/internal/merkle/builder.go new file mode 100644 index 000000000..9c66fe6d1 --- /dev/null +++ b/internal/merkle/builder.go @@ -0,0 +1,257 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +var ( + zero = big.NewInt(0) + one = big.NewInt(1) +) + +var ( + ErrBadInput = errors.New("merkle: invalid input") + ErrInvariant = errors.New("merkle: internal invariant violated") + ErrOpaqueTree = errors.New("merkle: proof enters an opaque subtree") +) + +// MerkleProof: dave/common-rs/merkle/src/tree.rs +type Proof struct { + Pos *big.Int + Node common.Hash + Siblings []common.Hash +} + +func Leaf(node common.Hash, pos *big.Int) *Proof { + return &Proof{ + Node: node, + Pos: pos, + Siblings: nil, + } +} + +func (proof *Proof) BuildRoot() common.Hash { + two := big.NewInt(2) + rootHash := proof.Node + + for i, s := range proof.Siblings { + + // ((pos >> i) % 2) == 0 + if new(big.Int).Rem(new(big.Int).Rsh(proof.Pos, uint(i)), two).Cmp(zero) == 0 { + rootHash = crypto.Keccak256Hash(rootHash[:], s[:]) + } else { + rootHash = crypto.Keccak256Hash(s[:], rootHash[:]) + } + } + return rootHash +} + +func (proof *Proof) BuildRootChildren() (common.Hash, common.Hash, error) { + if len(proof.Siblings) == 0 { + zero := common.Hash{} + return zero, zero, fmt.Errorf("siblings array is empty: %w", ErrBadInput) + } + two := big.NewInt(2) + height := len(proof.Siblings) + childHash := proof.Node + + for i, s := range proof.Siblings[:height-1] { + + // ((pos >> i) % 2) == 0 + if new(big.Int).Rem(new(big.Int).Rsh(proof.Pos, uint(i)), two).Cmp(zero) == 0 { + childHash = crypto.Keccak256Hash(childHash[:], s[:]) + } else { + childHash = crypto.Keccak256Hash(s[:], childHash[:]) + } + } + + // ((pos >> (height-1)) % 2) == 0 + if new(big.Int).Rem(new(big.Int).Rsh(proof.Pos, uint(height-1)), two).Cmp(zero) == 0 { + return childHash, proof.Siblings[height-1], nil + } else { + return proof.Siblings[height-1], childHash, nil + } +} + +func (proof *Proof) VerifyRoot(other common.Hash) bool { + return proof.BuildRoot() == other +} + +func (proof *Proof) PushHash(h common.Hash) { + proof.Siblings = append(proof.Siblings, h) +} + +func RootChildrenFromProof(leaf common.Hash, siblings []common.Hash, index uint64) (common.Hash, common.Hash, error) { + p := &Proof{ + Pos: new(big.Int).SetUint64(index), + Node: leaf, + Siblings: siblings, + } + return p.BuildRootChildren() +} + +//////////////////////////////////////////////////////////////////////////////// + +// MerkleTree: dave/common-rs/merkle/src/tree.rs +type Tree struct { + RootHash common.Hash + Height uint32 + opaque bool + Subtrees *InnerNode +} + +// InnerNode: dave/common-rs/merkle/src/tree.rs +// Emulate the rust enum type with a struct containing both {Pair, Iterated}. +type InnerNode struct { + // Pair + LHS, RHS *Tree + + // Iterated + Child *Tree +} + +func (inner *InnerNode) Valid() bool { + isPair := (inner.LHS != nil && inner.RHS != nil) + isIterated := inner.Child != nil + return (isPair || isIterated) && !(isPair && isIterated) // xor +} + +func (inner *InnerNode) Children() (*Tree, *Tree, error) { + if !inner.Valid() { + return nil, nil, fmt.Errorf("invalid InnerNode state: %+v: %w", inner, ErrInvariant) + } + + if inner.Child != nil { + return inner.Child, inner.Child, nil + } else { + return inner.LHS, inner.RHS, nil + } +} + +func TreeLeaf(hash common.Hash) *Tree { + return &Tree{ + Height: 0, + RootHash: hash, + Subtrees: nil, + } +} + +// OpaqueTree represents a subtree known only by its root and height. +// Height zero is an ordinary leaf and remains provable. +func OpaqueTree(root common.Hash, height uint32) *Tree { + if height == 0 { + return TreeLeaf(root) + } + return &Tree{RootHash: root, Height: height, opaque: true} +} + +func (tree *Tree) GetRootHash() common.Hash { + return tree.RootHash +} + +func (tree *Tree) Join(other *Tree) *Tree { + return &Tree{ + RootHash: crypto.Keccak256Hash(tree.RootHash[:], other.RootHash[:]), + Height: tree.Height + 1, + Subtrees: &InnerNode{ + LHS: tree, + RHS: other, + }, + } +} + +func (tree *Tree) Iterated(rep uint64) *Tree { + root := tree + for range rep { + root = &Tree{ + RootHash: crypto.Keccak256Hash(root.RootHash[:], root.RootHash[:]), + Height: root.Height + 1, + Subtrees: &InnerNode{ + Child: root, + }, + } + } + return root +} + +func (tree *Tree) ProveLeaf(index *big.Int) (*Proof, error) { + return tree.ProveLeafRec(index) +} + +func (tree *Tree) ProveLast() (*Proof, error) { + // index = (1 << height) - 1 + index := new(big.Int).Sub( + new(big.Int).Lsh( + one, + uint(tree.Height), + ), + one, + ) + return tree.ProveLeaf(index) +} + +func (tree *Tree) ProveLeafRec(index *big.Int) (*Proof, error) { + numLeafs := new(big.Int).Lsh(one, uint(tree.Height)) + if numLeafs.Cmp(index) <= 0 { + return nil, fmt.Errorf("index out of bounds: %v, %v: %w", numLeafs, index, ErrBadInput) + } + + subtree := tree.Subtrees + if subtree == nil { + if tree.opaque { + return nil, fmt.Errorf("proof enters subtree of height %d, root %v: %w", tree.Height, tree.RootHash, ErrOpaqueTree) + } + if index.Cmp(zero) != 0 { + return nil, fmt.Errorf("invalid Tree state: %v: %w", tree, ErrInvariant) + } + if tree.Height != 0 { + return nil, fmt.Errorf("invalid Tree state: %v: %w", tree, ErrInvariant) + } + return Leaf(tree.RootHash, index), nil + } + + shiftAmount := uint(tree.Height - 1) + isLeftLeaf := new(big.Int).Rsh(index, shiftAmount).Cmp(zero) == 0 + + // innerIndex = index & ~(1 << shiftAmount) + innerIndex := new(big.Int).AndNot( + index, + new(big.Int).Lsh( + one, + shiftAmount, + ), + ) + + lhs, rhs, err := subtree.Children() + if err != nil { + return nil, err + } + + if isLeftLeaf { + proof, err := lhs.ProveLeafRec(innerIndex) + if err != nil { + return nil, err + } + proof.PushHash(rhs.RootHash) + proof.Pos = index + return proof, nil + } else { + proof, err := rhs.ProveLeafRec(innerIndex) + if err != nil { + return nil, err + } + proof.PushHash(lhs.RootHash) + proof.Pos = index + return proof, nil + } +} + +//////////////////////////////////////////////////////////////////////////////// diff --git a/internal/merkle/builder_test.go b/internal/merkle/builder_test.go new file mode 100644 index 000000000..b02806624 --- /dev/null +++ b/internal/merkle/builder_test.go @@ -0,0 +1,130 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle/merkletest" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var zeroDigest = common.Hash{} + +func TestTreeAndProofErrorSentinels(t *testing.T) { + _, err := (&Tree{RootHash: zeroDigest, Height: 1}).ProveLeaf(big.NewInt(0)) + require.ErrorIs(t, err, ErrInvariant) + _, _, err = (&Proof{}).BuildRootChildren() + require.ErrorIs(t, err, ErrBadInput) +} + +func TestBuildRootChildren1(t *testing.T) { + p := Proof{ + Pos: big.NewInt(1), + Node: common.HexToHash("0x01"), + Siblings: []common.Hash{ + common.HexToHash("0x02"), + }, + } + rootHash := p.BuildRoot() + lhs, rhs, err := p.BuildRootChildren() + + require.NoError(t, err) + assert.Equal(t, rootHash, crypto.Keccak256Hash(lhs[:], rhs[:])) +} + +func TestBuildRootChildren2(t *testing.T) { + p := Proof{ + Pos: big.NewInt(1), + Node: common.HexToHash("0x01"), + Siblings: []common.Hash{ + common.HexToHash("0x02"), + common.HexToHash("0x03"), + }, + } + rootHash := p.BuildRoot() + lhs, rhs, err := p.BuildRootChildren() + + require.NoError(t, err) + assert.Equal(t, rootHash, crypto.Keccak256Hash(lhs[:], rhs[:])) +} + +func TestBuildRootChildrenAgainstReference(t *testing.T) { + hash0 := common.HexToHash("0x976dc34e226f0c9803d556f26426aaa82ba7b5f96a5ed094f4f150c3c27aeaf5") + hash1 := common.HexToHash("0xfffeb0e2d6fc065fdcf03c25e23e9730528ca7b890308765b0e6b07586db9c6e") + hash2 := common.HexToHash("0x1588d343bd73f167bf4886b8ab7694b4d83b60087ddbdb445c427c16f26d2644") + runs := []merkletest.Run{{Hash: hash0, Count: 1 << 24}, {Hash: hash1, Count: 1 << 24}, + {Hash: hash2, Count: (uint64(1) << 48) - (uint64(1) << 25)}} + expectedRoot := merkletest.Root(runs, 48) + expectedLeft, expectedRight := merkletest.RootChildren(runs, 48) + + proofSiblings := Proof{ + Pos: new(big.Int).SetUint64((1 << 48) - 1), + Node: common.HexToHash("0x1588d343bd73f167bf4886b8ab7694b4d83b60087ddbdb445c427c16f26d2644"), + Siblings: []common.Hash{ + common.HexToHash("0x1588d343bd73f167bf4886b8ab7694b4d83b60087ddbdb445c427c16f26d2644"), + common.HexToHash("0x6e94fed4ae1c88a9e09a36ad7b6a4e1cf16d3b9ca3af8cd2d7bb069451690e64"), + common.HexToHash("0x546d5eed29c39ab4194c600a8534e3d3502478384eb3dc9add095703e422ed38"), + common.HexToHash("0x38caf4f802a07958ed558995bab538a137d33b6172294362e6b21ac7b7121fd9"), + common.HexToHash("0xb5daca74fbcfb5af2f8e3c38dc8ad05ec83114b7bba846b3330950266aa40eab"), + common.HexToHash("0x020b890661da486ee8869b2b6109f4bd795f8bf4bbb88a8d3589a0603f3b824a"), + common.HexToHash("0xc2c4c83bec36615b797be1407a8e55b08b5cfbeb11dfe6a1db7f942c58d379ed"), + common.HexToHash("0x1998d07cf9e1006cc3b0f3c5fe08dda1119e471c20f7ceb29695cee940a7e75f"), + common.HexToHash("0x443631cea815fc6298f029109fe1fda376d3bb185f8e21544829b4b09c947f9c"), + common.HexToHash("0x159ad50b10bba4d6d2407ce012199523e3f45f2360ba288618c63bf8b91a56ab"), + common.HexToHash("0x75e8815dec610854174bee1c387a04b78ac942f9f675c7ddce7ec3f6edd69e03"), + common.HexToHash("0xca9bd9e206bd4e98e4443663dd4eef65c94cf02fd62abd30e36569c56191e9d2"), + common.HexToHash("0xed97ed05a833b3f0615df43ee27fcc8dc742ccbf84702719747be4f93afce440"), + common.HexToHash("0x228fddde650b389a339dab2088d3e1c9858989a1eecf1a424d0f4e1e70284047"), + common.HexToHash("0xb1b6565d519dc4b85d75d074a37a56186f27b240977d2a2ec0e6860217745316"), + common.HexToHash("0x32f63e77fd8f3762e6ae0efc82b62e0bd99430e656afde91792f4eb3c0eb2d0b"), + common.HexToHash("0xe50e2d01372de278e92c28a75277bbd83c747b89ef9933cb55c41d4c25f4e043"), + common.HexToHash("0x3b34305f34e7749600e503c11ad3bd739f3981a16470db39f5d9b324d0c9c2b3"), + common.HexToHash("0xc62e8ec325b8c50fc32e54b456943f7e191181b38e8decae1d784b06ec331fcd"), + common.HexToHash("0x176cd516c027b04ea15ef1bf3040f0295880ee9b06124a61707a1e95fd4d7032"), + common.HexToHash("0xf0ee382cc41769e98ccd36d2996dca753af0dbdf13ea4e121f896fe46ab734c8"), + common.HexToHash("0xc1ecfb62885ee2bb05bc1de974068c5e64f4f5a23dfdfb85d30dd47314ff3042"), + common.HexToHash("0x3c8db9c54f3cdc333d266c021b7b82fdd97c6a7936741fe482fe8792206485a9"), + common.HexToHash("0x599a4d9a455da52573f0a15a9716289e722411d814fffbf854ac0cf9c84f1b64"), + common.HexToHash("0xb57a65f271d3f4bf499536bcb1158ac8a19ec0b9b87204b0cd12f171cfaf5b50"), + common.HexToHash("0x66666cb6c0ba3ad91588a289c7207eeb68bc451840126b97aae50e8f9637ddae"), + common.HexToHash("0x416826c4ee8d20ec51cf42390a2948ef8d15c1d4169143c07152583bdecc64a4"), + common.HexToHash("0x759d0b439a3e16c4bcd780b724775279e4f6c20a1cee40c4c4452ce5444b8b53"), + common.HexToHash("0x7888795e43e9f91907342bf8694756ad251bce7d6aeeea393a7da65598873b61"), + common.HexToHash("0x21a9e92e15cd6e5d8c7b0f20416a7ced013f4f0dc7f42cce889cda91ea92d16a"), + common.HexToHash("0x8e30f629baa35c5de738d64d562e0492623fc1b42aa3205711c3b740124f281c"), + common.HexToHash("0xd3c85a40b8e5ecd91966a47acba3a6abb17b2995bb10fb61bf84b57ca25a7516"), + common.HexToHash("0x76e076fb82921263e6cff4a5579419d3b8fb9c9398f147bbc7108f72912cd67a"), + common.HexToHash("0x43bdd668fdaef54e7a315e68918095373b4d7538fb383535305265e29346e0c1"), + common.HexToHash("0x19df614f8b4749c1a9ed4ed3949691d0c2d9209541e1fbf2c30dbc32e990bcd9"), + common.HexToHash("0xfc2379f2a164ec902fff26ff3840383dcba0ea54f04f703b4f56844e4db1dbce"), + common.HexToHash("0x1dd54410bdd1baaeafc1dbf9c04f5f05f698fdbd7d3209fa55aeec1c7d0fe19a"), + common.HexToHash("0x721ad44b29a2c63e1ba75fd4774fa0e1a0a5bff806d4b441debfd6a95e75f5c9"), + common.HexToHash("0xfa1144bd963f10d4a325184fbe43a36ec3f96a4212d71592e84679975117ef5c"), + common.HexToHash("0xefef7b6fab7d64ac6f6764f932835af1f9015d11dc94ae1112956fd801cd8a30"), + common.HexToHash("0xea1e381daf0fd8ecfd94994fcc88fb2874f08ab3bbc397d86389d433e7fc8368"), + common.HexToHash("0xcfeff346f98bb48c38a0c650fe7fa2b9669ebdb37128cbe813a16a34e42fd7bf"), + common.HexToHash("0x86835aafc70841ff671b492912db2407511b5a631823f02f8b2bdc0308103a82"), + common.HexToHash("0xd86da47a2e5be3b6c43d25ae86977ce1db075ce9f82ead96d090d7a34a3bdc4f"), + common.HexToHash("0x081d234561620d2bf78176378d004ed891749c04600d1abd053616ce4aed15b4"), + common.HexToHash("0x85e7040a6c668e5fee700b3a3af4b9a0de0806a832f53458860c28ccd3ec92b8"), + common.HexToHash("0xcbb7692c46db878f98bd4ed47f028397ca1508eb381b8eb6f08bc0c2c9844d29"), + common.HexToHash("0x3dd5ebb22e39edc69e6259ef7f3fab49d4e73078e61c5a26737e8a387fa456e6"), + }, + } + + rootHashProof := proofSiblings.BuildRoot() + lhsProof, rhsProof, err := proofSiblings.BuildRootChildren() + + require.NoError(t, err) + assert.Equal(t, expectedRoot, rootHashProof) + assert.Equal(t, lhsProof, expectedLeft) + assert.Equal(t, rhsProof, expectedRight) + assert.Equal(t, expectedRoot, crypto.Keccak256Hash(expectedLeft[:], expectedRight[:])) + assert.Equal(t, rootHashProof, crypto.Keccak256Hash(lhsProof[:], rhsProof[:])) +} diff --git a/internal/merkle/dataset/000_add-1_tree-0.json b/internal/merkle/dataset/000_add-1_tree-0.json new file mode 100644 index 000000000..ae67c6af6 --- /dev/null +++ b/internal/merkle/dataset/000_add-1_tree-0.json @@ -0,0 +1,42 @@ +{ + "proof": {}, + "hashes": [ + { + "hash": "0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d", + "siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + } + ] +} diff --git a/internal/merkle/dataset/001_add-1_tree-1.json b/internal/merkle/dataset/001_add-1_tree-1.json new file mode 100644 index 000000000..67ce342b0 --- /dev/null +++ b/internal/merkle/dataset/001_add-1_tree-1.json @@ -0,0 +1,80 @@ +{ + "proof": { + "index": 0, + "hash": "0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d", + "rootHash": "0x4128b6c65e6131a6823bab8deee051078080bb82d505015976efe2fb3b4c91c0", + "siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + }, + "hashes": [ + { + "hash": "0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d", + "siblings": [ + "0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d", "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + } + ] +} diff --git a/internal/merkle/dataset/002_add-2_tree-1.json b/internal/merkle/dataset/002_add-2_tree-1.json new file mode 100644 index 000000000..2f87a166c --- /dev/null +++ b/internal/merkle/dataset/002_add-2_tree-1.json @@ -0,0 +1,117 @@ +{ + "proof": { + "index": 0, + "hash": "0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d", + "rootHash": "0x4128b6c65e6131a6823bab8deee051078080bb82d505015976efe2fb3b4c91c0", + "siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + }, + "hashes": [ + { + "hash": "0x63da2cf389a9980b9b533491f02924eeda534cbcedd09da15d79acc3f5a71654", + "siblings": [ + "0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d", "0x310fa02d23f2aafb45486f647fca54f1876ec2a67acda1a8162fc07b96c16a20", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + }, { + "hash": "0xeced1fef040083ab9cae22bf938dc7930ca2ebe8054bf9ce172850b648a3f140", + "siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", "0x6e164947f2c49cec7cbef9079d24ca2df71f7748fd7df63a1f5a7be915b2826b", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + } + ] +} + diff --git a/internal/merkle/dataset/003_add-1_tree-2.json b/internal/merkle/dataset/003_add-1_tree-2.json new file mode 100644 index 000000000..e6453a974 --- /dev/null +++ b/internal/merkle/dataset/003_add-1_tree-2.json @@ -0,0 +1,81 @@ +{ + "proof": { + "index": 1, + "rootHash": "0x63da2cf389a9980b9b533491f02924eeda534cbcedd09da15d79acc3f5a71654", + "siblings": [ + "0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d", "0x310fa02d23f2aafb45486f647fca54f1876ec2a67acda1a8162fc07b96c16a20", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + }, + "hashes": [ + { + }, { + "hash": "0xeced1fef040083ab9cae22bf938dc7930ca2ebe8054bf9ce172850b648a3f140", + "siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", "0x6e164947f2c49cec7cbef9079d24ca2df71f7748fd7df63a1f5a7be915b2826b", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + } + ] +} + diff --git a/internal/merkle/dataset/003_add-2_tree-2.json b/internal/merkle/dataset/003_add-2_tree-2.json new file mode 100644 index 000000000..ec9bf9d92 --- /dev/null +++ b/internal/merkle/dataset/003_add-2_tree-2.json @@ -0,0 +1,212 @@ +{ + "proof": { + "index": 1, + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "RootHash": "0x0a162946e56158bac0673e6dd3bdfdc1e4a0e7744a120fdb640050c8d7abe1c6", + "Siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", + "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", + "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", + "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", + "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", + "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", + "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", + "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", + "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", + "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", + "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", + "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", + "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", + "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", + "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", + "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + }, + "hashes": [ + { + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "RootHash": "0x0a162946e56158bac0673e6dd3bdfdc1e4a0e7744a120fdb640050c8d7abe1c6", + "Siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", + "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", + "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", + "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", + "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", + "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", + "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", + "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", + "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", + "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", + "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", + "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", + "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", + "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", + "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", + "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + }, + { + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "RootHash": "0x0a162946e56158bac0673e6dd3bdfdc1e4a0e7744a120fdb640050c8d7abe1c6", + "Siblings": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9", + "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757", + "0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2", + "0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da", + "0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f", + "0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f", + "0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816", + "0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347", + "0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9", + "0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939", + "0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318", + "0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c", + "0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f", + "0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c", + "0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9", + "0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807", + "0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6", + "0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65", + "0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f", + "0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc", + "0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da", + "0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212", + "0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9", + "0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3", + "0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253", + "0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126", + "0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20", + "0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5", + "0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748", + "0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff", + "0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a", + "0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40" + ] + } + ] +} diff --git a/internal/merkle/merkletest/outputs_reference.go b/internal/merkle/merkletest/outputs_reference.go new file mode 100644 index 000000000..c5d48885d --- /dev/null +++ b/internal/merkle/merkletest/outputs_reference.go @@ -0,0 +1,72 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkletest + +import ( + "slices" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const outputsDepth = 63 + +type outputNodeKey struct { + start uint64 + height uint +} + +// OutputHistory is a recursive reference over every preceding output leaf. +// It uses no Merkle builder, context fold, or aligned chunk decomposition. +type OutputHistory struct { + leaves []common.Hash + nodes map[outputNodeKey]common.Hash + pristine [outputsDepth + 1]common.Hash + root common.Hash +} + +// NewOutputHistory copies a complete small history and computes its root recursively. +func NewOutputHistory(leaves []common.Hash) *OutputHistory { + history := &OutputHistory{leaves: slices.Clone(leaves), nodes: make(map[outputNodeKey]common.Hash)} + for height := 1; height <= outputsDepth; height++ { + previous := history.pristine[height-1] + history.pristine[height] = crypto.Keccak256Hash(previous[:], previous[:]) + } + history.root = history.node(0, outputsDepth) + return history +} + +// Root returns the cumulative depth-63 root. +func (history *OutputHistory) Root() common.Hash { return history.root } + +// Proof returns the lowest sibling first for one leaf in the copied history. +func (history *OutputHistory) Proof(index uint64) []common.Hash { + if index >= uint64(len(history.leaves)) { + panic("output reference index is outside the history") + } + siblings := make([]common.Hash, outputsDepth) + for height := uint(0); height < outputsDepth; height++ { + start := ((index >> height) ^ 1) << height + siblings[height] = history.node(start, height) + } + return siblings +} + +func (history *OutputHistory) node(start uint64, height uint) common.Hash { + if start >= uint64(len(history.leaves)) { + return history.pristine[height] + } + if height == 0 { + return history.leaves[start] + } + key := outputNodeKey{start: start, height: height} + if hash, ok := history.nodes[key]; ok { + return hash + } + left := history.node(start, height-1) + right := history.node(start+(uint64(1)<<(height-1)), height-1) + hash := crypto.Keccak256Hash(left[:], right[:]) + history.nodes[key] = hash + return hash +} diff --git a/internal/merkle/merkletest/outputs_reference_test.go b/internal/merkle/merkletest/outputs_reference_test.go new file mode 100644 index 000000000..2e5ce121c --- /dev/null +++ b/internal/merkle/merkletest/outputs_reference_test.go @@ -0,0 +1,40 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkletest + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestOutputHistoryProofsAndCopies(t *testing.T) { + leaves := []common.Hash{{1}, {2}, {3}} + history := NewOutputHistory(leaves) + root := history.Root() + for index, leaf := range leaves { + proof := history.Proof(uint64(index)) + require.Len(t, proof, outputsDepth) + got := leaf + for height, sibling := range proof { + if (index>>height)&1 == 0 { + got = crypto.Keccak256Hash(got[:], sibling[:]) + } else { + got = crypto.Keccak256Hash(sibling[:], got[:]) + } + } + require.Equal(t, root, got) + } + leaves[0] = common.Hash{99} + require.Equal(t, root, history.Root()) + require.Panics(t, func() { history.Proof(uint64(len(leaves))) }) + empty := NewOutputHistory(nil) + var pristine common.Hash + for range outputsDepth { + pristine = crypto.Keccak256Hash(pristine[:], pristine[:]) + } + require.Equal(t, pristine, empty.Root()) +} diff --git a/internal/merkle/merkletest/range_reference.go b/internal/merkle/merkletest/range_reference.go new file mode 100644 index 000000000..ecfd100c9 --- /dev/null +++ b/internal/merkle/merkletest/range_reference.go @@ -0,0 +1,80 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkletest + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// ComputeRootHashFromProof independently opens an output leaf at its global index. +func ComputeRootHashFromProof(index uint64, hash common.Hash, siblings []common.Hash) common.Hash { + for level, sibling := range siblings { + if index>>level&1 == 0 { + hash = crypto.Keccak256Hash(hash[:], sibling[:]) + } else { + hash = crypto.Keccak256Hash(sibling[:], hash[:]) + } + } + return hash +} + +// ComputeSiblingsMatrix recursively opens a small range with arbitrary context roots. +// Contexts avoid allocating the preceding history, including indexes near 2^63. +func ComputeSiblingsMatrix(pre, leaves, post []common.Hash, begin uint64) ([]common.Hash, error) { + if len(leaves) == 0 || len(pre) < outputsDepth || len(post) < outputsDepth { + return nil, fmt.Errorf("missing range leaves or contexts") + } + _, matrix := rangeReference(pre, leaves, post, begin, outputsDepth) + return matrix, nil +} + +// CreateProofs supplies the small dense-output oracle used by integration tests. +func CreateProofs(leaves []common.Hash, height uint) (common.Hash, []common.Hash, error) { + if height > outputsDepth || uint64(len(leaves)) > uint64(1)<= end { + return post[h] + } + if start+(uint64(1)<>level)^1)< 0 { + siblings = make([]common.Hash, height) + } + for level := range height { + siblings[level] = root(runs, ends, total-(uint64(1)<<(level+1)), level) + } + return runs[len(runs)-1].Hash, siblings +} + +// RootChildren returns both halves of a non-leaf tree with Root's input requirements. +func RootChildren(runs []Run, height uint32) (common.Hash, common.Hash) { + ends := endpoints(runs, height) + if height == 0 { + panic("root children require positive height") + } + return root(runs, ends, 0, height-1), root(runs, ends, uint64(1)<<(height-1), height-1) +} + +func endpoints(runs []Run, height uint32) []uint64 { + const maxHeight = 63 + if height > maxHeight { + panic("reference height must be below 64") + } + ends := make([]uint64, len(runs)) + var count uint64 + for i, run := range runs { + if run.Count == 0 || run.Count > math.MaxUint64-count { + panic("invalid reference run count") + } + count += run.Count + ends[i] = count + } + if count != uint64(1)< start }) + if (uint64(1) << height) <= ends[index]-start { + hash := runs[index].Hash + for range height { + hash = crypto.Keccak256Hash(hash[:], hash[:]) + } + return hash + } + left := root(runs, ends, start, height-1) + right := root(runs, ends, start+(uint64(1)<<(height-1)), height-1) + return crypto.Keccak256Hash(left[:], right[:]) +} diff --git a/internal/merkle/merkletest/reference_test.go b/internal/merkle/merkletest/reference_test.go new file mode 100644 index 000000000..aa3c56995 --- /dev/null +++ b/internal/merkle/merkletest/reference_test.go @@ -0,0 +1,44 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkletest + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestRecursiveReferenceMatchesDenseLeaves(t *testing.T) { + a := common.HexToHash("0x1234") + b := common.HexToHash("0x5678") + runs := []Run{{a, 3}, {b, 4}, {a, 1}} + leaves := []common.Hash{a, a, a, b, b, b, b, a} + node := leaves[len(leaves)-1] + var siblings []common.Hash + for len(leaves) > 1 { + siblings = append(siblings, leaves[len(leaves)-2]) + next := make([]common.Hash, len(leaves)/2) + for i := range next { + next[i] = crypto.Keccak256Hash(leaves[2*i][:], leaves[2*i+1][:]) + } + leaves = next + } + require.Equal(t, leaves[0], Root(runs, 3)) + actualNode, actualSiblings := LastProof(runs, 3) + require.Equal(t, node, actualNode) + require.Equal(t, siblings, actualSiblings) + left, right := RootChildren(runs, 3) + require.Equal(t, leaves[0], crypto.Keccak256Hash(left[:], right[:])) +} + +func TestRecursiveReferenceUniformLargeRun(t *testing.T) { + hash := common.HexToHash("0xabcd") + root := hash + for range 54 { + root = crypto.Keccak256Hash(root[:], root[:]) + } + require.Equal(t, root, Root([]Run{{hash, 1 << 54}}, 54)) +} diff --git a/internal/merkle/opaque_test.go b/internal/merkle/opaque_test.go new file mode 100644 index 000000000..53398e61b --- /dev/null +++ b/internal/merkle/opaque_test.go @@ -0,0 +1,52 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestOpaqueTreeHeightZeroIsLeaf(t *testing.T) { + hash := common.HexToHash("0x1234") + require.Equal(t, TreeLeaf(hash), OpaqueTree(hash, 0)) + proof, err := OpaqueTree(hash, 0).ProveLast() + require.NoError(t, err) + require.Equal(t, hash, proof.Node) + require.Empty(t, proof.Siblings) + require.True(t, proof.VerifyRoot(hash)) +} + +func TestOpaqueTreeProofClassification(t *testing.T) { + hash := common.HexToHash("0x1234") + for _, index := range []int64{0, 1} { + _, err := OpaqueTree(hash, 1).ProveLeaf(big.NewInt(index)) + require.ErrorIs(t, err, ErrOpaqueTree) + require.NotErrorIs(t, err, ErrInvariant) + require.ErrorContains(t, err, "proof enters subtree of height 1") + require.ErrorContains(t, err, hash.Hex()) + _, err = (&Tree{RootHash: hash, Height: 1}).ProveLeaf(big.NewInt(index)) + require.ErrorIs(t, err, ErrInvariant) + require.NotErrorIs(t, err, ErrOpaqueTree) + } + _, err := OpaqueTree(hash, 1).ProveLast() + require.ErrorIs(t, err, ErrOpaqueTree) + _, err = OpaqueTree(hash, 1).ProveLeaf(big.NewInt(2)) + require.ErrorIs(t, err, ErrBadInput) +} + +func TestOpaqueTreeCanBeAProofSibling(t *testing.T) { + left := OpaqueTree(common.HexToHash("0x1234"), 1) + right := TreeLeaf(common.HexToHash("0x5678")).Iterated(1) + tree := left.Join(right) + proof, err := tree.ProveLast() + require.NoError(t, err) + require.True(t, proof.VerifyRoot(tree.RootHash)) + require.Equal(t, left.RootHash, proof.Siblings[1]) + _, err = tree.ProveLeaf(big.NewInt(0)) + require.ErrorIs(t, err, ErrOpaqueTree) +} diff --git a/internal/merkle/outputs_cases_race_test.go b/internal/merkle/outputs_cases_race_test.go new file mode 100644 index 000000000..7dd417fe7 --- /dev/null +++ b/internal/merkle/outputs_cases_race_test.go @@ -0,0 +1,11 @@ +//go:build race + +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +const ( + outputsDifferentialCases = 100 + outputsReferenceHistories = 4 +) diff --git a/internal/merkle/outputs_cases_test.go b/internal/merkle/outputs_cases_test.go new file mode 100644 index 000000000..44f6de4a9 --- /dev/null +++ b/internal/merkle/outputs_cases_test.go @@ -0,0 +1,11 @@ +//go:build !race + +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +const ( + outputsDifferentialCases = 2000 + outputsReferenceHistories = 40 +) diff --git a/internal/merkle/outputs_tree.go b/internal/merkle/outputs_tree.go new file mode 100644 index 000000000..b1e240f0c --- /dev/null +++ b/internal/merkle/outputs_tree.go @@ -0,0 +1,241 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "errors" + "fmt" + "iter" + "slices" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const outputParentWidth = 2 + +// ErrOutputsChanged identifies different leaves or counts between the two passes. +var ErrOutputsChanged = errors.New("outputs changed between passes") + +type outputTreeRange struct { + begin, end uint64 + chunkHeight uint +} + +// OutputsTreeBuilder collects one root per globally aligned chunk. +// A builder belongs to one pass and is not safe for concurrent use. +type OutputsTreeBuilder struct { + outputTreeRange + pre, post []common.Hash + next, chunkBegin uint64 + buffer, roots []common.Hash +} + +// NewOutputsTreeBuilder starts a nonempty range in the depth-63 outputs tree. +func NewOutputsTreeBuilder( + pre, post []common.Hash, + begin, count uint64, + chunkHeight uint, +) (*OutputsTreeBuilder, error) { + capacity := uint64(1) << TREE_DEPTH + if len(pre) < TREE_DEPTH || len(post) < TREE_DEPTH { + return nil, fmt.Errorf("outputs contexts require %d hashes: %w", TREE_DEPTH, ErrBadInput) + } + if chunkHeight > TREE_DEPTH || count == 0 || begin >= capacity || count > capacity-begin { + return nil, fmt.Errorf("invalid outputs range or chunk height: %w", ErrBadInput) + } + return &OutputsTreeBuilder{ + outputTreeRange: outputTreeRange{begin: begin, end: begin + count, chunkHeight: chunkHeight}, + pre: slices.Clone(pre[:TREE_DEPTH]), + post: slices.Clone(post[:TREE_DEPTH]), + next: begin, + chunkBegin: begin, + }, nil +} + +// ChunkRanges yields inclusive output-index ranges for the first pass. +func (builder *OutputsTreeBuilder) ChunkRanges() iter.Seq2[uint64, uint64] { + return outputChunkRanges(builder.outputTreeRange) +} + +// Append adds a hash from the declared range without retaining the epoch's leaves. +// The caller validates the scanned row index and count. +func (builder *OutputsTreeBuilder) Append(hash common.Hash) { + builder.buffer = append(builder.buffer, hash) + builder.next++ + if builder.next == outputChunkEnd(builder.chunkBegin, builder.outputTreeRange) { + root := foldOutputChunk(builder.buffer, builder.chunkBegin, builder.pre, builder.post, builder.chunkHeight, nil) + builder.roots = append(builder.roots, root) + builder.buffer = builder.buffer[:0] + builder.chunkBegin = builder.next + } +} + +// Build keeps the chunk roots after the caller validates the complete range. +func (builder *OutputsTreeBuilder) Build() *OutputsTree { + levels := [][]common.Hash{builder.roots} + begin := builder.begin >> builder.chunkHeight + for level := builder.chunkHeight; level < TREE_DEPTH; level++ { + parents := foldOutputParents(nil, levels[len(levels)-1], begin, builder.pre[level], builder.post[level]) + levels = append(levels, parents) + begin /= outputParentWidth + } + return &OutputsTree{ + outputTreeRange: builder.outputTreeRange, + pre: builder.pre, + post: builder.post, + levels: levels, + } +} + +// OutputsTree retains chunk roots and their ancestors for the second pass. +// It is immutable after Build and can supply independent provers. +type OutputsTree struct { + outputTreeRange + pre, post []common.Hash + levels [][]common.Hash +} + +// Root returns the cumulative outputs root for this epoch. +func (tree *OutputsTree) Root() common.Hash { + return tree.levels[len(tree.levels)-1][0] +} + +// ChunkRanges yields the same inclusive ranges as the first-pass builder. +func (tree *OutputsTree) ChunkRanges() iter.Seq2[uint64, uint64] { + return outputChunkRanges(tree.outputTreeRange) +} + +// ChunkProofs owns the original leaves and their flat, depth-63 sibling matrix. +type ChunkProofs struct { + Leaves []common.Hash + Siblings []common.Hash +} + +// OutputsProver rebuilds one chunk at a time and checks it against pass one. +// A prover is not safe for concurrent use. +type OutputsProver struct { + tree *OutputsTree + next, chunkBegin uint64 + buffer []common.Hash +} + +// NewProver creates an independent second pass over the tree's range. +func (tree *OutputsTree) NewProver() *OutputsProver { + return &OutputsProver{tree: tree, next: tree.begin, chunkBegin: tree.begin} +} + +// Append returns proofs when the next hash completes its aligned chunk. +// The caller validates row counts and stops at the first error. +func (prover *OutputsProver) Append(hash common.Hash) (*ChunkProofs, error) { + prover.buffer = append(prover.buffer, hash) + prover.next++ + if prover.next != outputChunkEnd(prover.chunkBegin, prover.tree.outputTreeRange) { + return nil, nil + } + proofs, err := prover.proveChunk() + if err != nil { + return nil, err + } + prover.buffer = prover.buffer[:0] + prover.chunkBegin = prover.next + return proofs, nil +} + +func (prover *OutputsProver) proveChunk() (*ChunkProofs, error) { + tree := prover.tree + leaves := slices.Clone(prover.buffer) + siblings := make([]common.Hash, len(leaves)*TREE_DEPTH) + root := foldOutputChunk(prover.buffer, prover.chunkBegin, tree.pre, tree.post, tree.chunkHeight, siblings) + chunkIndex := (prover.chunkBegin >> tree.chunkHeight) - (tree.begin >> tree.chunkHeight) + if root != tree.levels[0][chunkIndex] { + return nil, fmt.Errorf("output chunk root differs from first pass: %w", ErrOutputsChanged) + } + for level := tree.chunkHeight; level < TREE_DEPTH; level++ { + nodes := tree.levels[level-tree.chunkHeight] + begin := tree.begin >> level + end := begin + uint64(len(nodes)) + index := (prover.chunkBegin >> level) ^ 1 + sibling := outputNode(nodes, begin, end, index, tree.pre[level], tree.post[level]) + for leaf := range leaves { + siblings[leaf*TREE_DEPTH+int(level)] = sibling + } + } + return &ChunkProofs{Leaves: leaves, Siblings: siblings}, nil +} + +func outputChunkRanges(span outputTreeRange) iter.Seq2[uint64, uint64] { + return func(yield func(uint64, uint64) bool) { + for first := span.begin; first < span.end; { + end := outputChunkEnd(first, span) + if !yield(first, end-1) { + return + } + first = end + } + } +} + +func outputChunkEnd(first uint64, span outputTreeRange) uint64 { + mask := (uint64(1) << span.chunkHeight) - 1 + return min(span.end, (first|mask)+1) +} + +func foldOutputChunk( + nodes []common.Hash, + first uint64, + pre, post []common.Hash, + height uint, + siblings []common.Hash, +) common.Hash { + leafCount := len(nodes) + begin := first + for level := uint(0); level < height; level++ { + end := begin + uint64(len(nodes)) + if siblings != nil { + for leaf := range leafCount { + index := ((first + uint64(leaf)) >> level) ^ 1 + siblings[leaf*TREE_DEPTH+int(level)] = outputNode(nodes, begin, end, index, pre[level], post[level]) + } + } + nodes = foldOutputParents(nodes, nodes, begin, pre[level], post[level]) + begin /= outputParentWidth + } + return nodes[0] +} + +// foldOutputParents serves both in-place chunk folds and retained upper levels. +func foldOutputParents( + destination, children []common.Hash, + begin uint64, + before, after common.Hash, +) []common.Hash { + end := begin + uint64(len(children)) + parentCount := len(children) / outputParentWidth + if begin%outputParentWidth != 0 || len(children)%outputParentWidth != 0 { + parentCount++ + } + if destination == nil { + destination = make([]common.Hash, parentCount) + } else { + destination = destination[:parentCount] + } + parentBegin, parentEnd := begin/outputParentWidth, (end+1)/outputParentWidth + for parent := parentBegin; parent < parentEnd; parent++ { + left := outputNode(children, begin, end, outputParentWidth*parent, before, after) + right := outputNode(children, begin, end, outputParentWidth*parent+1, before, after) + destination[parent-parentBegin] = crypto.Keccak256Hash(left[:], right[:]) + } + return destination +} + +func outputNode(nodes []common.Hash, begin, end, index uint64, before, after common.Hash) common.Hash { + if index < begin { + return before + } + if index >= end { + return after + } + return nodes[index-begin] +} diff --git a/internal/merkle/outputs_tree_test.go b/internal/merkle/outputs_tree_test.go new file mode 100644 index 000000000..cf0229e75 --- /dev/null +++ b/internal/merkle/outputs_tree_test.go @@ -0,0 +1,317 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "encoding/binary" + "math" + "math/rand/v2" + "slices" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle/merkletest" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + outputsMaxCaseLeaves = 600 + outputsRightSpan = 5000 + outputsSmallSpan = 64 + outputsMiddleSpan = 1 << 14 + outputsCaseClasses = 6 + outputsRareHeight = 10 + outputsSmallHeights = 9 + outputsEpochs = 6 + outputsEpochLeaves = 700 + outputsReferenceHeightCount = 7 + outputsReferencePowerCount = 10 +) + +func outputTestHash(index uint64) common.Hash { + var hash common.Hash + binary.BigEndian.PutUint64(hash[:], index) + hash[len(hash)-1] = 1 + return hash +} + +func outputTestRandom(first, second uint64) *rand.Rand { + return rand.New(rand.NewPCG(first, second)) //nolint:gosec // Fixed seeds make differential fixtures reproducible. +} + +func outputTestPreContext(rng *rand.Rand, begin uint64, post []common.Hash) []common.Hash { + if begin == 0 { + return slices.Clone(post) + } + siblings := make([]common.Hash, TREE_DEPTH) + for index := range siblings { + siblings[index] = outputTestHash(rng.Uint64()) + } + return CreatePreContextFromProof(begin-1, outputTestHash(rng.Uint64()), siblings) +} + +func outputTestRanges(ranges func(func(uint64, uint64) bool)) [][2]uint64 { + var result [][2]uint64 + for first, last := range ranges { + result = append(result, [2]uint64{first, last}) + } + return result +} + +func streamOutputTest( + t *testing.T, + pre, post, leaves []common.Hash, + begin uint64, + height uint, +) (common.Hash, []common.Hash) { + t.Helper() + builder, err := NewOutputsTreeBuilder(pre, post, begin, uint64(len(leaves)), height) + require.NoError(t, err) + for first, last := range builder.ChunkRanges() { + for index := first; index <= last; index++ { + builder.Append(leaves[index-begin]) + } + } + tree := builder.Build() + require.Equal(t, outputTestRanges(builder.ChunkRanges()), outputTestRanges(tree.ChunkRanges())) + prover := tree.NewProver() + matrix := make([]common.Hash, len(leaves)*TREE_DEPTH) + for first, last := range tree.ChunkRanges() { + var chunk *ChunkProofs + for index := first; index <= last; index++ { + chunk, err = prover.Append(leaves[index-begin]) + require.NoError(t, err) + if index != last { + require.Nil(t, chunk) + } + } + require.NotNil(t, chunk) + require.Equal(t, leaves[first-begin:last-begin+1], chunk.Leaves) + require.Len(t, chunk.Siblings, len(chunk.Leaves)*TREE_DEPTH) + copy(matrix[(first-begin)*TREE_DEPTH:], chunk.Siblings) + } + return tree.Root(), matrix +} + +func compareOutputsWithMatrix(t *testing.T, rng *rand.Rand, begin, count uint64, height uint) { + t.Helper() + values := []common.Hash{{1}, {2}, {3}, {4}} + colors := values[:rng.IntN(len(values))+1] + leaves := make([]common.Hash, count) + for index := range leaves { + leaves[index] = colors[rng.IntN(len(colors))] + } + post := CreatePostContext() + pre := outputTestPreContext(rng, begin, post) + want, err := merkletest.ComputeSiblingsMatrix(pre, leaves, post, begin) + require.NoError(t, err) + root, got := streamOutputTest(t, pre, post, leaves, begin, height) + require.Equal(t, want, got) + require.Equal(t, merkletest.ComputeRootHashFromProof(begin, leaves[0], want[:TREE_DEPTH]), root) +} + +func TestOutputsTreeFixedBoundaries(t *testing.T) { + rng := outputTestRandom(0, 0) + const ( + capacity = uint64(1) << TREE_DEPTH + mixedBegin = 13 + mixedCount = 21 + mixedHeight = 4 + rightCount = 17 + ) + for _, height := range []uint{0, TREE_DEPTH} { + compareOutputsWithMatrix(t, rng, 0, 1, height) + compareOutputsWithMatrix(t, rng, mixedBegin, mixedCount, height) + compareOutputsWithMatrix(t, rng, capacity-1, 1, height) + compareOutputsWithMatrix(t, rng, capacity-rightCount, rightCount, height) + } + compareOutputsWithMatrix(t, rng, mixedBegin, mixedCount, mixedHeight) + post := CreatePostContext() + builder, err := NewOutputsTreeBuilder(post, post, mixedBegin, mixedCount, mixedHeight) + require.NoError(t, err) + require.Equal(t, [][2]uint64{{13, 15}, {16, 31}, {32, 33}}, outputTestRanges(builder.ChunkRanges())) + var calls int + for range builder.ChunkRanges() { + calls++ + break + } + require.Equal(t, 1, calls) + builder, err = NewOutputsTreeBuilder(post, post, 0, capacity, TREE_DEPTH) + require.NoError(t, err) + require.Equal(t, [][2]uint64{{0, capacity - 1}}, outputTestRanges(builder.ChunkRanges())) +} + +func TestOutputsTreeChunkRangesAreSharedAndAligned(t *testing.T) { + const begin, count, height = 13, 21, 4 + post := CreatePostContext() + builder, err := NewOutputsTreeBuilder(post, post, begin, count, height) + require.NoError(t, err) + want := [][2]uint64{{13, 15}, {16, 31}, {32, 33}} + require.Equal(t, want, outputTestRanges(builder.ChunkRanges())) + for range count { + builder.Append(common.Hash{1}) + } + tree := builder.Build() + require.Equal(t, want, outputTestRanges(tree.ChunkRanges())) +} + +func TestOutputsTreeEqualsComputeSiblingsMatrix(t *testing.T) { + rng := outputTestRandom(1, 1) + const capacity = uint64(1) << TREE_DEPTH + for index := range outputsDifferentialCases { + count := rng.Uint64N(outputsMaxCaseLeaves) + 1 + var begin uint64 + switch index % outputsCaseClasses { + case 0: + case 1: + begin = rng.Uint64N(outputsSmallSpan) + case 2: + begin = rng.Uint64N(outputsMiddleSpan) + case 3: + begin = (uint64(1) << (rng.Uint64N(TREE_DEPTH) + 1)) - 1 + case 4: + begin = (rng.Uint64N(capacity/outputParentWidth) * outputParentWidth) + 1 + case 5: + begin = capacity - rng.Uint64N(outputsRightSpan) - 1 + } + count = min(count, capacity-begin) + height := uint(rng.Uint64N(outputsSmallHeights)) + if index%outputsRareHeight == 0 { + height = uint(rng.Uint64N(TREE_DEPTH + 1)) + } + compareOutputsWithMatrix(t, rng, begin, count, height) + } +} + +func TestOutputsTreeEqualsReference(t *testing.T) { + rng := outputTestRandom(1, 0) + post := CreatePostContext() + for range outputsReferenceHistories { + history := make([]common.Hash, 0, outputsEpochLeaves*outputsEpochs) + var previous *merkletest.OutputHistory + for range outputsEpochs { + begin := uint64(len(history)) + count := rng.Uint64N(outputsEpochLeaves) + 1 + if rng.Uint64N(outputParentWidth) == 0 { + count = uint64(1) << rng.Uint64N(outputsReferencePowerCount) + } + leaves := make([]common.Hash, count) + for index := range leaves { + leaves[index] = outputTestHash(rng.Uint64()) + } + pre := post + if begin > 0 { + pre = CreatePreContextFromProof(begin-1, history[begin-1], previous.Proof(begin-1)) + } + history = append(history, leaves...) + current := merkletest.NewOutputHistory(history) + height := uint(rng.Uint64N(outputsReferenceHeightCount)) + root, siblings := streamOutputTest(t, pre, post, leaves, begin, height) + require.Equal(t, current.Root(), root) + for offset := range count { + require.Equal(t, current.Proof(begin+offset), siblings[offset*TREE_DEPTH:(offset+1)*TREE_DEPTH]) + } + previous = current + } + } +} + +func TestOutputsTreeRejectsBadBuilderInput(t *testing.T) { + post := CreatePostContext() + const capacity = uint64(1) << TREE_DEPTH + for _, test := range []struct { + pre, post []common.Hash + begin, count uint64 + height uint + }{ + {nil, post, 0, 1, 0}, {post, nil, 0, 1, 0}, + {post[:TREE_DEPTH-1], post, 0, 1, 0}, {post, post[:TREE_DEPTH-1], 0, 1, 0}, + {post, post, 0, 0, 0}, {post, post, capacity, 1, 0}, + {post, post, capacity - 1, outputParentWidth, 0}, + {post, post, 0, math.MaxUint64, 0}, {post, post, 0, 1, TREE_DEPTH + 1}, + } { + builder, err := NewOutputsTreeBuilder(test.pre, test.post, test.begin, test.count, test.height) + require.ErrorIs(t, err, ErrBadInput) + require.Nil(t, builder) + } + +} + +func buildOutputTestTree(t *testing.T) (*OutputsTree, []common.Hash) { + t.Helper() + const count = 3 + post := CreatePostContext() + leaves := []common.Hash{{1}, {2}, {3}} + builder, err := NewOutputsTreeBuilder(post, post, 0, count, 1) + require.NoError(t, err) + for _, hash := range leaves { + builder.Append(hash) + } + tree := builder.Build() + return tree, leaves +} + +func TestOutputsProverRejectsChangedRows(t *testing.T) { + tree, leaves := buildOutputTestTree(t) + prover := tree.NewProver() + _, err := prover.Append(leaves[0]) + require.NoError(t, err) + _, err = prover.Append(common.Hash{99}) + require.ErrorIs(t, err, ErrOutputsChanged) +} + +func TestOutputsTreeIndependentConcurrentProvers(t *testing.T) { + tree, leaves := buildOutputTestTree(t) + root := tree.Root() + t.Run("original", func(t *testing.T) { + t.Parallel() + prover := tree.NewProver() + for _, hash := range leaves { + _, err := prover.Append(hash) + require.NoError(t, err) + } + require.Equal(t, root, tree.Root()) + }) + t.Run("changed", func(t *testing.T) { + t.Parallel() + prover := tree.NewProver() + _, err := prover.Append(leaves[0]) + require.NoError(t, err) + _, err = prover.Append(common.Hash{99}) + require.ErrorIs(t, err, ErrOutputsChanged) + require.Equal(t, root, tree.Root()) + }) +} + +func TestOutputsTreeCopiesLeavesAndContexts(t *testing.T) { + post := CreatePostContext() + pre := slices.Clone(post) + leaves := []common.Hash{{1}, {2}, {3}} + const begin = 1 + want, err := merkletest.ComputeSiblingsMatrix(pre, leaves, post, begin) + require.NoError(t, err) + builder, err := NewOutputsTreeBuilder(pre, post, begin, uint64(len(leaves)), 1) + require.NoError(t, err) + for index := range pre { + pre[index] = common.Hash{99} + } + for index := range post { + post[index] = common.Hash{98} + } + for _, hash := range leaves { + builder.Append(hash) + } + tree := builder.Build() + prover := tree.NewProver() + first, err := prover.Append(leaves[0]) + require.NoError(t, err) + require.Equal(t, leaves[:1], first.Leaves) + retained := slices.Clone(first.Leaves) + for _, hash := range leaves[1:] { + _, err = prover.Append(hash) + require.NoError(t, err) + } + require.Equal(t, retained, first.Leaves) + require.Equal(t, want[:TREE_DEPTH], first.Siblings) +} diff --git a/internal/merkle/proof.go b/internal/merkle/proof.go new file mode 100644 index 000000000..f8fcbcefc --- /dev/null +++ b/internal/merkle/proof.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + TREE_DEPTH = 63 +) + +func dupArray(in []common.Hash) []common.Hash { + out := make([]common.Hash, len(in)) + copy(out, in) + return out +} + +// CreatePreContextFromProof creates the pre context for cumulative output proofs. +func CreatePreContextFromProof( + index uint64, + hash common.Hash, + siblings []common.Hash, +) []common.Hash { + preContext := dupArray(siblings) // preserve argument + + level := 0 + for ; level < TREE_DEPTH; level++ { + from := index >> level & 1 + to := (index + 1) >> level & 1 + if from == 0 && to == 1 { + break + } + hash = crypto.Keccak256Hash(siblings[level][:], hash[:]) + } + preContext[level] = hash + return preContext +} + +// CreatePostContext creates the post context for cumulative output proofs. +func CreatePostContext() []common.Hash { + pristine := make([]common.Hash, TREE_DEPTH+1) + for i := 1; i <= TREE_DEPTH; i++ { + pristine[i] = crypto.Keccak256Hash(pristine[i-1][:], pristine[i-1][:]) + } + return pristine +} diff --git a/internal/merkle/proof_test.go b/internal/merkle/proof_test.go new file mode 100644 index 000000000..ee8418658 --- /dev/null +++ b/internal/merkle/proof_test.go @@ -0,0 +1,160 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "encoding/json" + "os" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle/merkletest" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" +) + +type merkleProof struct { + Index uint64 `json:"index"` + Hash common.Hash `json:"hash"` + RootHash common.Hash `json:"RootHash"` + Siblings []common.Hash `json:"Siblings"` +} + +type merkleHashEntry struct { + Hash common.Hash `json:"hash"` + RootHash common.Hash `json:"RootHash"` + Siblings []common.Hash `json:"Siblings"` +} + +type merkleDataPoint struct { + Proof merkleProof `json:"proof"` + Hashes []merkleHashEntry `json:"hashes"` +} + +func load(t *testing.T, fileName string) merkleDataPoint { + var mdp merkleDataPoint + + contents, err := os.ReadFile(fileName) + assert.Nil(t, err) + + assert.Nil(t, json.Unmarshal(contents, &mdp)) + return mdp +} + +// Template function to create new datasets +//func TestCreateProof(t *testing.T) { +// leaves := []common.Hash{ +// common.HexToHash("0x00"), +// common.HexToHash("0x00"), +// } +// root, siblings, _ := merkletest.CreateProofs(leaves, TREE_DEPTH) +// +// mdp := merkleDataPoint{ +// Proof: merkleProof{ +// Index: uint64(len(leaves)-1), +// Hash: leaves[0], +// RootHash: root, +// Siblings: siblings[len(siblings)-TREE_DEPTH:], // take last +// }, +// Hashes: []merkleHashEntry{ +// { +// Hash: leaves[0], +// RootHash: root, +// Siblings: siblings[:TREE_DEPTH], +// }, { +// Hash: leaves[0], +// RootHash: root, +// Siblings: siblings[TREE_DEPTH:2*TREE_DEPTH], +// }, +// }, +// } +// s, err := json.MarshalIndent(mdp, "", "\t") +// assert.Nil(t, err) +// fmt.Println(string(s)) +//} + +func TestRootHash(t *testing.T) { + leaves := []common.Hash{ + common.HexToHash("0x00"), + } + root, siblings, _ := merkletest.CreateProofs(leaves, TREE_DEPTH) + + postContext := CreatePostContext() + assert.Equal(t, root, postContext[TREE_DEPTH]) + + siblings2, _ := merkletest.ComputeSiblingsMatrix(postContext, leaves, postContext, 0) + assert.Equal(t, siblings, siblings2) +} + +func TestIncorrectCreateProofsLevel(t *testing.T) { + leaves := []common.Hash{ + common.HexToHash("0x00"), + common.HexToHash("0x00"), + } + _, _, err := merkletest.CreateProofs(leaves, 0) + assert.NotNil(t, err) +} + +func TestComputSiblingsMatrixAssertions(t *testing.T) { + var err error + outputs := []common.Hash{{}} + post := CreatePostContext() // always the same + pre := post + index := uint64(0) + + _, err = merkletest.ComputeSiblingsMatrix(pre, []common.Hash{}, post, index) + assert.NotNil(t, err) + + _, err = merkletest.ComputeSiblingsMatrix(nil, outputs, post, index) + assert.NotNil(t, err) + + _, err = merkletest.ComputeSiblingsMatrix(pre, outputs, nil, index) + assert.NotNil(t, err) +} + +// check if creating a pre context from proof and then computing the siblings +// with ComputeSiblingsMatrix matches the reference values encoded in json. +func TestComputeSiblingsMatrix(t *testing.T) { + post := CreatePostContext() // always the same + dataSet := []string{ + "dataset/000_add-1_tree-0.json", // insert 1 element on a merkle with 0 elements + "dataset/001_add-1_tree-1.json", // insert 1 element on a merkle with 1 element + "dataset/002_add-2_tree-1.json", // insert 2 element on a merkle with 1 element + "dataset/003_add-2_tree-2.json", // insert 2 element on a merkle with 2 element + } + for _, testName := range dataSet { + t.Run(testName, func(t *testing.T) { + dataPoint := load(t, testName) + proof := &dataPoint.Proof + hashes := dataPoint.Hashes + + // pristine if there is no previous state + pre := post + index := uint64(0) + + // compute `pre` if the merkle has state + if len(proof.Siblings) != 0 { + // sanity check. Proof must comput rootHash + assert.Equal(t, proof.RootHash, + merkletest.ComputeRootHashFromProof(proof.Index, proof.Hash, proof.Siblings)) + pre = CreatePreContextFromProof(proof.Index, proof.Hash, proof.Siblings) + index = proof.Index + 1 + } + + // gather hashes + outputs := []common.Hash{} + for _, entry := range hashes { + outputs = append(outputs, entry.Hash) + } + + // compute siblings. must match reference. + siblings, err := merkletest.ComputeSiblingsMatrix(pre, outputs, post, index) + assert.Nil(t, err) + for i := range outputs { + begin := TREE_DEPTH * i + end := TREE_DEPTH * (i + 1) + assert.Equal(t, hashes[i].Siblings, siblings[begin:end]) + } + }) + } +} diff --git a/internal/merkle/streaming_builder.go b/internal/merkle/streaming_builder.go new file mode 100644 index 000000000..8fb3b6899 --- /dev/null +++ b/internal/merkle/streaming_builder.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +import ( + "fmt" + "hash" + "math" + "math/bits" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto/keccak" +) + +// StreamingBuilder builds trees with memory independent of the number of appends. +// It keeps one root per level and the last aligned block of the latest run. +// Build retains the last leaf for proof generation. Other indices can return +// ErrOpaqueTree; every successful proof is correct. An opaque last entry above +// height zero cannot be proved, by this representation. +// +// AppendRepeated and Build reject invalid inputs, with two special cases: +// totals of 2^64 entries or more overflow, and height is checked before overflow. +// The zero value is ready to use. It is not safe for concurrent use. +type StreamingBuilder struct { + frontier [64]common.Hash + count uint64 // Carried entries, excluding the pending block. + pendingTree *Tree // A non-nil pointer also marks initialization. + pendingRoot common.Hash + pendingPower uint32 + hasher hash.Hash + hashInput [2 * common.HashLength]byte + hashOutput common.Hash +} + +func (b *StreamingBuilder) Append(leaf *Tree) error { + return b.AppendRepeated(leaf, 1) +} + +// AppendRepeated appends reps equal-height entries. A rejected append changes nothing. +func (b *StreamingBuilder) AppendRepeated(leaf *Tree, reps uint64) error { + if leaf == nil { + return fmt.Errorf("invalid parameter: %w", ErrBadInput) + } + if reps == 0 { + return fmt.Errorf("invalid repetitions: %v: %w", reps, ErrBadInput) + } + var total uint64 + if b.pendingTree != nil { + if b.pendingTree.Height != leaf.Height { + return fmt.Errorf("mismatched tree sizes, height: %v and leaf height: %v: %w", b.pendingTree.Height, leaf.Height, ErrBadInput) + } + total = b.count + (uint64(1) << b.pendingPower) + } + if reps > math.MaxUint64-total { + return fmt.Errorf("merkle tree overflow: %w", ErrBadInput) + } + + if b.pendingTree != nil { + b.carry(b.pendingRoot, b.pendingPower) + } + var repeated [64]common.Hash + repeated[0] = leaf.RootHash + var computed uint32 + for remaining := reps; remaining != 0; { + // remaining is positive, so its logarithm and the aligned power are in [0, 63]. + power := uint32(min(bits.TrailingZeros64(b.count), bits.Len64(remaining)-1)) //nolint:gosec + for computed < power { + repeated[computed+1] = b.hashPair(repeated[computed], repeated[computed]) + computed++ + } + block := uint64(1) << power + if remaining == block { + b.pendingTree = leaf + b.pendingRoot = repeated[power] + b.pendingPower = power + return nil + } + b.carry(repeated[power], power) + remaining -= block + } + return nil +} + +func (b *StreamingBuilder) carry(root common.Hash, power uint32) { + level := power + for b.count&(uint64(1)< 0 { + left, right := merkletest.RootChildren(runs, height) + actualLeft, actualRight, err := tree.Subtrees.Children() + require.NoError(t, err) + require.Equal(t, left, actualLeft.RootHash) + require.Equal(t, right, actualRight.RootHash) + proofLeft, proofRight, err := proof.BuildRootChildren() + require.NoError(t, err) + require.Equal(t, left, proofLeft) + require.Equal(t, right, proofRight) + } +} + +func TestStreamingBuilderErrors(t *testing.T) { + a := TreeLeaf(common.HexToHash("0x1234")) + b := TreeLeaf(common.HexToHash("0x5678")) + var streaming StreamingBuilder + _, err := streaming.Build() + require.EqualError(t, err, "empty merkle builder: merkle: invalid input") + cases := []struct { + name string + leaf *Tree + reps uint64 + text string + }{ + {"nil", nil, 1, "invalid parameter: merkle: invalid input"}, + {"nil before repetitions", nil, 0, "invalid parameter: merkle: invalid input"}, + {"zero", a, 0, "invalid repetitions: 0: merkle: invalid input"}, + } + for _, test := range cases { + before := streaming + require.EqualError(t, streaming.AppendRepeated(test.leaf, test.reps), test.text, test.name) + require.Equal(t, before, streaming) + } + require.NoError(t, streaming.Append(a)) + for _, test := range cases { + before := streaming + require.EqualError(t, streaming.AppendRepeated(test.leaf, test.reps), test.text, test.name) + require.Equal(t, before, streaming) + } + before := streaming + require.EqualError(t, streaming.Append(a.Iterated(1)), + "mismatched tree sizes, height: 0 and leaf height: 1: merkle: invalid input") + require.Equal(t, before, streaming) + require.EqualError(t, streaming.AppendRepeated(a.Iterated(1), 0), "invalid repetitions: 0: merkle: invalid input") + require.Equal(t, before, streaming) + require.NoError(t, streaming.AppendRepeated(b, 2)) + _, err = streaming.Build() + require.EqualError(t, err, "builder has 3 leafs, which is not a power of two: merkle: invalid input") + require.NoError(t, streaming.Append(a)) + first, err := streaming.Build() + require.NoError(t, err) + second, err := streaming.Build() + require.NoError(t, err) + require.Equal(t, first, second) + require.NoError(t, streaming.AppendRepeated(b, 4)) + assertStreamingReference(t, &streaming, []merkletest.Run{ + {Hash: a.RootHash, Count: 1}, {Hash: b.RootHash, Count: 2}, + {Hash: a.RootHash, Count: 1}, {Hash: b.RootHash, Count: 4}}, 3) +} + +func TestStreamingBuilderUint64Boundary(t *testing.T) { + leaf := TreeLeaf(common.HexToHash("0xabcd")) + var streaming StreamingBuilder + require.NoError(t, streaming.AppendRepeated(leaf, uint64(1)<<63)) + assertStreamingReference(t, &streaming, []merkletest.Run{{Hash: leaf.RootHash, Count: uint64(1) << 63}}, 63) + require.NoError(t, streaming.AppendRepeated(leaf, (uint64(1)<<63)-1)) + for _, reps := range []uint64{1, 2, math.MaxUint64} { + before := streaming + require.EqualError(t, streaming.AppendRepeated(leaf, reps), "merkle tree overflow: merkle: invalid input") + require.Equal(t, before, streaming) + } + before := streaming + require.EqualError(t, streaming.AppendRepeated(leaf.Iterated(1), 1), + "mismatched tree sizes, height: 0 and leaf height: 1: merkle: invalid input") + require.Equal(t, before, streaming) + var maximum StreamingBuilder + require.NoError(t, maximum.AppendRepeated(leaf, math.MaxUint64)) + _, err := maximum.Build() + require.EqualError(t, err, "builder has 18446744073709551615 leafs, which is not a power of two: merkle: invalid input") +} + +func TestStreamingBuilderOpaqueLastLeaf(t *testing.T) { + leaf := TreeLeaf(common.HexToHash("0xcafe")).Iterated(6) + opaque := OpaqueTree(common.HexToHash("0xface"), 6) + var streaming StreamingBuilder + require.NoError(t, streaming.AppendRepeated(leaf, (uint64(1)<<48)-1)) + require.NoError(t, streaming.Append(opaque)) + tree, err := streaming.Build() + require.NoError(t, err) + require.Equal(t, uint32(54), tree.Height) + runs := []merkletest.Run{{Hash: leaf.RootHash, Count: (uint64(1) << 48) - 1}, {Hash: opaque.RootHash, Count: 1}} + require.Equal(t, merkletest.Root(runs, 48), tree.RootHash) + _, err = tree.ProveLast() + require.ErrorIs(t, err, ErrOpaqueTree) +} + +func TestStreamingBuilderMatchesRecursiveReference(t *testing.T) { + t.Run("other indices", assertStreamingProofsAtOtherIndices) + rng := rand.New(rand.NewPCG(23, 71)) //nolint:gosec // Reproducible test cases, not security material. + for trial := range streamingDifferentialCases { + t.Run(fmt.Sprint(trial), func(t *testing.T) { + entryHeight := rng.Uint32N(7) + entries := uint64(1) << rng.Uint32N(56) + var runs []merkletest.Run + var streaming StreamingBuilder + for remaining := entries; remaining != 0; { + reps := 1 + rng.Uint64N(remaining) + hash := streamingHash(rng) + structured := TreeLeaf(hash).Iterated(uint64(entryHeight)) + entry := structured + // Keep the last entry structured; earlier opaque entries have known leaf contents. + if reps != remaining && trial%2 == 0 { + entry = OpaqueTree(structured.RootHash, entryHeight) + } + require.NoError(t, streaming.AppendRepeated(entry, reps)) + runs = append(runs, merkletest.Run{Hash: hash, Count: reps << entryHeight}) + remaining -= reps + } + height := uint32(bits.TrailingZeros64(entries)) + entryHeight //nolint:gosec // At most 55 entry levels. + tree, err := streaming.Build() + require.NoError(t, err) + require.Equal(t, height, tree.Height) + require.Equal(t, merkletest.Root(runs, height), tree.RootHash) + proof, err := tree.ProveLast() + require.NoError(t, err) + node, siblings := merkletest.LastProof(runs, height) + require.Equal(t, node, proof.Node) + require.Equal(t, siblings, proof.Siblings) + require.Equal(t, new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), uint(height)), big.NewInt(1)), proof.Pos) + require.True(t, proof.VerifyRoot(tree.RootHash)) + if height > 0 { + left, right := merkletest.RootChildren(runs, height) + actualLeft, actualRight, err := tree.Subtrees.Children() + require.NoError(t, err) + require.Equal(t, left, actualLeft.RootHash) + require.Equal(t, right, actualRight.RootHash) + proofLeft, proofRight, err := proof.BuildRootChildren() + require.NoError(t, err) + require.Equal(t, left, proofLeft) + require.Equal(t, right, proofRight) + } + }) + } +} + +func assertStreamingProofsAtOtherIndices(t *testing.T) { + t.Helper() + left := common.HexToHash("0x1234") + right := common.HexToHash("0x5678") + var ordinary StreamingBuilder + require.NoError(t, ordinary.Append(TreeLeaf(left))) + require.NoError(t, ordinary.Append(TreeLeaf(right))) + two, err := ordinary.Build() + require.NoError(t, err) + require.Equal(t, merkletest.Root([]merkletest.Run{{Hash: left, Count: 1}, {Hash: right, Count: 1}}, 1), two.RootHash) + for _, index := range []int64{0, 1} { + proof, err := two.ProveLeaf(big.NewInt(index)) + require.NoError(t, err) + require.Equal(t, []common.Hash{left, right}[index], proof.Node) + require.True(t, proof.VerifyRoot(two.RootHash)) + } + var pruned StreamingBuilder + require.NoError(t, pruned.AppendRepeated(TreeLeaf(left), 3)) + require.NoError(t, pruned.Append(TreeLeaf(right))) + four, err := pruned.Build() + require.NoError(t, err) + _, err = four.ProveLeaf(big.NewInt(0)) + require.ErrorIs(t, err, ErrOpaqueTree) + proof, err := four.ProveLast() + require.NoError(t, err) + require.True(t, proof.VerifyRoot(four.RootHash)) +} + +func BenchmarkStreamingBuilderAppend(b *testing.B) { + leaf := OpaqueTree(common.HexToHash("0xabcd"), 6) + var builder StreamingBuilder + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if err := builder.Append(leaf); err != nil { + b.Fatal(err) + } + } + runtime.KeepAlive(&builder) +} diff --git a/internal/merkle/streaming_cases_race_test.go b/internal/merkle/streaming_cases_race_test.go new file mode 100644 index 000000000..2e49f47fd --- /dev/null +++ b/internal/merkle/streaming_cases_race_test.go @@ -0,0 +1,8 @@ +//go:build race + +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +const streamingDifferentialCases = 300 diff --git a/internal/merkle/streaming_cases_test.go b/internal/merkle/streaming_cases_test.go new file mode 100644 index 000000000..90c1ada31 --- /dev/null +++ b/internal/merkle/streaming_cases_test.go @@ -0,0 +1,8 @@ +//go:build !race + +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package merkle + +const streamingDifferentialCases = 5000 diff --git a/internal/model/application_lifecycle_test.go b/internal/model/application_lifecycle_test.go new file mode 100644 index 000000000..fba7ee8be --- /dev/null +++ b/internal/model/application_lifecycle_test.go @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestApplicationObservationHelper(t *testing.T) { + for _, status := range ApplicationStatusAllValues { + t.Run(status.String(), func(t *testing.T) { + app := &Application{Enabled: true, Status: status} + require.True(t, app.NeedsL1Observation()) + app.ForecloseBlock = 42 + require.True(t, app.NeedsL1Observation()) + app.Enabled = false + require.False(t, app.NeedsL1Observation()) + }) + } +} + +// TestForeclosureScanCaughtUp pins the single drain-readiness definition shared +// by the claimer, PRT, and manager: it consults last_input_check_block for +// IConsensus apps and both epoch/input cursors for DaveConsensus apps, and the +// foreclose_block boundary is inclusive (cursor == foreclose_block is caught up). +func TestForeclosureScanCaughtUp(t *testing.T) { + t.Run("IConsensus uses last_input_check_block", func(t *testing.T) { + app := &Application{ConsensusType: Consensus_Authority, ForecloseBlock: 100} + + app.LastInputCheckBlock = 99 + require.False(t, app.ForeclosureScanCaughtUp(), "below bound: not caught up") + + app.LastInputCheckBlock = 100 + require.True(t, app.ForeclosureScanCaughtUp(), "at bound: caught up (inclusive)") + + app.LastInputCheckBlock = 101 + require.True(t, app.ForeclosureScanCaughtUp(), "past bound: caught up") + + // The epoch cursor must not influence an IConsensus app. + app.LastInputCheckBlock = 99 + app.LastEpochCheckBlock = 1000 + require.False(t, app.ForeclosureScanCaughtUp(), "epoch cursor is ignored for IConsensus") + }) + + t.Run("DaveConsensus requires both scan cursors", func(t *testing.T) { + app := &Application{ConsensusType: Consensus_PRT, ForecloseBlock: 100} + + app.LastEpochCheckBlock = 99 + app.LastInputCheckBlock = 100 + require.False(t, app.ForeclosureScanCaughtUp(), "below bound: not caught up") + + app.LastEpochCheckBlock = 100 + app.LastInputCheckBlock = 100 + require.True(t, app.ForeclosureScanCaughtUp(), "at bound: caught up (inclusive)") + + app.LastInputCheckBlock = 99 + require.False(t, app.ForeclosureScanCaughtUp(), "open-epoch input scan is still behind") + + app.LastEpochCheckBlock = 99 + app.LastInputCheckBlock = 1000 + require.False(t, app.ForeclosureScanCaughtUp(), "sealed-epoch scan is still behind") + }) +} diff --git a/internal/model/application_problem.go b/internal/model/application_problem.go new file mode 100644 index 000000000..0eb2f1c14 --- /dev/null +++ b/internal/model/application_problem.go @@ -0,0 +1,12 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +// ApplicationProblem identifies the earliest known epoch that cannot publish +// local results. It survives a later integrity stop; it is not a status history. +type ApplicationProblem struct { + EpochIndex uint64 + Kind ApplicationStatus + InputIndex *uint64 +} diff --git a/internal/model/block_policy.go b/internal/model/block_policy.go new file mode 100644 index 000000000..bb3900ebd --- /dev/null +++ b/internal/model/block_policy.go @@ -0,0 +1,15 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import "strings" + +// Provisional identifies the supported policies whose observations can regress. +// It does not validate configuration; pending and unknown policies are not supported. +func (e DefaultBlock) Provisional() bool { + return e == DefaultBlock_Latest || e == DefaultBlock_Safe +} + +// Tag returns the JSON-RPC spelling. Configuration validation remains separate. +func (e DefaultBlock) Tag() string { return strings.ToLower(e.String()) } diff --git a/internal/model/deployment.go b/internal/model/deployment.go new file mode 100644 index 000000000..9cbdbfa0b --- /dev/null +++ b/internal/model/deployment.go @@ -0,0 +1,22 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +// DeploymentTier names a confirmed deployment restriction, independently of application health. +type DeploymentTier string + +const ( + DeploymentTierGeneration DeploymentTier = "generation" + DeploymentTierTable DeploymentTier = "table" +) + +// DeploymentAdmissionState retains a restriction independently of application health. +// ResetGeneration fences explicit enable resets and process startup episodes. +// A nil restriction is neutral storage, never proof of compatibility. +type DeploymentAdmissionState struct { + ApplicationID int64 + ResetGeneration int64 + ConfirmedTier *DeploymentTier + DeploymentReason *string +} diff --git a/internal/model/dispute_facts_json_test.go b/internal/model/dispute_facts_json_test.go new file mode 100644 index 000000000..e7f4980bf --- /dev/null +++ b/internal/model/dispute_facts_json_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "math" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestDisputeFactsJSONQuantities(t *testing.T) { + const largeHex = "0x100000000000000000000000000000001" + const maximumHex = "0xffffffffffffffff" + var large Uint256 + require.NoError(t, json.Unmarshal([]byte(`"0x100000000000000000000000000000001"`), &large)) + zero := common.Hash{} + for _, test := range []struct { + name string + value any + decoded any + want map[string]string + }{ + { + name: "tournament", + value: Tournament{BondValue: large, ContestedFinalStateOne: &zero, ContestedFinalStateTwo: &zero}, + decoded: new(Tournament), + want: map[string]string{ + "bond_value": largeHex, "contested_final_state_one": zero.Hex(), "contested_final_state_two": zero.Hex(), + }, + }, + { + name: "match", + value: Match{ResponderDeadline: math.MaxUint64}, decoded: new(Match), + want: map[string]string{"responder_deadline": maximumHex}, + }, + { + name: "advance facts", + value: MatchAdvanced{CurrentHeight: math.MaxUint64, ResponderDeadline: math.MaxUint64}, decoded: new(MatchAdvanced), + want: map[string]string{"current_height": maximumHex, "responder_deadline": maximumHex}, + }, + { + name: "seal", + value: LeafMatchSeal{ + AgreeState: zero, DivergencePosition: large, FinalStateOne: zero, FinalStateTwo: zero, + DeadlineOne: math.MaxUint64, DeadlineTwo: math.MaxUint64, + }, + decoded: new(LeafMatchSeal), + want: map[string]string{ + "agree_state": zero.Hex(), "divergence_position": largeHex, + "final_state_one": zero.Hex(), "final_state_two": zero.Hex(), + "deadline_one": maximumHex, "deadline_two": maximumHex, + }, + }, + { + name: "standing", + value: StandingChanged{ + EpochIndex: math.MaxUint64, MatchCount: large, ResultAt: math.MaxUint64, + WinnerExpiresAt: math.MaxUint64, BlockNumber: math.MaxUint64, LogIndex: math.MaxUint64, + }, + decoded: new(StandingChanged), + want: map[string]string{ + "epoch_index": maximumHex, "match_count": largeHex, "result_at": maximumHex, + "winner_expires_at": maximumHex, "block_number": maximumHex, "log_index": maximumHex, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + data, err := json.Marshal(test.value) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &fields)) + for field, want := range test.want { + require.JSONEq(t, `"`+want+`"`, string(fields[field]), field) + } + require.NoError(t, json.Unmarshal(data, test.decoded)) + reencoded, err := json.Marshal(test.decoded) + require.NoError(t, err) + require.JSONEq(t, string(data), string(reencoded)) + }) + } +} + +func TestDisputeFactsJSONHashPresence(t *testing.T) { + var root Tournament + require.NoError(t, json.Unmarshal([]byte(`{"contested_final_state_one":null,"contested_final_state_two":null}`), &root)) + require.Nil(t, root.ContestedFinalStateOne) + require.Nil(t, root.ContestedFinalStateTwo) + + var standing StandingChanged + require.NoError(t, json.Unmarshal([]byte(`{"dangling":null,"parent_commitment":null}`), &standing)) + require.Nil(t, standing.Dangling) + require.Nil(t, standing.ParentCommitment) + + data, err := json.Marshal(standing) + require.NoError(t, err) + require.Contains(t, string(data), `"dangling":null`) + require.Contains(t, string(data), `"parent_commitment":null`) +} diff --git a/internal/model/enum.go b/internal/model/enum.go new file mode 100644 index 000000000..1c70e145d --- /dev/null +++ b/internal/model/enum.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "fmt" + "slices" +) + +// scanEnum accepts the database representations supported by every string enum. +// An invalid value leaves the receiver unchanged. +func scanEnum[T ~string](dst *T, value any, values []T, name string) error { + // Keep the existing public error text, including these two naming exceptions. + prefix := "invalid value" + if name == "SnapshotPolicy" { + prefix = "invalid scan value" + } + typeName := name + if name == "Consensus" { + typeName = "ConsensusType" + } + var text string + switch value := value.(type) { + case string: + text = value + case []byte: + text = string(value) + default: + return fmt.Errorf("%s for %s enum. Enum value has to be of type string or []byte", prefix, typeName) + } + parsed := T(text) + if !slices.Contains(values, parsed) { + return fmt.Errorf("%s '%s' for %s enum", prefix, text, name) + } + *dst = parsed + return nil +} diff --git a/internal/model/enum_test.go b/internal/model/enum_test.go new file mode 100644 index 000000000..efd43a54d --- /dev/null +++ b/internal/model/enum_test.go @@ -0,0 +1,85 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + enumGoldenNone = "NONE" + enumInvalidValue = "UNKNOWN" +) + +func TestEnumScanners(t *testing.T) { + checkEnumScanner(t, "ApplicationStatus", ApplicationStatusAllValues, (*ApplicationStatus).Scan, + "invalid value", "ApplicationStatus", + []string{ + "OK", "FAILED", "DIVERGED", "CORRUPTED", "GUEST_EXCEPTION", "MACHINE_HALTED", "MCYCLE_OVERFLOW", + "UNEXPECTED_YIELD", "INVALID_OUTPUTS_ROOT", + }) + checkEnumScanner(t, "Consensus", ConsensusAllValues, (*Consensus).Scan, + "invalid value", "ConsensusType", []string{"AUTHORITY", "QUORUM", "PRT"}) + checkEnumScanner(t, "SnapshotPolicy", SnapshotPolicyAllValues, (*SnapshotPolicy).Scan, + "invalid scan value", "SnapshotPolicy", []string{enumGoldenNone, "EVERY_INPUT", "EVERY_EPOCH"}) + checkEnumScanner(t, "EpochStatus", EpochStatusAllValues, (*EpochStatus).Scan, + "invalid value", "EpochStatus", []string{ + "OPEN", "CLOSED", "INPUTS_PROCESSED", "CLAIM_COMPUTED", "CLAIM_SUBMITTED", "CLAIM_STAGED", + "CLAIM_ACCEPTED", "CLAIM_REJECTED", "CLAIM_FORECLOSED", + }) + checkEnumScanner(t, "InputCompletionStatus", InputCompletionStatusAllValues, (*InputCompletionStatus).Scan, + "invalid value", "InputCompletionStatus", + []string{ + enumGoldenNone, "ACCEPTED", "REJECTED", "EXCEPTION", "MACHINE_HALTED", "OVERFLOW", "UNEXPECTED_YIELD", + "INVALID_OUTPUTS_ROOT", + }) + checkEnumScanner(t, "DefaultBlock", DefaultBlockAllValues, (*DefaultBlock).Scan, + "invalid value", "DefaultBlock", []string{"FINALIZED", "LATEST", "PENDING", "SAFE"}) + checkEnumScanner(t, "MatchDeletionReason", MatchDeletionReasonAllValues, (*MatchDeletionReason).Scan, + "invalid value", "MatchDeletionReason", []string{"STEP", "TIMEOUT", "CHILD_TOURNAMENT", "NOT_DELETED"}) + checkEnumScanner(t, "WinnerCommitment", WinnerCommitmentAllValues, (*WinnerCommitment).Scan, + "invalid value", "WinnerCommitment", []string{enumGoldenNone, "ONE", "TWO"}) +} + +func checkEnumScanner[T ~string]( + t *testing.T, name string, values []T, scan func(*T, any) error, errorPrefix, typeName string, expected []string, +) { + t.Helper() + t.Run(name, func(t *testing.T) { + actual := make([]string, len(values)) + for i, value := range values { + actual[i] = string(value) + } + require.Equal(t, expected, actual, "AllValues must contain the full public enum vocabulary") + for _, text := range expected { + value := T(text) + t.Run(string(value), func(t *testing.T) { + for _, input := range []any{string(value), []byte(value)} { + var got T + require.NoError(t, scan(&got, input)) + require.Equal(t, value, got) + } + }) + } + for _, input := range []any{"INVALID", []byte("INVALID"), "", []byte(nil)} { + got := values[0] + text, ok := input.(string) + if !ok { + text = string(input.([]byte)) + } + require.EqualError(t, scan(&got, input), fmt.Sprintf("%s '%s' for %s enum", errorPrefix, text, name)) + require.Equal(t, values[0], got, "invalid enum values must not modify the receiver") + } + type namedString string + for _, input := range []any{nil, 1, true, namedString(values[0])} { + got := values[0] + require.EqualError(t, scan(&got, input), + fmt.Sprintf("%s for %s enum. Enum value has to be of type string or []byte", errorPrefix, typeName)) + require.Equal(t, values[0], got, "unsupported input types must not modify the receiver") + } + }) +} diff --git a/internal/model/epoch_status_test.go b/internal/model/epoch_status_test.go new file mode 100644 index 000000000..7dd3c3a22 --- /dev/null +++ b/internal/model/epoch_status_test.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNonTerminalEpochStatusesPartitionAllValues(t *testing.T) { + partition := NonTerminalEpochStatuses() + terminal := []EpochStatus{EpochStatus_ClaimAccepted, EpochStatus_ClaimRejected, EpochStatus_ClaimForeclosed} + partition = append(partition, terminal...) + require.ElementsMatch(t, EpochStatusAllValues, partition, + "classify every epoch status exactly once and keep the epoch_unreconciled_idx predicate in sync") +} + +func TestNonTerminalEpochStatusesReturnsOwnedSlice(t *testing.T) { + first, second := NonTerminalEpochStatuses(), NonTerminalEpochStatuses() + require.NotEmpty(t, first) + first[0] = EpochStatus_ClaimAccepted + require.NotContains(t, second, EpochStatus_ClaimAccepted) + require.Equal(t, second, NonTerminalEpochStatuses()) +} diff --git a/internal/model/execution_parameters_test.go b/internal/model/execution_parameters_test.go new file mode 100644 index 000000000..bacf6c3ed --- /dev/null +++ b/internal/model/execution_parameters_test.go @@ -0,0 +1,236 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestExecutionParametersJSONRoundtrip(t *testing.T) { + original := ExecutionParameters{ + SnapshotPolicy: SnapshotPolicy_EveryInput, + AdvanceIncCycles: 101, + AdvanceMaxCycles: 111, + InspectIncCycles: 202, + InspectMaxCycles: 222, + AdvanceIncDeadline: 3 * time.Second, + AdvanceMaxDeadline: 4 * time.Second, + InspectIncDeadline: 5 * time.Second, + InspectMaxDeadline: 6 * time.Second, + LoadDeadline: 7 * time.Second, + StoreDeadline: 8 * time.Second, + FastDeadline: 9 * time.Second, + MaxConcurrentInspects: 10, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + require.Contains(t, string(data), `"advance_inc_cycles":"0x65"`) + require.Contains(t, string(data), `"advance_max_cycles":"0x6f"`) + require.Contains(t, string(data), `"inspect_inc_cycles":"0xca"`) + require.Contains(t, string(data), `"inspect_max_cycles":"0xde"`) + + var decoded ExecutionParameters + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, original.SnapshotPolicy, decoded.SnapshotPolicy) + require.Equal(t, original.AdvanceIncCycles, decoded.AdvanceIncCycles) + require.Equal(t, original.AdvanceMaxCycles, decoded.AdvanceMaxCycles) + require.Equal(t, original.InspectIncCycles, decoded.InspectIncCycles) + require.Equal(t, original.InspectMaxCycles, decoded.InspectMaxCycles) + require.Equal(t, original.AdvanceIncDeadline, decoded.AdvanceIncDeadline) + require.Equal(t, original.AdvanceMaxDeadline, decoded.AdvanceMaxDeadline) + require.Equal(t, original.InspectIncDeadline, decoded.InspectIncDeadline) + require.Equal(t, original.InspectMaxDeadline, decoded.InspectMaxDeadline) + require.Equal(t, original.LoadDeadline, decoded.LoadDeadline) + require.Equal(t, original.StoreDeadline, decoded.StoreDeadline) + require.Equal(t, original.FastDeadline, decoded.FastDeadline) + require.Equal(t, original.MaxConcurrentInspects, decoded.MaxConcurrentInspects) +} + +func TestExecutionParametersZeroCycleMaximumsRoundtrip(t *testing.T) { + data, err := json.Marshal(&ExecutionParameters{}) + require.NoError(t, err) + require.Contains(t, string(data), `"advance_max_cycles":"0x0"`) + require.Contains(t, string(data), `"inspect_max_cycles":"0x0"`) + + var decoded ExecutionParameters + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Zero(t, decoded.AdvanceMaxCycles) + require.Zero(t, decoded.InspectMaxCycles) +} + +func TestExecutionParametersCycleMaximumBounds(t *testing.T) { + for _, value := range []uint64{0, 1, MaxExecutionCycleSpan} { + params := ExecutionParameters{ + SnapshotPolicy: SnapshotPolicy_None, + AdvanceIncCycles: 1, + AdvanceMaxCycles: value, + InspectIncCycles: 1, + InspectMaxCycles: value, + } + require.NoError(t, params.Validate()) + } + + for _, test := range []struct { + name string + mutate func(*ExecutionParameters) + }{ + {"advance", func(p *ExecutionParameters) { p.AdvanceMaxCycles = MaxExecutionCycles }}, + {"inspect", func(p *ExecutionParameters) { p.InspectMaxCycles = MaxExecutionCycles }}, + } { + t.Run(test.name, func(t *testing.T) { + params := ExecutionParameters{ + SnapshotPolicy: SnapshotPolicy_None, + AdvanceIncCycles: 1, + InspectIncCycles: 1, + } + test.mutate(¶ms) + require.ErrorContains(t, params.Validate(), "must be between 0 and 281474976710655") + }) + } +} + +func TestExecutionParametersRejectZeroCycleIncrements(t *testing.T) { + for _, test := range []struct { + name string + field string + mutate func(*ExecutionParameters) + }{ + { + name: "advance", + field: "advance_inc_cycles", + mutate: func(p *ExecutionParameters) { p.AdvanceIncCycles = 0 }, + }, + { + name: "inspect", + field: "inspect_inc_cycles", + mutate: func(p *ExecutionParameters) { p.InspectIncCycles = 0 }, + }, + } { + t.Run(test.name, func(t *testing.T) { + params := ExecutionParameters{ + SnapshotPolicy: SnapshotPolicy_None, + AdvanceIncCycles: 1, + InspectIncCycles: 1, + } + test.mutate(¶ms) + + require.ErrorContains(t, params.Validate(), test.field) + }) + } +} + +func TestInputCompletionStatusContract(t *testing.T) { + expected := []InputCompletionStatus{ + InputCompletionStatus_None, + InputCompletionStatus_Accepted, + InputCompletionStatus_Rejected, + InputCompletionStatus_Exception, + InputCompletionStatus_MachineHalted, + InputCompletionStatus_Overflow, + InputCompletionStatus_UnexpectedYield, + InputCompletionStatus_InvalidOutputsRoot, + } + require.Equal(t, expected, InputCompletionStatusAllValues) + + for _, value := range expected { + t.Run(value.String(), func(t *testing.T) { + var fromString InputCompletionStatus + require.NoError(t, fromString.Scan(value.String())) + require.Equal(t, value, fromString) + + var fromBytes InputCompletionStatus + require.NoError(t, fromBytes.Scan([]byte(value.String()))) + require.Equal(t, value, fromBytes) + require.Equal(t, value != InputCompletionStatus_None, value.IsCompleted()) + require.Equal(t, + value == InputCompletionStatus_Exception || + value == InputCompletionStatus_MachineHalted || + value == InputCompletionStatus_Overflow || + value == InputCompletionStatus_UnexpectedYield || + value == InputCompletionStatus_InvalidOutputsRoot, + value.IsTerminal(), + ) + }) + } + + removedOrInvalid := []string{ + "OUTPUTS_LIMIT_EXCEEDED", + "REPORTS_LIMIT_EXCEEDED", + "CYCLE_LIMIT_EXCEEDED", + "TIME_LIMIT_EXCEEDED", + "PAYLOAD_LENGTH_LIMIT_EXCEEDED", + "INVALID", + } + for _, value := range removedOrInvalid { + t.Run(value, func(t *testing.T) { + var status InputCompletionStatus + require.Error(t, status.Scan(value)) + require.False(t, InputCompletionStatus(value).IsCompleted()) + require.False(t, InputCompletionStatus(value).IsTerminal()) + }) + } +} + +func TestApplicationStatusContract(t *testing.T) { + expected := []ApplicationStatus{ + ApplicationStatus_OK, + ApplicationStatus_Failed, + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, + } + require.Equal(t, expected, ApplicationStatusAllValues) + + for _, value := range expected { + t.Run(value.String(), func(t *testing.T) { + var scanned ApplicationStatus + require.NoError(t, scanned.Scan(value.String())) + require.Equal(t, value, scanned) + require.Equal(t, + value != ApplicationStatus_OK && value != ApplicationStatus_Failed, + value.IsTerminal(), + ) + require.Equal(t, + value == ApplicationStatus_GuestException || + value == ApplicationStatus_MachineHalted || + value == ApplicationStatus_McycleOverflow || + value == ApplicationStatus_UnexpectedYield, + value.IsExecutionTerminal(), + ) + }) + } +} + +func TestTerminalApplicationStatus(t *testing.T) { + expected := map[InputCompletionStatus]ApplicationStatus{ + InputCompletionStatus_Exception: ApplicationStatus_GuestException, + InputCompletionStatus_MachineHalted: ApplicationStatus_MachineHalted, + InputCompletionStatus_Overflow: ApplicationStatus_McycleOverflow, + InputCompletionStatus_UnexpectedYield: ApplicationStatus_UnexpectedYield, + InputCompletionStatus_InvalidOutputsRoot: ApplicationStatus_InvalidOutputsRoot, + } + for inputStatus, applicationStatus := range expected { + got, terminal := inputStatus.TerminalApplicationStatus() + require.True(t, terminal) + require.Equal(t, applicationStatus, got) + } + for _, inputStatus := range []InputCompletionStatus{ + InputCompletionStatus_None, + InputCompletionStatus_Accepted, + InputCompletionStatus_Rejected, + } { + got, terminal := inputStatus.TerminalApplicationStatus() + require.False(t, terminal) + require.Empty(t, got) + } +} diff --git a/internal/model/input_hash_collection.go b/internal/model/input_hash_collection.go new file mode 100644 index 000000000..15ef64dd4 --- /dev/null +++ b/internal/model/input_hash_collection.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto/keccak" +) + +// PRT collections store roots of consecutive sample bundles as entries. +// Each sampled entry has repetition one. Its last bundle may contain final-state padding. +// Remaining entries repeat FinalStateEntry of the input's final machine hash. +// Entry repetitions total InputHashCollectionCapacity; a full sampled span has no padding row. +const ( + // Log2MaxAdvanceStatesPerEpoch is the number of input-index bits reserved + // by the protocol computation-hash tree for one epoch. + Log2MaxAdvanceStatesPerEpoch uint64 = 24 + + // MaxAdvanceStatesPerEpoch is the number of input slots reserved by the + // protocol computation-hash tree for one epoch. + MaxAdvanceStatesPerEpoch uint64 = 1 << Log2MaxAdvanceStatesPerEpoch + + // Log2MaxUarchCyclesPerMCycle is the emulator's maximum uarch-cycle span + // exponent for one mcycle. + Log2MaxUarchCyclesPerMCycle uint64 = 20 + + // Log2RootTournamentStride is ArbitrationConstants.log2step(0), the root + // tournament sampling period expressed in uarch cycles. + Log2RootTournamentStride uint64 = 38 + + // Log2MCycleComputationHashPeriod is the sampling period in mcycles. + Log2MCycleComputationHashPeriod uint64 = Log2RootTournamentStride - Log2MaxUarchCyclesPerMCycle + + // Log2MCyclesPerStateHashEntry fixes the storage density in mcycles per entry. + Log2MCyclesPerStateHashEntry uint64 = 24 + + // Log2StateHashesPerEntry is the height of each entry's sample subtree. + Log2StateHashesPerEntry uint64 = Log2MCyclesPerStateHashEntry - Log2MCycleComputationHashPeriod + + // Log2InputHashCollectionCapacity is the number of entry-index bits + // reserved for one input in the protocol computation-hash tree. + Log2InputHashCollectionCapacity uint64 = Log2MaxExecutionCycles - Log2MCyclesPerStateHashEntry + + // InputHashCollectionCapacity is the number of entries reserved for one input. + InputHashCollectionCapacity uint64 = 1 << Log2InputHashCollectionCapacity + + // Log2InputComputationHashLeafCount is the number of sample-index bits per input. + Log2InputComputationHashLeafCount uint64 = Log2InputHashCollectionCapacity + Log2StateHashesPerEntry + + // Log2EpochComputationHashLeafCount is the height of the epoch tree in leaves. + Log2EpochComputationHashLeafCount uint64 = Log2MaxAdvanceStatesPerEpoch + Log2InputComputationHashLeafCount +) + +// RepeatedStateHashRoot returns the root of 2^log2Count copies of h. +func RepeatedStateHashRoot(h common.Hash, log2Count uint64) common.Hash { + hasher := keccak.NewLegacyKeccak256() + for range log2Count { + hasher.Reset() + _, _ = hasher.Write(h[:]) + _, _ = hasher.Write(h[:]) + hasher.Sum(h[:0]) + } + return h +} + +// FinalStateEntry returns the entry root used to pad an input's final state. +func FinalStateEntry(h common.Hash) common.Hash { + return RepeatedStateHashRoot(h, Log2StateHashesPerEntry) +} + +// canonicalInputHashCollectionCounts maps a completed input's cycle delta to +// the emulator's raw count, the retained entry count, and final-state padding. +func canonicalInputHashCollectionCounts(deltaMCycles uint64) ( + rawCount, keptCount, padding uint64, err error, +) { + var periodicSamples uint64 + if deltaMCycles != 0 { + periodicSamples = (deltaMCycles - 1) >> Log2MCycleComputationHashPeriod + } + // Count the final sample bundle and its additional final-state-only entry. + const finalSampleAndStateEntries = 2 + rawCount = (periodicSamples >> Log2StateHashesPerEntry) + finalSampleAndStateEntries + keptCount = periodicSamples >> Log2StateHashesPerEntry + if periodicSamples&((uint64(1)< InputHashCollectionCapacity { + return 0, 0, 0, ValidateInputHashCollectionSpan(keptCount, 0) + } + return rawCount, keptCount, InputHashCollectionCapacity - keptCount, nil +} + +// CanonicalInputHashCollection retains entries containing periodic samples. +// It folds discarded final-state-only entries into padding and returns their root. +func CanonicalInputHashCollection(collected [][32]byte, deltaMCycles uint64) ( + kept [][32]byte, padding uint64, final [32]byte, err error, +) { + rawCount, keptCount, padding, err := canonicalInputHashCollectionCounts(deltaMCycles) + if err != nil { + return nil, 0, [32]byte{}, err + } + if uint64(len(collected)) != rawCount { + return nil, 0, [32]byte{}, fmt.Errorf("collected %d state hash entries, expected %d", len(collected), rawCount) + } + final = collected[len(collected)-1] + for _, entry := range collected[keptCount : len(collected)-1] { + if entry != final { + return nil, 0, [32]byte{}, errors.New("discarded state hash entry differs from final-state entry") + } + } + return collected[:keptCount], padding, final, nil +} + +// ValidateInputHashCollectionSpan verifies that periodic state hashes and the +// final-state padding cover exactly one persisted input hash collection. +func ValidateInputHashCollectionSpan(hashCount, paddingRepetitions uint64) error { + if hashCount > InputHashCollectionCapacity { + return fmt.Errorf( + "collected state hash count %d exceeds input hash collection capacity %d", + hashCount, + InputHashCollectionCapacity, + ) + } + expectedPaddingRepetitions := InputHashCollectionCapacity - hashCount + if paddingRepetitions != expectedPaddingRepetitions { + return fmt.Errorf( + "collected state hash count %d with padding repetitions %d does not cover input hash collection capacity %d", + hashCount, + paddingRepetitions, + InputHashCollectionCapacity, + ) + } + return nil +} diff --git a/internal/model/input_hash_collection_test.go b/internal/model/input_hash_collection_test.go new file mode 100644 index 000000000..bc5565fbf --- /dev/null +++ b/internal/model/input_hash_collection_test.go @@ -0,0 +1,197 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/binary" + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/sha3" +) + +func TestInputHashCollectionDimensions(t *testing.T) { + t.Parallel() + require.Equal(t, uint64(24), Log2MaxAdvanceStatesPerEpoch) + require.Equal(t, uint64(1)<<24, MaxAdvanceStatesPerEpoch) + require.Equal(t, uint64(24), Log2InputHashCollectionCapacity) + require.Equal(t, uint64(1)<<24, InputHashCollectionCapacity) + require.Equal(t, uint64(54), Log2EpochComputationHashLeafCount) + require.Equal(t, uint64(20), Log2MaxUarchCyclesPerMCycle) + require.Equal(t, uint64(38), Log2RootTournamentStride) + require.Equal(t, uint64(18), Log2MCycleComputationHashPeriod) + require.Equal(t, uint64(24), Log2MCyclesPerStateHashEntry) + require.Equal(t, uint64(6), Log2StateHashesPerEntry) + require.Equal(t, uint64(30), Log2InputComputationHashLeafCount) +} + +func TestValidateInputHashCollectionSpan(t *testing.T) { + t.Parallel() + tests := []struct { + name string + hashes uint64 + padding uint64 + wantErr bool + }{ + {name: "empty", padding: InputHashCollectionCapacity}, + {name: "partial", hashes: 2, padding: InputHashCollectionCapacity - 2}, + {name: "full", hashes: InputHashCollectionCapacity}, + {name: "short", hashes: 2, padding: InputHashCollectionCapacity - 3, wantErr: true}, + {name: "long", hashes: 2, padding: InputHashCollectionCapacity - 1, wantErr: true}, + {name: "overflow", hashes: InputHashCollectionCapacity + 1, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateInputHashCollectionSpan(test.hashes, test.padding) + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// denseEntryRoot builds every level from explicit leaves with a separate Keccak implementation. +func denseEntryRoot(leaves [][32]byte) [32]byte { + level := append([][32]byte(nil), leaves...) + for len(level) > 1 { + next := make([][32]byte, len(level)/2) + for index := range next { + hasher := sha3.NewLegacyKeccak256() + _, _ = hasher.Write(level[2*index][:]) + _, _ = hasher.Write(level[2*index+1][:]) + hasher.Sum(next[index][:0]) + } + level = next + } + return level[0] +} + +func TestRepeatedStateHashRootMatchesDenseTree(t *testing.T) { + hash := common.HexToHash("0x123456789abcdef") + require.Equal(t, hash, RepeatedStateHashRoot(hash, 0)) + leaves := make([][32]byte, 64) + for index := range leaves { + leaves[index] = hash + } + require.Equal(t, common.Hash(denseEntryRoot(leaves)), RepeatedStateHashRoot(hash, 6)) + require.Equal(t, RepeatedStateHashRoot(hash, Log2StateHashesPerEntry), FinalStateEntry(hash)) +} + +// denseTerminalEntries follows sample pushes, bundle completion, and final padding. +func denseTerminalEntries(periodic [][32]byte, final [32]byte, width int) (raw, kept [][32]byte) { + leaves := append(append([][32]byte(nil), periodic...), final) + for start := 0; start < len(leaves); start += width { + bundle := make([][32]byte, width) + for index := range bundle { + bundle[index] = final + if start+index < len(leaves) { + bundle[index] = leaves[start+index] + } + } + entry := denseEntryRoot(bundle) + raw = append(raw, entry) + if start < len(periodic) { + kept = append(kept, entry) + } + } + if width > 1 { + padding := make([][32]byte, width) + for index := range padding { + padding[index] = final + } + raw = append(raw, denseEntryRoot(padding)) + } + return raw, kept +} + +func TestCanonicalInputHashCollectionMatchesDenseSamples(t *testing.T) { + final := [32]byte{0xff} + for _, geometry := range [][2]uint64{{18, 6}} { + period, width := uint64(1)<= 1), so 0 is the unambiguous "not observed +// yet" sentinel. Once non-zero it remains so (the chain-level foreclosed +// flag is one-way). +func (a *Application) IsForeclosed() bool { + return a.ForecloseBlock != 0 +} + +func (a *Application) NeedsL1Observation() bool { + return a.Enabled +} + +// ForeclosureScanCaughtUp reports whether the historical L1 scan has reached +// foreclose_block, so the pre-foreclosure drain queries — which read the +// inputs/epochs already ingested into the DB — can be trusted. +// +// A freshly bootstrapped node can record foreclose_block before it has ingested +// the historical inputs/epochs. Until the scan catches up the drain tables are +// incomplete, and a "nothing left to drain" answer would be premature. Each +// IConsensus ingestion is driven by InputAdded scans (last_input_check_block). +// DaveConsensus has two independent scans: sealed epochs advance +// last_epoch_check_block, while inputs in the current open epoch advance +// last_input_check_block. Both must reach the foreclosure boundary before its +// historical state is complete. This is the single definition of +// drain-readiness shared by the claimer, PRT, and manager. +// +// Only meaningful for a foreclosed app (foreclose_block != 0). +func (a *Application) ForeclosureScanCaughtUp() bool { + if a.IsDaveConsensus() { + return a.LastEpochCheckBlock >= a.ForecloseBlock && + a.LastInputCheckBlock >= a.ForecloseBlock + } + return a.LastInputCheckBlock >= a.ForecloseBlock +} + +// WithdrawalConfig mirrors the on-chain five-immutable layout from the +// Application contract. Field order matches iapplicationfactory.WithdrawalConfig +// so the two are convertible via a Go type conversion. +type WithdrawalConfig struct { + Guardian common.Address `json:"guardian"` + Log2LeavesPerAccount uint8 `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts uint8 `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex uint64 `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder common.Address `json:"withdrawal_output_builder"` +} + +func (w WithdrawalConfig) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Guardian common.Address `json:"guardian"` + Log2LeavesPerAccount string `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts string `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex string `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder common.Address `json:"withdrawal_output_builder"` + }{ + Guardian: w.Guardian, + Log2LeavesPerAccount: fmt.Sprintf("0x%x", w.Log2LeavesPerAccount), + Log2MaxNumOfAccounts: fmt.Sprintf("0x%x", w.Log2MaxNumOfAccounts), + AccountsDriveStartIndex: fmt.Sprintf("0x%x", w.AccountsDriveStartIndex), + WithdrawalOutputBuilder: w.WithdrawalOutputBuilder, + }) +} + +func (w *WithdrawalConfig) UnmarshalJSON(data []byte) error { + aux := &struct { + Guardian common.Address `json:"guardian"` + Log2LeavesPerAccount string `json:"log2_leaves_per_account"` + Log2MaxNumOfAccounts string `json:"log2_max_num_of_accounts"` + AccountsDriveStartIndex string `json:"accounts_drive_start_index"` + WithdrawalOutputBuilder common.Address `json:"withdrawal_output_builder"` + }{} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + w.Guardian = aux.Guardian + w.WithdrawalOutputBuilder = aux.WithdrawalOutputBuilder + if aux.Log2LeavesPerAccount != "" { + v, err := ParseHexUint64(aux.Log2LeavesPerAccount) + if err != nil { + return fmt.Errorf("invalid log2_leaves_per_account: %w", err) + } + if v > math.MaxUint8 { + return fmt.Errorf("log2_leaves_per_account out of range for uint8: %d", v) + } + w.Log2LeavesPerAccount = uint8(v) + } + if aux.Log2MaxNumOfAccounts != "" { + v, err := ParseHexUint64(aux.Log2MaxNumOfAccounts) + if err != nil { + return fmt.Errorf("invalid log2_max_num_of_accounts: %w", err) + } + if v > math.MaxUint8 { + return fmt.Errorf("log2_max_num_of_accounts out of range for uint8: %d", v) + } + w.Log2MaxNumOfAccounts = uint8(v) + } + if aux.AccountsDriveStartIndex != "" { + v, err := ParseHexUint64(aux.AccountsDriveStartIndex) + if err != nil { + return fmt.Errorf("invalid accounts_drive_start_index: %w", err) + } + w.AccountsDriveStartIndex = v + } + return nil +} + +func (a *Application) MarshalJSON() ([]byte, error) { + // Create an alias to avoid infinite recursion in MarshalJSON. + type Alias Application + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + *Alias + IInputBoxBlock string `json:"iinputbox_block"` + LastEpochCheckBlock string `json:"last_epoch_check_block"` + LastInputCheckBlock string `json:"last_input_check_block"` + LastOutputCheckBlock string `json:"last_output_check_block"` + LastTournamentCheckBlock string `json:"last_tournament_check_block"` + LastForecloseCheckBlock string `json:"last_foreclose_check_block"` + LastAccountsDriveProvedCheckBlock string `json:"last_accounts_drive_proved_check_block"` + LastWithdrawalCheckBlock string `json:"last_withdrawal_check_block"` + EpochLength string `json:"epoch_length"` + ClaimStagingPeriod string `json:"claim_staging_period"` + ProcessedInputs string `json:"processed_inputs"` + ForecloseBlock string `json:"foreclose_block"` + AccountsDriveProvedBlock string `json:"accounts_drive_proved_block"` + }{ + Alias: (*Alias)(a), + IInputBoxBlock: fmt.Sprintf("0x%x", a.IInputBoxBlock), + LastEpochCheckBlock: fmt.Sprintf("0x%x", a.LastEpochCheckBlock), + LastInputCheckBlock: fmt.Sprintf("0x%x", a.LastInputCheckBlock), + LastOutputCheckBlock: fmt.Sprintf("0x%x", a.LastOutputCheckBlock), + LastTournamentCheckBlock: fmt.Sprintf("0x%x", a.LastTournamentCheckBlock), + LastForecloseCheckBlock: fmt.Sprintf("0x%x", a.LastForecloseCheckBlock), + LastAccountsDriveProvedCheckBlock: fmt.Sprintf("0x%x", a.LastAccountsDriveProvedCheckBlock), + LastWithdrawalCheckBlock: fmt.Sprintf("0x%x", a.LastWithdrawalCheckBlock), + EpochLength: fmt.Sprintf("0x%x", a.EpochLength), + ClaimStagingPeriod: fmt.Sprintf("0x%x", a.ClaimStagingPeriod), + ProcessedInputs: fmt.Sprintf("0x%x", a.ProcessedInputs), + ForecloseBlock: fmt.Sprintf("0x%x", a.ForecloseBlock), + AccountsDriveProvedBlock: fmt.Sprintf("0x%x", a.AccountsDriveProvedBlock), + } + return json.Marshal(aux) +} + +func (a *Application) UnmarshalJSON(in []byte) error { + type Alias Application + aux := &struct { + *Alias + + IInputBoxBlock string `json:"iinputbox_block"` + LastInputCheckBlock string `json:"last_input_check_block"` + LastOutputCheckBlock string `json:"last_output_check_block"` + LastEpochCheckBlock string `json:"last_epoch_check_block"` + LastTournamentCheckBlock string `json:"last_tournament_check_block"` + LastForecloseCheckBlock string `json:"last_foreclose_check_block"` + LastAccountsDriveProvedCheckBlock string `json:"last_accounts_drive_proved_check_block"` + LastWithdrawalCheckBlock string `json:"last_withdrawal_check_block"` + EpochLength string `json:"epoch_length"` + ClaimStagingPeriod string `json:"claim_staging_period"` + ProcessedInputs string `json:"processed_inputs"` + ForecloseBlock string `json:"foreclose_block"` + AccountsDriveProvedBlock string `json:"accounts_drive_proved_block"` + }{} + + var err error + + if err = json.Unmarshal(in, aux); err != nil { + return err + } + + *a = Application(*aux.Alias) + + a.IInputBoxBlock, err = ParseHexUint64(aux.IInputBoxBlock) + if err != nil { + return err + } + + a.LastInputCheckBlock, err = ParseHexUint64(aux.LastInputCheckBlock) + if err != nil { + return err + } + + a.LastOutputCheckBlock, err = ParseHexUint64(aux.LastOutputCheckBlock) + if err != nil { + return err + } + + a.LastEpochCheckBlock, err = ParseHexUint64(aux.LastEpochCheckBlock) + if err != nil { + return err + } + + a.LastTournamentCheckBlock, err = ParseHexUint64(aux.LastTournamentCheckBlock) + if err != nil { + return err + } + + a.LastForecloseCheckBlock, err = ParseHexUint64(aux.LastForecloseCheckBlock) + if err != nil { + return err + } + + if aux.LastAccountsDriveProvedCheckBlock != "" { + a.LastAccountsDriveProvedCheckBlock, err = ParseHexUint64(aux.LastAccountsDriveProvedCheckBlock) + if err != nil { + return err + } + } + + if aux.LastWithdrawalCheckBlock != "" { + a.LastWithdrawalCheckBlock, err = ParseHexUint64(aux.LastWithdrawalCheckBlock) + if err != nil { + return err + } + } + + a.EpochLength, err = ParseHexUint64(aux.EpochLength) + if err != nil { + return err + } + + if aux.ClaimStagingPeriod != "" { + a.ClaimStagingPeriod, err = ParseHexUint64(aux.ClaimStagingPeriod) + if err != nil { + return err + } + } + + a.ProcessedInputs, err = ParseHexUint64(aux.ProcessedInputs) + if err != nil { + return err + } + + if aux.ForecloseBlock != "" { + a.ForecloseBlock, err = ParseHexUint64(aux.ForecloseBlock) + if err != nil { + return err + } + } + + if aux.AccountsDriveProvedBlock != "" { + a.AccountsDriveProvedBlock, err = ParseHexUint64(aux.AccountsDriveProvedBlock) + if err != nil { + return err + } + } + + return nil +} + +func (a *Application) IsDaveConsensus() bool { + return a.ConsensusType == Consensus_PRT +} + +// ApplicationStatus records why the node may no longer execute an application +// or produce claims for it. It combines recoverable operational failures, +// integrity failures, and deterministic terminal machine outcomes. It is +// independent of lifecycle (foreclosure lives in the foreclose_block column) +// and of operator intent (the enabled flag). +// +// Transitions (enforced by DB trigger): +// +// OK ⇄ FAILED (FAILED is recoverable) +// OK, FAILED → DIVERGED, CORRUPTED (integrity terminal) +// OK → INVALID_OUTPUTS_ROOT (validation terminal) +// OK → GUEST_EXCEPTION, MACHINE_HALTED (execution terminal) +// MCYCLE_OVERFLOW, UNEXPECTED_YIELD +// execution/validation terminal → DIVERGED, CORRUPTED (integrity escalation) +// +// FAILED is recoverable and suspends execution, but enabled applications in +// every status continue L1 observation. Consequently, later evidence may +// supersede FAILED with DIVERGED or CORRUPTED. DIVERGED means the node's +// computed claim disagrees with what the chain accepted; CORRUPTED means local +// state or its relationship with L1 history is missing or inconsistent. +// +// GUEST_EXCEPTION, MACHINE_HALTED, MCYCLE_OVERFLOW, and UNEXPECTED_YIELD record +// deterministic machine outcomes that stop execution and must not be retried. +// Later L1 observation may supersede one with DIVERGED or CORRUPTED when it +// establishes an integrity failure. The input retains its original +// completion status and terminal state proof. Foreclosure is orthogonal and +// may coexist with any application status. +// INVALID_OUTPUTS_ROOT records a failed outputs-root rule. It can be detected +// during an advance or later by the validator, including for an empty epoch. +// It does not establish the fault's cause. It may escalate to DIVERGED or +// CORRUPTED; the typed stop boundary retains the original finding. +type ApplicationStatus string + +//nolint:revive // Public enum names preserve the generated/API naming convention. +const ( + ApplicationStatus_OK ApplicationStatus = "OK" // healthy; eligible for work when enabled and not foreclosed + ApplicationStatus_Failed ApplicationStatus = "FAILED" // recoverable failure (e.g., OOM, process crash) + ApplicationStatus_Diverged ApplicationStatus = "DIVERGED" // computed claim disagrees with the chain (terminal) + ApplicationStatus_Corrupted ApplicationStatus = "CORRUPTED" // local state missing or inconsistent (terminal) + ApplicationStatus_GuestException ApplicationStatus = "GUEST_EXCEPTION" + ApplicationStatus_MachineHalted ApplicationStatus = "MACHINE_HALTED" + ApplicationStatus_McycleOverflow ApplicationStatus = "MCYCLE_OVERFLOW" + ApplicationStatus_UnexpectedYield ApplicationStatus = "UNEXPECTED_YIELD" + ApplicationStatus_InvalidOutputsRoot ApplicationStatus = "INVALID_OUTPUTS_ROOT" +) + +var ApplicationStatusAllValues = []ApplicationStatus{ + ApplicationStatus_OK, + ApplicationStatus_Failed, + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, +} + +func (e ApplicationStatus) IsTerminal() bool { + switch e { + case ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot: + return true + case ApplicationStatus_OK, ApplicationStatus_Failed: + return false + } + return false +} + +// IsExecutionTerminal identifies outcomes that must be stored atomically with +// an input result. These four states may escalate to DIVERGED/CORRUPTED. The validation +// terminal INVALID_OUTPUTS_ROOT is separate: a validator can also set it after +// input storage. A genuine integrity finding can replace its status and reason +// while retaining the typed epoch stop boundary. +func (e ApplicationStatus) IsExecutionTerminal() bool { + switch e { + case ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield: + return true + case ApplicationStatus_OK, + ApplicationStatus_Failed, + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + ApplicationStatus_InvalidOutputsRoot: + return false + } + return false +} + +func (e *ApplicationStatus) Scan(value any) error { + return scanEnum(e, value, ApplicationStatusAllValues, "ApplicationStatus") +} + +func (e ApplicationStatus) String() string { + return string(e) +} + +type Consensus string + +const ( + Consensus_Authority Consensus = "AUTHORITY" + Consensus_Quorum Consensus = "QUORUM" + Consensus_PRT Consensus = "PRT" +) + +var ConsensusAllValues = []Consensus{ + Consensus_Authority, + Consensus_Quorum, + Consensus_PRT, +} + +func (e *Consensus) Scan(value any) error { + return scanEnum(e, value, ConsensusAllValues, "Consensus") +} + +func (e Consensus) String() string { + return string(e) +} + +type SnapshotPolicy string + +const ( + SnapshotPolicy_None SnapshotPolicy = "NONE" + SnapshotPolicy_EveryInput SnapshotPolicy = "EVERY_INPUT" + SnapshotPolicy_EveryEpoch SnapshotPolicy = "EVERY_EPOCH" +) + +var SnapshotPolicyAllValues = []SnapshotPolicy{ + SnapshotPolicy_None, + SnapshotPolicy_EveryInput, + SnapshotPolicy_EveryEpoch, +} + +func (e *SnapshotPolicy) Scan(value any) error { + return scanEnum(e, value, SnapshotPolicyAllValues, "SnapshotPolicy") +} + +func (e SnapshotPolicy) String() string { + return string(e) +} + +// PRT uses longer advance deadlines for the 2^18-mcycle sampling period. +const ( + PrtAdvanceIncDeadline = 60 * time.Second + PrtAdvanceMaxDeadline = 3600 * time.Second +) + +type ExecutionParameters struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + SnapshotPolicy SnapshotPolicy `json:"snapshot_policy"` + AdvanceIncCycles uint64 `json:"advance_inc_cycles"` + AdvanceMaxCycles uint64 `json:"advance_max_cycles"` + InspectIncCycles uint64 `json:"inspect_inc_cycles"` + InspectMaxCycles uint64 `json:"inspect_max_cycles"` + AdvanceIncDeadline time.Duration `json:"advance_inc_deadline"` + AdvanceMaxDeadline time.Duration `json:"advance_max_deadline"` + InspectIncDeadline time.Duration `json:"inspect_inc_deadline"` + InspectMaxDeadline time.Duration `json:"inspect_max_deadline"` + LoadDeadline time.Duration `json:"load_deadline"` + StoreDeadline time.Duration `json:"store_deadline"` + FastDeadline time.Duration `json:"fast_deadline"` + MaxConcurrentInspects uint32 `json:"max_concurrent_inspects"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (e *ExecutionParameters) MarshalJSON() ([]byte, error) { + // Create an alias to avoid infinite recursion in MarshalJSON. + type Alias ExecutionParameters + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + AdvanceIncCycles string `json:"advance_inc_cycles"` + AdvanceMaxCycles string `json:"advance_max_cycles"` + InspectIncCycles string `json:"inspect_inc_cycles"` + InspectMaxCycles string `json:"inspect_max_cycles"` + AdvanceIncDeadline string `json:"advance_inc_deadline"` + AdvanceMaxDeadline string `json:"advance_max_deadline"` + InspectIncDeadline string `json:"inspect_inc_deadline"` + InspectMaxDeadline string `json:"inspect_max_deadline"` + LoadDeadline string `json:"load_deadline"` + StoreDeadline string `json:"store_deadline"` + FastDeadline string `json:"fast_deadline"` + *Alias + }{ + AdvanceIncCycles: fmt.Sprintf("0x%x", e.AdvanceIncCycles), + AdvanceMaxCycles: fmt.Sprintf("0x%x", e.AdvanceMaxCycles), + InspectIncCycles: fmt.Sprintf("0x%x", e.InspectIncCycles), + InspectMaxCycles: fmt.Sprintf("0x%x", e.InspectMaxCycles), + AdvanceIncDeadline: fmt.Sprintf("0x%x", uint64(e.AdvanceIncDeadline)), + AdvanceMaxDeadline: fmt.Sprintf("0x%x", uint64(e.AdvanceMaxDeadline)), + InspectIncDeadline: fmt.Sprintf("0x%x", uint64(e.InspectIncDeadline)), + InspectMaxDeadline: fmt.Sprintf("0x%x", uint64(e.InspectMaxDeadline)), + LoadDeadline: fmt.Sprintf("0x%x", uint64(e.LoadDeadline)), + StoreDeadline: fmt.Sprintf("0x%x", uint64(e.StoreDeadline)), + FastDeadline: fmt.Sprintf("0x%x", uint64(e.FastDeadline)), + Alias: (*Alias)(e), + } + return json.Marshal(aux) +} + +func (e *ExecutionParameters) UnmarshalJSON(data []byte) error { + // Create an alias to avoid infinite recursion in UnmarshalJSON. + type Alias ExecutionParameters + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + AdvanceIncCycles string `json:"advance_inc_cycles"` + AdvanceMaxCycles string `json:"advance_max_cycles"` + InspectIncCycles string `json:"inspect_inc_cycles"` + InspectMaxCycles string `json:"inspect_max_cycles"` + AdvanceIncDeadline string `json:"advance_inc_deadline"` + AdvanceMaxDeadline string `json:"advance_max_deadline"` + InspectIncDeadline string `json:"inspect_inc_deadline"` + InspectMaxDeadline string `json:"inspect_max_deadline"` + LoadDeadline string `json:"load_deadline"` + StoreDeadline string `json:"store_deadline"` + FastDeadline string `json:"fast_deadline"` + *Alias + }{ + Alias: (*Alias)(e), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + if aux.AdvanceIncCycles != "" { + val, err := ParseHexUint64(aux.AdvanceIncCycles) + if err != nil { + return fmt.Errorf("invalid advance_inc_cycles: %w", err) + } + e.AdvanceIncCycles = val + } + if aux.AdvanceMaxCycles != "" { + val, err := ParseHexUint64(aux.AdvanceMaxCycles) + if err != nil { + return fmt.Errorf("invalid advance_max_cycles: %w", err) + } + e.AdvanceMaxCycles = val + } + + if aux.InspectIncCycles != "" { + val, err := ParseHexUint64(aux.InspectIncCycles) + if err != nil { + return fmt.Errorf("invalid inspect_inc_cycles: %w", err) + } + e.InspectIncCycles = val + } + if aux.InspectMaxCycles != "" { + val, err := ParseHexUint64(aux.InspectMaxCycles) + if err != nil { + return fmt.Errorf("invalid inspect_max_cycles: %w", err) + } + e.InspectMaxCycles = val + } + + if aux.AdvanceIncDeadline != "" { + val, err := ParseHexDuration(aux.AdvanceIncDeadline) + if err != nil { + return fmt.Errorf("invalid advance_inc_deadline: %w", err) + } + e.AdvanceIncDeadline = val + } + + if aux.AdvanceMaxDeadline != "" { + val, err := ParseHexDuration(aux.AdvanceMaxDeadline) + if err != nil { + return fmt.Errorf("invalid advance_max_deadline: %w", err) + } + e.AdvanceMaxDeadline = val + } + + if aux.InspectIncDeadline != "" { + val, err := ParseHexDuration(aux.InspectIncDeadline) + if err != nil { + return fmt.Errorf("invalid inspect_inc_deadline: %w", err) + } + e.InspectIncDeadline = val + } + + if aux.InspectMaxDeadline != "" { + val, err := ParseHexDuration(aux.InspectMaxDeadline) + if err != nil { + return fmt.Errorf("invalid inspect_max_deadline: %w", err) + } + e.InspectMaxDeadline = val + } + + if aux.LoadDeadline != "" { + val, err := ParseHexDuration(aux.LoadDeadline) + if err != nil { + return fmt.Errorf("invalid load_deadline: %w", err) + } + e.LoadDeadline = val + } + + if aux.StoreDeadline != "" { + val, err := ParseHexDuration(aux.StoreDeadline) + if err != nil { + return fmt.Errorf("invalid store_deadline: %w", err) + } + e.StoreDeadline = val + } + + if aux.FastDeadline != "" { + val, err := ParseHexDuration(aux.FastDeadline) + if err != nil { + return fmt.Errorf("invalid fast_deadline: %w", err) + } + e.FastDeadline = val + } + + return nil +} + +// Log2MaxExecutionCycles is the single node-side definition of the protocol +// execution window size. pkg/machine aliases it as Log2MaxMCyclesPerAdvanceState, +// so the execution ceiling and computation-hash dimensions cannot diverge. The +// emulator exposes the same value as +// CM_ROLLUP_LOG2_MAX_MCYCLES_PER_ADVANCE_STATE. +const Log2MaxExecutionCycles uint64 = 48 + +// MaxExecutionCycles is the number of cycles in one machine-enforced execution +// window. It is the mcycle window covered by one input hash collection. +const MaxExecutionCycles uint64 = 1 << Log2MaxExecutionCycles + +// MaxExecutionCycleSpan is the largest configurable distance between the +// starting mcycle and the execution endpoint. The endpoint itself is included +// in the MaxExecutionCycles-wide window, hence the subtraction by one. A +// configured maximum of zero means no operator-imposed cap; the machine's +// MaxExecutionCycleSpan ceiling applies instead. +const MaxExecutionCycleSpan uint64 = MaxExecutionCycles - 1 + +// validateParameters constants +const maxDuration = 24 * time.Hour +const maxConcurrentInspects = 1000 + +// validateParameters performs validation on the loaded parameters +func (e *ExecutionParameters) Validate() error { + if e.AdvanceIncCycles == 0 { + return errors.New("advance_inc_cycles must be greater than 0") + } + if e.AdvanceMaxCycles > MaxExecutionCycleSpan { + return fmt.Errorf("advance_max_cycles must be between 0 and %d", MaxExecutionCycleSpan) + } + if e.InspectIncCycles == 0 { + return errors.New("inspect_inc_cycles must be greater than 0") + } + if e.InspectMaxCycles > MaxExecutionCycleSpan { + return fmt.Errorf("inspect_max_cycles must be between 0 and %d", MaxExecutionCycleSpan) + } + + // Validate durations are reasonable + if e.AdvanceIncDeadline < 0 || e.AdvanceIncDeadline > maxDuration { + return fmt.Errorf("advance_inc_deadline must be between 0 and 24h") + } + + if e.AdvanceMaxDeadline < 0 || e.AdvanceMaxDeadline > maxDuration { + return fmt.Errorf("advance_max_deadline must be between 0 and 24h") + } + + if e.InspectIncDeadline < 0 || e.InspectIncDeadline > maxDuration { + return fmt.Errorf("inspect_inc_deadline must be between 0 and 24h") + } + + if e.InspectMaxDeadline < 0 || e.InspectMaxDeadline > maxDuration { + return fmt.Errorf("inspect_max_deadline must be between 0 and 24h") + } + + if e.LoadDeadline < 0 || e.LoadDeadline > maxDuration { + return fmt.Errorf("load_deadline must be between 0 and 24h") + } + + if e.StoreDeadline < 0 || e.StoreDeadline > maxDuration { + return fmt.Errorf("store_deadline must be between 0 and 24h") + } + + if e.FastDeadline < 0 || e.FastDeadline > maxDuration { + return fmt.Errorf("fast_deadline must be between 0 and 24h") + } + + // Validate max_concurrent_inspects + if e.MaxConcurrentInspects > maxConcurrentInspects { + return fmt.Errorf("max_concurrent_inspects must be between 0 and 1000") + } + + // Validate snapshot policy + validPolicy := false + switch e.SnapshotPolicy { + case SnapshotPolicy_None, SnapshotPolicy_EveryInput, SnapshotPolicy_EveryEpoch: + validPolicy = true + } + + if !validPolicy { + return fmt.Errorf("invalid snapshot policy: %s. Valid values are: NONE, EVERY_INPUT, EVERY_EPOCH", e.SnapshotPolicy) + } + + return nil +} + +func ParseHexUint64(s string) (uint64, error) { + if s == "" || len(s) < 3 || (!strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X")) { + return 0, fmt.Errorf("invalid hex string: %s", s) + } + return strconv.ParseUint(s[2:], 16, 64) +} + +func ParseHexInt64(s string) (int64, error) { + if s == "" || len(s) < 3 || (!strings.HasPrefix(s, "0x") && !strings.HasPrefix(s, "0X")) { + return 0, fmt.Errorf("invalid hex string: %s", s) + } + return strconv.ParseInt(s[2:], 16, 64) +} + +func ParseHexDuration(s string) (time.Duration, error) { + ns, err := ParseHexInt64(s) + if err != nil { + return 0, err + } + if ns < 0 { + return 0, fmt.Errorf("duration cannot be negative: %s", s) + } + return time.Duration(ns), nil +} + +type Epoch struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + Index uint64 `sql:"primary_key" json:"index"` + FirstBlock uint64 `json:"first_block"` + LastBlock uint64 `json:"last_block"` + InputIndexLowerBound uint64 `json:"input_index_lower_bound"` + InputIndexUpperBound uint64 `json:"input_index_upper_bound"` + MachineHash *common.Hash `json:"machine_hash"` + TxBufferDataBlock *common.Hash `json:"tx_buffer_data_block"` + TxBufferProof []common.Hash `json:"tx_buffer_proof,omitempty"` + IflagsYDataBlock *common.Hash `json:"iflags_y_data_block"` + IflagsYProof []common.Hash `json:"iflags_y_proof,omitempty"` + HtifTohostDataBlock *common.Hash `json:"htif_tohost_data_block"` + HtifTohostProof []common.Hash `json:"htif_tohost_proof,omitempty"` + ClaimTransactionHash *common.Hash `json:"claim_transaction_hash"` + Commitment *common.Hash `json:"commitment"` + CommitmentProof []common.Hash `json:"commitment_proof,omitempty"` + TournamentAddress *common.Address `json:"tournament_address"` + Status EpochStatus `json:"status"` + StagedAtBlock *uint64 `json:"staged_at_block"` + VirtualIndex uint64 `json:"virtual_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (e *Epoch) MarshalJSON() ([]byte, error) { + // Create an alias to avoid infinite recursion in MarshalJSON. + type Alias Epoch + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + Index string `json:"index"` + FirstBlock string `json:"first_block"` + LastBlock string `json:"last_block"` + InputIndexLowerBound string `json:"input_index_lower_bound"` + InputIndexUpperBound string `json:"input_index_upper_bound"` + StagedAtBlock *string `json:"staged_at_block"` + VirtualIndex string `json:"virtual_index"` + *Alias + }{ + Index: fmt.Sprintf("0x%x", e.Index), + FirstBlock: fmt.Sprintf("0x%x", e.FirstBlock), + LastBlock: fmt.Sprintf("0x%x", e.LastBlock), + InputIndexLowerBound: fmt.Sprintf("0x%x", e.InputIndexLowerBound), + InputIndexUpperBound: fmt.Sprintf("0x%x", e.InputIndexUpperBound), + VirtualIndex: fmt.Sprintf("0x%x", e.VirtualIndex), + Alias: (*Alias)(e), + } + if e.StagedAtBlock != nil { + s := fmt.Sprintf("0x%x", *e.StagedAtBlock) + aux.StagedAtBlock = &s + } + return json.Marshal(aux) +} + +func (e *Epoch) UnmarshalJSON(in []byte) error { + type Alias Epoch + aux := &struct { + *Alias + + Index string `json:"index"` + FirstBlock string `json:"first_block"` + LastBlock string `json:"last_block"` + InputIndexLowerBound string `json:"input_index_lower_bound"` + InputIndexUpperBound string `json:"input_index_upper_bound"` + StagedAtBlock *string `json:"staged_at_block"` + VirtualIndex string `json:"virtual_index"` + }{} + + var err error + + if err = json.Unmarshal(in, aux); err != nil { + return err + } + + *e = Epoch(*aux.Alias) + + // manually decode the following values as hex instead of the default (base64) + e.Index, err = ParseHexUint64(aux.Index) + if err != nil { + return err + } + + e.FirstBlock, err = ParseHexUint64(aux.FirstBlock) + if err != nil { + return err + } + + e.LastBlock, err = ParseHexUint64(aux.LastBlock) + if err != nil { + return err + } + + e.InputIndexLowerBound, err = ParseHexUint64(aux.InputIndexLowerBound) + if err != nil { + return err + } + + e.InputIndexUpperBound, err = ParseHexUint64(aux.InputIndexUpperBound) + if err != nil { + return err + } + + if aux.StagedAtBlock != nil { + v, err := ParseHexUint64(*aux.StagedAtBlock) + if err != nil { + return err + } + e.StagedAtBlock = &v + } + + e.VirtualIndex, err = ParseHexUint64(aux.VirtualIndex) + if err != nil { + return err + } + + return nil +} + +type EpochStatus string + +const ( + EpochStatus_Open EpochStatus = "OPEN" + EpochStatus_Closed EpochStatus = "CLOSED" + EpochStatus_InputsProcessed EpochStatus = "INPUTS_PROCESSED" + EpochStatus_ClaimComputed EpochStatus = "CLAIM_COMPUTED" + EpochStatus_ClaimSubmitted EpochStatus = "CLAIM_SUBMITTED" + EpochStatus_ClaimStaged EpochStatus = "CLAIM_STAGED" + EpochStatus_ClaimAccepted EpochStatus = "CLAIM_ACCEPTED" + EpochStatus_ClaimRejected EpochStatus = "CLAIM_REJECTED" + EpochStatus_ClaimForeclosed EpochStatus = "CLAIM_FORECLOSED" +) + +var EpochStatusAllValues = []EpochStatus{ + EpochStatus_Open, + EpochStatus_Closed, + EpochStatus_InputsProcessed, + EpochStatus_ClaimComputed, + EpochStatus_ClaimSubmitted, + EpochStatus_ClaimStaged, + EpochStatus_ClaimAccepted, + EpochStatus_ClaimRejected, + EpochStatus_ClaimForeclosed, +} + +// NonTerminalEpochStatuses returns the states that still require epoch or claim +// work. Each caller owns the returned slice. +// Keep this set equal to the epoch_unreconciled_idx predicate in the initial +// PostgreSQL migration (000001_create_initial_schema.up.sql). +func NonTerminalEpochStatuses() []EpochStatus { + return []EpochStatus{ + EpochStatus_Open, + EpochStatus_Closed, + EpochStatus_InputsProcessed, + EpochStatus_ClaimComputed, + EpochStatus_ClaimSubmitted, + EpochStatus_ClaimStaged, + } +} + +func (e *EpochStatus) Scan(value any) error { + return scanEnum(e, value, EpochStatusAllValues, "EpochStatus") +} + +func (e EpochStatus) String() string { + return string(e) +} + +type Input struct { + EpochApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + Index uint64 `sql:"primary_key" json:"index"` + BlockNumber uint64 `json:"block_number"` + RawData []byte `json:"raw_data"` + Status InputCompletionStatus `json:"status"` + ExceptionData []byte `json:"-"` + MachineHash *common.Hash `json:"machine_hash"` + TxBufferDataBlock *common.Hash `json:"tx_buffer_data_block"` + TransactionHash common.Hash `json:"transaction_hash"` + LogIndex uint64 `json:"log_index"` + SnapshotURI *string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (i *Input) MarshalJSON() ([]byte, error) { + // Create an alias to avoid infinite recursion in MarshalJSON. + type Alias Input + // Define a new structure that embeds the alias but overrides the hex fields. + var exceptionData *string + if i.ExceptionData != nil { + encoded := hexutil.Encode(i.ExceptionData) + exceptionData = &encoded + } + aux := &struct { + EpochIndex string `json:"epoch_index"` + Index string `json:"index"` + BlockNumber string `json:"block_number"` + RawData string `json:"raw_data"` + ExceptionData *string `json:"exception_data"` + LogIndex string `json:"log_index"` + *Alias + }{ + EpochIndex: fmt.Sprintf("0x%x", i.EpochIndex), + Index: fmt.Sprintf("0x%x", i.Index), + BlockNumber: fmt.Sprintf("0x%x", i.BlockNumber), + RawData: "0x" + hex.EncodeToString(i.RawData), + ExceptionData: exceptionData, + LogIndex: fmt.Sprintf("0x%x", i.LogIndex), + Alias: (*Alias)(i), + } + return json.Marshal(aux) +} + +func (i *Input) UnmarshalJSON(in []byte) error { + type Alias Input + aux := &struct { + EpochIndex string `json:"epoch_index"` + Index string `json:"index"` + BlockNumber string `json:"block_number"` + RawData string `json:"raw_data"` + ExceptionData *string `json:"exception_data"` + LogIndex string `json:"log_index"` + *Alias + }{Alias: (*Alias)(i)} + + var err error + if err = json.Unmarshal(in, aux); err != nil { + return err + } + + i.EpochIndex, err = ParseHexUint64(aux.EpochIndex) + if err != nil { + return fmt.Errorf("error on EpochIndex: %w", err) + } + + i.Index, err = ParseHexUint64(aux.Index) + if err != nil { + return fmt.Errorf("error on Index: %w", err) + } + + i.BlockNumber, err = ParseHexUint64(aux.BlockNumber) + if err != nil { + return fmt.Errorf("error on BlockNumber: %w", err) + } + + i.RawData, err = hexutil.Decode(aux.RawData) + if err != nil { + return fmt.Errorf("error on RawData: %w", err) + } + if aux.ExceptionData == nil { + i.ExceptionData = nil + } else { + i.ExceptionData, err = hexutil.Decode(*aux.ExceptionData) + if err != nil { + return fmt.Errorf("error on ExceptionData: %w", err) + } + } + + i.LogIndex, err = ParseHexUint64(aux.LogIndex) + if err != nil { + return fmt.Errorf("error on LogIndex: %w", err) + } + + return nil +} + +type InputCompletionStatus string + +//nolint:revive // Public enum names preserve the generated/API naming convention. +const ( + InputCompletionStatus_None InputCompletionStatus = "NONE" + InputCompletionStatus_Accepted InputCompletionStatus = "ACCEPTED" + InputCompletionStatus_Rejected InputCompletionStatus = "REJECTED" + InputCompletionStatus_Exception InputCompletionStatus = "EXCEPTION" + InputCompletionStatus_MachineHalted InputCompletionStatus = "MACHINE_HALTED" + InputCompletionStatus_Overflow InputCompletionStatus = "OVERFLOW" + InputCompletionStatus_UnexpectedYield InputCompletionStatus = "UNEXPECTED_YIELD" + InputCompletionStatus_InvalidOutputsRoot InputCompletionStatus = "INVALID_OUTPUTS_ROOT" +) + +var InputCompletionStatusAllValues = []InputCompletionStatus{ + InputCompletionStatus_None, + InputCompletionStatus_Accepted, + InputCompletionStatus_Rejected, + InputCompletionStatus_Exception, + InputCompletionStatus_MachineHalted, + InputCompletionStatus_Overflow, + InputCompletionStatus_UnexpectedYield, + InputCompletionStatus_InvalidOutputsRoot, +} + +// IsCompleted reports whether the status is a deterministic completed result +// of an advance execution. NONE represents an input that has not completed. +func (e InputCompletionStatus) IsCompleted() bool { + switch e { + case InputCompletionStatus_Accepted, + InputCompletionStatus_Rejected, + InputCompletionStatus_Exception, + InputCompletionStatus_MachineHalted, + InputCompletionStatus_Overflow, + InputCompletionStatus_UnexpectedYield, + InputCompletionStatus_InvalidOutputsRoot: + return true + case InputCompletionStatus_None: + return false + default: + return false + } +} + +// IsTerminal reports whether a completed input leaves the canonical machine +// unable to process another advance. Rejection is completed but not terminal. +func (e InputCompletionStatus) IsTerminal() bool { + _, terminal := e.TerminalApplicationStatus() + return terminal +} + +// TerminalApplicationStatus maps an execution terminal to the durable status +// exposed for the application. The boolean is false for pending, accepted, and +// rejected inputs. +func (e InputCompletionStatus) TerminalApplicationStatus() (ApplicationStatus, bool) { + switch e { + case InputCompletionStatus_Exception: + return ApplicationStatus_GuestException, true + case InputCompletionStatus_MachineHalted: + return ApplicationStatus_MachineHalted, true + case InputCompletionStatus_Overflow: + return ApplicationStatus_McycleOverflow, true + case InputCompletionStatus_UnexpectedYield: + return ApplicationStatus_UnexpectedYield, true + case InputCompletionStatus_InvalidOutputsRoot: + return ApplicationStatus_InvalidOutputsRoot, true + case InputCompletionStatus_None, + InputCompletionStatus_Accepted, + InputCompletionStatus_Rejected: + return "", false + default: + return "", false + } +} + +func (e *InputCompletionStatus) Scan(value any) error { + return scanEnum(e, value, InputCompletionStatusAllValues, "InputCompletionStatus") +} + +func (e InputCompletionStatus) String() string { + return string(e) +} + +type Output struct { + InputEpochApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + InputIndex uint64 `json:"input_index"` + Index uint64 `sql:"primary_key" json:"index"` + RawData []byte `json:"raw_data"` + Hash *common.Hash `json:"hash"` + OutputHashesSiblings []common.Hash `json:"output_hashes_siblings"` + ExecutionTransactionHash *common.Hash `json:"execution_transaction_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (o *Output) MarshalJSON() ([]byte, error) { + // Create an alias to avoid infinite recursion in MarshalJSON. + type Alias Output + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + EpochIndex string `json:"epoch_index"` + InputIndex string `json:"input_index"` + Index string `json:"index"` + RawData string `json:"raw_data"` + *Alias + }{ + EpochIndex: fmt.Sprintf("0x%x", o.EpochIndex), + InputIndex: fmt.Sprintf("0x%x", o.InputIndex), + Index: fmt.Sprintf("0x%x", o.Index), + RawData: "0x" + hex.EncodeToString(o.RawData), + Alias: (*Alias)(o), + } + return json.Marshal(aux) +} + +func (o *Output) UnmarshalJSON(data []byte) error { + type Alias Output + aux := &struct { + EpochIndex string `json:"epoch_index"` + InputIndex string `json:"input_index"` + Index string `json:"index"` + RawData string `json:"raw_data"` + *Alias + }{Alias: (*Alias)(o)} + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + *o = Output(*aux.Alias) + + var err error + o.EpochIndex, err = ParseHexUint64(aux.EpochIndex) + if err != nil { + return fmt.Errorf("error on EpochIndex: %w", err) + } + o.InputIndex, err = ParseHexUint64(aux.InputIndex) + if err != nil { + return fmt.Errorf("error on InputIndex: %w", err) + } + o.Index, err = ParseHexUint64(aux.Index) + if err != nil { + return fmt.Errorf("error on Index: %w", err) + } + o.RawData, err = hexutil.Decode(aux.RawData) + if err != nil { + return fmt.Errorf("error on RawData: %w", err) + } + return nil +} + +// Withdrawal records a Withdrawal(uint64 accountIndex, bytes account, bytes output) +// event emitted by an IApplication after the accounts drive has been proved. +// The node observes these only for applications with a non-zero ForecloseBlock +// and AccountsDriveProvedBlock; evmreader uses a FindTransitions scan on the +// on-chain getNumberOfWithdrawals counter to detect them. The contract marks +// each accountIndex as withdrawn, so the event fires at most once per slot. +// +// Account and Output are stored as raw bytes — the recipient encoding inside +// Account is defined by the per-app WithdrawalOutputBuilder and is opaque to +// the node. LogIndex is preserved (despite not being part of the primary key) +// so audits can locate the exact log on chain without re-querying. +type Withdrawal struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + AccountIndex uint64 `sql:"primary_key" json:"account_index"` + Account []byte `json:"account"` + Output []byte `json:"output"` + BlockNumber uint64 `json:"block_number"` + TransactionHash common.Hash `json:"transaction_hash"` + LogIndex uint `json:"log_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (w *Withdrawal) MarshalJSON() ([]byte, error) { + type Alias Withdrawal + aux := &struct { + AccountIndex string `json:"account_index"` + Account string `json:"account"` + Output string `json:"output"` + BlockNumber string `json:"block_number"` + LogIndex string `json:"log_index"` + *Alias + }{ + AccountIndex: fmt.Sprintf("0x%x", w.AccountIndex), + Account: "0x" + hex.EncodeToString(w.Account), + Output: "0x" + hex.EncodeToString(w.Output), + BlockNumber: fmt.Sprintf("0x%x", w.BlockNumber), + LogIndex: fmt.Sprintf("0x%x", w.LogIndex), + Alias: (*Alias)(w), + } + return json.Marshal(aux) +} + +func (w *Withdrawal) UnmarshalJSON(data []byte) error { + type Alias Withdrawal + aux := &struct { + AccountIndex string `json:"account_index"` + Account string `json:"account"` + Output string `json:"output"` + BlockNumber string `json:"block_number"` + LogIndex string `json:"log_index"` + *Alias + }{Alias: (*Alias)(w)} + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + *w = Withdrawal(*aux.Alias) + + var err error + w.AccountIndex, err = ParseHexUint64(aux.AccountIndex) + if err != nil { + return fmt.Errorf("error on AccountIndex: %w", err) + } + w.Account, err = hexutil.Decode(aux.Account) + if err != nil { + return fmt.Errorf("error on Account: %w", err) + } + w.Output, err = hexutil.Decode(aux.Output) + if err != nil { + return fmt.Errorf("error on Output: %w", err) + } + w.BlockNumber, err = ParseHexUint64(aux.BlockNumber) + if err != nil { + return fmt.Errorf("error on BlockNumber: %w", err) + } + logIndex, err := ParseHexUint64(aux.LogIndex) + if err != nil { + return fmt.Errorf("error on LogIndex: %w", err) + } + w.LogIndex = uint(logIndex) + return nil +} + +type Report struct { + InputEpochApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + InputIndex uint64 `json:"input_index"` + Index uint64 `sql:"primary_key" json:"index"` + RawData []byte `json:"raw_data"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (r *Report) MarshalJSON() ([]byte, error) { + // Create an alias to avoid infinite recursion in MarshalJSON. + type Alias Report + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + EpochIndex string `json:"epoch_index"` + InputIndex string `json:"input_index"` + Index string `json:"index"` + RawData string `json:"raw_data"` + *Alias + }{ + EpochIndex: fmt.Sprintf("0x%x", r.EpochIndex), + InputIndex: fmt.Sprintf("0x%x", r.InputIndex), + Index: fmt.Sprintf("0x%x", r.Index), + RawData: "0x" + hex.EncodeToString(r.RawData), + Alias: (*Alias)(r), + } + return json.Marshal(aux) +} + +func (r *Report) UnmarshalJSON(data []byte) error { + type Alias Report + aux := &struct { + EpochIndex string `json:"epoch_index"` + InputIndex string `json:"input_index"` + Index string `json:"index"` + RawData string `json:"raw_data"` + *Alias + }{Alias: (*Alias)(r)} + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + *r = Report(*aux.Alias) + + var err error + r.EpochIndex, err = ParseHexUint64(aux.EpochIndex) + if err != nil { + return fmt.Errorf("error on EpochIndex: %w", err) + } + r.InputIndex, err = ParseHexUint64(aux.InputIndex) + if err != nil { + return fmt.Errorf("error on InputIndex: %w", err) + } + r.Index, err = ParseHexUint64(aux.Index) + if err != nil { + return fmt.Errorf("error on Index: %w", err) + } + r.RawData, err = hexutil.Decode(aux.RawData) + if err != nil { + return fmt.Errorf("error on RawData: %w", err) + } + return nil +} + +type NodeConfig[T any] struct { + Key string + Value T + CreatedAt time.Time + UpdatedAt time.Time +} + +type StateProof struct { + TxBufferDataBlock common.Hash + TxBufferProof [][32]byte + MachineHash common.Hash + IflagsYDataBlock common.Hash + IflagsYProof [][32]byte + HtifTohostDataBlock common.Hash + HtifTohostProof [][32]byte +} + +// ErrIncompleteStateProof means an epoch does not contain every persisted +// component required to reconstruct its machine state proof. +var ErrIncompleteStateProof = errors.New("epoch state proof is incomplete") + +// StateProofSiblingCount is the height of the canonical machine +// memory tree above a 32-byte data block (64 - 5). +const StateProofSiblingCount = 59 + +// IsComplete reports whether all three state leaves have the canonical sibling +// depth. The machine validates proof contents when it collects them. The +// contracts validate the persisted proof when the node submits it. +func (p *StateProof) IsComplete() bool { + return p != nil && + len(p.TxBufferProof) == StateProofSiblingCount && + len(p.IflagsYProof) == StateProofSiblingCount && + len(p.HtifTohostProof) == StateProofSiblingCount +} + +// HasCompleteStateProof reports whether an epoch contains every persisted +// component of the machine state proof. +func (e *Epoch) HasCompleteStateProof() bool { + return e != nil && + e.MachineHash != nil && + e.TxBufferDataBlock != nil && + e.IflagsYDataBlock != nil && + e.HtifTohostDataBlock != nil && + len(e.TxBufferProof) == StateProofSiblingCount && + len(e.IflagsYProof) == StateProofSiblingCount && + len(e.HtifTohostProof) == StateProofSiblingCount +} + +// StateProof reconstructs the persisted machine state proof owned by an +// epoch. The returned value owns its sibling slices. +func (e *Epoch) StateProof() (StateProof, error) { + if !e.HasCompleteStateProof() { + return StateProof{}, ErrIncompleteStateProof + } + + return StateProof{ + TxBufferDataBlock: *e.TxBufferDataBlock, + TxBufferProof: copyStateProofSiblings(e.TxBufferProof), + MachineHash: *e.MachineHash, + IflagsYDataBlock: *e.IflagsYDataBlock, + IflagsYProof: copyStateProofSiblings(e.IflagsYProof), + HtifTohostDataBlock: *e.HtifTohostDataBlock, + HtifTohostProof: copyStateProofSiblings(e.HtifTohostProof), + }, nil +} + +func copyStateProofSiblings(siblings []common.Hash) [][32]byte { + result := make([][32]byte, len(siblings)) + for i := range siblings { + result[i] = siblings[i] + } + return result +} + +type AdvanceResult struct { + StateProof + EpochIndex uint64 + InputIndex uint64 + Status InputCompletionStatus + Outputs [][]byte + Reports [][]byte + ExceptionData []byte + // PeriodicStateHashes holds sampled entry roots; see input_hash_collection.go. + PeriodicStateHashes [][32]byte + // PaddingRepetitions counts final-state entries; see input_hash_collection.go. + PaddingRepetitions uint64 + IsDaveConsensus bool +} + +// ReplaySummary identifies the immutable completed-input prefix selected for +// one machine replay. +type ReplaySummary struct { + ApplicationID int64 + ProcessedInputs uint64 + Consensus Consensus +} + +// ReplayInput is the canonical input evidence needed to verify one replayed +// machine execution. It is deliberately narrower than Input: L1 metadata, +// timestamps, and snapshot location do not participate in the comparison. +type ReplayInput struct { + ApplicationID int64 + EpochIndex uint64 + InputIndex uint64 + RawData []byte + Status InputCompletionStatus + ExceptionData []byte + MachineHash *common.Hash + TxBufferDataBlock *common.Hash +} + +// ReplayStateHash is a persisted entry row, as defined in input_hash_collection.go. +// Repetitions counts entries. Index is a row ordinal in the epoch. +type ReplayStateHash struct { + Index uint64 + MachineHash common.Hash + Repetitions uint64 +} + +// ReplayRecord contains one completed input and its requested verification +// evidence. Canonical records leave Outputs, Reports, and StateHashes empty. +type ReplayRecord struct { + Input ReplayInput + Outputs [][]byte + Reports [][]byte + StateHashes []ReplayStateHash +} + +// FIXME: remove this type. Migrate claim to use Application + Epoch +type ClaimRow struct { + Epoch + IApplicationAddress common.Address + IConsensusAddress common.Address +} + +type DefaultBlock string + +const ( + DefaultBlock_Finalized DefaultBlock = "FINALIZED" + DefaultBlock_Latest DefaultBlock = "LATEST" + DefaultBlock_Pending DefaultBlock = "PENDING" + DefaultBlock_Safe DefaultBlock = "SAFE" +) + +var DefaultBlockAllValues = []DefaultBlock{ + DefaultBlock_Finalized, + DefaultBlock_Latest, + DefaultBlock_Pending, + DefaultBlock_Safe, +} + +func (e *DefaultBlock) Scan(value any) error { + return scanEnum(e, value, DefaultBlockAllValues, "DefaultBlock") +} + +func (e DefaultBlock) String() string { + return string(e) +} + +type MonitoredEvent string + +const ( + MonitoredEvent_InputAdded MonitoredEvent = "InputAdded" + MonitoredEvent_OutputExecuted MonitoredEvent = "OutputExecuted" + MonitoredEvent_Foreclosure MonitoredEvent = "Foreclosure" + MonitoredEvent_Withdrawal MonitoredEvent = "Withdrawal" + MonitoredEvent_AccountsDriveMerkleRootProved MonitoredEvent = "AccountsDriveMerkleRootProved" + MonitoredEvent_ClaimSubmitted MonitoredEvent = "ClaimSubmitted" + MonitoredEvent_ClaimAccepted MonitoredEvent = "ClaimAccepted" + MonitoredEvent_EpochSealed MonitoredEvent = "EpochSealed" + MonitoredEvent_CommitmentJoined MonitoredEvent = "CommitmentJoined" + MonitoredEvent_MatchAdvanced MonitoredEvent = "MatchAdvanced" + MonitoredEvent_MatchCreated MonitoredEvent = "MatchCreated" + MonitoredEvent_MatchDeleted MonitoredEvent = "MatchDeleted" + MonitoredEvent_NewInnerTournament MonitoredEvent = "NewInnerTournament" + MonitoredEvent_LeafMatchSealed MonitoredEvent = "LeafMatchSealed" + MonitoredEvent_PartialBondRefund MonitoredEvent = "PartialBondRefund" + MonitoredEvent_BondRecovered MonitoredEvent = "BondRecovered" + //nolint:revive // follows the MonitoredEvent_ constants + MonitoredEvent_StandingChanged MonitoredEvent = "StandingChanged" +) + +func (e MonitoredEvent) String() string { + return string(e) +} + +type Tournament struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` + Address common.Address `sql:"primary_key" json:"address"` + ParentTournamentAddress *common.Address `json:"parent_tournament_address"` + ParentMatchIDHash *common.Hash `json:"parent_match_id_hash"` + MaxLevel uint64 `json:"max_level"` + Level uint64 `json:"level"` + Log2Step uint64 `json:"log2step"` + Height uint64 `json:"height"` + InitialHash common.Hash `json:"initial_hash"` + BaseCycle Uint256 `json:"base_cycle"` + BondValue Uint256 `json:"bond_value"` + ContestedFinalStateOne *common.Hash `json:"contested_final_state_one"` + ContestedFinalStateTwo *common.Hash `json:"contested_final_state_two"` + Kind TournamentKind `json:"kind"` + StartInstant uint64 `json:"start_instant"` + Allowance uint64 `json:"allowance"` + CreationEvent *TournamentCreationEvent `json:"creation_event"` + Snapshot TournamentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value Tournament) MarshalJSON() ([]byte, error) { + type Alias Tournament + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + MaxLevel hexutil.Uint64 `json:"max_level"` + Level hexutil.Uint64 `json:"level"` + Log2Step hexutil.Uint64 `json:"log2step"` + Height hexutil.Uint64 `json:"height"` + StartInstant hexutil.Uint64 `json:"start_instant"` + Allowance hexutil.Uint64 `json:"allowance"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + MaxLevel: hexutil.Uint64(value.MaxLevel), + Level: hexutil.Uint64(value.Level), + Log2Step: hexutil.Uint64(value.Log2Step), + Height: hexutil.Uint64(value.Height), + StartInstant: hexutil.Uint64(value.StartInstant), + Allowance: hexutil.Uint64(value.Allowance), + }) +} + +func (value *Tournament) UnmarshalJSON(data []byte) error { + type Alias Tournament + var decoded Tournament + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + MaxLevel hexutil.Uint64 `json:"max_level"` + Level hexutil.Uint64 `json:"level"` + Log2Step hexutil.Uint64 `json:"log2step"` + Height hexutil.Uint64 `json:"height"` + StartInstant hexutil.Uint64 `json:"start_instant"` + Allowance hexutil.Uint64 `json:"allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.MaxLevel = uint64(aux.MaxLevel) + decoded.Level = uint64(aux.Level) + decoded.Log2Step = uint64(aux.Log2Step) + decoded.Height = uint64(aux.Height) + decoded.StartInstant = uint64(aux.StartInstant) + decoded.Allowance = uint64(aux.Allowance) + *value = decoded + return nil +} + +type Commitment struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` + TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` + Commitment common.Hash `sql:"primary_key" json:"commitment"` + FinalStateHash common.Hash `json:"final_state_hash"` + SubmitterAddress common.Address `json:"submitter_address"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + Snapshot CommitmentSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value Commitment) MarshalJSON() ([]byte, error) { + type Alias Commitment + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *Commitment) UnmarshalJSON(data []byte) error { + type Alias Commitment + var decoded Commitment + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type Match struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `sql:"primary_key" json:"epoch_index"` + TournamentAddress common.Address `sql:"primary_key" json:"tournament_address"` + IDHash common.Hash `sql:"primary_key" json:"id_hash"` + CommitmentOne common.Hash `json:"commitment_one"` + CommitmentTwo common.Hash `json:"commitment_two"` + LeftOfTwo common.Hash `json:"left_of_two"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + EliminableAt uint64 `json:"eliminable_at"` + ResponderDeadline uint64 `json:"responder_deadline"` + LeafSeal *LeafMatchSeal `json:"leaf_seal"` + Winner WinnerCommitment `json:"winner_commitment"` + DeletionReason MatchDeletionReason `json:"deletion_reason"` + DeletionBlockNumber uint64 `json:"deletion_block_number"` + DeletionTxHash *common.Hash `json:"deletion_tx_hash"` + DeletionLogIndex *uint64 `json:"deletion_log_index"` + Snapshot MatchSnapshot `json:"snapshot"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value Match) MarshalJSON() ([]byte, error) { + type Alias Match + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + ResponderDeadline hexutil.Uint64 `json:"responder_deadline"` + DeletionBlockNumber hexutil.Uint64 `json:"deletion_block_number"` + DeletionLogIndex *hexutil.Uint64 `json:"deletion_log_index"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + EliminableAt: hexutil.Uint64(value.EliminableAt), + ResponderDeadline: hexutil.Uint64(value.ResponderDeadline), + DeletionBlockNumber: hexutil.Uint64(value.DeletionBlockNumber), + DeletionLogIndex: (*hexutil.Uint64)(value.DeletionLogIndex), + }) +} + +func (value *Match) UnmarshalJSON(data []byte) error { + type Alias Match + var decoded Match + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + ResponderDeadline hexutil.Uint64 `json:"responder_deadline"` + DeletionBlockNumber hexutil.Uint64 `json:"deletion_block_number"` + DeletionLogIndex *hexutil.Uint64 `json:"deletion_log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.ResponderDeadline = uint64(aux.ResponderDeadline) + decoded.DeletionBlockNumber = uint64(aux.DeletionBlockNumber) + decoded.DeletionLogIndex = (*uint64)(aux.DeletionLogIndex) + *value = decoded + return nil +} + +type MatchAdvanced struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + IDHash common.Hash `json:"id_hash"` + OtherParent common.Hash `json:"other_parent"` + LeftNode common.Hash `json:"left_node"` + SegmentStartPosition Uint256 `json:"segment_start_position"` + EliminableAt uint64 `json:"eliminable_at"` + CurrentHeight uint64 `json:"current_height"` + ResponderDeadline uint64 `json:"responder_deadline"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `sql:"primary_key" json:"tx_hash"` + LogIndex uint64 `sql:"primary_key" json:"log_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value MatchAdvanced) MarshalJSON() ([]byte, error) { + type Alias MatchAdvanced + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + ResponderDeadline hexutil.Uint64 `json:"responder_deadline"` + CurrentHeight hexutil.Uint64 `json:"current_height"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + EliminableAt: hexutil.Uint64(value.EliminableAt), + ResponderDeadline: hexutil.Uint64(value.ResponderDeadline), + CurrentHeight: hexutil.Uint64(value.CurrentHeight), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *MatchAdvanced) UnmarshalJSON(data []byte) error { + type Alias MatchAdvanced + var decoded MatchAdvanced + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + ResponderDeadline hexutil.Uint64 `json:"responder_deadline"` + CurrentHeight hexutil.Uint64 `json:"current_height"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.ResponderDeadline = uint64(aux.ResponderDeadline) + decoded.CurrentHeight = uint64(aux.CurrentHeight) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +// MatchDeletionReason represents the reason why a match was deleted +type MatchDeletionReason string + +const ( + MatchDeletionReason_STEP MatchDeletionReason = "STEP" + MatchDeletionReason_TIMEOUT MatchDeletionReason = "TIMEOUT" + MatchDeletionReason_CHILD_TOURNAMENT MatchDeletionReason = "CHILD_TOURNAMENT" + MatchDeletionReason_NOT_DELETED MatchDeletionReason = "NOT_DELETED" +) + +var MatchDeletionReasonAllValues = []MatchDeletionReason{ + MatchDeletionReason_STEP, + MatchDeletionReason_TIMEOUT, + MatchDeletionReason_CHILD_TOURNAMENT, + MatchDeletionReason_NOT_DELETED, +} + +func (e *MatchDeletionReason) Scan(value any) error { + return scanEnum(e, value, MatchDeletionReasonAllValues, "MatchDeletionReason") +} + +func (e MatchDeletionReason) String() string { + return string(e) +} + +func MatchDeletionReasonFromUint8(v uint8) (MatchDeletionReason, error) { + switch v { + case 0: + return MatchDeletionReason_STEP, nil + case 1: + return MatchDeletionReason_TIMEOUT, nil + case 2: //nolint: mnd + return MatchDeletionReason_CHILD_TOURNAMENT, nil + default: + return "", fmt.Errorf("unmodelled MatchDeletionReason %d from contract", v) + } +} + +// WinnerCommitment represents the winner commitment of a match +type WinnerCommitment string + +const ( + WinnerCommitment_NONE WinnerCommitment = "NONE" + WinnerCommitment_ONE WinnerCommitment = "ONE" + WinnerCommitment_TWO WinnerCommitment = "TWO" +) + +var WinnerCommitmentAllValues = []WinnerCommitment{ + WinnerCommitment_NONE, + WinnerCommitment_ONE, + WinnerCommitment_TWO, +} + +func (e *WinnerCommitment) Scan(value any) error { + return scanEnum(e, value, WinnerCommitmentAllValues, "WinnerCommitment") +} + +func (e WinnerCommitment) String() string { + return string(e) +} + +func WinnerCommitmentFromUint8(v uint8) (WinnerCommitment, error) { + switch v { + case 0: + return WinnerCommitment_NONE, nil + case 1: + return WinnerCommitment_ONE, nil + case 2: //nolint: mnd + return WinnerCommitment_TWO, nil + default: + return "", fmt.Errorf("unmodelled WinnerCommitment %d from contract", v) + } +} + +// StateHash is a persisted collection entry, as defined in input_hash_collection.go. +// Index is the epoch row ordinal. Rows are consecutive in input order; Repetitions counts entries. +type StateHash struct { + InputEpochApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + InputIndex uint64 `json:"input_index"` + Index uint64 `sql:"primary_key" json:"index"` + MachineHash common.Hash `json:"machine_hash"` + Repetitions uint64 `json:"repetitions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// EpochStateHash is the narrow, by-value projection used for epoch pages. +// MachineHash holds an entry root. Repetitions counts entries, and Index is +// the row ordinal within the epoch. +type EpochStateHash struct { + Index uint64 + InputIndex uint64 + MachineHash common.Hash + Repetitions uint64 +} + +func (s *StateHash) MarshalJSON() ([]byte, error) { + // Create an alias to avoid infinite recursion in MarshalJSON. + type Alias StateHash + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + EpochIndex string `json:"epoch_index"` + InputIndex string `json:"input_index"` + Index string `json:"index"` + Repetitions string `json:"repetitions"` + *Alias + }{ + EpochIndex: fmt.Sprintf("0x%x", s.EpochIndex), + InputIndex: fmt.Sprintf("0x%x", s.InputIndex), + Index: fmt.Sprintf("0x%x", s.Index), + Repetitions: fmt.Sprintf("0x%x", s.Repetitions), + Alias: (*Alias)(s), + } + return json.Marshal(aux) +} + +func (s *StateHash) UnmarshalJSON(data []byte) error { + // Create an alias to avoid infinite recursion in UnmarshalJSON. + type Alias StateHash + // Define a new structure that embeds the alias but overrides the hex fields. + aux := &struct { + EpochIndex string `json:"epoch_index"` + InputIndex string `json:"input_index"` + Index string `json:"index"` + Repetitions string `json:"repetitions"` + *Alias + }{ + Alias: (*Alias)(s), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + if aux.EpochIndex != "" { + val, err := ParseHexUint64(aux.EpochIndex) + if err != nil { + return fmt.Errorf("invalid epoch_index: %w", err) + } + s.EpochIndex = val + } + + if aux.InputIndex != "" { + val, err := ParseHexUint64(aux.InputIndex) + if err != nil { + return fmt.Errorf("invalid input_index: %w", err) + } + s.InputIndex = val + } + + if aux.Index != "" { + val, err := ParseHexUint64(aux.Index) + if err != nil { + return fmt.Errorf("invalid index: %w", err) + } + s.Index = val + } + + if aux.Repetitions != "" { + val, err := ParseHexUint64(aux.Repetitions) + if err != nil { + return fmt.Errorf("invalid repetitions: %w", err) + } + s.Repetitions = val + } + + return nil +} + +func Pointer[T any](v T) *T { + return &v +} diff --git a/internal/model/models_json_test.go b/internal/model/models_json_test.go new file mode 100644 index 000000000..d592f70e3 --- /dev/null +++ b/internal/model/models_json_test.go @@ -0,0 +1,465 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestApplicationJSONRoundtrip(t *testing.T) { + original := Application{ + Name: "direct-input-box", + IApplicationAddress: common.HexToAddress("0x1234"), + IConsensusAddress: common.HexToAddress("0x5678"), + IInputBoxAddress: common.HexToAddress("0x9abc"), + IInputBoxBlock: 10, + EpochLength: 20, + ClaimStagingPeriod: 30, + ConsensusType: Consensus_Authority, + Enabled: true, + Status: ApplicationStatus_OK, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &fields)) + require.Contains(t, fields, "iinputbox_address") + require.NotContains(t, fields, "data_availability") + + var decoded Application + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, original, decoded) +} + +func TestEpochJSONRoundtrip(t *testing.T) { + root := common.HexToHash("0xabcd") + iflagsY := common.HexToHash("0x1234") + htifTohost := common.HexToHash("0x5678") + original := Epoch{ + ApplicationID: 1, + Index: 42, + FirstBlock: 100, + LastBlock: 200, + InputIndexLowerBound: 0, + InputIndexUpperBound: 10, + VirtualIndex: 5, + Status: EpochStatus_ClaimAccepted, + TxBufferDataBlock: &root, + TxBufferProof: []common.Hash{common.HexToHash("0xaaaa")}, + IflagsYDataBlock: &iflagsY, + IflagsYProof: []common.Hash{common.HexToHash("0x1111")}, + HtifTohostDataBlock: &htifTohost, + HtifTohostProof: []common.Hash{common.HexToHash("0x2222")}, + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + require.Contains(t, string(data), `"tx_buffer_data_block"`) + require.Contains(t, string(data), `"tx_buffer_proof"`) + require.NotContains(t, string(data), `"outputs_merkle_root"`) + require.NotContains(t, string(data), `"outputs_merkle_proof"`) + + var decoded Epoch + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + // ApplicationID is json:"-" (DB-only FK), intentionally lost in JSON roundtrip. + require.Zero(t, decoded.ApplicationID) + require.Equal(t, original.Index, decoded.Index) + require.Equal(t, original.FirstBlock, decoded.FirstBlock) + require.Equal(t, original.LastBlock, decoded.LastBlock) + require.Equal(t, original.InputIndexLowerBound, decoded.InputIndexLowerBound) + require.Equal(t, original.InputIndexUpperBound, decoded.InputIndexUpperBound) + require.Equal(t, original.VirtualIndex, decoded.VirtualIndex) + require.Equal(t, original.Status, decoded.Status) + require.Equal(t, original.TxBufferDataBlock, decoded.TxBufferDataBlock) + require.Equal(t, original.TxBufferProof, decoded.TxBufferProof) + require.Equal(t, original.IflagsYDataBlock, decoded.IflagsYDataBlock) + require.Equal(t, original.IflagsYProof, decoded.IflagsYProof) + require.Equal(t, original.HtifTohostDataBlock, decoded.HtifTohostDataBlock) + require.Equal(t, original.HtifTohostProof, decoded.HtifTohostProof) +} + +func TestStateProofCompleteness(t *testing.T) { + siblings := make([][32]byte, StateProofSiblingCount) + proof := &StateProof{ + TxBufferProof: append([][32]byte(nil), siblings...), + IflagsYProof: append([][32]byte(nil), siblings...), + HtifTohostProof: append([][32]byte(nil), siblings...), + } + require.True(t, proof.IsComplete()) + + proof.HtifTohostProof = proof.HtifTohostProof[:len(proof.HtifTohostProof)-1] + require.False(t, proof.IsComplete()) + require.False(t, (*StateProof)(nil).IsComplete()) + + hash := common.Hash{1} + epoch := &Epoch{ + MachineHash: &hash, + TxBufferDataBlock: &hash, + TxBufferProof: make([]common.Hash, StateProofSiblingCount), + IflagsYDataBlock: &hash, + IflagsYProof: make([]common.Hash, StateProofSiblingCount), + HtifTohostDataBlock: &hash, + HtifTohostProof: make([]common.Hash, StateProofSiblingCount), + } + require.True(t, epoch.HasCompleteStateProof()) + epoch.IflagsYDataBlock = nil + require.False(t, epoch.HasCompleteStateProof()) + require.False(t, (*Epoch)(nil).HasCompleteStateProof()) +} + +func TestEpochStateProof(t *testing.T) { + machineHash := common.Hash{1} + txBufferDataBlock := common.Hash{2} + iflagsYDataBlock := common.Hash{3} + htifTohostDataBlock := common.Hash{4} + epoch := &Epoch{ + MachineHash: &machineHash, + TxBufferDataBlock: &txBufferDataBlock, + TxBufferProof: make([]common.Hash, StateProofSiblingCount), + IflagsYDataBlock: &iflagsYDataBlock, + IflagsYProof: make([]common.Hash, StateProofSiblingCount), + HtifTohostDataBlock: &htifTohostDataBlock, + HtifTohostProof: make([]common.Hash, StateProofSiblingCount), + } + epoch.TxBufferProof[0] = common.Hash{5} + epoch.IflagsYProof[0] = common.Hash{6} + epoch.HtifTohostProof[0] = common.Hash{7} + + proof, err := epoch.StateProof() + require.NoError(t, err) + require.Equal(t, machineHash, proof.MachineHash) + require.Equal(t, txBufferDataBlock, proof.TxBufferDataBlock) + require.Equal(t, iflagsYDataBlock, proof.IflagsYDataBlock) + require.Equal(t, htifTohostDataBlock, proof.HtifTohostDataBlock) + require.Equal(t, [32]byte{5}, proof.TxBufferProof[0]) + require.Equal(t, [32]byte{6}, proof.IflagsYProof[0]) + require.Equal(t, [32]byte{7}, proof.HtifTohostProof[0]) + + proof.TxBufferProof[0][0] = 8 + proof.IflagsYProof[0][0] = 9 + proof.HtifTohostProof[0][0] = 10 + require.Equal(t, byte(5), epoch.TxBufferProof[0][0]) + require.Equal(t, byte(6), epoch.IflagsYProof[0][0]) + require.Equal(t, byte(7), epoch.HtifTohostProof[0][0]) +} + +func TestEpochStateProofRejectsIncompleteEpoch(t *testing.T) { + _, err := (&Epoch{}).StateProof() + require.ErrorIs(t, err, ErrIncompleteStateProof) + + _, err = (*Epoch)(nil).StateProof() + require.ErrorIs(t, err, ErrIncompleteStateProof) +} + +func TestInputJSONRoundtrip(t *testing.T) { + machineHash := common.HexToHash("0x1234") + txBufferDataBlock := common.HexToHash("0xabcd") + original := Input{ + EpochApplicationID: 1, + EpochIndex: 3, + Index: 7, + BlockNumber: 12345, + RawData: []byte{0xde, 0xad, 0xbe, 0xef}, + Status: InputCompletionStatus_Exception, + ExceptionData: []byte{0xff, 0x00, 0x80}, + MachineHash: &machineHash, + TxBufferDataBlock: &txBufferDataBlock, + TransactionHash: common.HexToHash("0x5678"), + LogIndex: 11, + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + // LogIndex must be hex-encoded like the other uint64 fields. + require.Contains(t, string(data), `"log_index":"0xb"`) + require.Contains(t, string(data), `"exception_data":"0xff0080"`) + require.Contains(t, string(data), `"tx_buffer_data_block"`) + require.NotContains(t, string(data), `"outputs_hash"`) + + var decoded Input + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + // EpochApplicationID is json:"-" (DB-only FK), intentionally lost in JSON roundtrip. + require.Zero(t, decoded.EpochApplicationID) + require.Equal(t, original.EpochIndex, decoded.EpochIndex) + require.Equal(t, original.Index, decoded.Index) + require.Equal(t, original.BlockNumber, decoded.BlockNumber) + require.Equal(t, original.RawData, decoded.RawData) + require.Equal(t, original.Status, decoded.Status) + require.Equal(t, original.ExceptionData, decoded.ExceptionData) + require.Equal(t, original.MachineHash, decoded.MachineHash) + require.Equal(t, original.TxBufferDataBlock, decoded.TxBufferDataBlock) + require.Equal(t, original.TransactionHash, decoded.TransactionHash) + require.Equal(t, original.LogIndex, decoded.LogIndex) +} + +func TestInputJSONDistinguishesMissingAndEmptyExceptionData(t *testing.T) { + base := Input{} + data, err := json.Marshal(&base) + require.NoError(t, err) + require.Contains(t, string(data), `"exception_data":null`) + + base.ExceptionData = []byte{} + data, err = json.Marshal(&base) + require.NoError(t, err) + require.Contains(t, string(data), `"exception_data":"0x"`) +} + +func TestInputUnmarshalJSONInvalidHex(t *testing.T) { + validJSON := `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x","log_index":"0x0"}` + tests := []struct { + name string + json string + wantErr string + }{ + { + name: "missing LogIndex", + json: `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x"}`, + wantErr: "LogIndex", + }, + { + name: "invalid LogIndex", + json: `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x","log_index":"bad"}`, + wantErr: "LogIndex", + }, + { + name: "invalid ExceptionData", + json: `{"epoch_index":"0x0","index":"0x0","block_number":"0x0","raw_data":"0x",` + + `"exception_data":"not-hex","log_index":"0x0"}`, + wantErr: "ExceptionData", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var input Input + err := json.Unmarshal([]byte(tt.json), &input) + require.Error(t, err) + require.ErrorContains(t, err, tt.wantErr) + }) + } + + t.Run("valid minimal input", func(t *testing.T) { + var input Input + require.NoError(t, json.Unmarshal([]byte(validJSON), &input)) + }) + + // Regression: JSON without any alias-level field must return a parse error, + // not nil-dereference the embedded alias pointer. + t.Run("empty object does not panic", func(t *testing.T) { + var input Input + err := json.Unmarshal([]byte(`{}`), &input) + require.Error(t, err) + }) +} + +func TestOutputJSONRoundtrip(t *testing.T) { + hash := common.HexToHash("0xaaaa") + txHash := common.HexToHash("0xbbbb") + original := Output{ + InputEpochApplicationID: 1, + EpochIndex: 2, + InputIndex: 5, + Index: 10, + RawData: []byte{0xca, 0xfe}, + Hash: &hash, + OutputHashesSiblings: []common.Hash{common.HexToHash("0x1111")}, + ExecutionTransactionHash: &txHash, + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Output + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + // InputEpochApplicationID is json:"-" (DB-only FK), intentionally lost in JSON roundtrip. + require.Zero(t, decoded.InputEpochApplicationID) + require.Equal(t, original.EpochIndex, decoded.EpochIndex) + require.Equal(t, original.InputIndex, decoded.InputIndex) + require.Equal(t, original.Index, decoded.Index) + require.Equal(t, original.RawData, decoded.RawData) + require.Equal(t, original.Hash, decoded.Hash) + require.Equal(t, original.OutputHashesSiblings, decoded.OutputHashesSiblings) + require.Equal(t, original.ExecutionTransactionHash, decoded.ExecutionTransactionHash) +} + +func TestReportJSONRoundtrip(t *testing.T) { + original := Report{ + InputEpochApplicationID: 1, + EpochIndex: 4, + InputIndex: 8, + Index: 0, + RawData: []byte{0x01, 0x02, 0x03}, + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Report + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + // InputEpochApplicationID is json:"-" (DB-only FK), intentionally lost in JSON roundtrip. + require.Zero(t, decoded.InputEpochApplicationID) + require.Equal(t, original.EpochIndex, decoded.EpochIndex) + require.Equal(t, original.InputIndex, decoded.InputIndex) + require.Equal(t, original.Index, decoded.Index) + require.Equal(t, original.RawData, decoded.RawData) +} + +func TestTournamentJSONRoundtrip(t *testing.T) { + parentAddr := common.HexToAddress("0x1234567890123456789012345678901234567890") + parentMatch := common.HexToHash("0xeeee") + winner := common.HexToHash("0xdddd") + finalState := common.HexToHash("0xcccc") + original := Tournament{ + ApplicationID: 1, + EpochIndex: 3, + Address: common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"), + ParentTournamentAddress: &parentAddr, + ParentMatchIDHash: &parentMatch, + MaxLevel: 4, + Level: 2, + Log2Step: 16, + Height: 8, + Snapshot: TournamentSnapshot{ + WinnerCommitment: &winner, + FinalStateHash: &finalState, + FinishedAtBlock: 9999, + }, + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Tournament + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + // ApplicationID is json:"-" (DB-only FK), intentionally lost in JSON roundtrip. + require.Zero(t, decoded.ApplicationID) + require.Equal(t, original.EpochIndex, decoded.EpochIndex) + require.Equal(t, original.Address, decoded.Address) + require.Equal(t, original.ParentTournamentAddress, decoded.ParentTournamentAddress) + require.Equal(t, original.ParentMatchIDHash, decoded.ParentMatchIDHash) + require.Equal(t, original.MaxLevel, decoded.MaxLevel) + require.Equal(t, original.Level, decoded.Level) + require.Equal(t, original.Log2Step, decoded.Log2Step) + require.Equal(t, original.Height, decoded.Height) + require.Equal(t, original.Snapshot, decoded.Snapshot) +} + +func TestCommitmentJSONRoundtrip(t *testing.T) { + original := Commitment{ + ApplicationID: 1, + EpochIndex: 7, + TournamentAddress: common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"), + Commitment: common.HexToHash("0x5555"), + FinalStateHash: common.HexToHash("0x6666"), + SubmitterAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + BlockNumber: 54321, + TxHash: common.HexToHash("0x7777"), + CreatedAt: time.Now().Truncate(time.Microsecond).UTC(), + UpdatedAt: time.Now().Truncate(time.Microsecond).UTC(), + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Commitment + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + // ApplicationID is json:"-" (DB-only FK), intentionally lost in JSON roundtrip. + require.Zero(t, decoded.ApplicationID) + require.Equal(t, original.EpochIndex, decoded.EpochIndex) + require.Equal(t, original.TournamentAddress, decoded.TournamentAddress) + require.Equal(t, original.Commitment, decoded.Commitment) + require.Equal(t, original.FinalStateHash, decoded.FinalStateHash) + require.Equal(t, original.SubmitterAddress, decoded.SubmitterAddress) + require.Equal(t, original.BlockNumber, decoded.BlockNumber) + require.Equal(t, original.TxHash, decoded.TxHash) +} + +func TestOutputJSONRoundtripZeroValues(t *testing.T) { + original := Output{ + EpochIndex: 0, + InputIndex: 0, + Index: 0, + RawData: []byte{}, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Output + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + require.Equal(t, original.EpochIndex, decoded.EpochIndex) + require.Equal(t, original.InputIndex, decoded.InputIndex) + require.Equal(t, original.Index, decoded.Index) +} + +func TestOutputUnmarshalJSONInvalidHex(t *testing.T) { + tests := []struct { + name string + json string + wantErr string + }{ + { + name: "invalid EpochIndex", + json: `{"epoch_index":"bad","input_index":"0x0","index":"0x0","raw_data":"0x"}`, + wantErr: "EpochIndex", + }, + { + name: "invalid InputIndex", + json: `{"epoch_index":"0x0","input_index":"bad","index":"0x0","raw_data":"0x"}`, + wantErr: "InputIndex", + }, + { + name: "invalid RawData", + json: `{"epoch_index":"0x0","input_index":"0x0","index":"0x0","raw_data":"not-hex"}`, + wantErr: "RawData", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output Output + err := json.Unmarshal([]byte(tt.json), &output) + require.Error(t, err) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestReportUnmarshalJSONInvalidHex(t *testing.T) { + invalidJSON := `{"epoch_index":"0x0","input_index":"bad","index":"0x0","raw_data":"0x"}` + var report Report + err := json.Unmarshal([]byte(invalidJSON), &report) + require.Error(t, err) + require.ErrorContains(t, err, "InputIndex") +} diff --git a/internal/model/prt_enums.go b/internal/model/prt_enums.go new file mode 100644 index 000000000..a46ea232c --- /dev/null +++ b/internal/model/prt_enums.go @@ -0,0 +1,188 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +type TournamentKind string + +const ( + TournamentKindLeaf TournamentKind = "LEAF" + TournamentKindNonLeaf TournamentKind = "NON_LEAF" +) + +var TournamentKindAllValues = []TournamentKind{ + TournamentKindLeaf, + TournamentKindNonLeaf, +} + +func (e *TournamentKind) Scan(value any) error { + return scanEnum(e, value, TournamentKindAllValues, "TournamentKind") +} + +func (e TournamentKind) String() string { + return string(e) +} + +type TournamentStandingState string + +const ( + TournamentStandingMatchesActive TournamentStandingState = "MATCHES_ACTIVE" + TournamentStandingAwaitingClosure TournamentStandingState = "AWAITING_CLOSURE" + TournamentStandingRootWinner TournamentStandingState = "ROOT_WINNER" + TournamentStandingRootFailed TournamentStandingState = "ROOT_FAILED" + TournamentStandingInnerWinner TournamentStandingState = "INNER_WINNER" + TournamentStandingInnerEliminableNoWinner TournamentStandingState = "INNER_ELIMINABLE_NO_WINNER" + TournamentStandingInnerEliminableWinnerExpired TournamentStandingState = "INNER_ELIMINABLE_WINNER_EXPIRED" +) + +var TournamentStandingStateAllValues = []TournamentStandingState{ + TournamentStandingMatchesActive, + TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, + TournamentStandingRootFailed, + TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, + TournamentStandingInnerEliminableWinnerExpired, +} + +func (e *TournamentStandingState) Scan(value any) error { + return scanEnum(e, value, TournamentStandingStateAllValues, "TournamentStandingState") +} + +func (e TournamentStandingState) String() string { + return string(e) +} + +type MatchPhase string + +const ( + MatchPhaseUninitialized MatchPhase = "UNINITIALIZED" + MatchPhaseBisecting MatchPhase = "BISECTING" + MatchPhaseReadyToSeal MatchPhase = "READY_TO_SEAL" + MatchPhaseSealed MatchPhase = "SEALED" +) + +var MatchPhaseAllValues = []MatchPhase{ + MatchPhaseUninitialized, + MatchPhaseBisecting, + MatchPhaseReadyToSeal, + MatchPhaseSealed, +} + +func (e *MatchPhase) Scan(value any) error { + return scanEnum(e, value, MatchPhaseAllValues, "MatchPhase") +} + +func (e MatchPhase) String() string { + return string(e) +} + +type CommitmentSide string + +const ( + CommitmentSideOne CommitmentSide = "ONE" + CommitmentSideTwo CommitmentSide = "TWO" +) + +var CommitmentSideAllValues = []CommitmentSide{ + CommitmentSideOne, + CommitmentSideTwo, +} + +func (e *CommitmentSide) Scan(value any) error { + return scanEnum(e, value, CommitmentSideAllValues, "CommitmentSide") +} + +func (e CommitmentSide) String() string { + return string(e) +} + +type MatchTimeoutOutcome string + +const ( + MatchTimeoutNone MatchTimeoutOutcome = "NONE" + MatchTimeoutOneWins MatchTimeoutOutcome = "ONE_WINS" + MatchTimeoutTwoWins MatchTimeoutOutcome = "TWO_WINS" + MatchTimeoutEliminateBoth MatchTimeoutOutcome = "ELIMINATE_BOTH" +) + +var MatchTimeoutOutcomeAllValues = []MatchTimeoutOutcome{ + MatchTimeoutNone, + MatchTimeoutOneWins, + MatchTimeoutTwoWins, + MatchTimeoutEliminateBoth, +} + +func (e *MatchTimeoutOutcome) Scan(value any) error { + return scanEnum(e, value, MatchTimeoutOutcomeAllValues, "MatchTimeoutOutcome") +} + +func (e MatchTimeoutOutcome) String() string { + return string(e) +} + +type InnerTournamentDisposition string + +const ( + InnerTournamentUnsettled InnerTournamentDisposition = "UNSETTLED" + InnerTournamentWinner InnerTournamentDisposition = "WINNER" + InnerTournamentEliminable InnerTournamentDisposition = "ELIMINABLE" +) + +var InnerTournamentDispositionAllValues = []InnerTournamentDisposition{ + InnerTournamentUnsettled, + InnerTournamentWinner, + InnerTournamentEliminable, +} + +func (e *InnerTournamentDisposition) Scan(value any) error { + return scanEnum(e, value, InnerTournamentDispositionAllValues, "InnerTournamentDisposition") +} + +func (e InnerTournamentDisposition) String() string { + return string(e) +} + +type BondDisposition string + +const ( + BondDispositionTournamentRunning BondDisposition = "TOURNAMENT_RUNNING" + BondDispositionNoWinner BondDisposition = "NO_WINNER" + BondDispositionRecoverable BondDisposition = "RECOVERABLE" + BondDispositionRecovered BondDisposition = "RECOVERED" +) + +var BondDispositionAllValues = []BondDisposition{ + BondDispositionTournamentRunning, + BondDispositionNoWinner, + BondDispositionRecoverable, + BondDispositionRecovered, +} + +func (e *BondDisposition) Scan(value any) error { + return scanEnum(e, value, BondDispositionAllValues, "BondDisposition") +} + +func (e BondDisposition) String() string { + return string(e) +} + +type BondEventType string + +const ( + BondEventPartialRefund BondEventType = "PARTIAL_BOND_REFUND" + BondEventRecovered BondEventType = "BOND_RECOVERED" +) + +var BondEventTypeAllValues = []BondEventType{ + BondEventPartialRefund, + BondEventRecovered, +} + +func (e *BondEventType) Scan(value any) error { + return scanEnum(e, value, BondEventTypeAllValues, "BondEventType") +} + +func (e BondEventType) String() string { + return string(e) +} diff --git a/internal/model/prt_enums_test.go b/internal/model/prt_enums_test.go new file mode 100644 index 000000000..911e18be0 --- /dev/null +++ b/internal/model/prt_enums_test.go @@ -0,0 +1,201 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTournamentKindValues(t *testing.T) { + expected := []string{"LEAF", "NON_LEAF"} + actual := make([]string, len(TournamentKindAllValues)) + for i, value := range TournamentKindAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value TournamentKind + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := TournamentKindLeaf + require.Error(t, value.Scan(invalid)) + require.Equal(t, TournamentKindLeaf, value) + } +} + +func TestTournamentStandingStateValues(t *testing.T) { + expected := []string{ + "MATCHES_ACTIVE", "AWAITING_CLOSURE", "ROOT_WINNER", "ROOT_FAILED", + "INNER_WINNER", "INNER_ELIMINABLE_NO_WINNER", "INNER_ELIMINABLE_WINNER_EXPIRED", + } + actual := make([]string, len(TournamentStandingStateAllValues)) + for i, value := range TournamentStandingStateAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value TournamentStandingState + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := TournamentStandingMatchesActive + require.Error(t, value.Scan(invalid)) + require.Equal(t, TournamentStandingMatchesActive, value) + } +} + +func TestMatchPhaseValues(t *testing.T) { + expected := []string{"UNINITIALIZED", "BISECTING", "READY_TO_SEAL", "SEALED"} + actual := make([]string, len(MatchPhaseAllValues)) + for i, value := range MatchPhaseAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value MatchPhase + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := MatchPhaseUninitialized + require.Error(t, value.Scan(invalid)) + require.Equal(t, MatchPhaseUninitialized, value) + } +} + +func TestCommitmentSideValues(t *testing.T) { + expected := []string{"ONE", "TWO"} + actual := make([]string, len(CommitmentSideAllValues)) + for i, value := range CommitmentSideAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value CommitmentSide + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := CommitmentSideOne + require.Error(t, value.Scan(invalid)) + require.Equal(t, CommitmentSideOne, value) + } +} + +func TestMatchTimeoutOutcomeValues(t *testing.T) { + expected := []string{enumGoldenNone, "ONE_WINS", "TWO_WINS", "ELIMINATE_BOTH"} + actual := make([]string, len(MatchTimeoutOutcomeAllValues)) + for i, value := range MatchTimeoutOutcomeAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value MatchTimeoutOutcome + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := MatchTimeoutNone + require.Error(t, value.Scan(invalid)) + require.Equal(t, MatchTimeoutNone, value) + } +} + +func TestInnerTournamentDispositionValues(t *testing.T) { + expected := []string{"UNSETTLED", "WINNER", "ELIMINABLE"} + actual := make([]string, len(InnerTournamentDispositionAllValues)) + for i, value := range InnerTournamentDispositionAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value InnerTournamentDisposition + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := InnerTournamentUnsettled + require.Error(t, value.Scan(invalid)) + require.Equal(t, InnerTournamentUnsettled, value) + } +} + +func TestBondDispositionValues(t *testing.T) { + expected := []string{"TOURNAMENT_RUNNING", "NO_WINNER", "RECOVERABLE", "RECOVERED"} + actual := make([]string, len(BondDispositionAllValues)) + for i, value := range BondDispositionAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value BondDisposition + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := BondDispositionTournamentRunning + require.Error(t, value.Scan(invalid)) + require.Equal(t, BondDispositionTournamentRunning, value) + } +} + +func TestBondEventTypeValues(t *testing.T) { + expected := []string{"PARTIAL_BOND_REFUND", "BOND_RECOVERED"} + actual := make([]string, len(BondEventTypeAllValues)) + for i, value := range BondEventTypeAllValues { + actual[i] = value.String() + } + require.Equal(t, expected, actual) + for _, text := range expected { + var value BondEventType + require.NoError(t, value.Scan(text)) + require.Equal(t, text, value.String()) + require.NoError(t, value.Scan([]byte(text))) + require.Equal(t, text, value.String()) + } + for _, invalid := range []any{nil, 1, true, "", []byte(""), enumInvalidValue, []byte(enumInvalidValue)} { + value := BondEventPartialRefund + require.Error(t, value.Scan(invalid)) + require.Equal(t, BondEventPartialRefund, value) + } +} + +func TestContractMatchEnumsRejectUnknownValues(t *testing.T) { + reasons := []MatchDeletionReason{MatchDeletionReason_STEP, MatchDeletionReason_TIMEOUT, MatchDeletionReason_CHILD_TOURNAMENT} + winners := []WinnerCommitment{WinnerCommitment_NONE, WinnerCommitment_ONE, WinnerCommitment_TWO} + for value := 0; value <= 255; value++ { + reason, reasonErr := MatchDeletionReasonFromUint8(uint8(value)) + winner, winnerErr := WinnerCommitmentFromUint8(uint8(value)) + if value < len(reasons) { + require.NoError(t, reasonErr) + require.Equal(t, reasons[value], reason) + require.NoError(t, winnerErr) + require.Equal(t, winners[value], winner) + } else { + require.Error(t, reasonErr) + require.Empty(t, reason) + require.Error(t, winnerErr) + require.Empty(t, winner) + } + } +} diff --git a/internal/model/prt_observer.go b/internal/model/prt_observer.go new file mode 100644 index 000000000..350e09978 --- /dev/null +++ b/internal/model/prt_observer.go @@ -0,0 +1,433 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +type TournamentSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Standing TournamentStandingState `json:"standing"` + AcceptsJoins bool `json:"accepts_joins"` + Candidate *common.Hash `json:"candidate"` + WinnerCommitment *common.Hash `json:"winner_commitment"` + FinalStateHash *common.Hash `json:"final_state_hash"` + ParentCommitment *common.Hash `json:"parent_commitment"` + FinishedAtBlock uint64 `json:"finished_at_block"` + WinnerExpiresAt uint64 `json:"winner_expires_at"` + InnerResult *TournamentInnerResult `json:"inner_result"` + BondRecovery TournamentBondRecovery `json:"bond_recovery"` +} + +func (value TournamentSnapshot) MarshalJSON() ([]byte, error) { + type Alias TournamentSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + FinishedAtBlock hexutil.Uint64 `json:"finished_at_block"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + FinishedAtBlock: hexutil.Uint64(value.FinishedAtBlock), + WinnerExpiresAt: hexutil.Uint64(value.WinnerExpiresAt), + }) +} + +func (value *TournamentSnapshot) UnmarshalJSON(data []byte) error { + type Alias TournamentSnapshot + var decoded TournamentSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + FinishedAtBlock hexutil.Uint64 `json:"finished_at_block"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.FinishedAtBlock = uint64(aux.FinishedAtBlock) + decoded.WinnerExpiresAt = uint64(aux.WinnerExpiresAt) + *value = decoded + return nil +} + +type TournamentInnerResult struct { + Disposition InnerTournamentDisposition `json:"disposition"` + ParentCommitment *common.Hash `json:"parent_commitment"` + PausedAllowance uint64 `json:"paused_allowance"` +} + +func (value TournamentInnerResult) MarshalJSON() ([]byte, error) { + type Alias TournamentInnerResult + return json.Marshal(struct { + *Alias + PausedAllowance hexutil.Uint64 `json:"paused_allowance"` + }{ + Alias: (*Alias)(&value), + PausedAllowance: hexutil.Uint64(value.PausedAllowance), + }) +} + +func (value *TournamentInnerResult) UnmarshalJSON(data []byte) error { + type Alias TournamentInnerResult + var decoded TournamentInnerResult + aux := struct { + *Alias + PausedAllowance hexutil.Uint64 `json:"paused_allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.PausedAllowance = uint64(aux.PausedAllowance) + *value = decoded + return nil +} + +type TournamentBondRecovery struct { + Disposition BondDisposition `json:"disposition"` + Claimer *common.Address `json:"claimer"` + Payment *Uint256 `json:"payment"` +} + +type CommitmentSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Claimer common.Address `json:"claimer"` + ClockRunning bool `json:"clock_running"` + ClockDeadline uint64 `json:"clock_deadline"` + ClockAllowance uint64 `json:"clock_allowance"` +} + +func (value CommitmentSnapshot) MarshalJSON() ([]byte, error) { + type Alias CommitmentSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + ClockDeadline hexutil.Uint64 `json:"clock_deadline"` + ClockAllowance hexutil.Uint64 `json:"clock_allowance"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + ClockDeadline: hexutil.Uint64(value.ClockDeadline), + ClockAllowance: hexutil.Uint64(value.ClockAllowance), + }) +} + +func (value *CommitmentSnapshot) UnmarshalJSON(data []byte) error { + type Alias CommitmentSnapshot + var decoded CommitmentSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + ClockDeadline hexutil.Uint64 `json:"clock_deadline"` + ClockAllowance hexutil.Uint64 `json:"clock_allowance"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.ClockDeadline = uint64(aux.ClockDeadline) + decoded.ClockAllowance = uint64(aux.ClockAllowance) + *value = decoded + return nil +} + +type MatchSnapshot struct { + AsOfBlock uint64 `json:"as_of_block"` + Phase MatchPhase `json:"phase"` + Bisection *MatchBisectionSnapshot `json:"bisection"` + Sealed *MatchSealedSnapshot `json:"sealed"` + TimeoutOutcome MatchTimeoutOutcome `json:"timeout_outcome"` + DeferredCharge uint64 `json:"deferred_charge"` +} + +func (value MatchSnapshot) MarshalJSON() ([]byte, error) { + type Alias MatchSnapshot + return json.Marshal(struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + DeferredCharge hexutil.Uint64 `json:"deferred_charge"` + }{ + Alias: (*Alias)(&value), + AsOfBlock: hexutil.Uint64(value.AsOfBlock), + DeferredCharge: hexutil.Uint64(value.DeferredCharge), + }) +} + +func (value *MatchSnapshot) UnmarshalJSON(data []byte) error { + type Alias MatchSnapshot + var decoded MatchSnapshot + aux := struct { + *Alias + AsOfBlock hexutil.Uint64 `json:"as_of_block"` + DeferredCharge hexutil.Uint64 `json:"deferred_charge"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.AsOfBlock = uint64(aux.AsOfBlock) + decoded.DeferredCharge = uint64(aux.DeferredCharge) + *value = decoded + return nil +} + +type MatchBisectionSnapshot struct { + RevealingParent common.Hash `json:"revealing_parent"` + WaitingLeft common.Hash `json:"waiting_left"` + WaitingRight common.Hash `json:"waiting_right"` + SegmentStartPosition Uint256 `json:"segment_start_position"` + SegmentStartCycle Uint256 `json:"segment_start_cycle"` + CurrentHeight *uint64 `json:"current_height"` + Responder CommitmentSide `json:"responder"` +} + +func (value MatchBisectionSnapshot) MarshalJSON() ([]byte, error) { + type Alias MatchBisectionSnapshot + return json.Marshal(struct { + *Alias + CurrentHeight *hexutil.Uint64 `json:"current_height"` + }{ + Alias: (*Alias)(&value), + CurrentHeight: (*hexutil.Uint64)(value.CurrentHeight), + }) +} + +func (value *MatchBisectionSnapshot) UnmarshalJSON(data []byte) error { + type Alias MatchBisectionSnapshot + var decoded MatchBisectionSnapshot + aux := struct { + *Alias + CurrentHeight *hexutil.Uint64 `json:"current_height"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.CurrentHeight = (*uint64)(aux.CurrentHeight) + *value = decoded + return nil +} + +type MatchSealedSnapshot struct { + AgreeState common.Hash `json:"agree_state"` + DivergencePosition Uint256 `json:"divergence_position"` + DivergenceCycle Uint256 `json:"divergence_cycle"` + FinalStateOne common.Hash `json:"final_state_one"` + FinalStateTwo common.Hash `json:"final_state_two"` +} + +type TournamentCreationEvent struct { + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` +} + +func (value TournamentCreationEvent) MarshalJSON() ([]byte, error) { + type Alias TournamentCreationEvent + return json.Marshal(struct { + *Alias + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *TournamentCreationEvent) UnmarshalJSON(data []byte) error { + type Alias TournamentCreationEvent + var decoded TournamentCreationEvent + aux := struct { + *Alias + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type LeafMatchSeal struct { + AgreeState common.Hash `json:"agree_state"` + DivergencePosition Uint256 `json:"divergence_position"` + FinalStateOne common.Hash `json:"final_state_one"` + FinalStateTwo common.Hash `json:"final_state_two"` + DeadlineOne uint64 `json:"deadline_one"` + DeadlineTwo uint64 `json:"deadline_two"` + EliminableAt uint64 `json:"eliminable_at"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` +} + +func (value LeafMatchSeal) MarshalJSON() ([]byte, error) { + type Alias LeafMatchSeal + return json.Marshal(struct { + *Alias + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + DeadlineTwo hexutil.Uint64 `json:"deadline_two"` + DeadlineOne hexutil.Uint64 `json:"deadline_one"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EliminableAt: hexutil.Uint64(value.EliminableAt), + DeadlineTwo: hexutil.Uint64(value.DeadlineTwo), + DeadlineOne: hexutil.Uint64(value.DeadlineOne), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *LeafMatchSeal) UnmarshalJSON(data []byte) error { + type Alias LeafMatchSeal + var decoded LeafMatchSeal + aux := struct { + *Alias + EliminableAt hexutil.Uint64 `json:"eliminable_at"` + DeadlineTwo hexutil.Uint64 `json:"deadline_two"` + DeadlineOne hexutil.Uint64 `json:"deadline_one"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EliminableAt = uint64(aux.EliminableAt) + decoded.DeadlineTwo = uint64(aux.DeadlineTwo) + decoded.DeadlineOne = uint64(aux.DeadlineOne) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type StandingChanged struct { + ApplicationID int64 `sql:"primary_key" json:"-"` + EpochIndex uint64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + MatchCount Uint256 `json:"match_count"` + Dangling *common.Hash `json:"dangling"` + ParentCommitment *common.Hash `json:"parent_commitment"` + ResultAt uint64 `json:"result_at"` + WinnerExpiresAt uint64 `json:"winner_expires_at"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `sql:"primary_key" json:"tx_hash"` + LogIndex uint64 `sql:"primary_key" json:"log_index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value StandingChanged) MarshalJSON() ([]byte, error) { + type Alias StandingChanged + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + ResultAt hexutil.Uint64 `json:"result_at"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + ResultAt: hexutil.Uint64(value.ResultAt), + WinnerExpiresAt: hexutil.Uint64(value.WinnerExpiresAt), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *StandingChanged) UnmarshalJSON(data []byte) error { + type Alias StandingChanged + var decoded StandingChanged + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + ResultAt hexutil.Uint64 `json:"result_at"` + WinnerExpiresAt hexutil.Uint64 `json:"winner_expires_at"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.ResultAt = uint64(aux.ResultAt) + decoded.WinnerExpiresAt = uint64(aux.WinnerExpiresAt) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type BondEvent struct { + ApplicationID int64 `json:"-"` + EpochIndex uint64 `json:"epoch_index"` + TournamentAddress common.Address `json:"tournament_address"` + Type BondEventType `json:"type"` + BlockNumber uint64 `json:"block_number"` + TxHash common.Hash `json:"tx_hash"` + LogIndex uint64 `json:"log_index"` + Refund *PartialBondRefund `json:"refund"` + Recovery *BondRecovered `json:"recovery"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (value BondEvent) MarshalJSON() ([]byte, error) { + type Alias BondEvent + return json.Marshal(struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{ + Alias: (*Alias)(&value), + EpochIndex: hexutil.Uint64(value.EpochIndex), + BlockNumber: hexutil.Uint64(value.BlockNumber), + LogIndex: hexutil.Uint64(value.LogIndex), + }) +} + +func (value *BondEvent) UnmarshalJSON(data []byte) error { + type Alias BondEvent + var decoded BondEvent + aux := struct { + *Alias + EpochIndex hexutil.Uint64 `json:"epoch_index"` + BlockNumber hexutil.Uint64 `json:"block_number"` + LogIndex hexutil.Uint64 `json:"log_index"` + }{Alias: (*Alias)(&decoded)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + decoded.EpochIndex = uint64(aux.EpochIndex) + decoded.BlockNumber = uint64(aux.BlockNumber) + decoded.LogIndex = uint64(aux.LogIndex) + *value = decoded + return nil +} + +type PartialBondRefund struct { + Recipient common.Address `json:"recipient"` + Value Uint256 `json:"value"` + Success bool `json:"success"` +} + +type BondRecovered struct { + Commitment common.Hash `json:"commitment"` + Claimer common.Address `json:"claimer"` + Payment Uint256 `json:"payment"` + Burned Uint256 `json:"burned"` +} diff --git a/internal/model/state_transition.go b/internal/model/state_transition.go new file mode 100644 index 000000000..0d4ad7799 --- /dev/null +++ b/internal/model/state_transition.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +// CartesiMachineMarchID identifies the required machine architecture. +const CartesiMachineMarchID uint64 = 22 + +// ErrIncompatibleStateTransition identifies a different machine state transition. +var ErrIncompatibleStateTransition = errors.New("incompatible state transition") + +// UarchPristineStateHash returns the required initial uarch state hash. +func UarchPristineStateHash() common.Hash { + return common.Hash{ + 0x55, 0x8f, 0x38, 0xf2, 0x35, 0x09, 0x70, 0xa0, + 0xa5, 0x66, 0xb2, 0x45, 0x0a, 0xfa, 0x64, 0x25, + 0x00, 0x59, 0xc1, 0xcf, 0xf6, 0x6c, 0x9a, 0x55, + 0x56, 0x0e, 0x5b, 0x93, 0xfa, 0x78, 0x39, 0x26, + } +} + +// CheckMarchID compares the subject's architecture with the required identity. +func CheckMarchID(subject string, marchID uint64) error { + if marchID != CartesiMachineMarchID { + return fmt.Errorf("%s marchid %d, expected %d: %w", + subject, marchID, CartesiMachineMarchID, ErrIncompatibleStateTransition) + } + return nil +} + +// CheckUarchPristineStateHash compares the subject's pristine uarch hash with the required identity. +func CheckUarchPristineStateHash(subject string, hash common.Hash) error { + expected := UarchPristineStateHash() + if hash != expected { + return fmt.Errorf("%s pristine uarch hash %s, expected %s: %w", + subject, hash, expected, ErrIncompatibleStateTransition) + } + return nil +} diff --git a/internal/model/state_transition_test.go b/internal/model/state_transition_test.go new file mode 100644 index 000000000..cc6438e1d --- /dev/null +++ b/internal/model/state_transition_test.go @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "fmt" + "math" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestStateTransitionPins(t *testing.T) { + t.Parallel() + require.Equal(t, uint64(22), CartesiMachineMarchID) + expected := common.HexToHash("0x558f38f2350970a0a566b2450afa64250059c1cff66c9a55560e5b93fa783926") + require.Equal(t, expected, UarchPristineStateHash()) + changed := UarchPristineStateHash() + changed[0] ^= 1 + require.Equal(t, expected, UarchPristineStateHash()) +} + +func TestCheckMarchID(t *testing.T) { + t.Parallel() + require.NoError(t, CheckMarchID("linked library", 22)) + for _, found := range []uint64{0, 21, 23, math.MaxUint64} { + err := CheckMarchID("remote server", found) + require.ErrorIs(t, err, ErrIncompatibleStateTransition) + require.EqualError(t, err, fmt.Sprintf( + "remote server marchid %d, expected 22: incompatible state transition", found)) + } +} + +func TestCheckUarchPristineStateHash(t *testing.T) { + t.Parallel() + expected := common.HexToHash("0x558f38f2350970a0a566b2450afa64250059c1cff66c9a55560e5b93fa783926") + require.NoError(t, CheckUarchPristineStateHash("linked library", expected)) + firstChanged, middleChanged, lastChanged := expected, expected, expected + firstChanged[0] ^= 1 + middleChanged[len(middleChanged)/2] ^= 1 + lastChanged[len(lastChanged)-1] ^= 1 + for _, found := range []common.Hash{{}, firstChanged, middleChanged, lastChanged} { + err := CheckUarchPristineStateHash("remote server", found) + require.ErrorIs(t, err, ErrIncompatibleStateTransition) + require.EqualError(t, err, fmt.Sprintf( + "remote server pristine uarch hash %s, expected %s: incompatible state transition", found, expected)) + } +} diff --git a/internal/model/tournament_geometry.go b/internal/model/tournament_geometry.go new file mode 100644 index 000000000..7fcdc2ef6 --- /dev/null +++ b/internal/model/tournament_geometry.go @@ -0,0 +1,27 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import "fmt" + +// TournamentLevelCount is the number of levels in the canonical table. +const TournamentLevelCount uint64 = 2 + +// TournamentLevelGeometry returns the stride and height of a canonical level. +func TournamentLevelGeometry(level uint64) (stride, height uint64, err error) { + switch level { + case 0: + return Log2RootTournamentStride, Log2EpochComputationHashLeafCount, nil + case 1: + return 0, Log2RootTournamentStride, nil + default: + return 0, 0, fmt.Errorf("tournament level %d is outside %d levels", level, TournamentLevelCount) + } +} + +// IsCanonicalTournamentLevel compares a declared level with the canonical geometry. +func IsCanonicalTournamentLevel(level, height, stride uint64) bool { + expectedStride, expectedHeight, err := TournamentLevelGeometry(level) + return err == nil && height == expectedHeight && stride == expectedStride +} diff --git a/internal/model/tournament_geometry_test.go b/internal/model/tournament_geometry_test.go new file mode 100644 index 000000000..375c346b4 --- /dev/null +++ b/internal/model/tournament_geometry_test.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTournamentGeometry(t *testing.T) { + t.Parallel() + require.Equal(t, uint64(2), TournamentLevelCount) + for _, test := range []struct { + level, height, stride uint64 + }{ + {level: 0, height: 54, stride: 38}, + {level: 1, height: 38, stride: 0}, + } { + stride, height, err := TournamentLevelGeometry(test.level) + require.NoError(t, err) + require.Equal(t, test.stride, stride) + require.Equal(t, test.height, height) + require.True(t, IsCanonicalTournamentLevel(test.level, test.height, test.stride)) + require.False(t, IsCanonicalTournamentLevel(test.level, test.height+1, test.stride)) + require.False(t, IsCanonicalTournamentLevel(test.level, test.height, test.stride+1)) + } + for _, level := range []uint64{2, math.MaxUint64} { + _, _, err := TournamentLevelGeometry(level) + require.Error(t, err) + require.False(t, IsCanonicalTournamentLevel(level, 54, 38)) + } + require.False(t, IsCanonicalTournamentLevel(0, 48, 44)) +} diff --git a/internal/model/uint256.go b/internal/model/uint256.go new file mode 100644 index 000000000..207876884 --- /dev/null +++ b/internal/model/uint256.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/holiman/uint256" +) + +// Uint256 stores a Solidity unsigned integer without narrowing its value. +// SQL uses decimal NUMERIC values; JSON uses canonical hexadecimal quantities. +type Uint256 uint256.Int + +var ErrInvalidUint256 = errors.New("invalid uint256") + +func Uint256FromBig(value *big.Int) (Uint256, error) { + if value == nil || value.Sign() < 0 { + return Uint256{}, ErrInvalidUint256 + } + parsed, overflow := uint256.FromBig(value) + if overflow { + return Uint256{}, ErrInvalidUint256 + } + return Uint256(*parsed), nil +} + +func (value Uint256) ToBig() *big.Int { + return (*uint256.Int)(&value).ToBig() +} + +func (value *Uint256) Scan(source any) error { + if source == nil { + return fmt.Errorf("cannot scan NULL into Uint256: %w", ErrInvalidUint256) + } + // PostgreSQL NUMERIC can retain decimal scale for an exact integer (1.00). + // Remove only zero fractional digits; a fractional value is not uint256. + var text string + switch source := source.(type) { + case string: + text = source + case []byte: + text = string(source) + default: + return fmt.Errorf("unsupported Uint256 scan type %T: %w", source, ErrInvalidUint256) + } + if text == "" { + return fmt.Errorf("empty Uint256 value: %w", ErrInvalidUint256) + } + if whole, fraction, found := strings.Cut(text, "."); found { + if fraction == "" || strings.Trim(fraction, "0") != "" { + return fmt.Errorf("fractional Uint256 value: %w", ErrInvalidUint256) + } + text = whole + } + var parsed uint256.Int + if err := parsed.Scan(text); err != nil { + return fmt.Errorf("scan Uint256: %w", err) + } + *value = Uint256(parsed) + return nil +} + +func (value Uint256) Value() (driver.Value, error) { + return (*uint256.Int)(&value).Value() +} + +func (value Uint256) MarshalJSON() ([]byte, error) { + return json.Marshal((*uint256.Int)(&value).Hex()) +} + +func (value *Uint256) UnmarshalJSON(data []byte) error { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return err + } + var parsed uint256.Int + if err := parsed.SetFromHex(text); err != nil { + return fmt.Errorf("decode Uint256: %w", err) + } + *value = Uint256(parsed) + return nil +} diff --git a/internal/model/uint256_test.go b/internal/model/uint256_test.go new file mode 100644 index 000000000..235d70a8b --- /dev/null +++ b/internal/model/uint256_test.go @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package model + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/require" +) + +func TestUint256Codec(t *testing.T) { + const maximum = "115792089237316195423570985008687907853269984665640564039457584007913129639935" + const over = "115792089237316195423570985008687907853269984665640564039457584007913129639936" + const maxHex = `"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"` + mapping := pgtype.NewMap() + for _, decimal := range []string{"0", "18446744073709551616", maximum} { + var value Uint256 + if err := value.Scan(decimal); err != nil { + t.Fatal(err) + } + for _, format := range []int16{pgtype.TextFormatCode, pgtype.BinaryFormatCode} { + encoded, err := mapping.Encode(pgtype.NumericOID, format, value, nil) + if err != nil { + t.Fatal(err) + } + var decoded Uint256 + if err := mapping.Scan(pgtype.NumericOID, format, encoded, &decoded); err != nil { + t.Fatal(err) + } + if value != decoded { + t.Fatalf("numeric round trip: got %v want %v", decoded, value) + } + } + if decimal == maximum { + encoded, err := json.Marshal(value) + if err != nil || string(encoded) != maxHex { + t.Fatalf("JSON: %s, %v", encoded, err) + } + } + } + for _, src := range []any{nil, "", "-1", "0.5", over, 123, "1e78"} { + value := Uint256(*uint256.NewInt(7)) + before := value + if err := value.Scan(src); err == nil { + t.Fatalf("accepted invalid SQL value %#v", src) + } + if value != before { + t.Fatalf("changed receiver after SQL error %#v", src) + } + } + for _, raw := range []string{`"0x0"`, `"0x10000000000000000"`, maxHex} { + var value Uint256 + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(value) + if err != nil || string(encoded) != raw { + t.Fatalf("JSON round trip: %s, %v", encoded, err) + } + } + for _, raw := range []string{ + `null`, `1`, `"1"`, `"0x"`, `"0x00"`, `"-0x1"`, + `"0x10000000000000000000000000000000000000000000000000000000000000000"`, + } { + value := Uint256(*uint256.NewInt(7)) + before := value + if err := json.Unmarshal([]byte(raw), &value); err == nil { + t.Fatalf("accepted invalid JSON %s", raw) + } + if value != before { + t.Fatalf("changed receiver after JSON error %s", raw) + } + } + var nullable *Uint256 + if err := mapping.Scan(pgtype.NumericOID, pgtype.TextFormatCode, nil, &nullable); err != nil { + t.Fatal(err) + } + if nullable != nil { + t.Fatal("SQL NULL must remain nil") + } + for _, format := range []int16{pgtype.TextFormatCode, pgtype.BinaryFormatCode} { + scaledInteger := pgtype.Numeric{Int: big.NewInt(100), Exp: -2, Valid: true} + encoded, err := mapping.Encode(pgtype.NumericOID, format, scaledInteger, nil) + require.NoError(t, err) + var decoded Uint256 + require.NoError(t, mapping.Scan(pgtype.NumericOID, format, encoded, &decoded)) + require.Equal(t, big.NewInt(1), decoded.ToBig()) + } +} + +func TestUint256FromBig(t *testing.T) { + limit := new(big.Int).Lsh(big.NewInt(1), 256) + for _, value := range []*big.Int{nil, big.NewInt(-1), limit} { + _, err := Uint256FromBig(value) + require.ErrorIs(t, err, ErrInvalidUint256) + } + maximum := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1)) + value, err := Uint256FromBig(maximum) + require.NoError(t, err) + require.Equal(t, maximum, value.ToBig()) + maximum.SetInt64(0) + require.NotZero(t, value.ToBig().Sign(), "the model must not share mutable big.Int storage") +} diff --git a/internal/node/chainid.go b/internal/node/chainid.go deleted file mode 100644 index 21115f7e8..000000000 --- a/internal/node/chainid.go +++ /dev/null @@ -1,45 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package node - -import ( - "context" - "fmt" - "time" - - "github.com/ethereum/go-ethereum/ethclient" -) - -const defaultTimeout = 3 * time.Second - -// Checks if the chain id from the configuration matches the chain id reported -// by the Ethereum node. If they don't, it returns an error. -func validateChainId(ctx context.Context, chainId uint64, ethereumNodeAddr string) error { - remoteChainId, err := getChainId(ctx, ethereumNodeAddr) - if err != nil { - return err - } else if chainId != remoteChainId { - return fmt.Errorf( - "chainId mismatch; expected %v but Ethereum node returned %v", - chainId, - remoteChainId, - ) - } - return nil -} - -func getChainId(ctx context.Context, ethereumNodeAddr string) (uint64, error) { - ctx, cancel := context.WithTimeout(ctx, defaultTimeout) - defer cancel() - - client, err := ethclient.Dial(ethereumNodeAddr) - if err != nil { - return 0, fmt.Errorf("create RPC client: %w", err) - } - chainId, err := client.ChainID(ctx) - if err != nil { - return 0, fmt.Errorf("get chain id: %w", err) - } - return chainId.Uint64(), nil -} diff --git a/internal/node/chainid_test.go b/internal/node/chainid_test.go deleted file mode 100644 index 9e4fb9d0b..000000000 --- a/internal/node/chainid_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package node - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/suite" -) - -type ValidateChainIdSuite struct { - suite.Suite -} - -func TestValidateChainId(t *testing.T) { - suite.Run(t, new(ValidateChainIdSuite)) -} - -func (s *ValidateChainIdSuite) TestItFailsIfChainIdsDoNotMatch() { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, `{"jsonrpc":"2.0","id":67,"result":"0x7a69"}`) - })) - defer ts.Close() - localChainId := uint64(11111) - - err := validateChainId(context.Background(), localChainId, ts.URL) - - s.NotNil(err) -} - -func (s *ValidateChainIdSuite) TestItReturnsNilIfChainIdsMatch() { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, `{"jsonrpc":"2.0","id":67,"result":"0x7a69"}`) - })) - defer ts.Close() - localChainId := uint64(31337) - - err := validateChainId(context.Background(), localChainId, ts.URL) - - s.Nil(err) -} diff --git a/internal/node/config/config.go b/internal/node/config/config.go deleted file mode 100644 index 8d591f827..000000000 --- a/internal/node/config/config.go +++ /dev/null @@ -1,151 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// The config package manages the node configuration, which comes from environment variables. -// The sub-package generate specifies these environment variables. -package config - -import ( - "fmt" - "os" -) - -// NodeConfig contains all the Node variables. -// See the corresponding environment variable for the variable documentation. -type NodeConfig struct { - LogLevel LogLevel - LogPretty bool - RollupsEpochLength uint64 - BlockchainID uint64 - BlockchainHttpEndpoint Redacted[string] - BlockchainWsEndpoint Redacted[string] - BlockchainIsLegacy bool - BlockchainFinalityOffset int - BlockchainBlockTimeout int - ContractsApplicationAddress string - ContractsHistoryAddress string - ContractsAuthorityAddress string - ContractsInputBoxAddress string - ContractsInputBoxDeploymentBlockNumber int64 - SnapshotDir string - PostgresEndpoint Redacted[string] - HttpAddress string - HttpPort int - FeatureHostMode bool - FeatureReaderModeEnabled bool - FeatureDisableClaimer bool - FeatureDisableMachineHashCheck bool - ExperimentalServerManagerBypassLog bool - ExperimentalSunodoValidatorEnabled bool - ExperimentalSunodoValidatorRedisEndpoint Redacted[string] - Auth Auth -} - -// Auth is used to sign transactions. -type Auth any - -// AuthPrivateKey allows signing through private keys. -type AuthPrivateKey struct { - PrivateKey Redacted[string] -} - -// AuthMnemonic allows signing through mnemonics. -type AuthMnemonic struct { - Mnemonic Redacted[string] - AccountIndex Redacted[int] -} - -// AuthAWS allows signing through AWS services. -type AuthAWS struct { - KeyID Redacted[string] - Region Redacted[string] -} - -// Redacted is a wrapper that redacts a given field from the logs. -type Redacted[T any] struct { - Value T -} - -func (r Redacted[T]) String() string { - return "[REDACTED]" -} - -// FromEnv loads the config from environment variables. -func FromEnv() NodeConfig { - var config NodeConfig - config.LogLevel = getLogLevel() - config.LogPretty = getLogPretty() - config.RollupsEpochLength = getEpochLength() - config.BlockchainID = getBlockchainId() - config.BlockchainHttpEndpoint = Redacted[string]{getBlockchainHttpEndpoint()} - config.BlockchainWsEndpoint = Redacted[string]{getBlockchainWsEndpoint()} - config.BlockchainIsLegacy = getBlockchainIsLegacy() - config.BlockchainFinalityOffset = getBlockchainFinalityOffset() - config.BlockchainBlockTimeout = getBlockchainBlockTimeout() - config.ContractsApplicationAddress = getContractsApplicationAddress() - config.ContractsHistoryAddress = getContractsHistoryAddress() - config.ContractsAuthorityAddress = getContractsAuthorityAddress() - config.ContractsInputBoxAddress = getContractsInputBoxAddress() - config.ContractsInputBoxDeploymentBlockNumber = getContractsInputBoxDeploymentBlockNumber() - if !getFeatureHostMode() { - config.SnapshotDir = getSnapshotDir() - } - config.PostgresEndpoint = Redacted[string]{getPostgresEndpoint()} - config.HttpAddress = getHttpAddress() - config.HttpPort = getHttpPort() - config.FeatureHostMode = getFeatureHostMode() - config.FeatureDisableMachineHashCheck = getFeatureDisableMachineHashCheck() - config.ExperimentalServerManagerBypassLog = getExperimentalServerManagerBypassLog() - config.FeatureDisableClaimer = getFeatureDisableClaimer() - config.FeatureReaderModeEnabled = getFeatureReaderModeEnabled() - config.ExperimentalSunodoValidatorEnabled = getExperimentalSunodoValidatorEnabled() - if config.ExperimentalSunodoValidatorEnabled { - config.ExperimentalSunodoValidatorRedisEndpoint = - Redacted[string]{getExperimentalSunodoValidatorRedisEndpoint()} - } - // Authentication is only available when the claimer is enabled - if !config.FeatureDisableClaimer { - config.Auth = authFromEnv() - } - return config -} - -func authFromEnv() Auth { - switch getAuthKind() { - case AuthKindPrivateKeyVar: - return AuthPrivateKey{ - PrivateKey: Redacted[string]{getAuthPrivateKey()}, - } - case AuthKindPrivateKeyFile: - path := getAuthPrivateKeyFile() - privateKey, err := os.ReadFile(path) - if err != nil { - panic(fmt.Sprintf("failed to read private-key file: %v", err)) - } - return AuthPrivateKey{ - PrivateKey: Redacted[string]{string(privateKey)}, - } - case AuthKindMnemonicVar: - return AuthMnemonic{ - Mnemonic: Redacted[string]{getAuthMnemonic()}, - AccountIndex: Redacted[int]{getAuthMnemonicAccountIndex()}, - } - case AuthKindMnemonicFile: - path := getAuthMnemonicFile() - mnemonic, err := os.ReadFile(path) - if err != nil { - panic(fmt.Sprintf("failed to read mnemonic file: %v", err)) - } - return AuthMnemonic{ - Mnemonic: Redacted[string]{string(mnemonic)}, - AccountIndex: Redacted[int]{getAuthMnemonicAccountIndex()}, - } - case AuthKindAWS: - return AuthAWS{ - KeyID: Redacted[string]{getAuthAwsKmsKeyId()}, - Region: Redacted[string]{getAuthAwsKmsRegion()}, - } - default: - panic("invalid auth kind") - } -} diff --git a/internal/node/config/config_test.go b/internal/node/config/config_test.go deleted file mode 100644 index b41de8284..000000000 --- a/internal/node/config/config_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package config - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -type ConfigTestSuite struct { - suite.Suite -} - -func (s *ConfigTestSuite) SetupSuite() { - os.Setenv("CARTESI_BLOCKCHAIN_ID", "31337") - os.Setenv("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT", "http://localhost:8545") - os.Setenv("CARTESI_BLOCKCHAIN_WS_ENDPOINT", "ws://localhost:8545") - os.Setenv("CARTESI_CONTRACTS_APPLICATION_ADDRESS", "0x") - os.Setenv("CARTESI_CONTRACTS_HISTORY_ADDRESS", "0x") - os.Setenv("CARTESI_CONTRACTS_AUTHORITY_ADDRESS", "0x") - os.Setenv("CARTESI_CONTRACTS_INPUT_BOX_ADDRESS", "0x") - os.Setenv("CARTESI_CONTRACTS_INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER", "0") - os.Setenv("CARTESI_SNAPSHOT_DIR", "/tmp") -} - -func TestConfigTest(t *testing.T) { - suite.Run(t, new(ConfigTestSuite)) -} - -func (s *ConfigTestSuite) TestAuthIsNotSetWhenClaimerIsDisabled() { - os.Setenv("CARTESI_FEATURE_DISABLE_CLAIMER", "true") - c := FromEnv() - assert.Nil(s.T(), c.Auth) -} - -func (s *ConfigTestSuite) TestExperimentalSunodoValidatorRedisEndpointIsRedacted() { - enableSunodoValidatorMode() - c := FromEnv() - assert.Equal(s.T(), "[REDACTED]", c.ExperimentalSunodoValidatorRedisEndpoint.String()) -} - -func enableSunodoValidatorMode() { - os.Setenv("CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_ENABLED", "true") - os.Setenv("CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_REDIS_ENDPOINT", - "redis://username:p@ssw0rd@hostname:9999") -} diff --git a/internal/node/config/generate/Config.toml b/internal/node/config/generate/Config.toml deleted file mode 100644 index f4211852c..000000000 --- a/internal/node/config/generate/Config.toml +++ /dev/null @@ -1,247 +0,0 @@ -# (c) Cartesi and individual authors (see AUTHORS) -# SPDX-License-Identifier: Apache-2.0 (see LICENSE) -# -# Logging -# -[logging.CARTESI_LOG_LEVEL] -default = "info" -go-type = "LogLevel" -description = """ -One of "debug", "info", "warn", "error".""" - -[logging.CARTESI_LOG_PRETTY] -default = "false" -go-type = "bool" -description = """ -If set to true, the node will add colors to its log output.""" - -# -# Features -# - -[features.CARTESI_FEATURE_HOST_MODE] -default = "false" -go-type = "bool" -description = """ -If set to true, the node will run in host mode. - -In host mode, computations will not be performed by the cartesi machine. -You should only use host mode for development and debugging!""" - -[features.CARTESI_FEATURE_READER_MODE_ENABLED] -default = "false" -go-type = "bool" -description = """ -If set to true, the node will not generate any claims.""" - -[features.CARTESI_FEATURE_DISABLE_CLAIMER] -default = "false" -go-type = "bool" -description = """ -If set to true, the authority-claimer service is disabled.""" - -[features.CARTESI_FEATURE_DISABLE_MACHINE_HASH_CHECK] -default = "false" -go-type = "bool" -description = """ -If set to true, the node will *not* check whether the Cartesi machine hash from -the snapshot matches the hash in the Application contract.""" - -# -# Rollups -# - -[rollups.CARTESI_EPOCH_LENGTH] -default = "7200" # 1 day (average) in blocks (considering one block is mined every 12 seconds) -go-type = "uint64" -description = """ -Length of a rollups epoch in blocks. - -At the end of each epoch, the node will send claims to the blockchain.""" - -# -# Blockchain -# - -[blockchain.CARTESI_BLOCKCHAIN_ID] -go-type = "uint64" -description = """ -An unique identifier representing a blockchain network.""" - -[blockchain.CARTESI_BLOCKCHAIN_HTTP_ENDPOINT] -go-type = "string" -description = """ -HTTP endpoint for the blockchain RPC provider.""" - -[blockchain.CARTESI_BLOCKCHAIN_WS_ENDPOINT] -go-type = "string" -description = """ -WebSocket endpoint for the blockchain RPC provider.""" - -[blockchain.CARTESI_BLOCKCHAIN_IS_LEGACY] -default = "false" -go-type = "bool" -description = """ -If set to true the node will send transactions using the legacy gas fee model -(instead of EIP-1559).""" - -[blockchain.CARTESI_BLOCKCHAIN_FINALITY_OFFSET] -default = "10" -go-type = "int" -description = """ -The node assumes that blocks offseted by N from the current block have reached finality -(N is the read depth).""" - -[blockchain.CARTESI_BLOCKCHAIN_BLOCK_TIMEOUT] -default = "60" -go-type = "int" -description = """ -Block subscription timeout in seconds.""" - -# -# Contracts -# - -[contracts.CARTESI_CONTRACTS_APPLICATION_ADDRESS] -go-type = "string" -description = """ -Address of the DApp's contract.""" - -[contracts.CARTESI_CONTRACTS_HISTORY_ADDRESS] -go-type = "string" -description = """ -Address of the History contract.""" - -[contracts.CARTESI_CONTRACTS_AUTHORITY_ADDRESS] -go-type = "string" -description = """ -Address of the Authority contract.""" - -[contracts.CARTESI_CONTRACTS_INPUT_BOX_ADDRESS] -go-type = "string" -description = """ -Address of the InputBox contract.""" - -[contracts.CARTESI_CONTRACTS_INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER] -go-type = "int64" -description = """ -The deployment block for the input box contract. -The node will begin to read blockchain events from this block.""" - -# -# Snapshot -# - -[snapshot.CARTESI_SNAPSHOT_DIR] -go-type = "string" -description = """ -Path to the directory with the cartesi-machine snapshot that will be loaded by the node.""" - -# -# Auth -# - -[auth.CARTESI_AUTH_KIND] -default = "mnemonic" -go-type = "AuthKind" -description = """ -One of "private_key", "private_key_file", "mnemonic", "mnemonic_file", "aws".""" - -[auth.CARTESI_AUTH_PRIVATE_KEY] -go-type = "string" -description = """ -The node will use this private key to sign transactions.""" - -[auth.CARTESI_AUTH_PRIVATE_KEY_FILE] -go-type = "string" -description = """ -The node will use the private key contained in this file to sign transactions.""" - -[auth.CARTESI_AUTH_MNEMONIC] -go-type = "string" -description = """ -The node will use the private key generated from this mnemonic to sign transactions.""" - -[auth.CARTESI_AUTH_MNEMONIC_FILE] -go-type = "string" -description = """ -The node will use the private key generated from the mnemonic contained in this file -to sign transactions.""" - -[auth.CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX] -default = "0" -go-type = "int" -description = """ -When using mnemonics to sign transactions, -the node will use this account index to generate the private key.""" - -[auth.CARTESI_AUTH_AWS_KMS_KEY_ID] -go-type = "string" -description = """ -If set, the node will use the AWS KMS service with this key ID to sign transactions. - -Must be set alongside `CARTESI_AUTH_AWS_KMS_REGION`.""" - -[auth.CARTESI_AUTH_AWS_KMS_REGION] -go-type = "string" -description = """ -An AWS KMS Region. - -Must be set alongside `CARTESI_AUTH_AWS_KMS_KEY_ID`.""" - -# -# Postgres -# - -[postgres.CARTESI_POSTGRES_ENDPOINT] -default = "" -go-type = "string" -description = """ -Postgres endpoint in the 'postgres://user:password@hostname:port/database' format. - -If not set, or set to empty string, will defer the behaviour to the PG driver. -See [this](https://www.postgresql.org/docs/current/libpq-envars.html) for more information. - -It is also possible to set the endpoint without a password and load it from Postgres' passfile. -See [this](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE) -for more information.""" - -# -# HTTP -# - -[http.CARTESI_HTTP_ADDRESS] -default = "127.0.0.1" -go-type = "string" -description = """ -HTTP address for the node.""" - -[http.CARTESI_HTTP_PORT] -default = "10000" -go-type = "int" -description = """ -HTTP port for the node. -The node will also use the 20 ports after this one for internal services.""" - -# -# Experimental -# - -[experimental.CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_ENABLED] -default = "false" -go-type = "bool" -description = """ -When enabled, the node does not start Redis. -It must be configured with an external Redis endpoint.""" - -[experimental.CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_REDIS_ENDPOINT] -go-type = "string" -description = """ -External Redis endpoint for the node when running in the experimental sunodo validator mode.""" - -[experimental.CARTESI_EXPERIMENTAL_SERVER_MANAGER_BYPASS_LOG] -default = "false" -go-type = "bool" -description = """ -When enabled, prints server-manager output to stdout and stderr directly. -All other log configurations are ignored.""" diff --git a/internal/node/config/generate/code.go b/internal/node/config/generate/code.go deleted file mode 100644 index 841d7c6e5..000000000 --- a/internal/node/config/generate/code.go +++ /dev/null @@ -1,176 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -import ( - "bytes" - "os" - "strings" - "text/template" - - "go/format" - - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -// generateCodeFile generates a Go file with the getters for the config variables. -func generateCodeFile(path string, env []Env) { - // Load template - funcMap := template.FuncMap{ - "toFunctionName": func(env string) string { - caser := cases.Title(language.English) - words := strings.Split(env, "_") - for i, word := range words { - words[i] = caser.String(word) - } - return strings.Join(words[1:], "") - }, - "toGoFunc": func(goType string) string { - return "to" + strings.ToUpper(goType[:1]) + goType[1:] - }, - } - tmpl := template.Must(template.New("code").Funcs(funcMap).Parse(codeTemplate)) - - // Execute template - var buff bytes.Buffer - err := tmpl.Execute(&buff, env) - if err != nil { - panic(err) - } - - // Format code - code, err := format.Source(buff.Bytes()) - if err != nil { - panic(err) - } - - // Write file - var perm os.FileMode = 0644 - err = os.WriteFile(path, code, perm) - if err != nil { - panic(err) - } -} - -const codeTemplate string = `// Code generated by internal/node/config/generate. -// DO NOT EDIT. - -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package config - -import ( - "fmt" - "log/slog" - "os" - "strconv" - "time" -) - -type ( - Duration = time.Duration - LogLevel = slog.Level -) - -// ------------------------------------------------------------------------------------------------ -// Auth Kind -// ------------------------------------------------------------------------------------------------ - -type AuthKind uint8 - -const ( - AuthKindPrivateKeyVar AuthKind = iota - AuthKindPrivateKeyFile - AuthKindMnemonicVar - AuthKindMnemonicFile - AuthKindAWS -) - -// ------------------------------------------------------------------------------------------------ -// Parsing functions -// ------------------------------------------------------------------------------------------------ - -func toInt64FromString(s string) (int64, error) { - return strconv.ParseInt(s, 10, 64) -} - -func toUint64FromString(s string) (uint64, error) { - value, err := strconv.ParseUint(s, 10, 64) - return value, err -} - -func toStringFromString(s string) (string, error) { - return s, nil -} - -func toDurationFromSeconds(s string) (time.Duration, error) { - return time.ParseDuration(s + "s") -} - -func toLogLevelFromString(s string) (LogLevel, error) { - var m = map[string]LogLevel{ - "debug": slog.LevelDebug, - "info": slog.LevelInfo, - "warn": slog.LevelWarn, - "error": slog.LevelError, - } - if v, ok := m[s]; ok { - return v, nil - } else { - var zeroValue LogLevel - return zeroValue, fmt.Errorf("invalid log level '%s'", s) - } -} - -func toAuthKindFromString(s string) (AuthKind, error) { - var m = map[string]AuthKind{ - "private_key": AuthKindPrivateKeyVar, - "private_key_file": AuthKindPrivateKeyFile, - "mnemonic": AuthKindMnemonicVar, - "mnemonic_file": AuthKindMnemonicFile, - "aws": AuthKindAWS, - } - if v, ok := m[s]; ok { - return v, nil - } else { - var zeroValue AuthKind - return zeroValue, fmt.Errorf("invalid auth kind '%s'", s) - } -} - -// Aliases to be used by the generated functions. -var ( - toBool = strconv.ParseBool - toInt = strconv.Atoi - toInt64 = toInt64FromString - toUint64 = toUint64FromString - toString = toStringFromString - toDuration = toDurationFromSeconds - toLogLevel = toLogLevelFromString - toAuthKind = toAuthKindFromString -) - -// ------------------------------------------------------------------------------------------------ -// Getters -// ------------------------------------------------------------------------------------------------ - -{{range .}} -func get{{toFunctionName .Name}}() {{.GoType}} { - s, ok := os.LookupEnv("{{.Name}}") - if !ok { - {{- if .Default}} - s = "{{.Default}}" - {{- else}} - panic("missing env var {{.Name}}") - {{- end}} - } - val, err := {{toGoFunc .GoType}}(s) - if err != nil { - panic(fmt.Sprintf("failed to parse {{.Name}}: %v", err)) - } - return val -} -{{end}}` diff --git a/internal/node/config/generate/docs.go b/internal/node/config/generate/docs.go deleted file mode 100644 index be2af96cd..000000000 --- a/internal/node/config/generate/docs.go +++ /dev/null @@ -1,63 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -import ( - "os" - "text/template" -) - -// generateDocsFile generates a Markdown file with the documentation of the config variables. -func generateDocsFile(path string, env []Env) { - // Open output file - file, err := os.Create(path) - if err != nil { - panic(err) - } - defer file.Close() - - // Load template - funcMap := template.FuncMap{ - "backtick": func(s string) string { - return "`" + s + "`" - }, - "quote": func(s string) string { - return `"` + s + `"` - }, - } - tmpl := template.Must(template.New("docs").Funcs(funcMap).Parse(docsTemplate)) - - // Execute template - err = tmpl.Execute(file, env) - if err != nil { - panic(err) - } -} - -const docsTemplate string = ` - - -# Node Configuration - -The node is configurable through environment variables. -(There is no other way to configure it.) - -This file documents the configuration options. - - -{{- range .}} - -## {{backtick .Name}} - -{{.Description}} - -* **Type:** {{backtick .GoType}} -{{- if .Default}} -* **Default:** {{.Default | quote | backtick}} -{{- end}} -{{- end}} -` diff --git a/internal/node/config/generate/env.go b/internal/node/config/generate/env.go deleted file mode 100644 index 91590b1a6..000000000 --- a/internal/node/config/generate/env.go +++ /dev/null @@ -1,33 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package main - -// An entry in the toml's top level table representing an environment variable. -type Env struct { - // Name of the environment variable. - Name string - - // The default value for the variable. - // This field is optional. - Default *string `toml:"default"` - - // The Go type for the environment variable. - // This field is required. - GoType string `toml:"go-type"` - - // A brief description of the environment variable. - // This field is required. - Description string `toml:"description"` -} - -// Validates whether the fields of the environment variables were initialized correctly -// and sets defaults for optional fields. -func (e *Env) validate() { - if e.GoType == "" { - panic("missing go-type for " + e.Name) - } - if e.Description == "" { - panic("missing description for " + e.Name) - } -} diff --git a/internal/node/config/generate/main.go b/internal/node/config/generate/main.go deleted file mode 100644 index c90fca4c9..000000000 --- a/internal/node/config/generate/main.go +++ /dev/null @@ -1,24 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//go:generate go run . - -// This script will read the Config.toml file and create: -// - a formatted get.go file, with get functions for each environment variable; -// - a config.md file with documentation for the environment variables. -// -// Each table entry in the toml file translates into an environment variable. -// In Go, this becomes a map[string](map[string]Env), with the keys of the outter map being topic -// names, and the keys of the inner map being variable names. -package main - -func main() { - data := readTOML("Config.toml") - config := decodeTOML(data) - envs := sortConfig(config) - for _, env := range envs { - env.validate() - } - generateDocsFile("../../../../docs/config.md", envs) - generateCodeFile("../generated.go", envs) -} diff --git a/internal/node/config/generated.go b/internal/node/config/generated.go deleted file mode 100644 index e1ff76069..000000000 --- a/internal/node/config/generated.go +++ /dev/null @@ -1,498 +0,0 @@ -// Code generated by internal/node/config/generate. -// DO NOT EDIT. - -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package config - -import ( - "fmt" - "log/slog" - "os" - "strconv" - "time" -) - -type ( - Duration = time.Duration - LogLevel = slog.Level -) - -// ------------------------------------------------------------------------------------------------ -// Auth Kind -// ------------------------------------------------------------------------------------------------ - -type AuthKind uint8 - -const ( - AuthKindPrivateKeyVar AuthKind = iota - AuthKindPrivateKeyFile - AuthKindMnemonicVar - AuthKindMnemonicFile - AuthKindAWS -) - -// ------------------------------------------------------------------------------------------------ -// Parsing functions -// ------------------------------------------------------------------------------------------------ - -func toInt64FromString(s string) (int64, error) { - return strconv.ParseInt(s, 10, 64) -} - -func toUint64FromString(s string) (uint64, error) { - value, err := strconv.ParseUint(s, 10, 64) - return value, err -} - -func toStringFromString(s string) (string, error) { - return s, nil -} - -func toDurationFromSeconds(s string) (time.Duration, error) { - return time.ParseDuration(s + "s") -} - -func toLogLevelFromString(s string) (LogLevel, error) { - var m = map[string]LogLevel{ - "debug": slog.LevelDebug, - "info": slog.LevelInfo, - "warn": slog.LevelWarn, - "error": slog.LevelError, - } - if v, ok := m[s]; ok { - return v, nil - } else { - var zeroValue LogLevel - return zeroValue, fmt.Errorf("invalid log level '%s'", s) - } -} - -func toAuthKindFromString(s string) (AuthKind, error) { - var m = map[string]AuthKind{ - "private_key": AuthKindPrivateKeyVar, - "private_key_file": AuthKindPrivateKeyFile, - "mnemonic": AuthKindMnemonicVar, - "mnemonic_file": AuthKindMnemonicFile, - "aws": AuthKindAWS, - } - if v, ok := m[s]; ok { - return v, nil - } else { - var zeroValue AuthKind - return zeroValue, fmt.Errorf("invalid auth kind '%s'", s) - } -} - -// Aliases to be used by the generated functions. -var ( - toBool = strconv.ParseBool - toInt = strconv.Atoi - toInt64 = toInt64FromString - toUint64 = toUint64FromString - toString = toStringFromString - toDuration = toDurationFromSeconds - toLogLevel = toLogLevelFromString - toAuthKind = toAuthKindFromString -) - -// ------------------------------------------------------------------------------------------------ -// Getters -// ------------------------------------------------------------------------------------------------ - -func getAuthAwsKmsKeyId() string { - s, ok := os.LookupEnv("CARTESI_AUTH_AWS_KMS_KEY_ID") - if !ok { - panic("missing env var CARTESI_AUTH_AWS_KMS_KEY_ID") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_AWS_KMS_KEY_ID: %v", err)) - } - return val -} - -func getAuthAwsKmsRegion() string { - s, ok := os.LookupEnv("CARTESI_AUTH_AWS_KMS_REGION") - if !ok { - panic("missing env var CARTESI_AUTH_AWS_KMS_REGION") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_AWS_KMS_REGION: %v", err)) - } - return val -} - -func getAuthKind() AuthKind { - s, ok := os.LookupEnv("CARTESI_AUTH_KIND") - if !ok { - s = "mnemonic" - } - val, err := toAuthKind(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_KIND: %v", err)) - } - return val -} - -func getAuthMnemonic() string { - s, ok := os.LookupEnv("CARTESI_AUTH_MNEMONIC") - if !ok { - panic("missing env var CARTESI_AUTH_MNEMONIC") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_MNEMONIC: %v", err)) - } - return val -} - -func getAuthMnemonicAccountIndex() int { - s, ok := os.LookupEnv("CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX") - if !ok { - s = "0" - } - val, err := toInt(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX: %v", err)) - } - return val -} - -func getAuthMnemonicFile() string { - s, ok := os.LookupEnv("CARTESI_AUTH_MNEMONIC_FILE") - if !ok { - panic("missing env var CARTESI_AUTH_MNEMONIC_FILE") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_MNEMONIC_FILE: %v", err)) - } - return val -} - -func getAuthPrivateKey() string { - s, ok := os.LookupEnv("CARTESI_AUTH_PRIVATE_KEY") - if !ok { - panic("missing env var CARTESI_AUTH_PRIVATE_KEY") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_PRIVATE_KEY: %v", err)) - } - return val -} - -func getAuthPrivateKeyFile() string { - s, ok := os.LookupEnv("CARTESI_AUTH_PRIVATE_KEY_FILE") - if !ok { - panic("missing env var CARTESI_AUTH_PRIVATE_KEY_FILE") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_AUTH_PRIVATE_KEY_FILE: %v", err)) - } - return val -} - -func getBlockchainBlockTimeout() int { - s, ok := os.LookupEnv("CARTESI_BLOCKCHAIN_BLOCK_TIMEOUT") - if !ok { - s = "60" - } - val, err := toInt(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_BLOCKCHAIN_BLOCK_TIMEOUT: %v", err)) - } - return val -} - -func getBlockchainFinalityOffset() int { - s, ok := os.LookupEnv("CARTESI_BLOCKCHAIN_FINALITY_OFFSET") - if !ok { - s = "10" - } - val, err := toInt(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_BLOCKCHAIN_FINALITY_OFFSET: %v", err)) - } - return val -} - -func getBlockchainHttpEndpoint() string { - s, ok := os.LookupEnv("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT") - if !ok { - panic("missing env var CARTESI_BLOCKCHAIN_HTTP_ENDPOINT") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_BLOCKCHAIN_HTTP_ENDPOINT: %v", err)) - } - return val -} - -func getBlockchainId() uint64 { - s, ok := os.LookupEnv("CARTESI_BLOCKCHAIN_ID") - if !ok { - panic("missing env var CARTESI_BLOCKCHAIN_ID") - } - val, err := toUint64(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_BLOCKCHAIN_ID: %v", err)) - } - return val -} - -func getBlockchainIsLegacy() bool { - s, ok := os.LookupEnv("CARTESI_BLOCKCHAIN_IS_LEGACY") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_BLOCKCHAIN_IS_LEGACY: %v", err)) - } - return val -} - -func getBlockchainWsEndpoint() string { - s, ok := os.LookupEnv("CARTESI_BLOCKCHAIN_WS_ENDPOINT") - if !ok { - panic("missing env var CARTESI_BLOCKCHAIN_WS_ENDPOINT") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_BLOCKCHAIN_WS_ENDPOINT: %v", err)) - } - return val -} - -func getContractsApplicationAddress() string { - s, ok := os.LookupEnv("CARTESI_CONTRACTS_APPLICATION_ADDRESS") - if !ok { - panic("missing env var CARTESI_CONTRACTS_APPLICATION_ADDRESS") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_CONTRACTS_APPLICATION_ADDRESS: %v", err)) - } - return val -} - -func getContractsAuthorityAddress() string { - s, ok := os.LookupEnv("CARTESI_CONTRACTS_AUTHORITY_ADDRESS") - if !ok { - panic("missing env var CARTESI_CONTRACTS_AUTHORITY_ADDRESS") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_CONTRACTS_AUTHORITY_ADDRESS: %v", err)) - } - return val -} - -func getContractsHistoryAddress() string { - s, ok := os.LookupEnv("CARTESI_CONTRACTS_HISTORY_ADDRESS") - if !ok { - panic("missing env var CARTESI_CONTRACTS_HISTORY_ADDRESS") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_CONTRACTS_HISTORY_ADDRESS: %v", err)) - } - return val -} - -func getContractsInputBoxAddress() string { - s, ok := os.LookupEnv("CARTESI_CONTRACTS_INPUT_BOX_ADDRESS") - if !ok { - panic("missing env var CARTESI_CONTRACTS_INPUT_BOX_ADDRESS") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: %v", err)) - } - return val -} - -func getContractsInputBoxDeploymentBlockNumber() int64 { - s, ok := os.LookupEnv("CARTESI_CONTRACTS_INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER") - if !ok { - panic("missing env var CARTESI_CONTRACTS_INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER") - } - val, err := toInt64(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_CONTRACTS_INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER: %v", err)) - } - return val -} - -func getExperimentalServerManagerBypassLog() bool { - s, ok := os.LookupEnv("CARTESI_EXPERIMENTAL_SERVER_MANAGER_BYPASS_LOG") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_EXPERIMENTAL_SERVER_MANAGER_BYPASS_LOG: %v", err)) - } - return val -} - -func getExperimentalSunodoValidatorEnabled() bool { - s, ok := os.LookupEnv("CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_ENABLED") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_ENABLED: %v", err)) - } - return val -} - -func getExperimentalSunodoValidatorRedisEndpoint() string { - s, ok := os.LookupEnv("CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_REDIS_ENDPOINT") - if !ok { - panic("missing env var CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_REDIS_ENDPOINT") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_EXPERIMENTAL_SUNODO_VALIDATOR_REDIS_ENDPOINT: %v", err)) - } - return val -} - -func getFeatureDisableClaimer() bool { - s, ok := os.LookupEnv("CARTESI_FEATURE_DISABLE_CLAIMER") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_FEATURE_DISABLE_CLAIMER: %v", err)) - } - return val -} - -func getFeatureDisableMachineHashCheck() bool { - s, ok := os.LookupEnv("CARTESI_FEATURE_DISABLE_MACHINE_HASH_CHECK") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_FEATURE_DISABLE_MACHINE_HASH_CHECK: %v", err)) - } - return val -} - -func getFeatureHostMode() bool { - s, ok := os.LookupEnv("CARTESI_FEATURE_HOST_MODE") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_FEATURE_HOST_MODE: %v", err)) - } - return val -} - -func getFeatureReaderModeEnabled() bool { - s, ok := os.LookupEnv("CARTESI_FEATURE_READER_MODE_ENABLED") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_FEATURE_READER_MODE_ENABLED: %v", err)) - } - return val -} - -func getHttpAddress() string { - s, ok := os.LookupEnv("CARTESI_HTTP_ADDRESS") - if !ok { - s = "127.0.0.1" - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_HTTP_ADDRESS: %v", err)) - } - return val -} - -func getHttpPort() int { - s, ok := os.LookupEnv("CARTESI_HTTP_PORT") - if !ok { - s = "10000" - } - val, err := toInt(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_HTTP_PORT: %v", err)) - } - return val -} - -func getLogLevel() LogLevel { - s, ok := os.LookupEnv("CARTESI_LOG_LEVEL") - if !ok { - s = "info" - } - val, err := toLogLevel(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_LOG_LEVEL: %v", err)) - } - return val -} - -func getLogPretty() bool { - s, ok := os.LookupEnv("CARTESI_LOG_PRETTY") - if !ok { - s = "false" - } - val, err := toBool(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_LOG_PRETTY: %v", err)) - } - return val -} - -func getPostgresEndpoint() string { - s, ok := os.LookupEnv("CARTESI_POSTGRES_ENDPOINT") - if !ok { - s = "" - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_POSTGRES_ENDPOINT: %v", err)) - } - return val -} - -func getEpochLength() uint64 { - s, ok := os.LookupEnv("CARTESI_EPOCH_LENGTH") - if !ok { - s = "7200" - } - val, err := toUint64(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_EPOCH_LENGTH: %v", err)) - } - return val -} - -func getSnapshotDir() string { - s, ok := os.LookupEnv("CARTESI_SNAPSHOT_DIR") - if !ok { - panic("missing env var CARTESI_SNAPSHOT_DIR") - } - val, err := toString(s) - if err != nil { - panic(fmt.Sprintf("failed to parse CARTESI_SNAPSHOT_DIR: %v", err)) - } - return val -} diff --git a/internal/node/handlers.go b/internal/node/handlers.go deleted file mode 100644 index 250344194..000000000 --- a/internal/node/handlers.go +++ /dev/null @@ -1,50 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package node - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httputil" - "net/url" - - "github.com/cartesi/rollups-node/internal/node/config" -) - -func newHttpServiceHandler(c config.NodeConfig) http.Handler { - handler := http.NewServeMux() - handler.Handle("/healthz", http.HandlerFunc(healthcheckHandler)) - - graphqlProxy := newReverseProxy(c.HttpAddress, getPort(c, portOffsetGraphQLServer)) - handler.Handle("/graphql", graphqlProxy) - - dispatcherProxy := newReverseProxy(c.HttpAddress, getPort(c, portOffsetDispatcher)) - handler.Handle("/metrics", dispatcherProxy) - - inspectProxy := newReverseProxy(c.HttpAddress, getPort(c, portOffsetInspectServer)) - handler.Handle("/inspect", inspectProxy) - handler.Handle("/inspect/", inspectProxy) - - if c.FeatureHostMode { - hostProxy := newReverseProxy(c.HttpAddress, getPort(c, portOffsetHostRunnerRollups)) - handler.Handle("/rollup/", http.StripPrefix("/rollup", hostProxy)) - } - return handler -} - -func healthcheckHandler(w http.ResponseWriter, r *http.Request) { - slog.Debug("Node received a healthcheck request") - w.WriteHeader(http.StatusOK) -} - -func newReverseProxy(address string, port int) *httputil.ReverseProxy { - urlStr := fmt.Sprintf("http://%v:%v/", address, port) - url, err := url.Parse(urlStr) - if err != nil { - panic(fmt.Sprintf("failed to parse url: %v", err)) - } - proxy := httputil.NewSingleHostReverseProxy(url) - return proxy -} diff --git a/internal/node/machinehash.go b/internal/node/machinehash.go deleted file mode 100644 index 3d7d05418..000000000 --- a/internal/node/machinehash.go +++ /dev/null @@ -1,84 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package node - -import ( - "context" - "fmt" - "os" - "path" - - "github.com/cartesi/rollups-node/pkg/contracts" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" -) - -// Validates if the hash from the Cartesi Machine at machineDir matches the template hash onchain. -// It returns an error if it doesn't. -func validateMachineHash( - ctx context.Context, - machineDir string, - applicationAddress string, - ethereumNodeAddr string, -) error { - offchainHash, err := readHash(machineDir) - if err != nil { - return err - } - onchainHash, err := getTemplateHash(ctx, applicationAddress, ethereumNodeAddr) - if err != nil { - return err - } - if offchainHash != onchainHash { - return fmt.Errorf( - "validate machine hash: hash mismatch; expected %v but got %v", - onchainHash, - offchainHash, - ) - } - return nil -} - -// Reads the Cartesi Machine hash from machineDir. Returns it as a hex string or -// an error -func readHash(machineDir string) (string, error) { - path := path.Join(machineDir, "hash") - hash, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read hash: %w", err) - } else if len(hash) != common.HashLength { - return "", fmt.Errorf( - "read hash: wrong size; expected %v bytes but read %v", - common.HashLength, - len(hash), - ) - } - return common.Bytes2Hex(hash), nil -} - -// Retrieves the template hash from the application contract. Returns it as a -// hex string or an error -func getTemplateHash( - ctx context.Context, - applicationAddress string, - ethereumNodeAddr string, -) (string, error) { - client, err := ethclient.DialContext(ctx, ethereumNodeAddr) - if err != nil { - return "", fmt.Errorf("get template hash: %w", err) - } - cartesiApplication, err := contracts.NewCartesiDAppCaller( - common.HexToAddress(applicationAddress), - client, - ) - if err != nil { - return "", fmt.Errorf("get template hash: %w", err) - } - hash, err := cartesiApplication.GetTemplateHash(&bind.CallOpts{Context: ctx}) - if err != nil { - return "", fmt.Errorf("get template hash: %w", err) - } - return common.Bytes2Hex(hash[:]), nil -} diff --git a/internal/node/machinehash_test.go b/internal/node/machinehash_test.go deleted file mode 100644 index 992a6ea4f..000000000 --- a/internal/node/machinehash_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package node - -import ( - "context" - "os" - "testing" - "time" - - "github.com/cartesi/rollups-node/internal/deps" - "github.com/cartesi/rollups-node/internal/machine" - "github.com/cartesi/rollups-node/pkg/addresses" - "github.com/cartesi/rollups-node/pkg/testutil" - "github.com/ethereum/go-ethereum/common" - "github.com/stretchr/testify/suite" -) - -type ValidateMachineHashSuite struct { - suite.Suite -} - -func TestValidateMachineHash(t *testing.T) { - suite.Run(t, new(ValidateMachineHashSuite)) -} - -func (s *ValidateMachineHashSuite) TestItFailsWhenSnapshotHasNoHash() { - machineDir, err := os.MkdirTemp("", "") - s.Require().Nil(err) - defer os.RemoveAll(machineDir) - - err = validateMachineHash(context.Background(), machineDir, "", "") - s.ErrorContains(err, "no such file or directory") -} - -func (s *ValidateMachineHashSuite) TestItFailsWhenHashHasWrongSize() { - machineDir, err := mockMachineDir("deadbeef") - s.Require().Nil(err) - defer os.RemoveAll(machineDir) - - err = validateMachineHash(context.Background(), machineDir, "", "") - s.ErrorContains(err, "wrong size") -} - -func (s *ValidateMachineHashSuite) TestItFailsWhenContextIsCanceled() { - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - machineDir, err := createMachineSnapshot() - s.Require().Nil(err) - defer os.RemoveAll(machineDir) - - dependencies, err := startDevnet() - s.Require().Nil(err) - defer func() { - err = deps.Terminate(context.Background(), dependencies) - s.Nil(err) - }() - - blockchainHttpEndpoint, err := dependencies.DevnetEndpoint(context.Background(), "http") - s.Require().Nil(err) - - err = validateMachineHash( - ctx, - machineDir, - addresses.GetTestBook().CartesiDApp.String(), - blockchainHttpEndpoint, - ) - s.NotNil(err) - s.ErrorIs(err, context.DeadlineExceeded) -} - -func (s *ValidateMachineHashSuite) TestItSucceedsWhenHashesAreEqual() { - ctx := context.Background() - - machineDir, err := createMachineSnapshot() - s.Require().Nil(err) - defer os.RemoveAll(machineDir) - - dependencies, err := startDevnet() - s.Require().Nil(err) - defer func() { - err = deps.Terminate(context.Background(), dependencies) - s.Nil(err) - }() - - blockchainHttpEndpoint, err := dependencies.DevnetEndpoint(context.Background(), "http") - s.Require().Nil(err) - - err = validateMachineHash( - ctx, - machineDir, - addresses.GetTestBook().CartesiDApp.String(), - blockchainHttpEndpoint, - ) - s.Nil(err) -} - -// ------------------------------------------------------------------------------------------------ -// Auxiliary functions -// ------------------------------------------------------------------------------------------------ - -// Mocks the Cartesi Machine directory by creating a temporary directory with -// a single file named "hash" with the contents of `hash`, a hexadecimal string -func mockMachineDir(hash string) (string, error) { - temp, err := os.MkdirTemp("", "") - if err != nil { - return "", err - } - hashFile := temp + "/hash" - err = os.WriteFile(hashFile, common.FromHex(hash), os.ModePerm) - if err != nil { - return "", err - } - return temp, nil -} - -// Generates a new Cartesi Machine snapshot in a temporary directory and returns -// its path -func createMachineSnapshot() (string, error) { - tmpDir, err := os.MkdirTemp("", "") - if err != nil { - return "", err - } - if err = machine.Save( - "cartesi/rollups-node-snapshot:devel", - tmpDir, - "snapshotTemp", - ); err != nil { - return "", err - } - return tmpDir, nil -} - -// Starts a devnet in a Docker container with the default parameters -func startDevnet() (*deps.DepsContainers, error) { - container, err := deps.Run(context.Background(), deps.DepsConfig{ - Devnet: &deps.DevnetConfig{ - DockerImage: deps.DefaultDevnetDockerImage, - BlockTime: deps.DefaultDevnetBlockTime, - BlockToWaitForOnStartup: deps.DefaultDevnetBlockToWaitForOnStartup, - Port: testutil.GetCartesiTestDepsPortRange(), - }, - }) - if err != nil { - return nil, err - } - return container, nil -} diff --git a/internal/node/node.go b/internal/node/node.go deleted file mode 100644 index 2ec878388..000000000 --- a/internal/node/node.go +++ /dev/null @@ -1,34 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package node - -import ( - "context" - - "github.com/cartesi/rollups-node/internal/node/config" - "github.com/cartesi/rollups-node/internal/services" -) - -// Setup creates the Node top-level supervisor. -func Setup(ctx context.Context, c config.NodeConfig, workDir string) (services.Service, error) { - // checks - err := validateChainId(ctx, c.BlockchainID, c.BlockchainHttpEndpoint.Value) - if err != nil { - return nil, err - } - - if !c.FeatureDisableMachineHashCheck { - if err := validateMachineHash( - ctx, - c.SnapshotDir, - c.ContractsApplicationAddress, - c.BlockchainHttpEndpoint.Value, - ); err != nil { - return nil, err - } - } - - // create service - return newSupervisorService(c, workDir), nil -} diff --git a/internal/node/services.go b/internal/node/services.go deleted file mode 100644 index 0e0fe0076..000000000 --- a/internal/node/services.go +++ /dev/null @@ -1,356 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package node - -import ( - "fmt" - "log/slog" - "os" - - "github.com/cartesi/rollups-node/internal/node/config" - "github.com/cartesi/rollups-node/internal/services" -) - -// We use an enum to define the ports of each service and avoid conflicts. -type portOffset = int - -const ( - portOffsetProxy = iota - portOffsetAdvanceRunner - portOffsetAuthorityClaimer - portOffsetDispatcher - portOffsetGraphQLServer - portOffsetGraphQLHealthcheck - portOffsetHostRunnerHealthcheck - portOffsetHostRunnerRollups - portOffsetIndexer - portOffsetInspectServer - portOffsetInspectHealthcheck - portOffsetRedis - portOffsetServerManager - portOffsetStateServer -) - -const ( - localhost = "127.0.0.1" - serverManagerSessionId = "default_session_id" -) - -// Get the port of the given service. -func getPort(c config.NodeConfig, offset portOffset) int { - return c.HttpPort + int(offset) -} - -// Get the redis endpoint based on whether the experimental sunodo validator mode is enabled. -func getRedisEndpoint(c config.NodeConfig) string { - if c.ExperimentalSunodoValidatorEnabled { - return c.ExperimentalSunodoValidatorRedisEndpoint.Value - } else { - return fmt.Sprintf("redis://%v:%v", localhost, getPort(c, portOffsetRedis)) - } -} - -// Create the RUST_LOG variable using the config log level. -// If the log level is set to debug, set tracing log for the given rust module. -func getRustLog(c config.NodeConfig, rustModule string) string { - switch c.LogLevel { - case slog.LevelDebug: - return fmt.Sprintf("RUST_LOG=info,%v=trace", rustModule) - case slog.LevelInfo: - return "RUST_LOG=info" - case slog.LevelWarn: - return "RUST_LOG=warn" - case slog.LevelError: - return "RUST_LOG=error" - default: - panic("impossible") - } -} - -func newAdvanceRunner(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "advance-runner" - s.HealthcheckPort = getPort(c, portOffsetAdvanceRunner) - s.Path = "cartesi-rollups-advance-runner" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "advance_runner")) - s.Env = append(s.Env, fmt.Sprintf("SERVER_MANAGER_ENDPOINT=http://%v:%v", - localhost, getPort(c, portOffsetServerManager))) - s.Env = append(s.Env, fmt.Sprintf("SESSION_ID=%v", serverManagerSessionId)) - s.Env = append(s.Env, fmt.Sprintf("REDIS_ENDPOINT=%v", getRedisEndpoint(c))) - s.Env = append(s.Env, fmt.Sprintf("CHAIN_ID=%v", c.BlockchainID)) - s.Env = append(s.Env, fmt.Sprintf("DAPP_CONTRACT_ADDRESS=%v", - c.ContractsApplicationAddress)) - s.Env = append(s.Env, fmt.Sprintf("PROVIDER_HTTP_ENDPOINT=%v", - c.BlockchainHttpEndpoint.Value)) - s.Env = append(s.Env, fmt.Sprintf("ADVANCE_RUNNER_HEALTHCHECK_PORT=%v", - getPort(c, portOffsetAdvanceRunner))) - s.Env = append(s.Env, fmt.Sprintf("READER_MODE=%v", c.FeatureReaderModeEnabled)) - if c.FeatureHostMode || c.FeatureDisableMachineHashCheck { - s.Env = append(s.Env, "SNAPSHOT_VALIDATION_ENABLED=false") - } - if !c.FeatureHostMode { - s.Env = append(s.Env, fmt.Sprintf("MACHINE_SNAPSHOT_PATH=%v", c.SnapshotDir)) - } - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newAuthorityClaimer(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "authority-claimer" - s.HealthcheckPort = getPort(c, portOffsetAuthorityClaimer) - s.Path = "cartesi-rollups-authority-claimer" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "authority_claimer")) - s.Env = append(s.Env, fmt.Sprintf("TX_PROVIDER_HTTP_ENDPOINT=%v", - c.BlockchainHttpEndpoint.Value)) - s.Env = append(s.Env, fmt.Sprintf("TX_CHAIN_ID=%v", c.BlockchainID)) - s.Env = append(s.Env, fmt.Sprintf("TX_CHAIN_IS_LEGACY=%v", c.BlockchainIsLegacy)) - s.Env = append(s.Env, fmt.Sprintf("TX_DEFAULT_CONFIRMATIONS=%v", - c.BlockchainFinalityOffset)) - s.Env = append(s.Env, fmt.Sprintf("REDIS_ENDPOINT=%v", getRedisEndpoint(c))) - s.Env = append(s.Env, fmt.Sprintf("HISTORY_ADDRESS=%v", c.ContractsHistoryAddress)) - s.Env = append(s.Env, fmt.Sprintf("AUTHORITY_ADDRESS=%v", c.ContractsAuthorityAddress)) - s.Env = append(s.Env, fmt.Sprintf("INPUT_BOX_ADDRESS=%v", c.ContractsInputBoxAddress)) - s.Env = append(s.Env, fmt.Sprintf("DAPP_CONTRACT_ADDRESS=%v", - c.ContractsApplicationAddress)) - s.Env = append(s.Env, fmt.Sprintf("GENESIS_BLOCK=%v", - c.ContractsInputBoxDeploymentBlockNumber)) - s.Env = append(s.Env, fmt.Sprintf("AUTHORITY_CLAIMER_HTTP_SERVER_PORT=%v", - getPort(c, portOffsetAuthorityClaimer))) - switch auth := c.Auth.(type) { - case config.AuthPrivateKey: - s.Env = append(s.Env, fmt.Sprintf("TX_SIGNING_PRIVATE_KEY=%v", - auth.PrivateKey.Value)) - case config.AuthMnemonic: - s.Env = append(s.Env, fmt.Sprintf("TX_SIGNING_MNEMONIC=%v", auth.Mnemonic.Value)) - s.Env = append(s.Env, fmt.Sprintf("TX_SIGNING_MNEMONIC_ACCOUNT_INDEX=%v", - auth.AccountIndex.Value)) - case config.AuthAWS: - s.Env = append(s.Env, fmt.Sprintf("TX_SIGNING_AWS_KMS_KEY_ID=%v", auth.KeyID.Value)) - s.Env = append(s.Env, fmt.Sprintf("TX_SIGNING_AWS_KMS_REGION=%v", - auth.Region.Value)) - default: - panic("invalid auth config") - } - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newDispatcher(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "dispatcher" - s.HealthcheckPort = getPort(c, portOffsetDispatcher) - s.Path = "cartesi-rollups-dispatcher" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "dispatcher")) - s.Env = append(s.Env, fmt.Sprintf("SC_GRPC_ENDPOINT=http://%v:%v", localhost, - getPort(c, portOffsetStateServer))) - s.Env = append(s.Env, fmt.Sprintf("SC_DEFAULT_CONFIRMATIONS=%v", - c.BlockchainFinalityOffset)) - s.Env = append(s.Env, fmt.Sprintf("REDIS_ENDPOINT=%v", getRedisEndpoint(c))) - s.Env = append(s.Env, fmt.Sprintf("DAPP_ADDRESS=%v", c.ContractsApplicationAddress)) - s.Env = append(s.Env, fmt.Sprintf("INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER=%v", - c.ContractsInputBoxDeploymentBlockNumber)) - s.Env = append(s.Env, fmt.Sprintf("HISTORY_ADDRESS=%v", c.ContractsHistoryAddress)) - s.Env = append(s.Env, fmt.Sprintf("AUTHORITY_ADDRESS=%v", c.ContractsAuthorityAddress)) - s.Env = append(s.Env, fmt.Sprintf("INPUT_BOX_ADDRESS=%v", c.ContractsInputBoxAddress)) - s.Env = append(s.Env, fmt.Sprintf("RD_EPOCH_LENGTH=%v", c.RollupsEpochLength)) - s.Env = append(s.Env, fmt.Sprintf("CHAIN_ID=%v", c.BlockchainID)) - s.Env = append(s.Env, fmt.Sprintf("DISPATCHER_HTTP_SERVER_PORT=%v", - getPort(c, portOffsetDispatcher))) - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newGraphQLServer(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "graphql-server" - s.HealthcheckPort = getPort(c, portOffsetGraphQLHealthcheck) - s.Path = "cartesi-rollups-graphql-server" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "graphql_server")) - s.Env = append(s.Env, fmt.Sprintf("POSTGRES_ENDPOINT=%v", c.PostgresEndpoint.Value)) - s.Env = append(s.Env, fmt.Sprintf("GRAPHQL_HOST=%v", localhost)) - s.Env = append(s.Env, fmt.Sprintf("GRAPHQL_PORT=%v", getPort(c, portOffsetGraphQLServer))) - s.Env = append(s.Env, fmt.Sprintf("GRAPHQL_HEALTHCHECK_PORT=%v", - getPort(c, portOffsetGraphQLHealthcheck))) - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newHostRunner(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "host-runner" - s.HealthcheckPort = getPort(c, portOffsetHostRunnerHealthcheck) - s.Path = "cartesi-rollups-host-runner" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "host_runner")) - s.Env = append(s.Env, fmt.Sprintf("GRPC_SERVER_MANAGER_ADDRESS=%v", localhost)) - s.Env = append(s.Env, fmt.Sprintf("GRPC_SERVER_MANAGER_PORT=%v", - getPort(c, portOffsetServerManager))) - s.Env = append(s.Env, fmt.Sprintf("HTTP_ROLLUP_SERVER_ADDRESS=%v", localhost)) - s.Env = append(s.Env, fmt.Sprintf("HTTP_ROLLUP_SERVER_PORT=%v", - getPort(c, portOffsetHostRunnerRollups))) - s.Env = append(s.Env, fmt.Sprintf("HOST_RUNNER_HEALTHCHECK_PORT=%v", - getPort(c, portOffsetHostRunnerHealthcheck))) - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newIndexer(c config.NodeConfig, workdir string) services.CommandService { - var s services.CommandService - s.Name = "indexer" - s.HealthcheckPort = getPort(c, portOffsetIndexer) - s.Path = "cartesi-rollups-indexer" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "indexer")) - s.Env = append(s.Env, fmt.Sprintf("POSTGRES_ENDPOINT=%v", c.PostgresEndpoint.Value)) - s.Env = append(s.Env, fmt.Sprintf("CHAIN_ID=%v", c.BlockchainID)) - s.Env = append(s.Env, fmt.Sprintf("DAPP_CONTRACT_ADDRESS=%v", - c.ContractsApplicationAddress)) - s.Env = append(s.Env, fmt.Sprintf("REDIS_ENDPOINT=%v", getRedisEndpoint(c))) - s.Env = append(s.Env, fmt.Sprintf("INDEXER_HEALTHCHECK_PORT=%v", - getPort(c, portOffsetIndexer))) - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workdir - return s -} - -func newInspectServer(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "inspect-server" - s.HealthcheckPort = getPort(c, portOffsetInspectHealthcheck) - s.Path = "cartesi-rollups-inspect-server" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "inspect_server")) - s.Env = append(s.Env, fmt.Sprintf("INSPECT_SERVER_ADDRESS=%v:%v", localhost, - getPort(c, portOffsetInspectServer))) - s.Env = append(s.Env, fmt.Sprintf("SERVER_MANAGER_ADDRESS=%v:%v", localhost, - getPort(c, portOffsetServerManager))) - s.Env = append(s.Env, fmt.Sprintf("SESSION_ID=%v", serverManagerSessionId)) - s.Env = append(s.Env, fmt.Sprintf("INSPECT_SERVER_HEALTHCHECK_PORT=%v", - getPort(c, portOffsetInspectHealthcheck))) - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newRedis(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "redis" - s.HealthcheckPort = getPort(c, portOffsetRedis) - s.Path = "redis-server" - s.Args = append(s.Args, "--port", fmt.Sprint(getPort(c, portOffsetRedis))) - // Disable persistence with --save and --appendonly config - s.Args = append(s.Args, "--save", "") - s.Args = append(s.Args, "--appendonly", "no") - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newServerManager(c config.NodeConfig, workDir string) services.ServerManager { - var s services.ServerManager - s.Name = "server-manager" - s.HealthcheckPort = getPort(c, portOffsetServerManager) - s.Path = "server-manager" - s.Args = append(s.Args, - fmt.Sprintf("--manager-address=%v:%v", localhost, getPort(c, portOffsetServerManager))) - s.Env = append(s.Env, "REMOTE_CARTESI_MACHINE_LOG_LEVEL=info") - if c.LogLevel == slog.LevelDebug { - s.Env = append(s.Env, "SERVER_MANAGER_LOG_LEVEL=info") - } else { - s.Env = append(s.Env, "SERVER_MANAGER_LOG_LEVEL=warning") - } - s.Env = append(s.Env, os.Environ()...) - s.BypassLog = c.ExperimentalServerManagerBypassLog - s.WorkDir = workDir - return s -} - -func newStateServer(c config.NodeConfig, workDir string) services.CommandService { - var s services.CommandService - s.Name = "state-server" - s.HealthcheckPort = getPort(c, portOffsetStateServer) - s.Path = "cartesi-rollups-state-server" - s.Env = append(s.Env, "LOG_ENABLE_TIMESTAMP=false") - s.Env = append(s.Env, "LOG_ENABLE_COLOR=false") - s.Env = append(s.Env, getRustLog(c, "state_server")) - s.Env = append(s.Env, "SF_CONCURRENT_EVENTS_FETCH=1") - s.Env = append(s.Env, fmt.Sprintf("SF_GENESIS_BLOCK=%v", - c.ContractsInputBoxDeploymentBlockNumber)) - s.Env = append(s.Env, fmt.Sprintf("SF_SAFETY_MARGIN=%v", c.BlockchainFinalityOffset)) - s.Env = append(s.Env, fmt.Sprintf("BH_WS_ENDPOINT=%v", c.BlockchainWsEndpoint.Value)) - s.Env = append(s.Env, fmt.Sprintf("BH_HTTP_ENDPOINT=%v", - c.BlockchainHttpEndpoint.Value)) - s.Env = append(s.Env, fmt.Sprintf("BLOCKCHAIN_BLOCK_TIMEOUT=%v", c.BlockchainBlockTimeout)) - s.Env = append(s.Env, fmt.Sprintf("SS_SERVER_ADDRESS=%v:%v", localhost, - getPort(c, portOffsetStateServer))) - s.Env = append(s.Env, os.Environ()...) - s.WorkDir = workDir - return s -} - -func newSupervisorService(c config.NodeConfig, workDir string) services.SupervisorService { - var s []services.Service - - if !c.ExperimentalSunodoValidatorEnabled { - // add Redis first - s = append(s, newRedis(c, workDir)) - } - - // add services without dependencies - s = append(s, newGraphQLServer(c, workDir)) - s = append(s, newIndexer(c, workDir)) - s = append(s, newStateServer(c, workDir)) - - // start either the server manager or host runner - if c.FeatureHostMode { - s = append(s, newHostRunner(c, workDir)) - } else { - s = append(s, newServerManager(c, workDir)) - } - - // enable claimer if reader mode and sunodo validator mode are disabled - if !c.FeatureDisableClaimer && !c.ExperimentalSunodoValidatorEnabled { - s = append(s, newAuthorityClaimer(c, workDir)) - } - - // add services with dependencies - s = append(s, newAdvanceRunner(c, workDir)) // Depends on the server-manager/host-runner - s = append(s, newDispatcher(c, workDir)) // Depends on the state server - s = append(s, newInspectServer(c, workDir)) // Depends on the server-manager/host-runner - - s = append(s, newHttpService(c)) - - supervisor := services.SupervisorService{ - Name: "rollups-node", - Services: s, - } - return supervisor -} - -func newHttpService(c config.NodeConfig) services.HttpService { - addr := fmt.Sprintf("%v:%v", c.HttpAddress, getPort(c, portOffsetProxy)) - handler := newHttpServiceHandler(c) - return services.HttpService{ - Name: "http", - Address: addr, - Handler: handler, - } -} diff --git a/internal/prt/abi.go b/internal/prt/abi.go new file mode 100644 index 000000000..5b576cbd5 --- /dev/null +++ b/internal/prt/abi.go @@ -0,0 +1,45 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +// staticABIWordCount returns the exact static data size in ABI words. +func staticABIWordCount(arguments abi.Arguments) (int, error) { + words := 0 + for _, argument := range arguments { + size, err := staticABITypeWordCount(argument.Type) + if err != nil { + return 0, err + } + words += size + } + return words, nil +} + +func staticABITypeWordCount(value abi.Type) (int, error) { + switch value.T { + case abi.ArrayTy: + words, err := staticABITypeWordCount(*value.Elem) + return value.Size * words, err + case abi.TupleTy: + words := 0 + for _, element := range value.TupleElems { + size, err := staticABITypeWordCount(*element) + if err != nil { + return 0, err + } + words += size + } + return words, nil + case abi.IntTy, abi.UintTy, abi.BoolTy, abi.AddressTy, abi.FixedBytesTy, abi.HashTy, abi.FixedPointTy, abi.FunctionTy: + return 1, nil + default: + return 0, fmt.Errorf("ABI type %s is not static", value.String()) + } +} diff --git a/internal/prt/acceptance_receipt.go b/internal/prt/acceptance_receipt.go new file mode 100644 index 000000000..2be88f0c6 --- /dev/null +++ b/internal/prt/acceptance_receipt.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Receipt RPCs cannot be pinned to the observation hash. Verify their block +// against the canonical header before using their events for durable decisions. +// The caller separately requires the receipt height to be observed already. +func (s *Service) validateAcceptanceReceipt(ctx context.Context, receipt *types.Receipt) error { + if receipt.BlockHash == (common.Hash{}) { + return fmt.Errorf("%w: acceptance receipt has no block hash", txsender.ErrInconclusive) + } + header, err := s.client.HeaderByNumber(ctx, receipt.BlockNumber) + if err != nil { + if errutil.IsOnlyCancellation(err) { + return err + } + return fmt.Errorf("reading acceptance receipt's canonical header: %w", errors.Join(txsender.ErrInconclusive, err)) + } + if header == nil || header.Number == nil || !header.Number.IsUint64() || header.Number.Cmp(receipt.BlockNumber) != 0 { + return fmt.Errorf("%w: acceptance receipt's canonical header is unavailable or has a different height", txsender.ErrInconclusive) + } + if header.Hash() != receipt.BlockHash { + return fmt.Errorf("%w: acceptance receipt block hash differs from the canonical header", txsender.ErrInconclusive) + } + return nil +} diff --git a/internal/prt/acceptance_receipt_test.go b/internal/prt/acceptance_receipt_test.go new file mode 100644 index 000000000..6457af339 --- /dev/null +++ b/internal/prt/acceptance_receipt_test.go @@ -0,0 +1,191 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type acceptanceReceiptFixture struct { + service *Service + repo *prtRepositoryMock + client *ethClientMock + consensus *daveConsensusAdapterMock + app *model.Application + epoch *model.Epoch + root *model.Tournament + header *types.Header + headerErr error + receipt *types.Receipt + log *types.Log +} + +func newAcceptanceReceiptFixture(t *testing.T) *acceptanceReceiptFixture { + t.Helper() + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 100 + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + header := &types.Header{Number: big.NewInt(100)} + raw := &types.Log{Address: app.IConsensusAddress, TxHash: *epoch.ClaimTransactionHash, + BlockNumber: 100, BlockHash: header.Hash()} + receipt := &types.Receipt{TxHash: raw.TxHash, BlockNumber: big.NewInt(100), BlockHash: raw.BlockHash, + Status: types.ReceiptStatusSuccessful, Logs: []*types.Log{raw}} + client := ðClientMock{} + s.client = client + root := &model.Tournament{Snapshot: model.TournamentSnapshot{ + AsOfBlock: 100, FinishedAtBlock: 90, WinnerCommitment: epoch.Commitment, FinalStateHash: epoch.MachineHash, + }} + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return(root, nil) + f := &acceptanceReceiptFixture{service: s, repo: repo, client: client, + consensus: newCompatibleDaveConsensusAdapterMock(), app: app, epoch: epoch, root: root, header: header, receipt: receipt, log: raw} + t.Cleanup(func() { + repo.AssertExpectations(t) + client.AssertExpectations(t) + f.consensus.AssertExpectations(t) + }) + return f +} + +func (f *acceptanceReceiptFixture) expectReceipt() { + f.client.On("TransactionReceipt", mock.Anything, *f.epoch.ClaimTransactionHash).Return(f.receipt, nil).Once() + if f.receipt.BlockHash != (common.Hash{}) { + f.client.On("HeaderByNumber", mock.Anything, f.receipt.BlockNumber).Return(f.header, f.headerErr).Once() + } +} + +func (f *acceptanceReceiptFixture) expectAcceptance() { + f.consensus.On("ParseEpochSealed", *f.log).Return(&idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), InitialMachineStateHash: *f.epoch.MachineHash, + OutputsMerkleRoot: *f.epoch.TxBufferDataBlock, Raw: *f.log, + }, nil).Once() + f.repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, f.app.ID, f.epoch.Index, f.epoch.ClaimTransactionHash). + Return(nil).Once() +} + +func TestAcceptanceReceiptRequiresCanonicalBlockAndMatchingLog(t *testing.T) { + for _, test := range []struct { + name string + change func(*acceptanceReceiptFixture) + cause error + }{ + {"orphaned receipt", func(f *acceptanceReceiptFixture) { + f.receipt.BlockHash = common.HexToHash("0xbad") + f.log.BlockHash = f.receipt.BlockHash + }, txsender.ErrInconclusive}, + {"missing receipt block hash", func(f *acceptanceReceiptFixture) { + f.receipt.BlockHash = common.Hash{} + }, txsender.ErrInconclusive}, + {"unavailable canonical header", func(f *acceptanceReceiptFixture) { + f.header = nil + }, txsender.ErrInconclusive}, + {"header has no number", func(f *acceptanceReceiptFixture) { + f.header = &types.Header{} + }, txsender.ErrInconclusive}, + {"header has another height", func(f *acceptanceReceiptFixture) { + f.header = &types.Header{Number: big.NewInt(99)} + }, txsender.ErrInconclusive}, + {"removed log", func(f *acceptanceReceiptFixture) { + f.log.Removed = true + }, txsender.ErrInconclusive}, + {"log has another block hash", func(f *acceptanceReceiptFixture) { + f.log.BlockHash = common.HexToHash("0xbad") + }, txsender.ErrInconclusive}, + {"log has no block hash", func(f *acceptanceReceiptFixture) { + f.log.BlockHash = common.Hash{} + }, txsender.ErrInconclusive}, + } { + t.Run(test.name, func(t *testing.T) { + f := newAcceptanceReceiptFixture(t) + test.change(f) + f.expectReceipt() + deferActions, err := f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.ErrorIs(t, err, test.cause) + require.True(t, deferActions) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestAcceptanceReceiptCanonicalHeaderFailureCanRecover(t *testing.T) { + f := newAcceptanceReceiptFixture(t) + f.headerErr = errors.New("temporary canonical header outage") + f.expectReceipt() + deferActions, err := f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.ErrorIs(t, err, f.headerErr) + require.True(t, deferActions) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.headerErr = nil + f.expectReceipt() + f.expectAcceptance() + deferActions, err = f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.NoError(t, err) + require.False(t, deferActions) +} + +func TestOrphanedAcceptanceReceiptCanRecoverOnCanonicalBranch(t *testing.T) { + f := newAcceptanceReceiptFixture(t) + canonicalHash := f.header.Hash() + f.receipt.BlockHash = common.HexToHash("0xbad") + f.log.BlockHash = f.receipt.BlockHash + f.expectReceipt() + // An orphaned event must never reach the decoder or a durable decision. + // The same transaction can later execute on the canonical branch. + deferActions, err := f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.True(t, deferActions) + f.consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.receipt.BlockHash, f.log.BlockHash = canonicalHash, canonicalHash + f.expectReceipt() + f.expectAcceptance() + deferActions, err = f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.NoError(t, err) + require.False(t, deferActions) +} + +func TestCanonicalAcceptanceReceiptStillDetectsDivergence(t *testing.T) { + for _, field := range []string{"machine", "outputs"} { + t.Run(field, func(t *testing.T) { + f := newAcceptanceReceiptFixture(t) + f.expectReceipt() + event := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), InitialMachineStateHash: *f.epoch.MachineHash, + OutputsMerkleRoot: *f.epoch.TxBufferDataBlock, Raw: *f.log, + } + if field == "machine" { + event.InitialMachineStateHash = common.HexToHash("0xbad") + } else { + event.OutputsMerkleRoot = common.HexToHash("0xbad") + } + f.consensus.On("ParseEpochSealed", *f.log).Return(event, nil).Once() + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + f.repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, f.app.ID, f.epoch.Index, f.epoch.ClaimTransactionHash). + Return(nil).Maybe() + expectStoredProtection(t, f.repo, f.app, f.epoch) + deferActions, err := f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.ErrorContains(t, err, "inconsistent") + require.True(t, deferActions) + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} diff --git a/internal/prt/alpha6_projection_test.go b/internal/prt/alpha6_projection_test.go new file mode 100644 index 000000000..49adbb824 --- /dev/null +++ b/internal/prt/alpha6_projection_test.go @@ -0,0 +1,367 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const projectionAdvanceCase = "advance" +const projectionChildCase = "child" +const projectionSealCase = "seal" + +func TestContextualProjectionChecksEachEventPair(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, kind := range []string{projectionAdvanceCase, projectionSealCase, projectionChildCase} { + for _, side := range []string{"one", "two"} { + t.Run(kind+"/"+side, func(t *testing.T) { + match := &model.Match{IDHash: common.HexToHash("0x33"), CommitmentOne: one, CommitmentTwo: two} + f := newEventBatchFixture(t, nil, []*model.Match{match}) + f.tournament.Height = 68 + a, b := one, two + if side == "one" { + a[0]++ + } else { + b[0]++ + } + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(one), [32]byte(two)).Return( + ObservedMatchSnapshot{Phase: model.MatchPhaseReadyToSeal, ReadyToSeal: &ReadyToSealMatch{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, nil).Maybe() + events := &TournamentEvents{} + switch kind { + case projectionAdvanceCase: + events.MatchAdvanced = []*itournament.ITournamentMatchAdvanced{{MatchIdHash: match.IDHash, One: a, Two: b, + SegmentStartPosition: big.NewInt(0), CurrentHeight: 67}} + case projectionSealCase: + events.LeafMatchSealed = []*itournament.ITournamentLeafMatchSealed{{MatchIdHash: match.IDHash, One: a, Two: b, + DivergencePosition: big.NewInt(0)}} + case projectionChildCase: + f.tournament.Kind = model.TournamentKindNonLeaf + events.NewInnerTournament = []*itournament.ITournamentNewInnerTournament{{MatchIdHash: match.IDHash, One: a, Two: b}} + } + got, err := f.project(events) + require.ErrorContains(t, err, "creation pair") + require.Nil(t, got) + require.Empty(t, f.adapter.Calls) + }) + } + } +} + +func TestContextualProjectionRejectsWrongKindAndAdvanceHeight(t *testing.T) { + for _, test := range []struct { + name string + kind model.TournamentKind + height uint64 + event string + }{ + {"seal nonleaf", model.TournamentKindNonLeaf, 68, projectionSealCase}, + {"child leaf", model.TournamentKindLeaf, 68, projectionChildCase}, + {"advance equal", model.TournamentKindLeaf, 68, projectionAdvanceCase}, + } { + t.Run(test.name, func(t *testing.T) { + m := &model.Match{IDHash: common.HexToHash("0x33"), CommitmentOne: common.HexToHash("0x11"), + CommitmentTwo: common.HexToHash("0x22")} + f := newEventBatchFixture(t, nil, []*model.Match{m}) + f.tournament.Kind, f.tournament.Height = test.kind, test.height + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(m.CommitmentOne), [32]byte(m.CommitmentTwo)).Return( + ObservedMatchSnapshot{Phase: model.MatchPhaseReadyToSeal, ReadyToSeal: &ReadyToSealMatch{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, nil).Maybe() + e := &TournamentEvents{} + switch test.event { + case projectionSealCase: + e.LeafMatchSealed = []*itournament.ITournamentLeafMatchSealed{{MatchIdHash: m.IDHash, One: m.CommitmentOne, + Two: m.CommitmentTwo, DivergencePosition: big.NewInt(0)}} + case projectionChildCase: + e.NewInnerTournament = []*itournament.ITournamentNewInnerTournament{{MatchIdHash: m.IDHash, + One: m.CommitmentOne, Two: m.CommitmentTwo}} + case projectionAdvanceCase: + e.MatchAdvanced = []*itournament.ITournamentMatchAdvanced{{MatchIdHash: m.IDHash, One: m.CommitmentOne, + Two: m.CommitmentTwo, + CurrentHeight: 68, SegmentStartPosition: big.NewInt(0)}} + } + got, err := f.project(e) + require.Error(t, err) + require.Nil(t, got) + require.Empty(t, f.adapter.Calls) + }) + } +} + +func TestStandingProjectionKeepsProspectiveAndTerminalHistories(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + parent := &model.Match{CommitmentOne: one, CommitmentTwo: two} + for _, test := range []struct { + name string + state model.TournamentStandingState + level uint64 + candidate, parent *common.Hash + result, expiry uint64 + }{ + {"before closure", model.TournamentStandingAwaitingClosure, 0, &one, nil, 0, 0}, + {"root failed", model.TournamentStandingRootFailed, 0, nil, nil, 30, 0}, + {"root winner", model.TournamentStandingRootWinner, 0, &one, nil, 30, 0}, + {"inner no winner", model.TournamentStandingInnerEliminableNoWinner, 1, nil, nil, 30, 0}, + {"inner expired", model.TournamentStandingInnerEliminableWinnerExpired, 1, &one, nil, 30, 0}, + {"inner winner", model.TournamentStandingInnerWinner, 1, &one, &two, 30, 50}, + } { + t.Run(test.name, func(t *testing.T) { + row := &model.Tournament{Level: test.level, StartInstant: 10, Allowance: 20, Snapshot: model.TournamentSnapshot{ + Standing: test.state, Candidate: test.candidate, ParentCommitment: test.parent, FinishedAtBlock: test.result, + WinnerExpiresAt: test.expiry}} + e := &itournament.ITournamentStandingChanged{MatchCount: big.NewInt(0), ResultAt: 30, Raw: types.Log{BlockNumber: 20, Index: 2}} + if test.candidate != nil { + e.Dangling = *test.candidate + } + if test.level == 1 && test.candidate != nil { + e.ParentCommitment = two + e.WinnerExpiresAt = 50 + } + batch := &repository.TournamentEventBatch{Tournament: row} + require.NoError(t, appendTournamentStandingEvents(batch, &model.Application{ID: 7}, &model.Epoch{Index: 8}, + &TournamentEvents{StandingChanged: []*itournament.ITournamentStandingChanged{e}}, nil, parent)) + require.Len(t, batch.StandingChanges, 1) + got := batch.StandingChanges[0] + require.Equal(t, uint64(30), got.ResultAt) + require.Equal(t, e.WinnerExpiresAt, got.WinnerExpiresAt) + if test.level == 1 && test.candidate != nil { + require.Equal(t, &two, got.ParentCommitment) + } else { + require.Nil(t, got.ParentCommitment) + } + require.Equal(t, test.candidate, got.Dangling) + }) + } +} + +func TestStandingProjectionUsesFinalLogAndFullWidthCount(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + row := &model.Tournament{Snapshot: model.TournamentSnapshot{Standing: model.TournamentStandingMatchesActive}} + matches := map[common.Hash]*model.Match{{1}: {DeletionReason: model.MatchDeletionReason_NOT_DELETED}} + e := &TournamentEvents{StandingChanged: []*itournament.ITournamentStandingChanged{ + {MatchCount: big.NewInt(1), Raw: types.Log{BlockNumber: 20, TxHash: one, Index: 9}}, + {MatchCount: big.NewInt(0), Dangling: two, ResultAt: 10, Raw: types.Log{BlockNumber: 20, TxHash: two, Index: 3}}, + }} + require.NoError(t, appendTournamentStandingEvents(&repository.TournamentEventBatch{Tournament: row}, &model.Application{}, + &model.Epoch{}, e, matches, nil)) + reversed := &TournamentEvents{StandingChanged: []*itournament.ITournamentStandingChanged{e.StandingChanged[1], e.StandingChanged[0]}} + require.NoError(t, appendTournamentStandingEvents(&repository.TournamentEventBatch{Tournament: row}, &model.Application{}, + &model.Epoch{}, reversed, matches, nil)) + for _, change := range []struct { + name string + edit func(*itournament.ITournamentStandingChanged) + }{ + {"count high bits", func(s *itournament.ITournamentStandingChanged) { + s.MatchCount = new(big.Int).Add(big.NewInt(1), new(big.Int).Lsh(big.NewInt(1), 64)) + }}, + {"wrong candidate", func(s *itournament.ITournamentStandingChanged) { s.Dangling = one }}, + {"root parent", func(s *itournament.ITournamentStandingChanged) { s.ParentCommitment = one }}, + } { + t.Run(change.name, func(t *testing.T) { + first := *e.StandingChanged[0] + change.edit(&first) + err := appendTournamentStandingEvents(&repository.TournamentEventBatch{Tournament: row}, &model.Application{}, &model.Epoch{}, + &TournamentEvents{StandingChanged: []*itournament.ITournamentStandingChanged{&first}}, matches, nil) + require.Error(t, err) + }) + } +} + +func TestStandingProjectionChecksCurrentWinnerAndHistoricalParentMembership(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, fault := range []string{"no fault", "parent", "expiry", "closure", "terminal"} { + t.Run(fault, func(t *testing.T) { + row := &model.Tournament{Level: 1, StartInstant: 10, Allowance: 20, + Snapshot: model.TournamentSnapshot{Standing: model.TournamentStandingInnerWinner, Candidate: &one, ParentCommitment: &two, + FinishedAtBlock: 30, WinnerExpiresAt: 50}} + e := &itournament.ITournamentStandingChanged{MatchCount: big.NewInt(0), Dangling: one, ParentCommitment: two, ResultAt: 30, + WinnerExpiresAt: 50} + switch fault { + case "parent": + e.ParentCommitment = common.HexToHash("0x33") + case "expiry": + e.WinnerExpiresAt++ + case "closure": + e.ResultAt = 29 + case "terminal": + e.ResultAt = 31 + } + err := appendTournamentStandingEvents(&repository.TournamentEventBatch{Tournament: row}, &model.Application{}, &model.Epoch{}, + &TournamentEvents{StandingChanged: []*itournament.ITournamentStandingChanged{e}}, nil, &model.Match{CommitmentOne: one, + CommitmentTwo: two}) + if fault == "no fault" { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestContestedStatesCompareOnlyCurrentSealedParent(t *testing.T) { + zero, other := common.Hash{}, common.HexToHash("0x22") + child := &model.Tournament{ContestedFinalStateOne: &zero, ContestedFinalStateTwo: &other} + parent := &model.Match{Snapshot: model.MatchSnapshot{Phase: model.MatchPhaseSealed, + Sealed: &model.MatchSealedSnapshot{FinalStateOne: zero, FinalStateTwo: other}}} + require.NoError(t, validateChildContestedStates(child, parent)) + parent.Snapshot.Sealed.FinalStateTwo = zero + require.Error(t, validateChildContestedStates(child, parent)) + parent.Snapshot = model.MatchSnapshot{Phase: model.MatchPhaseUninitialized} + require.NoError(t, validateChildContestedStates(child, parent)) + child.ContestedFinalStateOne = nil + require.Error(t, validateChildContestedStates(child, parent)) +} + +func TestStoredStandingBaselineUsesActualRows(t *testing.T) { + require.NoError(t, validateStoredStandingCount(repository.TournamentEventCounts{CommitmentJoined: 7, MatchDeleted: 3, + StandingChanged: 10})) + require.Error(t, validateStoredStandingCount(repository.TournamentEventCounts{CommitmentJoined: 7, MatchDeleted: 3, + StandingChanged: 9})) + require.Error(t, validateStoredStandingCount(repository.TournamentEventCounts{CommitmentJoined: ^uint64(0), MatchDeleted: 1, + StandingChanged: 0})) +} + +func TestSealProjectionPreservesFullWidthAndZeroValues(t *testing.T) { + for _, test := range []struct { + name string + position *big.Int + valid bool + }{ + {"zero", big.NewInt(0), true}, + {"wide valid", new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 65), big.NewInt(7)), true}, + {"last", new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 68), big.NewInt(1)), true}, + {"end", new(big.Int).Lsh(big.NewInt(1), 68), false}, + {"same low64 invalid", new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 68), big.NewInt(7)), false}, + } { + t.Run(test.name, func(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + m := &model.Match{IDHash: common.HexToHash("0x33"), CommitmentOne: one, CommitmentTwo: two} + f := newEventBatchFixture(t, nil, []*model.Match{m}) + f.tournament.Height = 68 + viewCall := f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(one), [32]byte(two)).Return( + ObservedMatchSnapshot{Phase: model.MatchPhaseSealed, Sealed: &SealedMatch{ + DivergencePosition: new(big.Int).Set(test.position), DivergenceCycle: big.NewInt(0), FinalStateTwo: two}}, nil) + if test.valid { + viewCall.Once() + } else { + viewCall.Maybe() + } + + e := &itournament.ITournamentLeafMatchSealed{MatchIdHash: m.IDHash, One: one, Two: two, DivergencePosition: test.position, + FinalStateTwo: two, DeadlineOne: 71, DeadlineTwo: 83, EliminableAt: 83, Raw: types.Log{BlockNumber: 50, + TxHash: common.HexToHash("0x44"), Index: 2}} + batch, err := f.project(&TournamentEvents{LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{e}}) + if !test.valid { + require.ErrorContains(t, err, "divergence position") + require.Nil(t, batch) + return + } + require.NoError(t, err) + seal := batch.Matches[0].LeafSeal + require.NotNil(t, seal) + require.Zero(t, test.position.Cmp(seal.DivergencePosition.ToBig())) + require.Zero(t, seal.AgreeState) + require.Zero(t, seal.FinalStateOne) + require.Equal(t, two, seal.FinalStateTwo) + require.Equal(t, uint64(71), seal.DeadlineOne) + require.Equal(t, uint64(83), seal.DeadlineTwo) + }) + } +} + +func TestProjectionFoldsRepairAndTwoDeletionsInOneTransaction(t *testing.T) { + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + oldOne := &model.Match{IDHash: common.HexToHash("0xa1"), CommitmentOne: one, CommitmentTwo: two} + oldTwo := &model.Match{IDHash: common.HexToHash("0xa2"), CommitmentOne: two, CommitmentTwo: three} + f := newEventBatchFixture(t, nil, []*model.Match{oldOne, oldTwo}) + f.tournament.Snapshot.Standing = model.TournamentStandingMatchesActive + tx := common.HexToHash("0xbb") + created := &itournament.ITournamentMatchCreated{MatchIdHash: common.HexToHash("0xa3"), One: one, Two: three, + ResponderDeadline: 120, EliminableAt: 130, Raw: types.Log{BlockNumber: 90, TxHash: tx, Index: 4}} + e := &TournamentEvents{MatchCreated: []*itournament.ITournamentMatchCreated{created}, + MatchDeleted: []*itournament.ITournamentMatchDeleted{ + {MatchIdHash: oldOne.IDHash, One: one, Two: two, Reason: 1, WinnerCommitment: 1, Raw: types.Log{BlockNumber: 90, + TxHash: tx, Index: 5}}, + {MatchIdHash: oldTwo.IDHash, One: two, Two: three, Reason: 1, WinnerCommitment: 0, Raw: types.Log{BlockNumber: 90, + TxHash: tx, Index: 7}}, + }, StandingChanged: []*itournament.ITournamentStandingChanged{ + {MatchCount: big.NewInt(1), Raw: types.Log{BlockNumber: 90, TxHash: tx, Index: 8}}, + {MatchCount: big.NewInt(2), Raw: types.Log{BlockNumber: 90, TxHash: tx, Index: 6}}, + }} + for _, m := range []*model.Match{oldOne, oldTwo} { + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(m.CommitmentOne), + [32]byte(m.CommitmentTwo)).Return(ObservedMatchSnapshot{Phase: model.MatchPhaseUninitialized, + TimeoutOutcome: model.MatchTimeoutNone}, nil).Once() + } + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(one), + [32]byte(three)).Return(ObservedMatchSnapshot{Phase: model.MatchPhaseReadyToSeal, + ReadyToSeal: &ReadyToSealMatch{SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, nil).Once() + batch, err := f.project(e) + require.NoError(t, err) + require.Len(t, batch.Matches, 3) + require.Len(t, batch.StandingChanges, 2) + require.Equal(t, uint64(8), batch.StandingChanges[0].LogIndex) + for _, m := range batch.Matches { + if m.IDHash == created.MatchIdHash { + require.Equal(t, uint64(120), m.ResponderDeadline) + } + } + require.Nil(t, oldOne.DeletionTxHash) + require.Nil(t, oldTwo.DeletionTxHash) +} + +func TestEmptyStandingWindowDoesNotComparePastHistoryWithChangedClock(t *testing.T) { + row := &model.Tournament{Snapshot: model.TournamentSnapshot{Standing: model.TournamentStandingRootFailed, FinishedAtBlock: 100}} + batch := &repository.TournamentEventBatch{Tournament: row} + require.NoError(t, appendTournamentStandingEvents(batch, &model.Application{}, &model.Epoch{}, &TournamentEvents{}, + map[common.Hash]*model.Match{{1}: {}}, nil)) + require.Empty(t, batch.StandingChanges) +} + +func TestAdvanceProjectionKeepsIndependentHeightAndSchedule(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + m := &model.Match{IDHash: common.HexToHash("0xa0"), CommitmentOne: one, CommitmentTwo: two} + f := newEventBatchFixture(t, nil, []*model.Match{m}) + f.tournament.Height = 68 + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(one), + [32]byte(two)).Return(ObservedMatchSnapshot{Phase: model.MatchPhaseReadyToSeal, + ReadyToSeal: &ReadyToSealMatch{SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, nil).Once() + e := &itournament.ITournamentMatchAdvanced{MatchIdHash: m.IDHash, One: one, Two: two, CurrentHeight: 67, ResponderDeadline: 71, + EliminableAt: 83, SegmentStartPosition: big.NewInt(7)} + batch, err := f.project(&TournamentEvents{MatchAdvanced: []*itournament.ITournamentMatchAdvanced{e}}) + require.NoError(t, err) + require.Equal(t, uint64(67), batch.MatchAdvances[0].CurrentHeight) + require.Equal(t, uint64(71), batch.MatchAdvances[0].ResponderDeadline) + require.Equal(t, uint64(83), batch.MatchAdvances[0].EliminableAt) +} + +func TestInnerStandingUsesStoredParentPairAndRejectsForeignHistoricalParent(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + f := newEventBatchFixture(t, nil, nil) + f.tournament.Level = 1 + f.tournament.ParentTournamentAddress = new(common.HexToAddress("0x99")) + f.tournament.ParentMatchIDHash = new(common.HexToHash("0x9a")) + f.tournament.Snapshot = model.TournamentSnapshot{Standing: model.TournamentStandingInnerEliminableWinnerExpired, Candidate: &one, + FinishedAtBlock: 30} + f.repo.On("GetMatch", mock.Anything, f.app.Name, f.epoch.Index, f.tournament.ParentTournamentAddress.Hex(), + f.tournament.ParentMatchIDHash.Hex()).Return(&model.Match{CommitmentOne: one, CommitmentTwo: two}, nil).Once() + e := &itournament.ITournamentStandingChanged{MatchCount: big.NewInt(0), Dangling: one, ParentCommitment: two, ResultAt: 30, + WinnerExpiresAt: 50} + batch, err := f.project(&TournamentEvents{StandingChanged: []*itournament.ITournamentStandingChanged{e}}) + require.NoError(t, err) + require.Equal(t, &two, batch.StandingChanges[0].ParentCommitment) + e.ParentCommitment = common.HexToHash("0x33") + require.ErrorContains(t, appendTournamentStandingEvents(&repository.TournamentEventBatch{Tournament: f.tournament}, f.app, f.epoch, + &TournamentEvents{StandingChanged: []*itournament.ITournamentStandingChanged{e}}, nil, &model.Match{CommitmentOne: one, + CommitmentTwo: two}), "parent match pair") +} diff --git a/internal/prt/anvil_capacity_test.go b/internal/prt/anvil_capacity_test.go new file mode 100644 index 000000000..720242442 --- /dev/null +++ b/internal/prt/anvil_capacity_test.go @@ -0,0 +1,337 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package prt + +import ( + "context" + "fmt" + "io" + "log/slog" + "math/big" + "net/url" + "os" + "sync" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres" + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +const ( + prtCapacityApplications = 16 + prtCapacityPoll = 3 * time.Second + prtCapacityLimit = 120 * time.Second + prtCapacityFixtureLimit = 6 * time.Minute +) + +type prtPublication struct { + hash common.Hash + nonce uint64 + at time.Time +} + +type prtCapacityBackend struct { + txsender.ProtectedClient + mu sync.Mutex + publications map[common.Address]prtPublication +} + +func (b *prtCapacityBackend) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + hash, err := b.ProtectedClient.Publish(ctx, raw) + if err != nil { + return hash, err + } + tx := new(types.Transaction) + if err = tx.UnmarshalBinary(raw); err != nil { + return hash, err + } + contractABI, err := itournament.ITournamentMetaData.GetAbi() + if err != nil { + return hash, err + } + if tx.To() != nil && len(tx.Data()) >= 4 && string(tx.Data()[:4]) == string(contractABI.Methods["joinTournament"].ID) { + b.mu.Lock() + if _, recorded := b.publications[*tx.To()]; !recorded { + b.publications[*tx.To()] = prtPublication{hash: hash, nonce: tx.Nonce(), at: time.Now()} + } + b.mu.Unlock() + } + return hash, nil +} + +func (b *prtCapacityBackend) snapshot() map[common.Address]prtPublication { + b.mu.Lock() + defer b.mu.Unlock() + result := make(map[common.Address]prtPublication, len(b.publications)) + for root, publication := range b.publications { + result[root] = publication + } + return result +} + +// This fixture measures one healthy deployment profile. Every nonce goes +// through the production Observe and dispatch pass at the default cadence. +// It uses a fresh PostgreSQL database and random funded signer, and relies on +// the devnet's natural producer without snapshots, mining, or resetting state. +func TestAnvilPRTInitialSixteenApplicationCapacity(t *testing.T) { + if os.Getenv("CARTESI_PRT_CAPACITY_TEST") != "1" { + t.Skip("set CARTESI_PRT_CAPACITY_TEST=1 for the isolated PostgreSQL + Anvil PRT capacity fixture") + } + ctx, cancel := context.WithTimeout(t.Context(), prtCapacityFixtureLimit) + defer cancel() + repo := newPRTCapacityRepository(ctx, t) + client, signer, chainID := prtCapacityChain(ctx, t) + _, err := repo.RegisterObserver(ctx, "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: chainID.Uint64(), + }) + require.NoError(t, err) + apps, roots, lastDeployment := seedPRTCapacityApplications(ctx, t, repo, client, signer) + prtCapacityFinalized(ctx, t, client, lastDeployment) + backend := &prtCapacityBackend{ProtectedClient: txsender.ProtectedClient{Client: client}, + publications: make(map[common.Address]prtPublication)} + s := newPRTCapacityService(ctx, t, repo, client, signer, chainID, backend) + startHeader, err := client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + for _, root := range roots { + adapter, err := NewITournamentAdapter(root, client, ethutil.Filter{}) + require.NoError(t, err) + descriptor, err := adapter.Descriptor(&bind.CallOpts{Context: ctx}) + require.NoError(t, err) + require.EqualValues(t, model.Log2EpochComputationHashLeafCount, descriptor.Height) + require.Less(t, startHeader.Number.Uint64()+1, descriptor.StartInstant+descriptor.Allowance, + "all sixteen roots must still accept joins at the common measurement start") + t.Logf("initial root=%s age=%d allowance=%d", root, startHeader.Number.Uint64()-descriptor.StartInstant, descriptor.Allowance) + } + started := time.Now() + run, stop := context.WithCancel(ctx) + finished := make(chan error, 1) + go func() { finished <- s.Serve(run) }() + defer func() { + stop() + require.ErrorIs(t, <-finished, context.Canceled) + }() + require.Eventually(t, func() bool { return len(backend.snapshot()) == len(roots) }, prtCapacityLimit, 250*time.Millisecond, + "every initially ready application must receive its first join publication") + publications := backend.snapshot() + nonces := make(map[uint64]bool, len(roots)) + var lastDispatch time.Duration + var lastReceipt uint64 + for index, root := range roots { + publication, ok := publications[root] + require.True(t, ok) + latency := publication.at.Sub(started) + require.Less(t, latency, prtCapacityLimit) + lastDispatch = max(lastDispatch, latency) + require.False(t, nonces[publication.nonce], "every initial join follows consumption of the preceding nonce") + nonces[publication.nonce] = true + tx, _, err := client.TransactionByHash(ctx, publication.hash) + require.NoError(t, err) + receipt := prtCapacityMined(ctx, t, client, tx) + lastReceipt = max(lastReceipt, receipt.BlockNumber.Uint64()) + adapter, err := NewITournamentAdapter(root, client, ethutil.Filter{}) + require.NoError(t, err) + descriptor, err := adapter.Descriptor(&bind.CallOpts{Context: ctx}) + require.NoError(t, err) + require.Less(t, receipt.BlockNumber.Uint64(), descriptor.StartInstant+descriptor.Allowance, + "join execution must precede the strict on-chain deadline") + t.Logf("initial app=%s nonce=%d dispatch=%s receipt=%d gas_used=%d", apps[index].Name, publication.nonce, + latency.Round(time.Millisecond), receipt.BlockNumber.Uint64(), receipt.GasUsed) + } + endHeader, err := client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + t.Logf("measured PRT capacity: apps=%d poll=%s last_dispatch=%s natural_blocks=%d block_timestamp_span=%ds", + len(apps), prtCapacityPoll, lastDispatch.Round(time.Millisecond), + endHeader.Number.Uint64()-startHeader.Number.Uint64(), endHeader.Time-startHeader.Time) + prtCapacityFinalized(ctx, t, client, lastReceipt) + require.Eventually(t, func() bool { return prtCapacityJoinsObserved(ctx, repo, apps, roots, signer.From) }, + 30*time.Second, 250*time.Millisecond, "Observe must project every finalized join into PostgreSQL") + require.True(t, s.Ready()) +} + +func prtCapacityJoinsObserved( + ctx context.Context, repo repository.Repository, apps []*model.Application, roots []common.Address, signer common.Address, +) bool { + for index, app := range apps { + epoch, err := repo.GetEpoch(ctx, app.Name, 0) + if err != nil || epoch == nil || epoch.Commitment == nil { + return false + } + commitment, err := repo.GetCommitment(ctx, app.Name, 0, roots[index].Hex(), epoch.Commitment.Hex()) + if err != nil || commitment == nil || commitment.Snapshot.Claimer != signer { + return false + } + } + return true +} + +func prtCapacityChain(ctx context.Context, t *testing.T) (*ethclient.Client, *bind.TransactOpts, *big.Int) { + t.Helper() + endpoint := os.Getenv("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8545" + } + client, err := ethclient.DialContext(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(client.Close) + chainID, err := client.ChainID(ctx) + require.NoError(t, err) + key, err := crypto.GenerateKey() + require.NoError(t, err) + signer, err := bind.NewKeyedTransactorWithChainID(key, chainID) + require.NoError(t, err) + signer.Context = ctx + var funded any + require.NoError(t, client.Client().CallContext(ctx, &funded, "anvil_setBalance", signer.From, "0x56bc75e2d63100000")) + return client, signer, chainID +} + +func prtCapacityMined(ctx context.Context, t *testing.T, client *ethclient.Client, tx *types.Transaction) *types.Receipt { + t.Helper() + receipt, err := bind.WaitMined(ctx, client, tx) + require.NoError(t, err) + require.Equal(t, uint64(types.ReceiptStatusSuccessful), receipt.Status) + return receipt +} + +func prtCapacityFinalized(ctx context.Context, t *testing.T, client *ethclient.Client, atLeast uint64) { + t.Helper() + wait, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + backend := txsender.ProtectedClient{Client: client} + require.Eventually(t, func() bool { + header, err := backend.Header(wait, "finalized") + if err != nil || header.Number.Uint64() < atLeast { + return false + } + latest, err := backend.Header(wait, "latest") + require.NoError(t, err) + t.Logf("natural producer: latest=%d finalized=%d finality_lag=%d", latest.Number.Uint64(), header.Number.Uint64(), + latest.Number.Uint64()-header.Number.Uint64()) + return true + }, 90*time.Second, 250*time.Millisecond, "deployment/receipt must become naturally finalized") +} + +func seedPRTCapacityApplications( + ctx context.Context, t *testing.T, repo repository.Repository, client *ethclient.Client, signer *bind.TransactOpts, +) ([]*model.Application, []common.Address, uint64) { + t.Helper() + factoryAddress := os.Getenv("CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS") + if factoryAddress == "" { + factoryAddress = "0xF8f6dFA3A3e40C10926839FFd8a221C87C8b2ccD" + } + factory, err := idaveappfactory.NewIDaveAppFactory(common.HexToAddress(factoryAddress), client) + require.NoError(t, err) + apps := make([]*model.Application, 0, prtCapacityApplications) + roots := make([]common.Address, 0, prtCapacityApplications) + var lastDeployment uint64 + for index := range prtCapacityApplications { + salt := crypto.Keccak256Hash(signer.From.Bytes(), big.NewInt(int64(index)).Bytes()) + withdrawal := idaveappfactory.WithdrawalConfig{} + addresses, err := factory.CalculateDaveAppAddress(&bind.CallOpts{Context: ctx}, salt, big.NewInt(1_000_000), + signer.From, nil, withdrawal, salt) + require.NoError(t, err) + tx, err := factory.NewDaveApp(signer, salt, big.NewInt(1_000_000), signer.From, nil, withdrawal, salt) + require.NoError(t, err) + receipt := prtCapacityMined(ctx, t, client, tx) + lastDeployment = receipt.BlockNumber.Uint64() + consensus, err := NewDaveConsensusAdapter(addresses.DaveConsensusAddress, client) + require.NoError(t, err) + sealed, err := consensus.GetCurrentSealedEpoch(&bind.CallOpts{Context: ctx}) + require.NoError(t, err) + require.Zero(t, sealed.EpochNumber) + app := repotest.NewApplicationBuilder().WithName(fmt.Sprintf("prt-capacity-%02d", index)). + WithAddress(addresses.AppContractAddress).WithConsensus(model.Consensus_PRT).WithEpochLength(1). + WithClaimStagingPeriod(1_000_000).WithWithdrawalConfig(model.WithdrawalConfig{}).Build() + app.IConsensusAddress, app.TemplateHash = addresses.DaveConsensusAddress, salt + app.IInputBoxAddress = common.HexToAddress("0xEbE9f4Dfc04ae10bBeE663859c3dc5A23f94eA3C") + app.IInputBoxBlock, app.LastInputCheckBlock = lastDeployment, lastDeployment + app.ID, err = repo.CreateApplication(ctx, app, false) + require.NoError(t, err) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed). + WithBlocks(lastDeployment, lastDeployment). + WithInputBounds(sealed.InputIndexLowerBound, sealed.InputIndexUpperBound).Build() + epoch.TournamentAddress = &sealed.Tournament + require.NoError(t, repo.CreateEpochsAndInputs(ctx, app.Name, map[*model.Epoch][]*model.Input{epoch: nil}, lastDeployment)) + proof := repotest.KeccakStateProof(salt) + require.NoError(t, repo.UpdateEpochInputsProcessed(ctx, app.Name, 0, &proof)) + epoch, err = repo.GetEpoch(ctx, app.Name, 0) + require.NoError(t, err) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + tree := merkle.Proof{Node: *epoch.MachineHash, Pos: big.NewInt(1< sealed.InputIndexUpperBound { + return fmt.Errorf("sealed epoch %d has input lower bound %d above upper bound %d", + sealed.EpochNumber, sealed.InputIndexLowerBound, sealed.InputIndexUpperBound) + } + if sealed.Tournament == (common.Address{}) { + return fmt.Errorf("sealed epoch %d has zero tournament address", sealed.EpochNumber) + } + + if sealed.IsTournamentResultStaged { + if sealed.StagingBlockNumber == 0 || sealed.StagingBlockNumber > mostRecentBlock { + return fmt.Errorf("sealed epoch %d has invalid staging block %d at observed block %d", + sealed.EpochNumber, sealed.StagingBlockNumber, mostRecentBlock) + } + if sealed.StagedPostEpochMachineStateHash != accept.StagedPostEpochMachineStateHash { + return fmt.Errorf("%w: staged machine state differs between sealed and accept views", txsender.ErrInconclusive) + } + if sealed.StagedPostEpochOutputsMerkleRoot != accept.StagedPostEpochOutputsMerkleRoot { + return fmt.Errorf("%w: staged outputs root differs between sealed and accept views", txsender.ErrInconclusive) + } + if stage.WinnerPostEpochMachineStateHash != sealed.StagedPostEpochMachineStateHash { + return fmt.Errorf("%w: staged machine state differs from the tournament winner", txsender.ErrInconclusive) + } + } + return nil +} + +func pendingTournamentResult(epoch *model.Epoch) bool { + return epoch != nil && (epoch.Status == model.EpochStatus_ClaimComputed || epoch.Status == model.EpochStatus_ClaimStaged) +} + +// recordTournamentResult consumes a snapshot at a published tournament block +// that is no newer than the configured head. +func (s *Service) recordTournamentResult( + ctx context.Context, + app *model.Application, + epoch *model.Epoch, + snapshot daveConsensusSnapshot, +) error { + if epoch.InputIndexLowerBound != snapshot.sealed.InputIndexLowerBound || + epoch.InputIndexUpperBound != snapshot.sealed.InputIndexUpperBound { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has input bounds inconsistent with DaveConsensus: local=[%d,%d] on-chain=[%d,%d]", + epoch.Index, epoch.InputIndexLowerBound, epoch.InputIndexUpperBound, + snapshot.sealed.InputIndexLowerBound, snapshot.sealed.InputIndexUpperBound) + } + state, _, gateErr := s.deploymentVerdictState(ctx, app) + if gateErr != nil { + return gateErr + } + if !deploymentAllowsVerdict(state) { + return nil + } + if err := s.validateResultEpoch(ctx, app, epoch, snapshot.sealed.EpochNumber); err != nil { + return err + } + if err := matchConsensusSnapshotToEpoch(epoch, snapshot); err != nil { + return s.setApplicationDiverged(ctx, app, "%v", err) + } + if snapshot.stage.IsTournamentFailed { + s.logFailedRootTournament(app, epoch.Index, snapshot.sealed.Tournament) + return nil + } + if !snapshot.sealed.IsTournamentResultStaged { + return nil + } + if epoch.Status == model.EpochStatus_ClaimStaged { + if epoch.StagedAtBlock == nil { + return s.setApplicationCorrupted(ctx, app, "staged epoch %d has no staging block", epoch.Index) + } + if *epoch.StagedAtBlock != snapshot.sealed.StagingBlockNumber { + if !s.defaultBlock.Provisional() { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has staging block inconsistent with DaveConsensus", epoch.Index) + } + if err := s.repository.UpdateEpochStagingBlock(ctx, app.ID, epoch.Index, + *epoch.StagedAtBlock, snapshot.sealed.StagingBlockNumber); err != nil { + return fmt.Errorf("refreshing epoch %d staging block: %w", epoch.Index, err) + } + epoch.StagedAtBlock = new(snapshot.sealed.StagingBlockNumber) + } + return nil + } + if err := s.repository.UpdateEpochReconciledStaged(ctx, app.ID, epoch.Index, snapshot.sealed.StagingBlockNumber); err != nil { + return fmt.Errorf("recording staged tournament result for epoch %d: %w", epoch.Index, err) + } + epoch.Status = model.EpochStatus_ClaimStaged + epoch.StagedAtBlock = new(snapshot.sealed.StagingBlockNumber) + s.Logger.Info("Recorded staged tournament result", + "application", app.Name, "epoch_index", epoch.Index, "staging_block", snapshot.sealed.StagingBlockNumber) + return nil +} + +func (s *Service) validateResultEpoch(ctx context.Context, app *model.Application, epoch *model.Epoch, index uint64) error { + if epoch.TournamentAddress == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + return s.setApplicationCorrupted(ctx, app, + "epoch %d has missing required fields for tournament result processing", epoch.Index) + } + if epoch.Status == model.EpochStatus_ClaimComputed { + if _, err := epoch.StateProof(); err != nil { + return s.setApplicationCorrupted(ctx, app, + "epoch %d persisted machine state proof is incomplete: %v", epoch.Index, err) + } + } + if epoch.Index != index { + return s.setApplicationCorrupted(ctx, app, + "loaded epoch %d for current sealed epoch %d", epoch.Index, index) + } + return nil +} + +func matchConsensusSnapshotToEpoch(epoch *model.Epoch, snapshot daveConsensusSnapshot) error { + if epoch.InputIndexLowerBound != snapshot.sealed.InputIndexLowerBound || + epoch.InputIndexUpperBound != snapshot.sealed.InputIndexUpperBound { + return fmt.Errorf( + "epoch %d has input bounds inconsistent with DaveConsensus: local=[%d,%d] on-chain=[%d,%d]", + epoch.Index, + epoch.InputIndexLowerBound, + epoch.InputIndexUpperBound, + snapshot.sealed.InputIndexLowerBound, + snapshot.sealed.InputIndexUpperBound) + } + if *epoch.TournamentAddress != snapshot.sealed.Tournament { + return fmt.Errorf( + "epoch %d has inconsistent root tournament between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TournamentAddress, snapshot.sealed.Tournament) + } + if snapshot.stage.IsTournamentFailed { + return nil + } + if !snapshot.stage.IsFinished { + return nil + } + if *epoch.MachineHash != snapshot.stage.WinnerPostEpochMachineStateHash { + return fmt.Errorf( + "epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash, snapshot.stage.WinnerPostEpochMachineStateHash) + } + if snapshot.sealed.IsTournamentResultStaged { + if *epoch.MachineHash != snapshot.sealed.StagedPostEpochMachineStateHash { + return fmt.Errorf( + "epoch %d has inconsistent staged machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash, snapshot.sealed.StagedPostEpochMachineStateHash) + } + if *epoch.TxBufferDataBlock != snapshot.sealed.StagedPostEpochOutputsMerkleRoot { + return fmt.Errorf( + "epoch %d has inconsistent staged outputs root between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TxBufferDataBlock, snapshot.sealed.StagedPostEpochOutputsMerkleRoot) + } + } + return nil +} diff --git a/internal/prt/consensus_consistency_test.go b/internal/prt/consensus_consistency_test.go new file mode 100644 index 000000000..75f38c249 --- /dev/null +++ b/internal/prt/consensus_consistency_test.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "log/slog" + "strconv" + "strings" + "testing" +) + +func TestInconsistentConsensusViewsRetryWithoutWritesAndReportPersistentFailure(t *testing.T) { + for _, test := range inconsistentConsensusViews() { + t.Run(test.name, func(t *testing.T) { + f, client := tickFixture(t) + var output bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&output, nil)) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{f.epoch}, uint64(1), nil).Times(6) + view := resultTestSnapshot(f.epoch, true) + test.mutate(&view) + f.consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(view.sealed, nil).Times(6) + f.consensus.On("CanStageTournamentResult", mock.Anything).Return(view.stage, nil).Times(6) + f.consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(view.accept, nil).Times(6) + ready := f.d.Ready() + for _, head := range []uint64{101, 101, 102, 103, 104, 105} { + tickHead(client, head) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(head), f.epoch) + reschedule, err := f.d.Tick(t.Context()) + require.NoError(t, err, "inconsistent views retry without an ERROR from the tick loop") + require.False(t, reschedule) + require.Equal(t, head-100, uint64(f.d.observationFailures[f.app.ID].failedHeads)) + require.Equal(t, ready, f.d.Ready(), "application read failures do not change hard prerequisites") + if head < 105 { + require.NotContains(t, output.String(), "level=ERROR") + } + } + require.Equal(t, 1, strings.Count(output.String(), "PRT tournament observation is stalled")) + require.Contains(t, output.String(), test.want) + require.Contains(t, output.String(), "application="+f.app.Name) + require.Contains(t, output.String(), "address="+strings.ToLower(f.app.IApplicationAddress.Hex())) + require.Empty(t, f.repo.windows, "no rejected snapshot can publish a tournament interval") + require.Empty(t, f.backend.published) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertExpectations(t) + f.consensus.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestFinalizedStagingClockContradictionPreservesSavedStage(t *testing.T) { + for _, block := range []uint64{9, 11} { + t.Run(strconv.FormatUint(block, 10), func(t *testing.T) { + f := newDeliveryFixture(t) + f.epoch.Status, f.epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(10)) + view := resultTestSnapshot(f.epoch, true) + view.sealed.StagingBlockNumber = block + f.repo.On("UpdateEpochStagingBlock", mock.Anything, f.app.ID, f.epoch.Index, uint64(10), block). + Return(nil).Maybe() + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "staging block inconsistent with DaveConsensus") + })).Return(nil).Once() + + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + err := f.d.recordTournamentResult(t.Context(), f.app, f.epoch, view) + require.ErrorContains(t, err, "staging block inconsistent with DaveConsensus") + require.Equal(t, model.ApplicationStatus_Corrupted, f.app.Status) + require.Equal(t, uint64(10), *f.epoch.StagedAtBlock) + f.repo.AssertNotCalled(t, "UpdateEpochStagingBlock", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/consensus_test.go b/internal/prt/consensus_test.go new file mode 100644 index 000000000..2ef9949d6 --- /dev/null +++ b/internal/prt/consensus_test.go @@ -0,0 +1,573 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestCheckedUint64(t *testing.T) { + require.Equal(t, uint64(42), mustCheckedUint64(t, big.NewInt(42))) + for _, value := range []*big.Int{ + nil, + big.NewInt(-1), + new(big.Int).Lsh(common.Big1, 64), + } { + _, err := checkedUint64(value, "test field") + require.ErrorContains(t, err, "test field is not a uint64") + } +} + +func mustCheckedUint64(t *testing.T, value *big.Int) uint64 { + t.Helper() + result, err := checkedUint64(value, "test field") + require.NoError(t, err) + return result +} + +func TestDaveMachineValidityProofUsesContractOrderAndOwnsSiblings(t *testing.T) { + proof := model.StateProof{ + IflagsYDataBlock: common.HexToHash("0x11"), IflagsYProof: [][32]byte{{0x12}, {0x13}}, + HtifTohostDataBlock: common.HexToHash("0x21"), HtifTohostProof: [][32]byte{{0x22}, {0x23}}, + TxBufferDataBlock: common.HexToHash("0x31"), TxBufferProof: [][32]byte{{0x32}, {0x33}}, + } + wire := daveMachineValidityProof(proof) + require.Equal(t, [32]byte(proof.IflagsYDataBlock), wire.IflagsYProof.DataBlock) + require.Equal(t, proof.IflagsYProof, wire.IflagsYProof.Siblings) + require.Equal(t, [32]byte(proof.HtifTohostDataBlock), wire.HtifTohostProof.DataBlock) + require.Equal(t, proof.HtifTohostProof, wire.HtifTohostProof.Siblings) + require.Equal(t, [32]byte(proof.TxBufferDataBlock), wire.TxBufferProof.DataBlock) + require.Equal(t, proof.TxBufferProof, wire.TxBufferProof.Siblings) + wire.IflagsYProof.Siblings[0][0] ^= 0xff + wire.HtifTohostProof.Siblings[0][0] ^= 0xff + wire.TxBufferProof.Siblings[0][0] ^= 0xff + require.Equal(t, byte(0x12), proof.IflagsYProof[0][0]) + require.Equal(t, byte(0x22), proof.HtifTohostProof[0][0]) + require.Equal(t, byte(0x32), proof.TxBufferProof[0][0]) +} + +func TestReadDaveConsensusSnapshotUsesOnePinnedCallOptions(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + snapshot := resultTestSnapshot(epoch, true) + consensus := newCompatibleDaveConsensusAdapterMock() + var first *bind.CallOpts + checkOpts := func(args mock.Arguments) { + opts := args.Get(0).(*bind.CallOpts) + require.Equal(t, int64(20), opts.BlockNumber.Int64()) + if first == nil { + first = opts + } else { + require.Same(t, first, opts) + } + } + consensus.On("GetCurrentSealedEpoch", mock.Anything).Run(checkOpts).Return(snapshot.sealed, nil).Once() + consensus.On("CanStageTournamentResult", mock.Anything).Run(checkOpts).Return(snapshot.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Run(checkOpts).Return(snapshot.accept, nil).Once() + + got, err := readDaveConsensusSnapshot(context.Background(), consensus, 20) + require.NoError(t, err) + require.Equal(t, snapshot, got) + consensus.AssertExpectations(t) +} + +type inconsistentConsensusView struct { + name string + mutate func(*daveConsensusSnapshot) + want string +} + +func inconsistentConsensusViews() []inconsistentConsensusView { + return []inconsistentConsensusView{ + {name: "epoch number", mutate: func(s *daveConsensusSnapshot) { s.accept.EpochNumber++ }, want: "epoch numbers"}, + {name: "staged flag", mutate: func(s *daveConsensusSnapshot) { + s.accept.IsTournamentResultStaged = false + }, want: "staged flags"}, + {name: "staged machine", mutate: func(s *daveConsensusSnapshot) { + s.accept.StagedPostEpochMachineStateHash[0] ^= 0xff + }, want: "staged machine state differs"}, + {name: "staged outputs", mutate: func(s *daveConsensusSnapshot) { + s.accept.StagedPostEpochOutputsMerkleRoot[0] ^= 0xff + }, want: "staged outputs root differs"}, + {name: "winner machine", mutate: func(s *daveConsensusSnapshot) { + s.stage.WinnerPostEpochMachineStateHash[0] ^= 0xff + }, want: "differs from the tournament winner"}, + } +} + +func TestValidateDaveConsensusSnapshotRejectsInconsistentViews(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + for _, test := range inconsistentConsensusViews() { + t.Run(test.name, func(t *testing.T) { + snapshot := resultTestSnapshot(epoch, true) + test.mutate(&snapshot) + err := validateDaveConsensusSnapshot(snapshot, 20) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.ErrorContains(t, err, test.want) + }) + } + t.Run("invalid staging block", func(t *testing.T) { + snapshot := resultTestSnapshot(epoch, true) + snapshot.sealed.StagingBlockNumber = 21 + err := validateDaveConsensusSnapshot(snapshot, 20) + require.ErrorContains(t, err, "staging block") + require.NotErrorIs(t, err, txsender.ErrInconclusive, "structurally invalid data is distinct from disagreeing views") + }) +} + +// These cases exercise the separated observer and planner. Latest-view +// failures never publish a durable application diagnosis. +func TestTournamentResultPlansPreserveConsensusReadiness(t *testing.T) { + for _, test := range []struct { + name string + kind actionKind + staged, agree, period, finished, failed bool + want bool + }{ + {name: "stage complete result", kind: stageTournament, finished: true, want: true}, + {name: "unfinished result cannot stage", kind: stageTournament}, + {name: "failed root remains observable", kind: stageTournament, finished: true, failed: true}, + {name: "already staged cannot stage again", kind: stageTournament, staged: true, finished: true}, + {name: "sentries agree", kind: acceptTournament, staged: true, finished: true, agree: true, want: true}, + {name: "staging period over", kind: acceptTournament, staged: true, finished: true, period: true, want: true}, + {name: "staging period still running", kind: acceptTournament, staged: true, finished: true}, + {name: "accept requires staged result", kind: acceptTournament, finished: true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, test.staged) + view.stage.IsFinished, view.stage.IsTournamentFailed = test.finished, test.failed + view.accept.DoAllSentriesAgreeWithStagedTournamentResult, view.accept.IsClaimStagingPeriodOver = test.agree, test.period + f.snapshot(view) + call, err := f.d.planAction(t.Context(), f.app, test.kind, f.block, observerFacts(100)) + require.NoError(t, err) + require.Equal(t, test.want, call != nil) + if test.want { + require.Equal(t, test.kind, call.Key.Kind) + require.Equal(t, f.epoch.Index, call.Key.Epoch) + require.Equal(t, f.app.IConsensusAddress, call.To) + abi, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + method, err := abi.MethodById(call.Data[:4]) + require.NoError(t, err) + if test.kind == stageTournament { + require.Equal(t, "stageTournamentResult", method.Name) + } else { + require.Equal(t, "acceptStagedTournamentResult", method.Name) + } + values, err := method.Inputs.Unpack(call.Data[4:]) + require.NoError(t, err) + require.Equal(t, f.epoch.Index, values[0].(*big.Int).Uint64()) + } + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestTournamentResultLatestMismatchIsPure(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*daveConsensusSnapshot) + }{ + {"lower input bound", func(v *daveConsensusSnapshot) { v.sealed.InputIndexLowerBound++ }}, + {"upper input bound", func(v *daveConsensusSnapshot) { v.sealed.InputIndexUpperBound++ }}, + {"staged winner state", func(v *daveConsensusSnapshot) { v.stage.WinnerPostEpochMachineStateHash[0] ^= 1 }}, + {"staged output root", func(v *daveConsensusSnapshot) { + v.sealed.StagedPostEpochOutputsMerkleRoot[0] ^= 1 + v.accept.StagedPostEpochOutputsMerkleRoot = v.sealed.StagedPostEpochOutputsMerkleRoot + }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, true) + test.mutate(&view) + f.snapshot(view) + call, err := f.d.planAction(t.Context(), f.app, acceptTournament, f.block, observerFacts(100)) + require.Error(t, err) + require.Nil(t, call) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestTournamentResultObservationRecordsOnlyPublishedStage(t *testing.T) { + for _, test := range []struct { + name string + staged bool + stored uint64 + }{ + {"matching published stage", true, 0}, {"reader remains passive", false, 0}, {"stored stage is newer", true, 101}, + } { + t.Run(test.name, func(t *testing.T) { + f := newDeliveryFixture(t) + f.d.submissionEnabled = false + if test.stored != 0 { + f.epoch.Status, f.epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(test.stored) + } + view := resultTestSnapshot(f.epoch, test.staged) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(view.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(view.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(view.accept, nil).Once() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + if test.staged && test.stored == 0 { + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, uint64(10)).Return(nil).Once() + } + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), f.epoch) + require.NoError(t, f.d.observeApplication(t.Context(), f.app, observerFacts(100))) + switch { + case test.staged && test.stored == 0: + require.Equal(t, model.EpochStatus_ClaimStaged, f.epoch.Status) + require.Equal(t, new(uint64(10)), f.epoch.StagedAtBlock) + case test.stored != 0: + require.Equal(t, new(test.stored), f.epoch.StagedAtBlock) + default: + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + } + require.Empty(t, f.backend.published) + f.repo.AssertExpectations(t) + f.consensus.AssertExpectations(t) + }) + } +} + +func TestCheckEpochsAcceptsComputedOrStagedEpochFromExactEpochSealedEvent(t *testing.T) { + for _, status := range []model.EpochStatus{ + model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimStaged, + } { + t.Run(status.String(), func(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(status) + epoch.LastBlock = 10 + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + header := &types.Header{Number: big.NewInt(12)} + wrongEmitterLog := &types.Log{ + Address: common.HexToAddress("0xbad"), TxHash: acceptTx, BlockNumber: 12, Index: 1, + } + wrongHashLog := &types.Log{ + Address: app.IConsensusAddress, TxHash: common.HexToHash("0xbad"), BlockNumber: 12, Index: 2, + } + staleLog := &types.Log{Address: app.IConsensusAddress, TxHash: acceptTx, BlockNumber: 12, BlockHash: header.Hash(), Index: 3} + exactLog := &types.Log{Address: app.IConsensusAddress, TxHash: acceptTx, BlockNumber: 12, BlockHash: header.Hash(), Index: 4} + receipt := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: acceptTx, + BlockNumber: big.NewInt(12), + BlockHash: header.Hash(), + Logs: []*types.Log{wrongEmitterLog, wrongHashLog, staleLog, exactLog}, + } + staleEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(99), + Raw: *staleLog, + } + exactEvent := &idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: new(big.Int).SetUint64(epoch.Index + 1), + InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, + Raw: *exactLog, + } + + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, + mock.MatchedBy(func(filter repository.EpochFilter) bool { + return filter.HasTournament != nil && *filter.HasTournament && len(filter.Status) == 0 + }), repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ + ApplicationID: app.ID, + EpochIndex: epoch.Index, + Address: *epoch.TournamentAddress, + MaxLevel: 3, + Level: 0, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9, AsOfBlock: 20}, + }, nil).Once() + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, + mock.MatchedBy(func(hash *common.Hash) bool { return hash != nil && *hash == acceptTx })). + Return(nil).Once() + + consensus := newCompatibleDaveConsensusAdapterMock() + consensus.On("ParseEpochSealed", *staleLog).Return(staleEvent, nil).Once() + consensus.On("ParseEpochSealed", *exactLog).Return(exactEvent, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(receipt, nil).Once() + client.On("HeaderByNumber", mock.Anything, receipt.BlockNumber).Return(header, nil).Once() + + service, _ := newPRTServiceMock() + service.repository = repo + admitTestDeployment(service, repo, app) + service.adapterFactory = factory + service.client = client + service.submissionEnabled = true + deferActions, err := observeEpochsForTest(context.Background(), service, app, 20) + require.NoError(t, err) + require.False(t, deferActions) + require.Empty(t, service.delivery.rootBondRecoveryScans, "acceptance is not a recovery discovery source") + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestCheckEpochsValidatesAcceptanceReceiptAgainstPinnedHead(t *testing.T) { + for _, test := range []struct { + name string + receipt *types.Receipt + wantErr string + }{ + { + name: "wrong transaction hash", + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0xbad"), + BlockNumber: big.NewInt(12), + }, + wantErr: "differs from observed hash", + }, + { + name: "receipt after pinned head", + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0x700"), + BlockNumber: big.NewInt(21), + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ApplicationID: app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9, AsOfBlock: 20}}, nil).Once() + consensus := newCompatibleDaveConsensusAdapterMock() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(test.receipt, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + admitTestDeployment(service, repo, app) + service.adapterFactory = factory + service.client = client + + deferActions, err := observeEpochsForTest(context.Background(), service, app, 20) + if test.wantErr == "" { + require.NoError(t, err) + require.True(t, deferActions) + } else { + require.ErrorContains(t, err, test.wantErr) + } + repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestValidateApplicationDefersActionsForAcceptanceAfterPinnedHead(t *testing.T) { + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 20 + epoch := resultTestEpoch(model.EpochStatus_ClaimStaged) + acceptTx := common.HexToHash("0x700") + epoch.ClaimTransactionHash = &acceptTx + repo := &prtRepositoryMock{} + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ApplicationID: app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: 9, AsOfBlock: 20}}, nil).Once() + consensus := newCompatibleDaveConsensusAdapterMock() + view := resultTestSnapshot(epoch, true) + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(view.sealed, nil).Once() + consensus.On("CanStageTournamentResult", mock.Anything).Return(view.stage, nil).Once() + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(view.accept, nil).Once() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Once() + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, acceptTx).Return(&types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: acceptTx, + BlockNumber: big.NewInt(21), + }, nil).Once() + service, _ := newPRTServiceMock() + service.repository = repo + admitTestDeployment(service, repo, app) + service.adapterFactory = factory + service.client = client + service.defaultBlock = model.DefaultBlock_Finalized + + expectProtectionReads(t, factory, consensus, observerFacts(20), epoch) + require.NoError(t, service.delivery.observeApplication(t.Context(), app, observerFacts(20))) + consensus.AssertNumberOfCalls(t, "GetCurrentSealedEpoch", 2) + consensus.AssertNumberOfCalls(t, "CanStageTournamentResult", 1) + consensus.AssertNumberOfCalls(t, "CanAcceptStagedTournamentResult", 1) + repo.AssertExpectations(t) + consensus.AssertExpectations(t) + factory.AssertExpectations(t) + client.AssertExpectations(t) +} + +func resultCallOptsAtBlock(block uint64) func(*bind.CallOpts) bool { + return func(opts *bind.CallOpts) bool { + if opts == nil { + return false + } + if opts.BlockHash != (common.Hash{}) { + pinned, ok := opts.Context.Value(liveBlockContextKey{}).(liveBlock) + return ok && pinned.number == block && pinned.hash == opts.BlockHash && opts.BlockNumber == nil + } + return opts.BlockNumber != nil && opts.BlockNumber.IsUint64() && opts.BlockNumber.Uint64() == block + } +} + +func resultTestEpoch(status model.EpochStatus) *model.Epoch { + tournament := common.HexToAddress("0x200") + commitment := common.HexToHash("0x300") + machineHash := common.HexToHash("0x400") + outputsRoot := common.HexToHash("0x500") + epoch := &model.Epoch{ + ApplicationID: 7, + Index: 3, + InputIndexLowerBound: 4, + InputIndexUpperBound: 9, + Status: status, + TournamentAddress: &tournament, + Commitment: &commitment, + MachineHash: &machineHash, + TxBufferDataBlock: &outputsRoot, + IflagsYDataBlock: new(common.Hash), + HtifTohostDataBlock: new(common.Hash), + TxBufferProof: make([]common.Hash, model.StateProofSiblingCount), + IflagsYProof: make([]common.Hash, model.StateProofSiblingCount), + HtifTohostProof: make([]common.Hash, model.StateProofSiblingCount), + } + if status == model.EpochStatus_ClaimStaged { + epoch.StagedAtBlock = new(uint64) + *epoch.StagedAtBlock = 10 + } + return epoch +} + +func resultTestSnapshot(epoch *model.Epoch, staged bool) daveConsensusSnapshot { + snapshot := daveConsensusSnapshot{ + sealed: CurrentSealedEpoch{ + EpochNumber: epoch.Index, + InputIndexLowerBound: epoch.InputIndexLowerBound, + InputIndexUpperBound: epoch.InputIndexUpperBound, + Tournament: *epoch.TournamentAddress, + IsTournamentResultStaged: staged, + }, + stage: CanStageTournamentResult{ + IsFinished: true, + IsTournamentResultStaged: staged, + EpochNumber: epoch.Index, + WinnerCommitment: *epoch.Commitment, + WinnerPostEpochMachineStateHash: *epoch.MachineHash, + }, + accept: CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: staged, + EpochNumber: epoch.Index, + }, + } + if staged { + snapshot.sealed.StagingBlockNumber = 10 + snapshot.sealed.StagedPostEpochMachineStateHash = *epoch.MachineHash + snapshot.sealed.StagedPostEpochOutputsMerkleRoot = *epoch.TxBufferDataBlock + snapshot.accept.StagedPostEpochMachineStateHash = *epoch.MachineHash + snapshot.accept.StagedPostEpochOutputsMerkleRoot = *epoch.TxBufferDataBlock + } + return snapshot +} + +func applyPRTStateProof(epoch *model.Epoch, proof model.StateProof) { + epoch.MachineHash = new(common.Hash) + *epoch.MachineHash = proof.MachineHash + epoch.TxBufferDataBlock = new(common.Hash) + *epoch.TxBufferDataBlock = proof.TxBufferDataBlock + epoch.TxBufferProof = stateProofHashes(proof.TxBufferProof) + epoch.IflagsYDataBlock = new(common.Hash) + *epoch.IflagsYDataBlock = proof.IflagsYDataBlock + epoch.IflagsYProof = stateProofHashes(proof.IflagsYProof) + epoch.HtifTohostDataBlock = new(common.Hash) + *epoch.HtifTohostDataBlock = proof.HtifTohostDataBlock + epoch.HtifTohostProof = stateProofHashes(proof.HtifTohostProof) +} + +func stateProofHashes(proof [][32]byte) []common.Hash { + result := make([]common.Hash, len(proof)) + for i := range proof { + result[i] = proof[i] + } + return result +} + +func TestTournamentResultStageProofPreservesContractValidation(t *testing.T) { + for _, incomplete := range []bool{true, false} { + t.Run(fmt.Sprint(incomplete), func(t *testing.T) { + f := newDeliveryFixture(t) + if incomplete { + f.epoch.TxBufferProof = nil + } else { + f.epoch.TxBufferProof[0][0] ^= 1 + } + f.snapshot(resultTestSnapshot(f.epoch, false)) + call, err := f.d.planAction(t.Context(), f.app, stageTournament, f.block, observerFacts(100)) + if incomplete { + require.Error(t, err) + require.Nil(t, call) + } else { + require.NoError(t, err) + require.NotNil(t, call) + proof, err := f.epoch.StateProof() + require.NoError(t, err) + abi, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + expected, err := abi.Pack("stageTournamentResult", new(big.Int).SetUint64(f.epoch.Index), daveMachineValidityProof(proof)) + require.NoError(t, err) + require.Equal(t, expected, call.Data) + failure := daveConsensusRevertError("InvalidMachineMerkleProof") + f.backend.preflightError = failure + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, *call}) + require.ErrorIs(t, err, failure) + require.False(t, report.Attempted) + require.Empty(t, f.backend.published) + } + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} diff --git a/internal/prt/creation_fixture_test.go b/internal/prt/creation_fixture_test.go new file mode 100644 index 000000000..02bbc1a37 --- /dev/null +++ b/internal/prt/creation_fixture_test.go @@ -0,0 +1,21 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func creationFixture(t *testing.T, address common.Address, descriptor TournamentDescriptor) *TournamentCreation { + t.Helper() + bond, err := model.Uint256FromBig(big.NewInt(1)) + require.NoError(t, err) + return &TournamentCreation{Address: address, Descriptor: descriptor, BondValue: bond, + Event: model.TournamentCreationEvent{BlockNumber: descriptor.StartInstant, TxHash: common.HexToHash("0xabc"), LogIndex: 2}} +} diff --git a/internal/prt/creation_retry_test.go b/internal/prt/creation_retry_test.go new file mode 100644 index 000000000..2460d4736 --- /dev/null +++ b/internal/prt/creation_retry_test.go @@ -0,0 +1,163 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewRootRereadsDescriptorUntilCompleteIntervalCommits(t *testing.T) { + for _, ambiguous := range []bool{false, true} { + t.Run(fmt.Sprint(ambiguous), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0xa0") + epoch.Status = model.EpochStatus_Closed + root := *epoch.TournamentAddress + cause := errors.New("publication acknowledgment unavailable") + turns := 2 + if ambiguous { + turns = 1 + } + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Times(turns) + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), + root.Hex()).Return((*model.Tournament)(nil), nil).Times(turns) + for turn := 0; turn < turns; turn++ { + d := TournamentDescriptor{BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, Height: 68, + StartInstant: 10, Allowance: 20} + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", root).Return(adapter, nil).Once() + adapter.On("Descriptor", mock.Anything).Return(d, nil).Once() + adapter.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: model.TournamentStandingRootFailed, + FinishedAt: 30}, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, model.TournamentStandingRootFailed) + adapter.On("RetrieveAllEvents", mock.Anything).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", mock.Anything).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, root) + var writeErr error + if turn == 0 { + writeErr = cause + } + call := f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(writeErr).Once() + if ambiguous { + call.Run(func(args mock.Arguments) { + row := args.Get(2).([]*repository.TournamentEventBatch)[0].Tournament + f.repo.storedTournaments = []*model.Tournament{row} + }) + } + t.Cleanup(func() { adapter.AssertExpectations(t) }) + } + err := f.s.observeTournamentState(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, observerFacts(100)) + require.ErrorIs(t, err, cause) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + require.NoError(t, f.s.observeTournamentState(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, observerFacts(100))) + if !ambiguous { + require.Nil(t, f.repo.committedTournaments[root].CreationEvent) + } else { + require.Nil(t, f.repo.storedTournaments[0].CreationEvent) + } + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + }) + } +} + +func TestNewRootRejectsDescriptorStartDifferentFromEpochSeal(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0xa0") + address := *epoch.TournamentAddress + d := TournamentDescriptor{BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, Height: 68, StartInstant: 11} + adapter := &tournamentAdapterMock{} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return((*model.Tournament)(nil), nil).Once() + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + adapter.On("Descriptor", mock.Anything).Return(d, nil).Once() + adapter.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: model.TournamentStandingAwaitingClosure}, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, model.TournamentStandingAwaitingClosure) + adapter.On("RetrieveAllEvents", mock.Anything).Return(&TournamentEvents{}, nil).Maybe() + adapter.On("StructuralEventCounts", mock.Anything).Return(zeroStructuralEventCounts(), nil).Maybe() + filterAddress := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, repository.CommitmentFilter{EpochIndex: &epoch.Index, + TournamentAddress: &filterAddress}, repository.Pagination{}, false).Return([]*model.Commitment{}, uint64(0), nil).Maybe() + f.repo.On("ListMatches", mock.Anything, f.app.Name, repository.MatchFilter{EpochIndex: &epoch.Index, + TournamentAddress: &filterAddress}, repository.Pagination{}, false).Return([]*model.Match{}, uint64(0), nil).Maybe() + batches, err := f.s.gatherTournamentData(t.Context(), f.app, epoch, RootLevel, nil, nil, address, 1, 100, creationFixture(t, + address, d), nil) + require.ErrorContains(t, err, "epoch seal") + require.Nil(t, batches) + f.repo.AssertNotCalled(t, "StoreTournamentInterval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + adapter.AssertExpectations(t) +} + +func TestRootObservationNeedsNoFactoryLogAcrossVerdicts(t *testing.T) { + for _, state := range []deploymentState{deploymentNone, deploymentPending, deploymentTable, deploymentGeneration} { + t.Run(string(state), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0xa0") + epoch.Status = model.EpochStatus_Closed + f.epochs(epoch) + key := deploymentIdentity(f.app) + capture := f.s.deployments.capture(key) + snapshot := capture.snapshot + snapshot.Assessment.State = state + snapshot.Alpha6Shape = true + snapshot.ShapeFactory = common.HexToAddress("0x160") + f.s.deployments.publish(key, snapshot) + f.consensus.deploymentRead = func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + e := compatibleDeploymentEvidence(head) + e.Candidate = deploymentNone + e.FactoryKnown = false + e.Factory = common.Address{} + return e, nil + } + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + address := *epoch.TournamentAddress + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), + address.Hex()).Return((*model.Tournament)(nil), nil).Once() + d := TournamentDescriptor{BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, Height: 68, StartInstant: 10, + Allowance: 300} + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + adapter.On("Descriptor", mock.Anything).Return(d, nil).Once() + adapter.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: model.TournamentStandingAwaitingClosure}, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, model.TournamentStandingAwaitingClosure) + adapter.On("RetrieveAllEvents", mock.Anything).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", mock.Anything).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + require.NoError(t, f.s.observeApplication(t.Context(), f.app, observerFacts(100))) + require.Nil(t, f.repo.committedTournaments[address].CreationEvent) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + adapter.AssertExpectations(t) + }) + } +} + +func TestRootCreationReloadsDurableTruthAfterAnOlderList(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0xa0") + epoch.Status = model.EpochStatus_Closed + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + f.repo.On("ListTournaments", mock.Anything, f.app.Name, repository.TournamentFilter{}, repository.Pagination{}, false). + Return([]*model.Tournament{}, uint64(0), nil).Once() + // The list preceded a late backend COMMIT. The subsequent row read sees it. + row := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 1, Kind: model.TournamentKindLeaf, + Snapshot: model.TournamentSnapshot{AsOfBlock: 100}} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), row.Address.Hex()).Return(row, nil).Once() + require.NoError(t, f.s.observeTournamentState(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, observerFacts(100))) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", row.Address) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) +} diff --git a/internal/prt/current_projection_test.go b/internal/prt/current_projection_test.go new file mode 100644 index 000000000..7067e5547 --- /dev/null +++ b/internal/prt/current_projection_test.go @@ -0,0 +1,146 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestMatchProjectionPreservesCurrentPhaseAndFullWidth(t *testing.T) { + position := new(big.Int).Lsh(big.NewInt(1), 200) + cycle := new(big.Int).Add(position, big.NewInt(3)) + left, right, parent := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + for _, phase := range []MatchPhase{MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed, MatchPhaseUninitialized} { + t.Run(string(phase), func(t *testing.T) { + view := ObservedMatchSnapshot{Phase: phase, TimeoutOutcome: MatchTimeoutTwoWins, DeferredCharge: 7} + switch phase { + case MatchPhaseBisecting: + view.Bisecting = &BisectingMatch{RevealingParent: parent, WaitingLeft: left, WaitingRight: right, + SegmentStartPosition: new(big.Int).Set(position), SegmentStartCycle: new(big.Int).Set(cycle), + CurrentHeight: 0, Responder: CommitmentSideTwo} + case MatchPhaseReadyToSeal: + view.ReadyToSeal = &ReadyToSealMatch{RevealingParent: parent, WaitingLeft: left, WaitingRight: right, + SegmentStartPosition: new(big.Int).Set(position), SegmentStartCycle: new(big.Int).Set(cycle), + Responder: CommitmentSideOne} + case MatchPhaseSealed: + view.Sealed = &SealedMatch{AgreeState: parent, FinalStateOne: left, FinalStateTwo: right, + DivergencePosition: new(big.Int).Set(position), DivergenceCycle: new(big.Int).Set(cycle)} + case MatchPhaseUninitialized: + view.TimeoutOutcome, view.DeferredCharge = MatchTimeoutNone, 0 + } + projection, err := projectMatchSnapshot(view, 100, phase == MatchPhaseUninitialized) + require.NoError(t, err) + require.Equal(t, uint64(100), projection.AsOfBlock) + require.Equal(t, phase, projection.Phase) + require.Equal(t, view.TimeoutOutcome, projection.TimeoutOutcome) + require.Equal(t, view.DeferredCharge, projection.DeferredCharge) + switch phase { + case MatchPhaseBisecting, MatchPhaseReadyToSeal: + require.NotNil(t, projection.Bisection) + require.Nil(t, projection.Sealed) + require.Equal(t, parent, projection.Bisection.RevealingParent) + require.Equal(t, left, projection.Bisection.WaitingLeft) + require.Equal(t, right, projection.Bisection.WaitingRight) + require.Equal(t, position, projection.Bisection.SegmentStartPosition.ToBig()) + require.Equal(t, cycle, projection.Bisection.SegmentStartCycle.ToBig()) + if phase == MatchPhaseBisecting { + require.NotNil(t, projection.Bisection.CurrentHeight, "zero height is present in BISECTING") + require.Zero(t, *projection.Bisection.CurrentHeight) + require.Equal(t, CommitmentSideTwo, projection.Bisection.Responder) + view.Bisecting.SegmentStartPosition.SetUint64(0) + } else { + require.Nil(t, projection.Bisection.CurrentHeight) + require.Equal(t, CommitmentSideOne, projection.Bisection.Responder) + view.ReadyToSeal.SegmentStartPosition.SetUint64(0) + } + require.Equal(t, position, projection.Bisection.SegmentStartPosition.ToBig()) + case MatchPhaseSealed: + require.Nil(t, projection.Bisection) + require.Equal(t, parent, projection.Sealed.AgreeState) + require.Equal(t, left, projection.Sealed.FinalStateOne) + require.Equal(t, right, projection.Sealed.FinalStateTwo) + require.Equal(t, position, projection.Sealed.DivergencePosition.ToBig()) + require.Equal(t, cycle, projection.Sealed.DivergenceCycle.ToBig()) + view.Sealed.DivergencePosition.SetUint64(0) + require.Equal(t, position, projection.Sealed.DivergencePosition.ToBig()) + case MatchPhaseUninitialized: + require.Nil(t, projection.Bisection) + require.Nil(t, projection.Sealed) + } + }) + } +} + +func TestMatchProjectionRejectsInvalidPhasePayloads(t *testing.T) { + for _, view := range []ObservedMatchSnapshot{ + {Phase: unknownEnumValue}, + {Phase: MatchPhaseBisecting}, + {Phase: MatchPhaseReadyToSeal}, + {Phase: MatchPhaseSealed}, + {Phase: MatchPhaseBisecting, Bisecting: &BisectingMatch{SegmentStartPosition: big.NewInt(-1), SegmentStartCycle: new(big.Int)}}, + {Phase: MatchPhaseReadyToSeal, ReadyToSeal: &ReadyToSealMatch{SegmentStartPosition: new(big.Int), SegmentStartCycle: nil}}, + {Phase: MatchPhaseSealed, Sealed: &SealedMatch{ + DivergencePosition: new(big.Int).Lsh(big.NewInt(1), 256), DivergenceCycle: new(big.Int)}}, + {Phase: MatchPhaseBisecting, Bisecting: &BisectingMatch{}, Sealed: &SealedMatch{}}, + {Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutOneWins}, + } { + _, err := projectMatchSnapshot(view, 100, false) + require.Error(t, err) + } + _, err := projectMatchSnapshot(ObservedMatchSnapshot{Phase: MatchPhaseBisecting}, 100, true) + require.ErrorContains(t, err, "deleted match") + _, err = projectMatchSnapshot(ObservedMatchSnapshot{Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone}, 100, false) + require.ErrorContains(t, err, "no matching deletion") +} + +func TestInnerCurrentProjectionExpiresWithoutRestoringHistoricalWinner(t *testing.T) { + s, repo := newPRTServiceMock() + app, epoch := prtRevertTestApp(), checkpointEpoch(0, "0x100") + projection := &Tournament{Address: common.HexToAddress("0x101"), Level: 1, MaxLevel: 2} + adapter := &tournamentAdapterMock{} + candidate, parent, final := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + for _, block := range []uint64{100, 101, 102} { + opts := mock.MatchedBy(resultCallOptsAtBlock(block)) + standing := TournamentStanding{State: TournamentStandingInnerWinner, HasCandidate: true, Candidate: candidate, + FinalState: final, ParentCommitment: parent, FinishedAt: 90, WinnerExpiresAt: 102} + inner := InnerResult{Disposition: InnerTournamentWinner, ParentCommitment: parent, PausedAllowance: 102 - block} + if block == 102 { + standing.State, standing.FinalState, standing.ParentCommitment, standing.WinnerExpiresAt = + TournamentStandingInnerEliminableWinnerExpired, common.Hash{}, common.Hash{}, 0 + inner = InnerResult{Disposition: InnerTournamentEliminable} + } + adapter.On("Standing", opts).Return(standing, nil).Once() + adapter.On("InnerResult", opts).Return(inner, nil).Once() + adapter.On("BondRecovery", opts). + Return(canonicalBondRecovery(BondDispositionRecoverable, common.HexToAddress("0x777"), 0), nil).Once() + require.NoError(t, s.updateTournamentStanding(t.Context(), app, epoch, 1, adapter, projection, block)) + require.Equal(t, block, projection.Snapshot.AsOfBlock) + require.Equal(t, candidate, *projection.Snapshot.Candidate) + require.NotNil(t, projection.Snapshot.BondRecovery.Payment, "zero recovery amount is still present") + if block < 102 { + require.Equal(t, candidate, *projection.Snapshot.WinnerCommitment) + require.Equal(t, final, *projection.Snapshot.FinalStateHash) + require.Equal(t, parent, *projection.Snapshot.ParentCommitment) + require.Equal(t, uint64(102), projection.Snapshot.WinnerExpiresAt) + require.Equal(t, uint64(102-block), projection.Snapshot.InnerResult.PausedAllowance) + } else { + require.Nil(t, projection.Snapshot.WinnerCommitment) + require.Nil(t, projection.Snapshot.FinalStateHash) + require.Nil(t, projection.Snapshot.ParentCommitment) + require.Zero(t, projection.Snapshot.WinnerExpiresAt) + require.Equal(t, InnerTournamentEliminable, projection.Snapshot.InnerResult.Disposition) + require.Nil(t, projection.Snapshot.InnerResult.ParentCommitment) + require.Zero(t, projection.Snapshot.InnerResult.PausedAllowance) + } + } + require.Empty(t, repo.Calls) + adapter.AssertNotCalled(t, "CommitmentStanding", mock.Anything, mock.Anything) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/deleted_match_projection_test.go b/internal/prt/deleted_match_projection_test.go new file mode 100644 index 000000000..51d431abc --- /dev/null +++ b/internal/prt/deleted_match_projection_test.go @@ -0,0 +1,302 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func certifiedDeletedMatch() *model.Match { + return &model.Match{ + IDHash: common.HexToHash("0xabc"), TournamentAddress: common.HexToAddress("0x100"), + CommitmentOne: common.HexToHash("0x11"), CommitmentTwo: common.HexToHash("0x22"), + LeftOfTwo: common.HexToHash("0x33"), BlockNumber: 20, TxHash: common.HexToHash("0x44"), + LogIndex: 2, EliminableAt: 30, Winner: model.WinnerCommitment_ONE, + DeletionReason: model.MatchDeletionReason_TIMEOUT, DeletionBlockNumber: 35, + DeletionTxHash: new(common.HexToHash("0x55")), DeletionLogIndex: new(uint64(3)), + Snapshot: model.MatchSnapshot{AsOfBlock: 40, Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone}, + CreatedAt: time.Unix(20, 0), UpdatedAt: time.Unix(40, 0), + } +} + +func matchDeletionEvent(match *model.Match) *itournament.ITournamentMatchDeleted { + return &itournament.ITournamentMatchDeleted{ + MatchIdHash: match.IDHash, One: match.CommitmentOne, Two: match.CommitmentTwo, Reason: 1, WinnerCommitment: 1, + Raw: types.Log{BlockNumber: match.DeletionBlockNumber, TxHash: *match.DeletionTxHash, Index: uint(*match.DeletionLogIndex)}, + } +} + +func TestTournamentEventBatchReusesCertifiedDeletionWithoutReadOrWrite(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + commitment := &model.Commitment{Commitment: stored.CommitmentOne, FinalStateHash: common.HexToHash("0x66")} + f := newEventBatchFixture(t, []*model.Commitment{commitment}, []*model.Match{stored}) + f.tournament.Snapshot.AsOfBlock = 50 + f.adapter.On("CommitmentStanding", mock.MatchedBy(resultCallOptsAtBlock(100)), [32]byte(commitment.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 7}, nil).Once() + + batch, err := f.project(&TournamentEvents{}) + require.NoError(t, err) + require.Empty(t, batch.Matches, "the unchanged match must not reach the repository write batch") + require.Equal(t, before, *stored, "keep the certified block, deletion facts, and timestamps") + require.Len(t, batch.Commitments, 1, "a deleted match does not make its commitments immutable") + require.Equal(t, uint64(100), batch.Commitments[0].Snapshot.AsOfBlock) + f.adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchReusesDeletionAtPublishedHead(t *testing.T) { + stored := certifiedDeletedMatch() + stored.DeletionBlockNumber, stored.Snapshot.AsOfBlock = 100, 100 + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.tournament.Snapshot.AsOfBlock = 100 + + batch, err := f.project(&TournamentEvents{}) + require.NoError(t, err) + require.Empty(t, batch.Matches, "deletion, snapshot, cursor, and head can have the same block") + require.Equal(t, before, *stored) + f.adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchValidatesFirstDeletionAtPinnedBlock(t *testing.T) { + for _, test := range []struct { + name string + view ObservedMatchSnapshot + fail bool + }{ + {name: "deleted view", view: ObservedMatchSnapshot{ + Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone, + }}, + {name: "live view contradicts deletion", view: ObservedMatchSnapshot{Phase: model.MatchPhaseBisecting}, fail: true}, + } { + t.Run(test.name, func(t *testing.T) { + stored := certifiedDeletedMatch() + event := matchDeletionEvent(stored) + event.Raw.BlockNumber = 100 + stored.DeletionReason, stored.Winner = model.MatchDeletionReason_NOT_DELETED, model.WinnerCommitment_NONE + stored.DeletionBlockNumber, stored.DeletionTxHash, stored.DeletionLogIndex = 0, nil, nil + stored.Snapshot = model.MatchSnapshot{AsOfBlock: 40, Phase: model.MatchPhaseSealed, + Sealed: &model.MatchSealedSnapshot{}, TimeoutOutcome: model.MatchTimeoutNone} + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.tournament.Snapshot.AsOfBlock = 50 + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(test.view, nil).Once() + + batch, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + if test.fail { + require.ErrorContains(t, err, "deleted match has phase") + require.Nil(t, batch) + } else { + require.NoError(t, err) + require.Len(t, batch.Matches, 1) + require.Equal(t, uint64(100), batch.Matches[0].Snapshot.AsOfBlock) + require.Equal(t, model.MatchPhaseUninitialized, batch.Matches[0].Snapshot.Phase) + require.Equal(t, event.Raw.TxHash, *batch.Matches[0].DeletionTxHash) + } + require.Equal(t, before, *stored, "publication must not mutate the repository-owned row") + }) + } +} + +func TestTournamentEventBatchRefreshesUncertifiedDeletions(t *testing.T) { + for _, test := range []struct { + name string + change func(*model.Match, *model.Application) + }{ + {"deletion transaction is absent", func(v *model.Match, _ *model.Application) { v.DeletionTxHash = nil }}, + {"empty transaction hash", func(v *model.Match, _ *model.Application) { v.DeletionTxHash = new(common.Hash) }}, + {"missing log index", func(v *model.Match, _ *model.Application) { v.DeletionLogIndex = nil }}, + {"missing deletion block", func(v *model.Match, _ *model.Application) { v.DeletionBlockNumber = 0 }}, + {"not deleted", func(v *model.Match, _ *model.Application) { v.DeletionReason = model.MatchDeletionReason_NOT_DELETED }}, + {"deletion reason is unknown", func(v *model.Match, _ *model.Application) { v.DeletionReason = "UNKNOWN" }}, + {"deletion winner is unknown", func(v *model.Match, _ *model.Application) { v.Winner = "UNKNOWN" }}, + {"missing snapshot", func(v *model.Match, _ *model.Application) { v.Snapshot.AsOfBlock = 0 }}, + {"deletion after snapshot", func(v *model.Match, _ *model.Application) { v.DeletionBlockNumber = 41 }}, + {"snapshot after cursor", func(v *model.Match, _ *model.Application) { v.Snapshot.AsOfBlock = 51 }}, + {"head below published cursor", func(_ *model.Match, app *model.Application) { app.LastTournamentCheckBlock = 101 }}, + {"live phase", func(v *model.Match, _ *model.Application) { v.Snapshot.Phase = model.MatchPhaseBisecting }}, + {"retained timeout", func(v *model.Match, _ *model.Application) { v.Snapshot.TimeoutOutcome = model.MatchTimeoutOneWins }}, + {"retained charge", func(v *model.Match, _ *model.Application) { v.Snapshot.DeferredCharge = 1 }}, + {"retained bisection", func(v *model.Match, _ *model.Application) { v.Snapshot.Bisection = &model.MatchBisectionSnapshot{} }}, + {"retained seal", func(v *model.Match, _ *model.Application) { v.Snapshot.Sealed = &model.MatchSealedSnapshot{} }}, + } { + t.Run(test.name, func(t *testing.T) { + stored := certifiedDeletedMatch() + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.tournament.Snapshot.AsOfBlock = 50 + test.change(stored, f.app) + if test.name == "head below published cursor" { + f.tournament.Snapshot.AsOfBlock = 101 + } + before := *stored + cause := errors.New("pinned snapshot unavailable") + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(ObservedMatchSnapshot{}, cause).Once() + + batch, err := f.project(&TournamentEvents{}) + require.ErrorIs(t, err, cause, "uncertified state must not bypass the pinned read") + require.Nil(t, batch) + require.Equal(t, before, *stored) + }) + } +} + +func TestTournamentEventBatchKeepsNewFactsForCertifiedDeletion(t *testing.T) { + const ( + matchCreationEventName = "creation" + matchAdvanceEventName = "advance" + leafSealEventName = "leaf seal" + matchDeletionEventName = "deletion" + ) + for _, eventName := range []string{matchCreationEventName, matchAdvanceEventName, leafSealEventName, matchDeletionEventName} { + t.Run(eventName, func(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.tournament.Snapshot.AsOfBlock = 50 + events := &TournamentEvents{} + switch eventName { + case matchCreationEventName: + events.MatchCreated = []*itournament.ITournamentMatchCreated{{ + MatchIdHash: stored.IDHash, One: stored.CommitmentOne, Two: stored.CommitmentTwo, + LeftOfTwo: stored.LeftOfTwo, EliminableAt: stored.EliminableAt, + Raw: types.Log{BlockNumber: stored.BlockNumber, TxHash: stored.TxHash, Index: uint(stored.LogIndex)}, + }} + case matchAdvanceEventName: + events.MatchAdvanced = []*itournament.ITournamentMatchAdvanced{{ + MatchIdHash: stored.IDHash, One: stored.CommitmentOne, Two: stored.CommitmentTwo, SegmentStartPosition: big.NewInt(8), + Raw: types.Log{BlockNumber: 30, TxHash: common.HexToHash("0x77"), Index: 1}, + }} + case leafSealEventName: + events.LeafMatchSealed = []*itournament.ITournamentLeafMatchSealed{{ + MatchIdHash: stored.IDHash, One: stored.CommitmentOne, Two: stored.CommitmentTwo, + DivergencePosition: big.NewInt(0), EliminableAt: 34, + Raw: types.Log{BlockNumber: 30, TxHash: common.HexToHash("0x77"), Index: 1}, + }} + case matchDeletionEventName: + events.MatchDeleted = []*itournament.ITournamentMatchDeleted{matchDeletionEvent(stored)} + } + f.adapter.On("MatchSnapshot", mock.MatchedBy(resultCallOptsAtBlock(100)), + [32]byte(stored.CommitmentOne), [32]byte(stored.CommitmentTwo)).Return(ObservedMatchSnapshot{ + Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone, + }, nil).Once() + + batch, err := f.project(events) + require.NoError(t, err) + require.Len(t, batch.Matches, 1, "an incoming fact must not be hidden by snapshot reuse") + require.Equal(t, uint64(100), batch.Matches[0].Snapshot.AsOfBlock) + require.Equal(t, before, *stored) + if eventName == matchAdvanceEventName { + require.Len(t, batch.MatchAdvances, 1) + require.Equal(t, big.NewInt(8), batch.MatchAdvances[0].SegmentStartPosition.ToBig()) + } + if eventName == leafSealEventName { + require.NotNil(t, batch.Matches[0].LeafSeal) + require.Equal(t, uint64(34), batch.Matches[0].LeafSeal.EliminableAt) + } + }) + } +} + +func TestTournamentEventBatchRejectsConflictingCertifiedDeletion(t *testing.T) { + stored := certifiedDeletedMatch() + before := *stored + f := newEventBatchFixture(t, nil, []*model.Match{stored}) + f.tournament.Snapshot.AsOfBlock = 50 + event := matchDeletionEvent(stored) + event.Raw.Index++ + batch, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + require.ErrorContains(t, err, "conflicting deletion events") + require.Nil(t, batch) + require.Equal(t, before, *stored) + require.Empty(t, f.adapter.Calls) +} + +func TestDeletedParentMatchStillObservesLiveChild(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + stored := certifiedDeletedMatch() + before := *stored + parent, adapter := expectDeletedMatchTournament(t, f, epoch, *epoch.TournamentAddress, 0, []*model.Match{stored}) + child, _ := expectDeletedMatchTournament(t, f, epoch, common.HexToAddress("0x101"), 1, nil) + f.children(epoch, parent, child) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 1, "stored children commit independently of their parent") + if batches[0].Tournament.Address == parent.Address { + require.Empty(t, batches[0].Matches) + } else { + require.Equal(t, child.Address, batches[0].Tournament.Address) + require.Equal(t, model.TournamentStandingMatchesActive, batches[0].Tournament.Snapshot.Standing) + } + require.Equal(t, uint64(100), batches[0].Tournament.Snapshot.AsOfBlock) + }).Return(nil).Twice() + + err := f.s.delivery.observeWindow(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, observerFacts(100)) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, before, *stored) + adapter.AssertNotCalled(t, "MatchSnapshot", mock.Anything, mock.Anything, mock.Anything) +} + +func expectDeletedMatchTournament( + t *testing.T, f *observerCheckpointFixture, epoch *model.Epoch, address common.Address, level uint64, matches []*model.Match, +) (*model.Tournament, *tournamentAdapterMock) { + t.Helper() + kind := model.TournamentKindNonLeaf + if level == 1 { + kind = model.TournamentKindLeaf + } + tournament := &model.Tournament{ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: address, Level: level, + MaxLevel: 2, Kind: kind, StartInstant: 10, Snapshot: model.TournamentSnapshot{AsOfBlock: 50}} + adapter := &tournamentAdapterMock{} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(tournament, nil).Once() + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: level, Kind: kind, StartInstant: 10, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: model.TournamentStandingMatchesActive}, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, TournamentLevel(level), model.TournamentStandingMatchesActive) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 51 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + counts := zeroStructuralEventCounts() + if len(matches) != 0 { + counts.MatchCreated.SetUint64(1) + counts.MatchDeleted.SetUint64(1) + } + if f.repo.eventCounts == nil { + f.repo.eventCounts = map[common.Address]repository.TournamentEventCounts{} + } + f.repo.eventCounts[address] = repository.TournamentEventCounts{StandingChanged: counts.MatchDeleted.Uint64(), + MatchCreated: counts.MatchCreated.Uint64(), MatchDeleted: counts.MatchDeleted.Uint64(), + } + adapter.On("StructuralEventCounts", opts).Return(counts, nil).Once() + filterAddress := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*model.Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return(matches, uint64(len(matches)), nil).Once() + t.Cleanup(func() { adapter.AssertExpectations(t) }) + return tournament, adapter +} diff --git a/internal/prt/delivery.go b/internal/prt/delivery.go new file mode 100644 index 000000000..aac2b41e4 --- /dev/null +++ b/internal/prt/delivery.go @@ -0,0 +1,677 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "math" + "math/big" + "time" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// delivery belongs to the publisher goroutine. It stores traversal positions, +// cooldowns and diagnostics; signed requests belong only to txsender. +type delivery struct { + *Service + sender *txsender.Sender[actionID] + deliveryBackend txsender.Backend + deliveryPolicy txsender.Policy + deliverySweeps map[actionKind]*applicationSweep + rootBondRecoveryScans map[int64]*rootBondRecoveryScan + candidateCooldown map[actionID]time.Time + observedFacts txsender.BlockRef + diagnostics publicationDiagnostics +} + +type actionID struct { + ApplicationID int64 + Application common.Address + Target common.Address + Kind actionKind + Epoch uint64 + Identity common.Hash + Deadline uint64 +} + +type plannedAction struct { + Key actionID + txsender.Request + Deadline uint64 +} + +type applicationSweep struct{ after, through int64 } + +const ( + deliveryApplicationPageSize uint64 = 8 + joinWarningMargin uint64 = 6 + publicationCandidateLimit = 16 +) + +func (s *delivery) nextDeliveryApplications(ctx context.Context, kind actionKind) ([]*model.Application, error) { + filter := repository.ApplicationFilter{ConsensusType: new(model.Consensus_PRT), Enabled: new(true)} + sweep := s.deliverySweeps[kind] + if sweep == nil { + end, err := s.repository.GetApplicationIDBoundary(ctx, filter) + if err != nil { + return nil, err + } + sweep = &applicationSweep{through: end} + s.deliverySweeps[kind] = sweep + } + apps, err := s.repository.ListApplicationsByID(ctx, filter, sweep.after, sweep.through, deliveryApplicationPageSize) + if err == nil { + if len(apps) == 0 { + delete(s.deliverySweeps, kind) + } else { + sweep.after = apps[len(apps)-1].ID + if sweep.after >= sweep.through { + delete(s.deliverySweeps, kind) + } + } + } + return apps, err +} + +func (s *delivery) guardPublication(ctx context.Context) error { + if s.publicationStopped() { + return s.publicationFailure() + } + return ctx.Err() +} + +func (s *delivery) observationReference(ctx context.Context) (txsender.BlockRef, error) { + if s.publicationStopped() { + return txsender.BlockRef{}, s.publicationFailure() + } + candidate, err := s.validatedObservationReference(ctx, s.observedFacts) + if errors.Is(err, txsender.ErrFinality) { + s.stopPublication(err) + } + if err == nil { + s.observedFacts = candidate + } + return candidate, err +} + +func applicationEligible(app *model.Application, snapshot txsender.BlockRef) error { + if !app.Enabled { + return txsender.ErrIneligible + } + if app.ForecloseBlock > snapshot.Number { + return txsender.ErrInconclusive + } + if !completionStatusAllowed(app.Status) || app.IsForeclosed() { + return txsender.ErrIneligible + } + return nil +} + +func (s *delivery) planAction( + ctx context.Context, app *model.Application, kind actionKind, snap txsender.BlockRef, facts txsender.BlockRef, +) (*plannedAction, error) { + return s.planEpochAction(ctx, app, kind, snap, facts, nil) +} + +// Fixed-action validation remains independent of incomplete fresh-work observation. +// Current protected protocol checks still apply. +func (s *delivery) planEpochAction( + ctx context.Context, app *model.Application, kind actionKind, snap txsender.BlockRef, + facts txsender.BlockRef, expected *actionID, +) (*plannedAction, error) { + ctx = withLiveBlock(ctx, snap.Number, snap.Hash) + if kind == recoverBond { + return s.planRecovery(ctx, app, snap, facts) + } + revalidating := expected != nil + retained := revalidating + var state *repository.EpochCompletionState + if expected != nil { + var gateErr error + state, gateErr = s.checkDeploymentAction(ctx, app, expected.Epoch, kind, snap) + if gateErr != nil && state == nil { + return nil, gateErr + } + // Unknown admission does not prevent fresh protocol evidence from proving + // obsolescence. Useful work still needs the final deployment check below. + app = state.Application + } else { + if err := applicationEligible(app, snap); err != nil { + return nil, err + } + state := s.deployments.capture(deploymentIdentity(app)).snapshot + if !state.Acknowledged || state.Assessment.State == deploymentNone || state.Assessment.State == deploymentPending { + return nil, txsender.ErrInconclusive + } + if deploymentBlocksAction(state.Assessment.State, kind) { + return nil, txsender.ErrIneligible + } + } + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return nil, err + } + view, err := readDaveConsensusSnapshot(ctx, consensus, snap.Number) + if err != nil { + return nil, fmt.Errorf("application %s (%s), consensus snapshot block %d hash %s: %w", + app.Name, app.IApplicationAddress, snap.Number, snap.Hash, err) + } + if revalidating { + if state.Epoch != nil && state.Epoch.LastBlock > snap.Number { + return nil, txsender.ErrInconclusive + } + if view.sealed.EpochNumber != expected.Epoch { + return nil, txsender.ErrIneligible + } + } + if state == nil { + state, err = s.repository.GetEpochCompletionState(ctx, app.IApplicationAddress.Hex(), view.sealed.EpochNumber) + if err != nil { + return nil, err + } + } + if state == nil || state.Application == nil || state.Application.ID != app.ID { + return nil, txsender.ErrIneligible + } + current := state.Application + if current.ConsensusType != model.Consensus_PRT || current.IConsensusAddress != app.IConsensusAddress { + return nil, txsender.ErrIneligible + } + if err := applicationEligible(current, snap); err != nil { + return nil, err + } + if state.Epoch == nil { + if retained { + return nil, txsender.ErrInconclusive + } + return nil, txsender.ErrIneligible + } + allowed, err := state.AllowsEpochCompletion() + if err != nil { + return nil, fmt.Errorf("%w: application %s epoch %d: %w", txsender.ErrInconclusive, current.Name, view.sealed.EpochNumber, err) + } + if !allowed { + return nil, txsender.ErrIneligible + } + app, epoch := current, state.Epoch + if epoch.Index != view.sealed.EpochNumber { + return nil, txsender.ErrInconclusive + } + if revalidating { + if epoch.StagedAtBlock != nil && *epoch.StagedAtBlock > snap.Number { + return nil, txsender.ErrInconclusive + } + if epoch.TournamentAddress != nil { + root, loadErr := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()) + if loadErr != nil { + return nil, loadErr + } + if root != nil && root.Snapshot.AsOfBlock > snap.Number { + return nil, txsender.ErrInconclusive + } + } + } + // Stored progress selects fresh work. A retained call instead follows the + // current protocol state, even if a provisional terminal observation remains. + if !retained && !pendingTournamentResult(epoch) { + return nil, txsender.ErrIneligible + } + if epoch.TournamentAddress == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + if revalidating { + return nil, txsender.ErrInconclusive + } + return nil, txsender.ErrIneligible + } + if err = matchConsensusSnapshotToEpoch(epoch, view); err != nil { + if revalidating { + return nil, fmt.Errorf("%w: %w", txsender.ErrIneligible, err) + } + return nil, err + } + if !revalidating && epoch.LastBlock > facts.Number { + return nil, fmt.Errorf("%w: observation block %d precedes epoch %d seal block %d", + txsender.ErrInconclusive, facts.Number, epoch.Index, epoch.LastBlock) + } + if view.sealed.Tournament != *epoch.TournamentAddress { + return nil, txsender.ErrIneligible + } + if !revalidating { + if err = s.requirePublishedRoot(ctx, app, epoch, facts); err != nil { + return nil, err + } + } + switch kind { + case joinTournament: + if view.stage.IsFinished || view.stage.IsTournamentFailed || view.sealed.IsTournamentResultStaged || + !retained && epoch.Status != model.EpochStatus_ClaimComputed { + return nil, nil + } + case stageTournament: + if !view.stage.IsFinished || view.stage.IsTournamentFailed || view.sealed.IsTournamentResultStaged { + if revalidating && !view.stage.IsFinished && !view.stage.IsTournamentFailed && + !view.sealed.IsTournamentResultStaged { + return nil, errProtocolWait + } + return nil, nil + } + case acceptTournament: + if !view.sealed.IsTournamentResultStaged { + return nil, nil + } + if !view.accept.DoAllSentriesAgreeWithStagedTournamentResult && !view.accept.IsClaimStagingPeriodOver { + if revalidating { + return nil, errProtocolWait + } + return nil, nil + } + case noAction: + return nil, errors.New("neutral resolution has no PRT protocol plan") + case recoverBond: + return nil, errors.New("bond recovery uses its own plan") + default: + return nil, fmt.Errorf("unsupported PRT action %d", kind) + } + // Registration is the operator's trust boundary for deployed consensus, + // factory, clone implementation and economic parameters. Verify relationships + // and supported geometry here; code presence alone does not establish trust. + if err = s.validateApplicationLinks(ctx, app, snap); err != nil { + if err != txsender.ErrIneligible && errors.Is(err, txsender.ErrIneligible) { + if kind == joinTournament { + return nil, &joinRefusal{epoch: epoch.Index, tournament: *epoch.TournamentAddress, reason: err.Error()} + } + return nil, &consensusRefusal{key: actionID{ApplicationID: app.ID, Kind: kind, Epoch: epoch.Index}, reason: err} + } + return nil, err + } + var call plannedAction + if kind == joinTournament { + call, err = s.joinCall(ctx, app, epoch, snap) + } else { + data, e := consensusActionData(epoch, kind) + if e != nil { + return nil, e + } + call = s.call(app, epoch.Index, kind, app.IConsensusAddress, data, new(big.Int), 0) + } + if err != nil { + return nil, err + } + if call.Key.Kind == 0 { + return nil, nil + } + final, err := s.checkDeploymentAction(ctx, app, epoch.Index, kind, snap) + if err != nil { + return nil, err + } + currentEpoch := final.Epoch + if currentEpoch.TournamentAddress == nil || currentEpoch.MachineHash == nil || currentEpoch.Commitment == nil || + currentEpoch.TxBufferDataBlock == nil || *currentEpoch.TournamentAddress != *epoch.TournamentAddress || + *currentEpoch.MachineHash != *epoch.MachineHash || *currentEpoch.Commitment != *epoch.Commitment || + *currentEpoch.TxBufferDataBlock != *epoch.TxBufferDataBlock { + return nil, txsender.ErrInconclusive + } + if err = matchConsensusSnapshotToEpoch(currentEpoch, view); err != nil { + return nil, txsender.ErrInconclusive + } + if !retained && !pendingTournamentResult(currentEpoch) { + return nil, txsender.ErrIneligible + } + var exactData []byte + if kind == joinTournament { + exactData, err = joinData(currentEpoch) + } else { + exactData, err = consensusActionData(currentEpoch, kind) + } + if err != nil || !bytes.Equal(exactData, call.Data) { + return nil, errors.Join(txsender.ErrInconclusive, err) + } + return &call, nil +} + +// Fresh work requires the root's own committed observation, independently of siblings. +func (s *delivery) requirePublishedRoot( + ctx context.Context, app *model.Application, epoch *model.Epoch, facts txsender.BlockRef, +) error { + root, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()) + if errors.Is(err, repository.ErrNotFound) { + return txsender.ErrInconclusive + } + if err != nil { + return err + } + if root == nil || root.ApplicationID != app.ID || root.EpochIndex != epoch.Index || + root.Address != *epoch.TournamentAddress || root.Level != uint64(RootLevel) || root.ParentTournamentAddress != nil || + root.Snapshot.AsOfBlock == 0 || root.Snapshot.AsOfBlock < root.StartInstant || root.Snapshot.AsOfBlock < epoch.LastBlock || + root.Snapshot.AsOfBlock > facts.Number { + return txsender.ErrInconclusive + } + return nil +} + +func (s *delivery) call( + app *model.Application, epoch uint64, kind actionKind, to common.Address, data []byte, value *big.Int, deadline uint64, +) plannedAction { + // Include every content field, including target/value. model.Epoch is also explicit. + valueBytes := common.LeftPadBytes(value.Bytes(), common.HashLength) + key := actionID{ApplicationID: app.ID, Application: app.IApplicationAddress, Target: to, Kind: kind, Epoch: epoch, + Identity: crypto.Keccak256Hash(to[:], valueBytes, data), Deadline: deadline} + request := txsender.Request{To: to, Data: data, Value: value, + Logger: s.Logger.With("application", app.Name, "address", app.IApplicationAddress, + "action", actionName(kind), "epoch", epoch)} + if kind == recoverBond { + request.GasLimit = recoveryGasLimit + } + return plannedAction{Key: key, Request: request, Deadline: deadline} +} + +func (s *delivery) validateApplicationLinks(ctx context.Context, app *model.Application, snap txsender.BlockRef) error { + for _, addr := range []common.Address{app.IApplicationAddress, app.IConsensusAddress} { + code, e := s.deliveryBackend.Code(ctx, addr, snap) + if e != nil { + return e + } + if len(code) == 0 { + return fmt.Errorf("%w: registered contract %s has no code", txsender.ErrIneligible, addr) + } + } + abi, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return err + } + data, err := abi.Pack("isForeclosed") + if err != nil { + return err + } + vals, err := s.readABI(ctx, snap, app.IApplicationAddress, data, abi.Methods["isForeclosed"].Outputs) + if err != nil { + return err + } + if len(vals) != 1 { + return errors.New("invalid foreclosure view") + } + foreclosed, ok := vals[0].(bool) + if !ok || foreclosed { + return txsender.ErrIneligible + } + data, err = abi.Pack("getOutputsMerkleRootValidator") + if err != nil { + return err + } + vals, err = s.readABI(ctx, snap, app.IApplicationAddress, data, abi.Methods["getOutputsMerkleRootValidator"].Outputs) + if err != nil { + return err + } + if len(vals) != 1 || vals[0] != app.IConsensusAddress { + return fmt.Errorf("%w: application's outputs validator differs from registered consensus %s", + txsender.ErrIneligible, app.IConsensusAddress) + } + dave, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + if err != nil { + return err + } + for _, name := range []string{"getApplicationContract", "getTournamentFactory"} { + data, err = dave.Pack(name) + if err != nil { + return err + } + vals, err = s.readABI(ctx, snap, app.IConsensusAddress, data, dave.Methods[name].Outputs) + if err != nil { + return err + } + if len(vals) != 1 { + return errors.New("invalid Dave relationship view") + } + addr, ok := vals[0].(common.Address) + if !ok || addr == (common.Address{}) { + return fmt.Errorf("%w: DaveConsensus %s has no valid %s", txsender.ErrIneligible, app.IConsensusAddress, name) + } + if name == "getApplicationContract" && addr != app.IApplicationAddress { + return fmt.Errorf("%w: DaveConsensus application %s differs from registered application %s", + txsender.ErrIneligible, addr, app.IApplicationAddress) + } + if name == "getTournamentFactory" { + code, e := s.deliveryBackend.Code(ctx, addr, snap) + if e != nil { + return e + } + if len(code) == 0 { + return fmt.Errorf("%w: registered tournament factory %s has no code", txsender.ErrIneligible, addr) + } + } + } + return nil +} + +func (s *delivery) joinCall( + ctx context.Context, app *model.Application, epoch *model.Epoch, snap txsender.BlockRef, +) (plannedAction, error) { + target := *epoch.TournamentAddress + code, err := s.deliveryBackend.Code(ctx, target, snap) + if err != nil { + return plannedAction{}, err + } + if len(code) == 0 { + return plannedAction{}, &joinRefusal{epoch: epoch.Index, tournament: target, reason: "tournament has no code"} + } + if !app.Enabled { + return plannedAction{}, txsender.ErrIneligible + } + adapter, err := s.adapterFactory.CreateTournamentAdapter(target) + if err != nil { + return plannedAction{}, err + } + opts := pinnedCallOpts(ctx, snap.Number) + standing, err := adapter.CommitmentStanding(opts, *epoch.Commitment) + if err != nil { + return plannedAction{}, err + } + if standing.Joined { + if standing.FinalState != *epoch.MachineHash { + return plannedAction{}, fmt.Errorf("epoch %d joined commitment has an inconsistent final state", epoch.Index) + } + return plannedAction{}, nil + } + descriptor, err := adapter.Descriptor(opts) + if err != nil { + return plannedAction{}, err + } + var deadline uint64 + if descriptor.Allowance <= math.MaxUint64-descriptor.StartInstant { + deadline = descriptor.StartInstant + descriptor.Allowance + } + if descriptor.Level != 0 || descriptor.Height != model.Log2EpochComputationHashLeafCount || + descriptor.Log2Stride != model.Log2RootTournamentStride { + return plannedAction{}, &joinRefusal{epoch: epoch.Index, tournament: target, deadline: deadline, + reason: fmt.Sprintf("unsupported root geometry: level=%d height=%d stride=%d; expected level=0 height=%d stride=%d", + descriptor.Level, descriptor.Height, descriptor.Log2Stride, + model.Log2EpochComputationHashLeafCount, model.Log2RootTournamentStride)} + } + if descriptor.StartInstant > snap.Number || descriptor.Allowance > math.MaxUint64-descriptor.StartInstant { + return plannedAction{}, &joinRefusal{epoch: epoch.Index, tournament: target, + reason: fmt.Sprintf("invalid tournament clock: start=%d allowance=%d", descriptor.StartInstant, descriptor.Allowance)} + } + if snap.Number == math.MaxUint64 || snap.Number+1 >= deadline { + return plannedAction{}, &joinRefusal{epoch: epoch.Index, tournament: target, deadline: deadline, + reason: "join deadline leaves no block in which this transaction can execute"} + } + state, err := adapter.Standing(opts) + if err != nil { + return plannedAction{}, err + } + if !state.AcceptsJoins { + return plannedAction{}, &joinRefusal{epoch: epoch.Index, tournament: target, deadline: deadline, + reason: "tournament no longer accepts joins"} + } + bond, err := adapter.BondValue(opts) + if err != nil { + return plannedAction{}, err + } + if bond == nil || bond.Sign() < 0 { + return plannedAction{}, fmt.Errorf("tournament join bond is not a non-negative amount") + } + data, err := joinData(epoch) + if err != nil { + return plannedAction{}, err + } + return s.call(app, epoch.Index, joinTournament, target, data, bond, deadline), nil +} + +func joinData(epoch *model.Epoch) ([]byte, error) { + idx := uint64(1<= scan.ThroughEpoch { + delete(s.rootBondRecoveryScans, app.ID) + } + return nil, nil +} + +func (s *delivery) recoveryCall( + ctx context.Context, app *model.Application, epoch uint64, target common.Address, + snap txsender.BlockRef, _ txsender.BlockRef, +) (*plannedAction, error) { + if !app.Enabled { + return nil, txsender.ErrIneligible + } + adapter, err := s.adapterFactory.CreateTournamentAdapter(target) + if err != nil { + return nil, err + } + recovery, err := adapter.BondRecovery(pinnedCallOpts(ctx, snap.Number)) + if err != nil { + return nil, err + } + if recovery.Disposition != model.BondDispositionRecoverable { + return nil, nil + } + if recovery.Claimer != s.txOptsFactory.From() { + return nil, nil + } + code, err := s.deliveryBackend.Code(ctx, recovery.Claimer, snap) + if err != nil { + return nil, err + } + if len(code) != 0 { + return nil, errors.New("configured bond recipient has unsupported code") + } + abi, err := itournament.ITournamentMetaData.GetAbi() + if err != nil { + return nil, err + } + data, err := abi.Pack("tryRecoveringBond") + if err != nil { + return nil, err + } + call := s.call(app, epoch, recoverBond, target, data, new(big.Int), 0) + call.CheckReturn = func(raw []byte) bool { + v, e := abi.Unpack("tryRecoveringBond", raw) + if e != nil || len(v) != 1 { + return false + } + useful, ok := v[0].(bool) + return ok && useful + } + return &call, nil +} + +func (s *delivery) readABI( + ctx context.Context, snap txsender.BlockRef, to common.Address, data []byte, outputs abi.Arguments, +) ([]any, error) { + msg := ethereum.CallMsg{From: s.txOptsFactory.From(), To: &to, Data: data, Gas: maximumJoinGas} + raw, err := s.deliveryBackend.Call(ctx, msg, snap) + if err != nil { + return nil, err + } + return outputs.Unpack(raw) +} diff --git a/internal/prt/delivery_create.go b/internal/prt/delivery_create.go new file mode 100644 index 000000000..c22cc6b83 --- /dev/null +++ b/internal/prt/delivery_create.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/ethclient" +) + +func newDelivery( + _ context.Context, c *CreateInfo, s *Service, _ *PersistentConfig, chainID *big.Int, +) (*delivery, error) { + if c.AdapterFactory != nil { + s.adapterFactory = c.AdapterFactory + } else { + client, ok := s.client.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("EthClient must be *ethclient.Client when AdapterFactory is not provided") + } + s.adapterFactory = NewDefaultAdapterFactory(client, s.filter) + } + policy := txsender.RuntimePolicy() + var err error + if s.submissionEnabled { + policy, err = deliveryPolicy(c.Config) + if err != nil { + return nil, fmt.Errorf("PRT transaction policy: %w", err) + } + } + d := &delivery{ + Service: s, deliveryPolicy: policy, + deliverySweeps: map[actionKind]*applicationSweep{}, candidateCooldown: map[actionID]time.Time{}, + } + d.rootBondRecoveryScans = make(map[int64]*rootBondRecoveryScan) + if !s.submissionEnabled { + return d, nil + } + backend := c.DeliveryBackend + if backend == nil { + client, ok := s.client.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("PRT submission needs a concrete Ethereum client or explicit DeliveryBackend") + } + backend = txsender.ProtectedClient{Client: client} + } + d.deliveryBackend = backend + d.sender, err = txsender.New[actionID](backend, s.txOptsFactory, chainID.Uint64(), policy, d.guardPublication, s.Logger) + if err != nil { + return nil, fmt.Errorf("PRT transaction sender: %w", err) + } + return d, nil +} diff --git a/internal/prt/delivery_diagnostics.go b/internal/prt/delivery_diagnostics.go new file mode 100644 index 000000000..27b14c361 --- /dev/null +++ b/internal/prt/delivery_diagnostics.go @@ -0,0 +1,203 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" +) + +const ( + publicationWarningInterval = 30 * time.Second + frozenLiveHeadInterval = time.Minute + stalledNonceBlocks = 6 +) + +type publicationDiagnostics struct { + head txsender.BlockRef + headSince time.Time + nonce, nonceAtBlock uint64 + hadNonce bool + warnedHead, warnedNonce, warnedJoin, warnedHardFailure time.Time + lastReply txsender.ReplyClass + joinRefusals map[int64]joinRefusalWarning + planningWarnings map[actionID]time.Time + lastPublishedAction actionID + lastPublishedNonce uint64 + hadPublication bool + warnedRepeatedAction time.Time + warnedPublication time.Time +} + +func actionName(kind actionKind) string { + switch kind { + case noAction: + return "cancel obsolete transaction" + case joinTournament: + return "join tournament" + case stageTournament: + return "stage tournament result" + case acceptTournament: + return "accept tournament result" + case recoverBond: + return "recover bond" + default: + return fmt.Sprintf("unknown action %d", kind) + } +} + +func warningDue(last time.Time) bool { return time.Since(last) >= publicationWarningInterval } + +func (s *delivery) recordLiveProgress(report txsender.Report[actionID], selected *actionID, err error) { + if report.Block == (txsender.BlockRef{}) { + return + } + d := &s.diagnostics + block := report.Block + if report.Current == nil && selected != nil && d.head != (txsender.BlockRef{}) && report.Nonce > d.nonce && + (errors.Is(err, txsender.ErrStale) || report.Class == txsender.NonceLow) && warningDue(d.warnedNonce) { + s.Logger.Warn("PRT publication lost a race while signer nonce advanced; inspect other signer users", + "address", selected.Application, "action", actionName(selected.Kind), "epoch", selected.Epoch, + "signer", s.txOptsFactory.From(), "previous_nonce", d.nonce, "nonce", report.Nonce, "error", err) + d.warnedNonce = time.Now() + } + if d.head != block { + d.head, d.headSince = block, time.Now() + } else if time.Since(d.headSince) >= frozenLiveHeadInterval && warningDue(d.warnedHead) { + s.Logger.Warn("PRT latest block has not advanced; publication may miss a deadline", "block", block.Number) + d.warnedHead = time.Now() + } + if report.Attempted { + d.lastReply = report.Class + if report.Current != nil && !report.Current.Neutral && + (report.Class == txsender.Accepted || report.Class == txsender.Known || report.Class == txsender.Uncertain) { + action := report.Current.Action + if d.hadPublication && report.Nonce > d.lastPublishedNonce && action == d.lastPublishedAction && + warningDue(d.warnedRepeatedAction) { + s.Logger.Warn("PRT action is still required after the signer nonce advanced; inspect execution and other signer users", + "address", action.Application, "action", actionName(action.Kind), "epoch", action.Epoch, + "previous_nonce", d.lastPublishedNonce, "nonce", report.Nonce, "block", block.Number) + d.warnedRepeatedAction = time.Now() + } + d.lastPublishedAction, d.lastPublishedNonce, d.hadPublication = action, report.Nonce, true + } + } + if report.Current == nil { + d.nonce = report.Nonce + d.hadNonce = false + return + } + action := report.Current.Action + if !d.hadNonce || d.nonce != report.Nonce || block.Number < d.nonceAtBlock { + d.nonce, d.nonceAtBlock, d.hadNonce = report.Nonce, block.Number, true + } + if block.Number-d.nonceAtBlock >= stalledNonceBlocks && warningDue(d.warnedNonce) { + s.Logger.Warn("PRT signer nonce is stalled", "nonce", report.Nonce, "address", action.Application, + "action", actionName(action.Kind), "epoch", action.Epoch, "blocks_held", block.Number-d.nonceAtBlock, "last_reply", d.lastReply) + d.warnedNonce = time.Now() + } + if !report.Current.Neutral && action.Kind == joinTournament && action.Deadline > block.Number && + action.Deadline-block.Number <= joinWarningMargin && warningDue(d.warnedJoin) { + s.Logger.Warn("PRT join remains pending near its deadline", "address", action.Application, + "epoch", action.Epoch, "nonce", report.Nonce, "blocks_remaining", action.Deadline-block.Number, "last_reply", d.lastReply) + d.warnedJoin = time.Now() + } +} + +type consensusRefusal struct { + key actionID + reason error +} + +func (e *consensusRefusal) Error() string { return e.reason.Error() } +func (e *consensusRefusal) Unwrap() error { return e.reason } + +func (s *delivery) planningWarningDue(key actionID) bool { + if s.diagnostics.planningWarnings == nil { + s.diagnostics.planningWarnings = make(map[actionID]time.Time) + } + for action, last := range s.diagnostics.planningWarnings { + if time.Since(last) >= 2*publicationWarningInterval { + delete(s.diagnostics.planningWarnings, action) + } + } + if !warningDue(s.diagnostics.planningWarnings[key]) { + return false + } + s.diagnostics.planningWarnings[key] = time.Now() + return true +} + +func (s *delivery) warnPlanningPause(app *model.Application, kind actionKind, block txsender.BlockRef, err error) { + if err == nil || txsender.HardFailure(err) { + return + } + var join *joinRefusal + if errors.As(err, &join) { + return // warnJoinPlanningRefusal supplies its tournament and deadline context. + } + var refusal *consensusRefusal + if errors.As(err, &refusal) { + if !s.planningWarningDue(refusal.key) { + return + } + s.Logger.Warn("PRT cannot publish consensus action with the registered contracts", + "application", app.Name, "address", app.IApplicationAddress, "epoch", refusal.key.Epoch, + "action", actionName(kind), "block", block.Number, "reason", refusal.reason) + return + } + if errors.Is(err, txsender.ErrIneligible) || errors.Is(err, errProtocolWait) || txsender.WaitOnly(err) { + return + } + if s.planningWarningDue(actionID{ApplicationID: app.ID, Application: app.IApplicationAddress, Kind: kind}) { + s.Logger.Warn("PRT application action could not be planned; other applications can continue", + "application", app.Name, "address", app.IApplicationAddress, "action", actionName(kind), + "block", block.Number, "error", err) + } +} + +func (s *delivery) warnPublicationRefusal(action actionID, report txsender.Report[actionID], err error) { + if !s.planningWarningDue(action) { + return + } + kind := actionName(action.Kind) + if report.Canceling { + kind = "cancel obsolete " + kind + } + s.Logger.Warn("PRT transaction could not publish; planning will retry", "address", action.Application, + "action", kind, "epoch", action.Epoch, "nonce", report.Nonce, + "block", report.Block.Number, "deadline", action.Deadline, "error", err) +} + +func (s *delivery) reportPublicationError(ctx context.Context, err error) { + if txsender.ShutdownOnly(ctx, err, repository.ErrTournamentIntervalStale, txsender.ErrLimit) { + return + } + if !warningDue(s.diagnostics.warnedPublication) { + s.Logger.Debug("PRT publication deferred", "error", err) + return + } + s.Logger.Warn("PRT publication deferred", "error", err) + s.diagnostics.warnedPublication = time.Now() +} + +func (s *delivery) reportHardPublicationFailure(ctx context.Context, err error) { + if warningDue(s.diagnostics.warnedHardFailure) { + s.Logger.Error("PRT publication stopped by a protected-state contradiction; operator intervention required", "error", err) + s.diagnostics.warnedHardFailure = time.Now() + filter := repository.ApplicationFilter{ConsensusType: new(model.Consensus_PRT), Enabled: new(true)} + through, readErr := s.repository.GetApplicationIDBoundary(ctx, filter) + if readErr != nil { + s.Logger.Error("PRT global stop deadline discovery unavailable", "clock_available", false, "cause", errors.Join(err, readErr)) + return + } + s.reportGlobalProtectionStop(ctx, through, err) + } +} diff --git a/internal/prt/delivery_estimate_race_test.go b/internal/prt/delivery_estimate_race_test.go new file mode 100644 index 000000000..830f5f6fb --- /dev/null +++ b/internal/prt/delivery_estimate_race_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type advancingPRTEstimator struct { + *deliveryBackendFake + duringEstimate func() + raceBlock txsender.BlockRef +} + +func (b *advancingPRTEstimator) Estimate(ctx context.Context, msg ethereum.CallMsg, + block txsender.BlockRef) (uint64, error) { + if advance := b.duringEstimate; advance != nil { + b.raceBlock = block + b.duringEstimate = nil + advance() + } + // The protected historical estimate still sees the pre-completion state. + // Estimating against latest would instead revert on the completed join or epoch. + return b.deliveryBackendFake.Estimate(ctx, msg, block) +} + +func TestPRTPinnedEstimateSurvivesConcurrentActionCompletion(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe, model.DefaultBlock_Finalized} { + for _, kind := range []actionKind{joinTournament, acceptTournament} { + for _, ownInclusion := range []bool{false, true} { + name := policy.String() + "/" + actionName(kind) + "/" + + map[bool]string{false: "competitor completes action", true: "our transaction is included"}[ownInclusion] + t.Run(name, func(t *testing.T) { + f, call, view := preparePRTAction(t, kind) + f.d.defaultBlock = policy + backend := &advancingPRTEstimator{deliveryBackendFake: f.backend} + var err error + f.d.sender, err = txsender.New[actionID](backend, f.d.txOptsFactory, 31337, + f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + submitPRTAction(t, f, call) + original := f.block + backend.duringEstimate = func() { + if ownInclusion { + f.backend.nonce++ + } + if kind == joinTournament { + adapter, loadErr := f.factory.CreateTournamentAdapter(*f.epoch.TournamentAddress) + require.NoError(t, loadErr) + tournament, ok := adapter.(*tournamentAdapterMock) + require.True(t, ok) + for _, expectation := range tournament.ExpectedCalls { + if expectation.Method == "CommitmentStanding" { + expectation.ReturnArguments = mock.Arguments{ + CommitmentStanding{Joined: true, FinalState: *f.epoch.MachineHash}, nil, + } + } + } + } else { + view.sealed.EpochNumber++ + view.stage.EpochNumber++ + view.accept.EpochNumber++ + } + updatePRTView(f, original.Number+1, view) + } + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + err = f.d.tick(t.Context()) + want := txsender.ErrIneligible + if ownInclusion { + want = txsender.ErrStale + } + require.ErrorIs(t, err, want) + require.True(t, deliveryWaitOnly(err), "completed work must produce an ordinary publication wait: %v", err) + require.Equal(t, original, backend.raceBlock, "sizing must stay pinned when latest advances") + require.Equal(t, original.Number+1, f.backend.latest) + if ownInclusion { + require.Len(t, f.backend.published, 1, "a spent nonce must not produce another POST") + } else { + // The competitor made our held action obsolete without spending + // our nonce. Resolving it with a neutral replacement is valid. + require.Len(t, f.backend.published, 2) + neutral := f.backend.published[1] + require.Equal(t, f.repo.signer, *neutral.To()) + require.Empty(t, neutral.Data(), "fresh checks must stop redundant protocol calldata") + require.Zero(t, neutral.Value().Sign()) + require.Equal(t, f.backend.published[0].Nonce(), neutral.Nonce()) + } + require.NotContains(t, logs.String(), "PRT transaction could not publish") + require.NotContains(t, logs.String(), "level=ERROR") + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Empty(t, f.repo.windows, "only observation can persist protocol completion") + }) + } + } + } +} diff --git a/internal/prt/delivery_facts_test.go b/internal/prt/delivery_facts_test.go new file mode 100644 index 000000000..843e39d30 --- /dev/null +++ b/internal/prt/delivery_facts_test.go @@ -0,0 +1,114 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestFinalizedFactsRejectUnvalidatedCandidateWithoutPoisoningWatermark(t *testing.T) { + f, client := tickFixture(t) + tickHead(client, 100) + first, err := f.d.observationReference(t.Context()) + require.NoError(t, err) + inflated := &types.Header{Number: big.NewInt(1_000_000)} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(inflated, nil).Once() + // The ordinary numeric response disagrees with the single bad tag reply. + inflated.Extra = []byte("unvalidated tag reply") + _, err = f.d.observationReference(t.Context()) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, first, f.d.observedFacts) + tickHead(client, 101) + last, err := f.d.observationReference(t.Context()) + require.NoError(t, err) + require.Equal(t, uint64(101), last.Number) +} + +func TestFinalizedFactsWaitOnRegressionAndStopOnConfirmedContradiction(t *testing.T) { + for _, conflict := range []bool{false, true} { + t.Run(fmt.Sprint(conflict), func(t *testing.T) { + f := newDeliveryFixture(t) + f.d.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + f.d.client = client + header := &types.Header{Number: big.NewInt(100)} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(header, nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(header, nil).Once() + first, err := f.d.observationReference(t.Context()) + require.NoError(t, err) + older := &types.Header{Number: big.NewInt(90)} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(older, nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(90)).Return(older, nil).Once() + _, err = f.d.observationReference(t.Context()) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, first, f.d.observedFacts) + next := &types.Header{Number: big.NewInt(101)} + previous := types.CopyHeader(header) + if conflict { + previous.Extra = []byte("contradictory finalized branch") + } + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(next, nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(101)).Return(next, nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(previous, nil).Once() + _, err = f.d.observationReference(t.Context()) + if conflict { + require.ErrorIs(t, err, txsender.ErrFinality) + _, err = f.d.observationReference(t.Context()) + require.ErrorIs(t, err, txsender.ErrFinality, "a confirmed finalized contradiction cannot be demoted") + } else { + require.NoError(t, err) + } + client.AssertExpectations(t) + }) + } +} + +func TestProtectedReadFailureRetriesWithoutChangingProtocolState(t *testing.T) { + f, client := tickFixture(t) + f.backend.headerError = txsender.ErrUnsupported + for range 2 { + tickHead(client, 100) + require.ErrorIs(t, f.d.tick(t.Context()), txsender.ErrUnsupported) + } + require.False(t, f.d.publicationStopped()) + require.Len(t, f.backend.headerReads, 2) + require.Empty(t, f.backend.published) + f.backend.headerError = nil + f.repo.apps = nil + tickHead(client, 100) + require.NoError(t, f.d.tick(t.Context())) + require.Empty(t, f.backend.published) +} + +func TestObservedComputedEpochIncompleteProofMarksCorrupted(t *testing.T) { + for _, part := range []string{"transaction buffer", "iflags", "htif"} { + t.Run(part, func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, false) + switch part { + case "transaction buffer": + f.epoch.TxBufferProof = nil + case "iflags": + f.epoch.IflagsYDataBlock = nil + case "htif": + f.epoch.HtifTohostProof = f.epoch.HtifTohostProof[:model.StateProofSiblingCount-1] + } + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Corrupted, + mock.Anything).Return(nil).Once() + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + err := f.d.recordTournamentResult(t.Context(), f.app, f.epoch, view) + require.ErrorContains(t, err, "persisted machine state proof is incomplete") + require.Equal(t, model.ApplicationStatus_Corrupted, f.app.Status) + f.repo.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/delivery_final_guards_test.go b/internal/prt/delivery_final_guards_test.go new file mode 100644 index 000000000..dae917850 --- /dev/null +++ b/internal/prt/delivery_final_guards_test.go @@ -0,0 +1,42 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestPRTObserverFinalityContradictionStopsTheRealPublisherAfterALaterGoodHead(t *testing.T) { + f := newDeliveryFixture(t) + backend := f.backend + f.d.delivery, f.d.submissionEnabled = f.d, true + f.d.observerFacts = txsender.BlockRef{Number: 100, Hash: backend.headers[100].Hash()} + backend.advance(111) + client := ðClientMock{} + f.d.client = client + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(backend.headers[111], nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(111)).Return(backend.headers[111], nil).Once() + conflict := types.CopyHeader(backend.headers[100]) + conflict.Extra = []byte("contradictory finalized history") + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(conflict, nil).Once() + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + require.ErrorIs(t, f.d.observe(t.Context()), txsender.ErrFinality) + require.False(t, f.d.Ready()) + backend.advance(112) + call := f.d.call(f.app, f.epoch.Index, stageTournament, f.app.IConsensusAddress, []byte{1}, new(big.Int), 0) + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, call}) + require.ErrorIs(t, err, txsender.ErrFinality) + require.False(t, report.Attempted) + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + require.ErrorIs(t, f.d.tick(t.Context()), txsender.ErrFinality) + require.Empty(t, backend.published) + require.False(t, f.d.Ready(), "a later valid head cannot clear the observer's hard stop") + client.AssertExpectations(t) +} diff --git a/internal/prt/delivery_fixture_test.go b/internal/prt/delivery_fixture_test.go new file mode 100644 index 000000000..ffe5aec0a --- /dev/null +++ b/internal/prt/delivery_fixture_test.go @@ -0,0 +1,155 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" + "time" +) + +type deliveryFixture struct { + d *delivery + app *model.Application + epoch *model.Epoch + repo *deliveryRepoFake + backend *deliveryBackendFake + block txsender.BlockRef + consensus *daveConsensusAdapterMock + factory *adapterFactoryMock +} + +func newDeliveryFixture(t *testing.T) *deliveryFixture { + t.Helper() + service, legacyRepo := newPRTServiceMock() + app := prtRevertTestApp() + app.Enabled, app.LastTournamentCheckBlock = true, 100 + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + applyPRTStateProof(epoch, repotest.KeccakStateProof(common.HexToHash("0x500"))) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + key, err := crypto.GenerateKey() + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(31337)) + require.NoError(t, err) + repo := &deliveryRepoFake{prtRepositoryMock: legacyRepo, apps: []*model.Application{app}, signer: opts.From} + repo.publishRoot(app, epoch, 100) + legacyRepo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Maybe() + backend := &deliveryBackendFake{app: app, code: map[common.Address][]byte{opts.From: nil}, nonce: 7, + headers: map[uint64]*types.Header{}} + backend.advance(100) + backend.advance(110) + block := txsender.BlockRef{Number: 110, Hash: backend.headers[110].Hash()} + consensus := newCompatibleDaveConsensusAdapterMock() + factory := &adapterFactoryMock{} + factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil).Maybe() + service.repository, service.defaultBlock, service.pollInterval = repo, model.DefaultBlock_Finalized, 10*time.Millisecond + defaultClient := ðClientMock{} + expectNumericWindowHeaders(defaultClient) + defaultClient.On("HeaderByNumber", mock.Anything, mock.MatchedBy(func(number *big.Int) bool { + return number == nil || number.Sign() < 0 + })).Return(&types.Header{Number: big.NewInt(100)}, nil).Maybe() + service.client, service.adapterFactory = defaultClient, factory + service.txOptsFactory = ethutil.NewStaticTransactOptsFactory(opts) + policy, err := deliveryPolicy(config.PrtConfig{BlockchainTxMaxFeePerGas: "100"}) + require.NoError(t, err) + d := &delivery{Service: service, deliveryBackend: backend, deliveryPolicy: policy, + deliverySweeps: map[actionKind]*applicationSweep{}, + candidateCooldown: map[actionID]time.Time{}, rootBondRecoveryScans: map[int64]*rootBondRecoveryScan{}} + d.sender, err = txsender.New[actionID](backend, service.txOptsFactory, 31337, policy, d.guardPublication, service.Logger) + require.NoError(t, err) + return &deliveryFixture{d, app, epoch, repo, backend, block, consensus, factory} +} + +func (f *deliveryFixture) snapshot(view daveConsensusSnapshot) { + matcher := mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockNumber == nil && opts.BlockHash == f.block.Hash && !opts.Pending + }) + f.consensus.On("GetCurrentSealedEpoch", matcher).Return(view.sealed, nil).Maybe() + f.consensus.On("CanStageTournamentResult", matcher).Return(view.stage, nil).Maybe() + f.consensus.On("CanAcceptStagedTournamentResult", matcher).Return(view.accept, nil).Maybe() +} + +func (b *deliveryBackendFake) advance(number uint64) { + b.latest = number + b.headers[number] = &types.Header{Number: new(big.Int).SetUint64(number), GasLimit: 30_000_000, BaseFee: big.NewInt(1)} +} +func (b *deliveryBackendFake) Header(ctx context.Context, tag string) (*types.Header, error) { + b.headerReads = append(b.headerReads, tag) + if b.onHeader != nil { + if err := b.onHeader(ctx); err != nil { + return nil, err + } + } + if b.headerError != nil { + return nil, b.headerError + } + number := b.latest + if tag == "safe" || tag == "finalized" { + number = 100 + } + return types.CopyHeader(b.headers[number]), nil +} +func (b *deliveryBackendFake) Canonical(_ context.Context, block txsender.BlockRef) (bool, error) { + header := b.headers[block.Number] + return header != nil && header.Hash() == block.Hash, nil +} +func (b *deliveryBackendFake) Nonce(context.Context, common.Address, txsender.BlockRef) (uint64, error) { + return b.nonce, nil +} +func (b *deliveryBackendFake) Balance(context.Context, common.Address, txsender.BlockRef) (*big.Int, error) { + if b.balance != nil { + return new(big.Int).Set(b.balance), nil + } + return new(big.Int).Exp(big.NewInt(10), big.NewInt(22), nil), nil +} +func (b *deliveryBackendFake) Tip(context.Context) (*big.Int, error) { return big.NewInt(1), nil } +func (b *deliveryBackendFake) Price(context.Context) (*big.Int, error) { return big.NewInt(3), nil } +func (b *deliveryBackendFake) Estimate(context.Context, ethereum.CallMsg, txsender.BlockRef) (uint64, error) { + return intrinsicGasFloor, nil +} +func (b *deliveryBackendFake) Publish(_ context.Context, raw []byte) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + b.published = append(b.published, tx) + b.raw = append(b.raw, append([]byte(nil), raw...)) + if b.onPublish != nil { + b.onPublish(tx) + } + return tx.Hash(), b.publishError +} + +// singleAction drives real preparation/signing/POST for a domain plan. It does +// not emulate nonce retention or transaction publication. +type singleAction struct { + planner *delivery + action plannedAction +} + +// Direct sender tests create one fresh pass, like delivery.tick does in production. +func (d *delivery) Next(ctx context.Context, block txsender.BlockRef, + current *txsender.Current[actionID]) (txsender.Decision[actionID], error) { + return (&deliveryPass{delivery: d}).Next(ctx, block, current) +} + +func (p singleAction) Next(context.Context, txsender.BlockRef, *txsender.Current[actionID]) (txsender.Decision[actionID], error) { + return txsender.Publish(p.action.Key, p.action.Request), nil +} +func (p singleAction) Recheck(ctx context.Context, block txsender.BlockRef, action actionID) (txsender.Eligibility, error) { + return p.planner.Recheck(ctx, block, action) +} diff --git a/internal/prt/delivery_independence_test.go b/internal/prt/delivery_independence_test.go new file mode 100644 index 000000000..bead3bbab --- /dev/null +++ b/internal/prt/delivery_independence_test.go @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "log/slog" + "strings" + "testing" +) + +// A successful sibling cannot clear an incomplete application observation pass. +func deliveryRegressionObserveHeads(t *testing.T, withHealthySibling bool, heads int) string { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + stuck, healthy := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + failure := errors.New("provider cannot serve this tournament's logs") + epochs := []*model.Epoch{stuck} + if withHealthySibling { + epochs = append(epochs, healthy) + } + for i := range heads { + head := uint64(100 + i) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(head))).Return(uint64(2), nil).Once() + f.tournament(stuck, *stuck.TournamentAddress, 0, 2, 0, head, &TournamentEvents{}, failure) + if withHealthySibling { + f.tournament(healthy, *healthy.TournamentAddress, 0, 2, 0, head, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(b []*repository.TournamentEventBatch) bool { + return len(b) == 1 && b[0].Tournament.Address == *healthy.TournamentAddress + }), head).Return(nil).Once() + } + err := f.s.observeTournamentState(t.Context(), f.app, epochs, f.consensus, observerFacts(head)) + require.ErrorIs(t, err, failure) + } + return output.String() +} + +const deliveryRegressionStallMessage = "PRT tournament observation is stalled" + +func TestDeliveryStallAlarmControlSingleFailingTournament(t *testing.T) { + logs := deliveryRegressionObserveHeads(t, false, 6) + require.Contains(t, logs, deliveryRegressionStallMessage, "control: the alarm fires for a lone stuck tournament") +} + +func TestDeliveryStallAlarmFiresDespiteHealthySibling(t *testing.T) { + logs := deliveryRegressionObserveHeads(t, true, 6) + require.Contains(t, logs, deliveryRegressionStallMessage, + "a tournament that failed at six increasing heads must raise the stall alarm; got %d failure records cleared", + strings.Count(logs, "level=ERROR")) +} + +// deliveryRegressionHeldStage publishes a held stage through the real sender, then moves the +// protected live head to 111 (same protocol view), as in +// TestHeldConsensusSurvivesIncompleteObservationAndSpendingPause. + +// Held maintenance is independent of the observer's consecutive failures. + +// Replica skew is uncertainty, not proof that the held action must be cancelled. diff --git a/internal/prt/delivery_initial_root_test.go b/internal/prt/delivery_initial_root_test.go new file mode 100644 index 000000000..5bfc89b6f --- /dev/null +++ b/internal/prt/delivery_initial_root_test.go @@ -0,0 +1,172 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +// Store the observed projections only after the complete-window write succeeds. +// The normal fake records writes but does not expose their resulting read view. +type initialRootRepository struct { + *deliveryRepoFake +} + +func TestDeliveryFirstRootDiscoveredAfterEmptyObserveDoesNotNeedAnotherBlock(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.app.LastTournamentCheckBlock = 0 + f.repo.publishedRoots = nil + f.d.repository = &initialRootRepository{f.repo} + f.checkpoint.epochs() + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(f.facts)).Return(CurrentSealedEpoch{}, nil).Once() + expectProtectionReads(t, f.factory, f.consensus, f.facts) + require.NoError(t, f.d.observeApplication(t.Context(), f.app, f.facts)) + require.Zero(t, f.app.LastTournamentCheckBlock, "an empty initial observation cannot suppress same-head root discovery") + require.Empty(t, f.repo.windows) + f.consensus.AssertNotCalled(t, "TournamentLevelCount", mock.Anything) + + // The reader now publishes this head's first sealed epoch. Its computation + // is still pending; no additional chain block is available. + f.epoch.Status = model.EpochStatus_Closed + f.epoch.LastBlock = f.facts.Number + f.checkpoint.epochs(f.epoch) + f.levels(1) + address := *f.epoch.TournamentAddress + f.repo.targets = []testTournamentScan{{ + EpochIndex: f.epoch.Index, Address: address, FromBlock: f.facts.Number, ThroughBlock: f.facts.Number, + }} + f.block = f.facts + view := resultTestSnapshot(f.epoch, false) + view.stage.IsFinished = false + f.snapshot(view) + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil) + opts := mock.MatchedBy(resultCallOptsAtBlock(f.facts.Number)) + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, Level: 0, + Height: model.Log2EpochComputationHashLeafCount, + Log2Stride: model.Log2RootTournamentStride, + StartInstant: f.facts.Number, Allowance: 300, + } + adapter.On("Descriptor", opts).Return(descriptor, nil).Twice() + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + }, nil).Twice() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, model.TournamentStandingAwaitingClosure) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == f.facts.Number && opts.End != nil && *opts.End == f.facts.Number + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.checkpoint.emptyParticipants(f.epoch, address) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + require.NoError(t, f.d.observeApplication(t.Context(), f.app, f.facts)) + require.Len(t, f.repo.windows, 1) + window := f.repo.windows[0] + require.Zero(t, window.ExpectedPreviousCursor) + require.Equal(t, f.repo.targets, window.CompletedScans) + require.Len(t, window.Batches, 1) + require.Equal(t, f.facts.Number, f.app.LastTournamentCheckBlock) + require.Equal(t, model.EpochStatus_Closed, f.epoch.Status) + + // Local computation can finish at this same head. The published root now + // supplies the prerequisite for an eligible join, without cursor repair. + f.epoch.Status = model.EpochStatus_ClaimComputed + adapter.On("CommitmentStanding", opts, [32]byte(*f.epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("BondValue", opts).Return(big.NewInt(50), nil).Once() + call, err := f.d.planAction(t.Context(), f.app, joinTournament, f.block, f.facts) + require.NoError(t, err) + require.NotNil(t, call) + require.Equal(t, joinTournament, call.Key.Kind) + require.Equal(t, f.epoch.Index, call.Key.Epoch) + require.Equal(t, address, call.To) + require.Equal(t, uint64(400), call.Deadline) + require.Empty(t, f.backend.published) + adapter.AssertExpectations(t) +} + +func TestDeliveryDoesNotInventRootWithoutSealedEpochEvidence(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.repo.publishedRoots = nil + require.NoError(t, f.d.observeWindow(t.Context(), f.app, nil, f.consensus, f.facts)) + require.Empty(t, f.repo.windows) + require.Empty(t, f.backend.published) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + f.consensus.AssertNotCalled(t, "TournamentLevelCount", mock.Anything) +} + +func TestDeliveryInitialRootIsObservedAtItsSealingBlockWithoutLocalProof(t *testing.T) { + statuses := []model.EpochStatus{ + model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, model.EpochStatus_ClaimComputed, + } + for _, status := range statuses { + for _, discoveredAfterList := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/target_discovered_after_list=%t", status, discoveredAfterList), func(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.epoch.LastBlock = f.facts.Number + f.epoch.Status = status + f.epoch.Commitment, f.epoch.MachineHash, f.epoch.TxBufferDataBlock = nil, nil, nil + f.epoch.CommitmentProof = nil + f.app.LastEpochCheckBlock = f.facts.Number - 1 // frozen reader does not cap this known root + f.levels(1) + address := *f.epoch.TournamentAddress + f.repo.publishedRoots = nil + loads := 1 + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()). + Return((*model.Tournament)(nil), nil).Times(loads) + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(f.facts.Number)) + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, Level: 0, + Height: model.Log2EpochComputationHashLeafCount, + Log2Stride: model.Log2RootTournamentStride, + StartInstant: f.facts.Number, Allowance: 300, + } + adapter.On("Descriptor", opts).Return(descriptor, nil).Once() + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, model.TournamentStandingAwaitingClosure) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == f.facts.Number && opts.End != nil && *opts.End == f.facts.Number + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.checkpoint.emptyParticipants(f.epoch, address) + f.repo.targets = []testTournamentScan{{ + EpochIndex: f.epoch.Index, Address: address, FromBlock: f.facts.Number, ThroughBlock: f.facts.Number, + }} + var epochs []*model.Epoch + if discoveredAfterList { + require.NoError(t, f.d.observeWindow(t.Context(), f.app, nil, f.consensus, f.facts)) + } + { + f.checkpoint.epochs(f.epoch) + var err error + epochs, _, err = getTournamentObservationEpochs(t.Context(), f.repo, f.app.Name) + require.NoError(t, err) + require.Len(t, epochs, 1, "observation selects a known root independently of local proof readiness") + } + require.NoError(t, f.d.observeWindow(t.Context(), f.app, epochs, f.consensus, f.facts)) + require.Len(t, f.repo.windows, 1) + window := f.repo.windows[0] + require.Equal(t, f.repo.targets, window.CompletedScans) + require.Len(t, window.Batches, 1) + require.Equal(t, address, window.Batches[0].Tournament.Address) + require.Equal(t, f.facts.Number, window.Batches[0].Tournament.Snapshot.AsOfBlock) + require.Equal(t, f.facts.Number, f.app.LastTournamentCheckBlock) + require.Equal(t, status, f.epoch.Status) + require.Empty(t, f.backend.published) + adapter.AssertExpectations(t) + }) + } + } +} diff --git a/internal/prt/delivery_join_refusal.go b/internal/prt/delivery_join_refusal.go new file mode 100644 index 000000000..1f7931f74 --- /dev/null +++ b/internal/prt/delivery_join_refusal.go @@ -0,0 +1,59 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" +) + +// A join refusal names a protection opportunity that planning cannot publish. +// Ordinary no-work results, including an already joined commitment, are not refusals. +type joinRefusal struct { + epoch uint64 + tournament common.Address + deadline uint64 + reason string +} + +func (e *joinRefusal) Error() string { return e.reason } +func (e *joinRefusal) Unwrap() error { return txsender.ErrIneligible } + +type joinRefusalWarning struct { + epoch uint64 + warnedAt time.Time +} + +func (s *delivery) warnJoinPlanningRefusal(app *model.Application, snapshot txsender.BlockRef, err error) { + var refusal *joinRefusal + if !errors.As(err, &refusal) { + return + } + if s.diagnostics.joinRefusals == nil { + s.diagnostics.joinRefusals = map[int64]joinRefusalWarning{} + } + // Keep one warning per application, and retire inactive entries. Removed + // applications must not leave a lifetime warning registry in the publisher. + for id, warning := range s.diagnostics.joinRefusals { + if time.Since(warning.warnedAt) >= 2*publicationWarningInterval { + delete(s.diagnostics.joinRefusals, id) + } + } + previous := s.diagnostics.joinRefusals[app.ID] + if previous.epoch == refusal.epoch && !warningDue(previous.warnedAt) { + return + } + var deadline any = "unknown" + if refusal.deadline != 0 { + deadline = refusal.deadline + } + s.Logger.Warn("PRT cannot join tournament; this node cannot defend this epoch", + "application", app.Name, "address", app.IApplicationAddress, "epoch", refusal.epoch, + "tournament", refusal.tournament, "block", snapshot.Number, "deadline", deadline, "reason", refusal.reason) + s.diagnostics.joinRefusals[app.ID] = joinRefusalWarning{epoch: refusal.epoch, warnedAt: time.Now()} +} diff --git a/internal/prt/delivery_kind_fairness_test.go b/internal/prt/delivery_kind_fairness_test.go new file mode 100644 index 000000000..ad24fa462 --- /dev/null +++ b/internal/prt/delivery_kind_fairness_test.go @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +type delayedPlanFactory struct { + AdapterFactory + application common.Address + delay time.Duration +} + +func (f *delayedPlanFactory) CreateDaveConsensusAdapter(address common.Address) (DaveConsensusAdapter, error) { + adapter, err := f.AdapterFactory.CreateDaveConsensusAdapter(address) + if err != nil || (f.application != (common.Address{}) && address != f.application) { + return adapter, err + } + return &delayedPlanConsensus{DaveConsensusAdapter: adapter, delay: f.delay}, nil +} + +type delayedPlanConsensus struct { + DaveConsensusAdapter + delay time.Duration +} + +func (a *delayedPlanConsensus) GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) { + timer := time.NewTimer(a.delay) + defer timer.Stop() + select { + case <-opts.Context.Done(): + return CurrentSealedEpoch{}, opts.Context.Err() + case <-timer.C: + return a.DaveConsensusAdapter.GetCurrentSealedEpoch(opts) + } +} + +func TestEarlierJoinScanHonorsParentCancellation(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, false) + view.stage.IsFinished = false + f.snapshot(view) + deliveryJoinAdapter(f, f.epoch, 15) + addSecondStageApplication(f) + f.d.adapterFactory = &delayedPlanFactory{AdapterFactory: f.factory, + application: f.app.IConsensusAddress, delay: 120 * time.Millisecond} + ctx, done := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer done() + urgent, err := f.d.candidates(ctx, f.block, observerFacts(100)) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, urgent) + require.Empty(t, f.backend.published, "scanning urgency only constructs a plan") +} diff --git a/internal/prt/delivery_lifecycle_test.go b/internal/prt/delivery_lifecycle_test.go new file mode 100644 index 000000000..26c69089b --- /dev/null +++ b/internal/prt/delivery_lifecycle_test.go @@ -0,0 +1,159 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type lifecycleBackend struct { + *deliveryBackendFake + armed bool + started chan struct{} + release chan struct{} + exited chan struct{} +} + +func (b *lifecycleBackend) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + if !b.armed { + return b.deliveryBackendFake.Publish(ctx, raw) + } + close(b.started) + <-ctx.Done() + <-b.release + close(b.exited) + return common.Hash{}, ctx.Err() +} + +func waitForDeliverySignal(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for delivery lifecycle transition") + } +} + +func TestSubmittingLoopsMaintainWhileObserverBlocksAndWaitForBothToExit(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + base := f.backend + submitPRTAction(t, f, call) + f.backend.foreclosure = true + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Current.Neutral) + backend := &lifecycleBackend{deliveryBackendFake: base, started: make(chan struct{}), + release: make(chan struct{}), exited: make(chan struct{})} + // Rebuild the sender with the same key. Its empty cache reconstructs fresh + // work; choose a useful stage and then block that publication at POST. + f.backend.foreclosure = false + sender, err := txsender.New[actionID](backend, f.d.txOptsFactory, 31337, f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + f.d.sender, f.d.deliveryBackend = sender, backend + + observerBlocked, releaseObserver, observerExited := make(chan struct{}), make(chan struct{}), make(chan struct{}) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Run(func(args mock.Arguments) { + close(observerBlocked) + <-args.Get(0).(context.Context).Done() + <-releaseObserver + close(observerExited) + }).Return([]*model.Epoch{}, uint64(0), context.Canceled).Once() + client := ðClientMock{} + f.d.client = client + f.d.observedFacts = txsender.BlockRef{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(base.headers[100], nil) + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(base.headers[100], nil) + f.d.delivery, f.d.submissionEnabled, f.d.defaultBlock = f.d, true, model.DefaultBlock_Finalized + f.d.pollInterval = time.Millisecond + require.NoError(t, service.InitTickServiceTemplate(&f.d.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: PrtConfigKey, Logger: f.d.Logger}, PollInterval: time.Hour, + }, f.d.Service)) + backend.armed = true + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- f.d.Serve(ctx) }() + waitForDeliverySignal(t, observerBlocked) + waitForDeliverySignal(t, backend.started) + cancel() + close(backend.release) + waitForDeliverySignal(t, backend.exited) + select { + case err = <-done: + t.Fatalf("Serve returned before observer cleanup completed: %v", err) + default: + } + close(releaseObserver) + select { + case err = <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(5 * time.Second): + t.Fatal("submitting service did not stop") + } + require.False(t, f.d.Ready(), "shutdown clears publication readiness") +} + +func TestSubmittingLoopsRateLimitRecoverablePublicationWarnings(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + f.d.delivery, f.d.pollInterval = f.d, time.Millisecond + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + observerStarted := make(chan struct{}) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Run(func(args mock.Arguments) { + close(observerStarted) + <-args.Get(0).(context.Context).Done() + }).Return([]*model.Epoch{}, uint64(0), context.Canceled).Once() + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(f.backend.headers[100], nil) + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(f.backend.headers[100], nil) + f.d.client = client + require.NoError(t, service.InitTickServiceTemplate(&f.d.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: PrtConfigKey, Logger: f.d.Logger}, PollInterval: time.Hour, + }, f.d.Service)) + failure := errors.New("provider execution read unavailable") + attempts := 0 + f.backend.onPreflight = func(ctx context.Context, _ ethereum.CallMsg, _ txsender.BlockRef) error { + select { + case <-observerStarted: + case <-ctx.Done(): + return ctx.Err() + } + attempts++ + if attempts == 3 { + cancel() + } + return failure + } + require.ErrorIs(t, f.d.serveLoops(ctx), context.Canceled) + require.Equal(t, 3, attempts, "the actual publisher loop must retry the recoverable failure") + require.Equal(t, 1, strings.Count(logs.String(), "PRT publication deferred"), logs.String()) + require.Equal(t, 1, strings.Count(logs.String(), "PRT transaction could not publish"), logs.String()) + require.NotContains(t, logs.String(), "level=ERROR") + require.Contains(t, logs.String(), failure.Error()) + require.Len(t, f.backend.published, 1, "all subsequent attempts failed before publication") + f.repo.AssertExpectations(t) +} diff --git a/internal/prt/delivery_observation_interval_test.go b/internal/prt/delivery_observation_interval_test.go new file mode 100644 index 000000000..e29523741 --- /dev/null +++ b/internal/prt/delivery_observation_interval_test.go @@ -0,0 +1,216 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +type deliveryIntervalFixture struct { + *deliveryFixture + checkpoint *observerCheckpointFixture + facts txsender.BlockRef +} + +func newDeliveryIntervalFixture(t *testing.T) *deliveryIntervalFixture { + t.Helper() + f := newDeliveryFixture(t) + f.app.LastTournamentCheckBlock = 50 + f.app.LastEpochCheckBlock = 40 // The EVM Reader has stopped before this tournament interval. + f.epoch.LastBlock = 10 + client := ðClientMock{} + expectNumericWindowHeaders(client) + header := &types.Header{Number: big.NewInt(100)} + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(header, nil).Maybe() + f.d.client = client + checkpoint := &observerCheckpointFixture{t: t, s: f.d.Service, repo: f.repo.prtRepositoryMock, + factory: f.factory, consensus: f.consensus, client: client, app: f.app} + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + f.consensus.AssertExpectations(t) + client.AssertExpectations(t) + }) + return &deliveryIntervalFixture{f, checkpoint, txsender.BlockRef{Number: 100, Hash: header.Hash()}} +} + +func (f *deliveryIntervalFixture) levels(levels uint64) { + f.checkpoint.t.Helper() + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(f.facts.Number))). + Return(levels, nil).Once() +} + +func TestDeliveryIntervalIncludesEmptyRootAndChildScans(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.levels(2) + root := f.checkpoint.tournament(f.epoch, *f.epoch.TournamentAddress, 0, 2, 90, 100, &TournamentEvents{}, nil) + childAddress := common.HexToAddress("0x101") + child := f.checkpoint.tournament(f.epoch, childAddress, 1, 2, 88, 100, &TournamentEvents{}, nil) + f.checkpoint.children(f.epoch, root, child) + f.repo.targets = []testTournamentScan{ + {EpochIndex: f.epoch.Index, Address: root.Address, FromBlock: 51, ThroughBlock: 100}, + {EpochIndex: f.epoch.Index, Address: child.Address, FromBlock: 51, ThroughBlock: 100}, + } + require.NoError(t, f.d.observeWindow(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, f.facts)) + require.Len(t, f.repo.windows, 2, "stored parent and child commit their own independent intervals") + var scans []testTournamentScan + for _, interval := range f.repo.windows { + require.Equal(t, f.app.ID, interval.ApplicationID) + require.Equal(t, uint64(50), interval.ExpectedPreviousCursor) + require.Equal(t, uint64(100), interval.ThroughBlock) + require.Len(t, interval.Batches, 1) + scans = append(scans, interval.CompletedScans...) + } + require.ElementsMatch(t, f.repo.targets, scans) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + +} + +func TestDeliveryIntervalIncludesNewRootHistoryBeforeCursor(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.repo.publishedRoots = nil + f.levels(1) + address := *f.epoch.TournamentAddress + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()). + Return((*model.Tournament)(nil), nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, StartInstant: f.epoch.LastBlock, + } + adapter.On("Descriptor", opts).Return(descriptor, nil).Once() + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: model.TournamentStandingRootWinner, HasCandidate: true, Candidate: *f.epoch.Commitment, + FinalState: *f.epoch.MachineHash, FinishedAt: 49, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, model.TournamentStandingRootWinner) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 10 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.checkpoint.emptyParticipants(f.epoch, address) + f.repo.targets = []testTournamentScan{{ + EpochIndex: f.epoch.Index, Address: address, FromBlock: 10, ThroughBlock: 100, + }} + require.NoError(t, f.d.observeWindow(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, f.facts)) + require.Len(t, f.repo.windows, 1) + require.Zero(t, f.repo.windows[0].ExpectedPreviousCursor, "new root has no previous own cursor") + require.Equal(t, f.repo.targets, f.repo.windows[0].CompletedScans) + require.Len(t, f.repo.windows[0].Batches, 1) + require.Equal(t, address, f.repo.windows[0].Batches[0].Tournament.Address) + adapter.AssertExpectations(t) +} + +func TestDeliveryIntervalReadsRequiredChildOutsideRootList(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.levels(2) + address := common.HexToAddress("0x102") + child := f.checkpoint.tournament(f.epoch, address, 1, 2, 88, 100, &TournamentEvents{}, nil) + child.ParentTournamentAddress = f.epoch.TournamentAddress + child.ParentMatchIDHash = new(common.HexToHash("0xabc")) + f.repo.targets = []testTournamentScan{{ + EpochIndex: f.epoch.Index, Address: address, FromBlock: 51, ThroughBlock: 100, + }} + require.NoError(t, f.d.observeWindow(t.Context(), f.app, nil, f.consensus, f.facts)) + require.Len(t, f.repo.windows, 1) + require.Equal(t, f.repo.targets, f.repo.windows[0].CompletedScans) + require.Len(t, f.repo.windows[0].Batches, 1) + require.Equal(t, address, f.repo.windows[0].Batches[0].Tournament.Address) +} + +func TestDeliveryIntervalMissingRequiredChildDoesNotCommitPrefix(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.levels(2) + f.repo.publishedRoots = nil + address := common.HexToAddress("0x103") + f.repo.storedTournaments = []*model.Tournament{{ApplicationID: f.app.ID, EpochIndex: f.epoch.Index, Address: address, + Level: 1, MaxLevel: 2, ParentTournamentAddress: f.epoch.TournamentAddress, Snapshot: model.TournamentSnapshot{AsOfBlock: 50}}} + f.repo.targets = []testTournamentScan{{ + EpochIndex: f.epoch.Index, Address: address, FromBlock: 51, ThroughBlock: 100, + }} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()). + Return((*model.Tournament)(nil), repository.ErrNotFound).Once() + require.ErrorIs(t, f.d.observeWindow(t.Context(), f.app, nil, f.consensus, f.facts), repository.ErrNotFound) + require.Empty(t, f.repo.windows) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) +} + +func TestDeliveryIntervalRejectsChangedHashBeforeScanning(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.d.defaultBlock = model.DefaultBlock_Latest + f.facts.Hash = common.HexToHash("0xBAD") + f.repo.publishedRoots = nil + f.repo.storedTournaments = []*model.Tournament{{ApplicationID: f.app.ID, EpochIndex: f.epoch.Index, + Address: *f.epoch.TournamentAddress, Snapshot: model.TournamentSnapshot{AsOfBlock: 50}}} + require.ErrorIs(t, f.d.observeWindow(t.Context(), f.app, nil, f.consensus, f.facts), txsender.ErrStale) + require.Empty(t, f.repo.windows) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + f.consensus.AssertNotCalled(t, "TournamentLevelCount", mock.Anything) +} + +func TestDeliveryIntervalFailedPublicationPreservesCursor(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.levels(1) + f.checkpoint.tournament(f.epoch, *f.epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.windowError = repository.ErrTournamentIntervalStale + require.ErrorIs(t, f.d.observeWindow(t.Context(), f.app, nil, f.consensus, f.facts), repository.ErrTournamentIntervalStale) + require.Len(t, f.repo.windows, 1) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) +} + +func TestDeliveryObservesEnabledTerminalApplicationButStopsDisabledApplication(t *testing.T) { + for _, enabled := range []bool{false, true} { + name := "disabled" + if enabled { + name = "enabled terminal" + } + t.Run(name, func(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.app.Enabled = enabled + f.app.Status = model.ApplicationStatus_Failed + f.app.ForecloseBlock = 30 + if !enabled { + require.NoError(t, f.d.observeApplication(t.Context(), f.app, f.facts)) + require.Empty(t, f.repo.windows) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + f.repo.AssertNotCalled(t, "ListEpochs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + return + } + f.checkpoint.epochs(f.epoch) + f.levels(1) + f.checkpoint.tournament(f.epoch, *f.epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + require.NoError(t, f.d.observeApplication(t.Context(), f.app, f.facts)) + require.Len(t, f.repo.windows, 1) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, uint64(40), f.app.LastEpochCheckBlock) + require.Equal(t, model.ApplicationStatus_Failed, f.app.Status) + require.Empty(t, f.backend.published, "observation does not publish actions for a terminal application") + }) + } +} + +func TestDeliveryDisabledApplicationStopsAndClearsObservationFailure(t *testing.T) { + f := newDeliveryIntervalFixture(t) + f.app.Enabled = false + f.app.Status = model.ApplicationStatus_Failed + f.d.observationFailures[f.app.ID] = tournamentObservationFailure{failedHeads: tournamentObservationFailureThreshold} + err := f.d.observeApplication(t.Context(), f.app, f.facts) + require.NoError(t, err) + require.NotContains(t, f.d.observationFailures, f.app.ID) + require.Empty(t, f.repo.windows) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + f.repo.AssertNotCalled(t, "ListEpochs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/prt/delivery_observe.go b/internal/prt/delivery_observe.go new file mode 100644 index 000000000..2bf9083c3 --- /dev/null +++ b/internal/prt/delivery_observe.go @@ -0,0 +1,384 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// validatedObservationReference validates the selected head itself and the +// previously accepted finalized hash. Callers own their separate watermarks. +func (s *Service) validatedObservationReference(ctx context.Context, previous txsender.BlockRef) (txsender.BlockRef, error) { + header, err := s.getDefaultBlockHeader(ctx) + if err != nil { + return txsender.BlockRef{}, err + } + candidate := txsender.BlockRef{Number: header.Number.Uint64(), Hash: header.Hash()} + canonical, err := s.client.HeaderByNumber(ctx, new(big.Int).SetUint64(candidate.Number)) + if err != nil { + return txsender.BlockRef{}, err + } + if canonical == nil || canonical.Hash() != candidate.Hash { + return txsender.BlockRef{}, txsender.ErrInconclusive + } + if s.defaultBlock == model.DefaultBlock_Finalized && previous.Hash != (common.Hash{}) { + if candidate.Number < previous.Number { + return txsender.BlockRef{}, txsender.ErrInconclusive + } + old, readErr := s.client.HeaderByNumber(ctx, new(big.Int).SetUint64(previous.Number)) + if readErr != nil { + return txsender.BlockRef{}, readErr + } + if old == nil { + return txsender.BlockRef{}, txsender.ErrInconclusive + } + if old.Hash() != previous.Hash { + return txsender.BlockRef{}, fmt.Errorf("%w: block %d saved_hash=%s canonical_hash=%s", + txsender.ErrFinality, previous.Number, previous.Hash, old.Hash()) + } + } + return candidate, nil +} + +// observe owns traversal and domain writes at the selected policy. A failed tournament +// rolls back its own interval, without discarding another tournament's progress. +func (s *Service) observe(ctx context.Context) error { + filter := repository.ApplicationFilter{ConsensusType: new(model.Consensus_PRT), Enabled: new(true)} + through, err := s.repository.GetApplicationIDBoundary(ctx, filter) + if err != nil || through == 0 { + if err == nil { + s.protectionNotices = nil + } + return err + } + wasStopped := s.publicationStopped() + defer func() { + if !wasStopped && s.publicationStopped() { + s.reportGlobalProtectionStop(ctx, through, s.publicationFailure()) + } + }() + facts, err := s.validatedObservationReference(ctx, s.observerFacts) + if err != nil { + if errors.Is(err, txsender.ErrFinality) { + s.stopPublication(err) + } + return err + } + s.observerFacts = facts + var errs []error + active := make(map[int64]struct{}) + for after := int64(0); after < through; { + apps, listErr := s.repository.ListApplicationsByID(ctx, filter, after, through, deliveryApplicationPageSize) + if listErr != nil { + return errors.Join(append(errs, listErr)...) + } + if len(apps) == 0 { + break + } + for _, app := range apps { + active[app.ID] = struct{}{} + after = app.ID + if err = s.observeApplication(ctx, app, facts); err != nil { + errs = append(errs, fmt.Errorf("observing PRT application %s: %w", app.Name, err)) + } + if ctx.Err() != nil { + return errors.Join(append(errs, ctx.Err())...) + } + } + } + for id := range s.protectionNotices { + if _, ok := active[id]; !ok { + delete(s.protectionNotices, id) + } + } + return errors.Join(errs...) +} + +func (s *Service) observeApplication(ctx context.Context, app *model.Application, facts txsender.BlockRef) (resultErr error) { + if !app.Enabled { + s.clearTournamentObservationFailure(app.ID) + delete(s.protectionNotices, app.ID) + return nil + } + var deploymentErr error + defer func() { resultErr = errors.Join(resultErr, deploymentErr) }() + s.warnZeroStagingPeriod(app) + epochs, _, epochErr := getTournamentObservationEpochs(ctx, s.repository, app.Name) + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if epochErr == nil && err == nil && len(epochs) == 0 && + app.Status == model.ApplicationStatus_OK && app.LastTournamentCheckBlock == 0 { + // Registration at latest does not establish visibility at this observation block. + opts := pinnedCallOpts(withLiveBlock(ctx, facts.Number, facts.Hash), facts.Number) + if _, visibilityErr := consensus.GetCurrentSealedEpoch(opts); errors.Is(visibilityErr, bind.ErrNoCode) { + return nil + } + } + clocks := s.observeProtection(ctx, app, epochs, consensus, facts, errors.Join(epochErr, err)) + var deployment deploymentSnapshot + if err == nil { + deployment, deploymentErr = s.observeDeployment(ctx, app, consensus, facts) + } + state, generation, admissionErr := s.deploymentVerdictState(ctx, app) + deploymentErr = errors.Join(deploymentErr, admissionErr) + s.reportProtectionStops(ctx, app, clocks, facts, state, generation, deploymentErr) + if ctx.Err() != nil { + return errors.Join(epochErr, err, ctx.Err()) + } + if epochErr != nil || err != nil { + return errors.Join(epochErr, err) + } + var view *daveConsensusSnapshot + var eligibilityErr error + if completionStatusAllowed(app.Status) && !app.IsForeclosed() { + for _, epoch := range epochs { + if !pendingTournamentResult(epoch) || epoch.LastBlock > facts.Number { + continue + } + eligible, readErr := s.observerCompletionEpoch(ctx, app, epoch) + if readErr != nil { + eligibilityErr = errors.Join(eligibilityErr, readErr) + continue + } + if eligible == nil { + continue + } + snapshot, readErr := readDaveConsensusSnapshot(withLiveBlock(ctx, facts.Number, facts.Hash), consensus, facts.Number) + if errors.Is(readErr, bind.ErrNoCode) && app.Status == model.ApplicationStatus_OK { + return nil + } + if readErr != nil { + s.recordTournamentObservationFailure(ctx, app, facts.Number, facts.Number, readErr) + readErr = fmt.Errorf("application %s (%s), consensus snapshot block %d hash %s: %w", + app.Name, app.IApplicationAddress, facts.Number, facts.Hash, readErr) + if app.Status == model.ApplicationStatus_OK { + return readErr + } + eligibilityErr = errors.Join(eligibilityErr, readErr) + break + } + view = &snapshot + break + } + } + observeErr := eligibilityErr + if deployment.Alpha6Shape { + observeErr = errors.Join(observeErr, s.observeTournamentState(ctx, app, epochs, consensus, facts)) + } + if ctx.Err() != nil { + return errors.Join(observeErr, ctx.Err()) + } + // Existing committed roots remain independently usable even if another root + // failed. Reconciliation uses each owning root's durable endpoint below. + if eligibilityErr != nil || !completionStatusAllowed(app.Status) { + return observeErr + } + if app.IsForeclosed() { + return errors.Join(observeErr, s.drainForeclosedClaims(ctx, app, epochs, consensus, facts.Number)) + } + deferred, reconcileErr := s.reconcileAcceptedEpochs(ctx, app, epochs, consensus, facts.Number) + if reconcileErr != nil || deferred || len(epochs) == 0 { + return errors.Join(observeErr, reconcileErr) + } + hasCurrentRoot := false + for _, epoch := range epochs { + if !pendingTournamentResult(epoch) || epoch.TournamentAddress == nil { + continue + } + root, loadErr := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()) + if loadErr != nil { + return errors.Join(observeErr, loadErr) + } + if rootObservationCovers(root, facts.Number) { + hasCurrentRoot = true + break + } + } + if !hasCurrentRoot { + return observeErr + } + if view == nil { + return observeErr + } + epoch, err := s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), view.sealed.EpochNumber) + if err != nil { + return errors.Join(observeErr, err) + } + if pendingTournamentResult(epoch) && (epoch.StagedAtBlock == nil || *epoch.StagedAtBlock <= facts.Number) { + epoch, err = s.observerCompletionEpoch(ctx, app, epoch) + if err != nil || epoch == nil { + return errors.Join(observeErr, err) + } + root, readErr := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), view.sealed.Tournament.Hex()) + if readErr != nil { + return errors.Join(observeErr, readErr) + } + if rootObservationCovers(root, facts.Number) { + err = s.recordTournamentResult(ctx, app, epoch, *view) + } + } + return errors.Join(observeErr, err) +} + +func (s *Service) observeTournamentState( + ctx context.Context, app *model.Application, epochs []*model.Epoch, consensus DaveConsensusAdapter, facts txsender.BlockRef, +) error { + stored, _, err := s.repository.ListTournaments(ctx, app.Name, repository.TournamentFilter{}, repository.Pagination{}, false) + if err != nil { + return err + } + known := make(map[common.Address]bool, len(stored)) + var targets []*model.Tournament + for _, tournament := range stored { + known[tournament.Address] = true + if tournament.Snapshot.AsOfBlock < facts.Number && + !tournamentObservationComplete(tournament, tournament.Snapshot.AsOfBlock, facts.Number) { + targets = append(targets, tournament) + } + } + for _, epoch := range epochs { + if epoch.TournamentAddress != nil && epoch.LastBlock <= facts.Number && !known[*epoch.TournamentAddress] { + targets = append(targets, &model.Tournament{ApplicationID: app.ID, EpochIndex: epoch.Index, + Address: *epoch.TournamentAddress, StartInstant: epoch.LastBlock}) + } + } + if len(targets) == 0 { + if failure, exists := s.observationFailures[app.ID]; exists && facts.Number >= failure.lastHead { + s.clearTournamentObservationFailure(app.ID) + } + return nil + } + if err := s.validateObservationBlock(ctx, facts); err != nil { + return err + } + ctx = withLiveBlock(ctx, facts.Number, facts.Hash) + levels, err := consensus.TournamentLevelCount(pinnedCallOpts(ctx, facts.Number)) + if err == nil && levels == 0 { + err = errors.New("tournament level count is zero") + } + if err != nil { + s.recordTournamentObservationFailure(ctx, app, facts.Number, tournamentObservationStart(targets[0]), err) + return err + } + epochsByIndex := make(map[uint64]*model.Epoch, len(epochs)) + for _, epoch := range epochs { + epochsByIndex[epoch.Index] = epoch + } + var errs []error + var failedFrom *uint64 + completed := false + for _, target := range targets { + epoch := epochsByIndex[target.EpochIndex] + var loadErr error + if epoch == nil { + epoch, loadErr = s.repository.GetEpoch(ctx, app.IApplicationAddress.Hex(), target.EpochIndex) + } + if loadErr != nil || epoch == nil { + errs = append(errs, errors.Join(loadErr, fmt.Errorf("missing epoch for %s", target.Address))) + if failedFrom == nil { + failedFrom = new(tournamentObservationStart(target)) + } + continue + } + var expected *uint64 + if known[target.Address] { + expected = new(target.Snapshot.AsOfBlock) + } + batches, readErr := s.gatherTournamentData(ctx, app, epoch, TournamentLevel(target.Level), target.ParentMatchIDHash, + target.ParentTournamentAddress, target.Address, levels, facts.Number, nil, nil) + if readErr == nil && len(batches) > 0 { + // Revalidate after all reads and before publishing this complete unit. + readErr = s.validateObservationBlock(ctx, facts) + if readErr == nil { + readErr = s.repository.StoreTournamentInterval(ctx, app.ID, expected, batches) + if readErr == nil { + app.LastTournamentCheckBlock = max(app.LastTournamentCheckBlock, facts.Number) + } + } + } + if readErr != nil { + errs = append(errs, fmt.Errorf("tournament %s interval: %w", target.Address, readErr)) + if !errors.Is(readErr, repository.ErrTournamentIntervalStale) && failedFrom == nil { + failedFrom = new(tournamentObservationStart(target)) + } + } else if len(batches) > 0 { + completed = true + } + if ctx.Err() != nil { + errs = append(errs, ctx.Err()) + break + } + } + passErr := errors.Join(errs...) + if failedFrom != nil { + s.recordTournamentObservationFailure(ctx, app, facts.Number, *failedFrom, passErr) + } else if completed { + s.clearTournamentObservationFailure(app.ID) + } + return passErr +} + +func tournamentObservationStart(tournament *model.Tournament) uint64 { + return max(tournament.StartInstant, tournament.Snapshot.AsOfBlock+1) +} + +func (s *Service) validateObservationBlock(ctx context.Context, facts txsender.BlockRef) error { + header, err := s.client.HeaderByNumber(ctx, new(big.Int).SetUint64(facts.Number)) + if err != nil { + return err + } + if header == nil || header.Number == nil || !header.Number.IsUint64() || header.Number.Uint64() != facts.Number { + return txsender.ErrInconclusive + } + if header.Hash() == facts.Hash { + return nil + } + if s.defaultBlock != model.DefaultBlock_Finalized { + return txsender.ErrStale + } + err = fmt.Errorf("%w: observation block %d selected_hash=%s canonical_hash=%s", + txsender.ErrFinality, facts.Number, facts.Hash, header.Hash()) + s.stopPublication(err) + return err +} + +func completionStatusAllowed(status model.ApplicationStatus) bool { + return status == model.ApplicationStatus_OK || status.IsExecutionTerminal() || status == model.ApplicationStatus_InvalidOutputsRoot +} + +// Healthy observation retains its existing reconciliation path. Continuing it +// after ET/IOR requires positive, coherent evidence that this epoch precedes the +// stop. Tournament interval observation itself remains independent of that gate. +func (s *Service) observerCompletionEpoch(ctx context.Context, app *model.Application, epoch *model.Epoch) (*model.Epoch, error) { + if !completionStatusAllowed(app.Status) { + return nil, txsender.ErrIneligible + } + if app.Status == model.ApplicationStatus_OK { + return epoch, nil + } + state, err := s.repository.GetEpochCompletionState(ctx, app.IApplicationAddress.Hex(), epoch.Index) + if err != nil { + return nil, err + } + if state == nil || state.Application == nil || state.Application.ID != app.ID || !state.Application.Enabled || + !completionStatusAllowed(state.Application.Status) { + return nil, txsender.ErrIneligible + } + allowed, err := state.AllowsEpochCompletion() + if err != nil { + return nil, fmt.Errorf("%w: application %s epoch %d: %w", txsender.ErrInconclusive, app.Name, epoch.Index, err) + } + if !allowed { + return nil, nil + } + return state.Epoch, nil +} diff --git a/internal/prt/delivery_pass_test.go b/internal/prt/delivery_pass_test.go new file mode 100644 index 000000000..e31385181 --- /dev/null +++ b/internal/prt/delivery_pass_test.go @@ -0,0 +1,286 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPRTPassPlansOneWindowAndAttemptsEachRefusedCandidateOnce(t *testing.T) { + for _, interval := range []time.Duration{time.Second, time.Nanosecond} { + t.Run(interval.String(), func(t *testing.T) { + f, _, _ := preparePRTAction(t, stageTournament) + for id := int64(8); id < 15; id++ { + addDeliveryJoinCandidate(f, id, 25) + } + f.d.pollInterval = interval + f.backend.balance = big.NewInt(1) + before := len(f.factory.Calls) + err := f.d.tick(t.Context()) + require.ErrorIs(t, err, txsender.ErrLimit) + require.Empty(t, f.backend.published) + require.Len(t, f.repo.pages, 4, "fallthrough must not advance another application window") + require.Len(t, f.d.candidateCooldown, 8, "all eight candidates get an independent preparation attempt") + plans := 0 + for _, call := range f.factory.Calls[before:] { + if call.Method == "CreateDaveConsensusAdapter" { + plans++ + } + } + require.LessOrEqual(t, plans, 4*8, "eight refused candidates must not rebuild all eight plans") + }) + } +} + +func failDeliveryEpochRead(f *deliveryFixture, app *model.Application, cause error) { + for _, call := range f.repo.ExpectedCalls { + if call.Method == "GetEpoch" && call.Arguments.Get(1) == app.IApplicationAddress.Hex() { + call.ReturnArguments = mock.Arguments{(*model.Epoch)(nil), cause} + } + } +} + +func TestPRTPlanningFailureIsVisibleWhileAnotherApplicationPublishes(t *testing.T) { + f, _, _ := preparePRTAction(t, stageTournament) + other := addSecondStageApplication(f) + cause := errors.New("provider could not read the application's epoch") + failDeliveryEpochRead(f, other, cause) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + for range 2 { + require.NoError(t, f.d.tick(t.Context())) + } + require.Len(t, f.backend.published, 2, "unrelated failed reads cannot suppress useful current work") + require.Contains(t, logs.String(), other.Name) + require.Contains(t, logs.String(), other.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), cause.Error()) + require.Equal(t, 3, strings.Count(logs.String(), "PRT application action could not be planned"), + "each affected action kind warns once, rather than once per poll or fallthrough") +} + +func TestPRTHeldReadFailureAloneDoesNotJustifySubstitution(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + // This pass has no separately verified priority evidence after the failed read. + addDeliveryJoinCandidate(f, 8, 25) + cause := errors.New("held application's epoch could not be read") + failDeliveryEpochRead(f, f.app, cause) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.ErrorIs(t, err, cause) + require.False(t, report.Attempted) + require.NotNil(t, report.Current) + require.Equal(t, call.Key, report.Current.Action) + require.False(t, report.Current.Neutral) + require.Len(t, f.backend.published, 1) +} + +func TestPRTReadyJoinPreemptsUntimedCurrentWork(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + other := addDeliveryJoinCandidate(f, 8, 25) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, other.ID, report.Current.Action.ApplicationID) + require.Equal(t, joinTournament, report.Current.Action.Kind) + require.Equal(t, uint64(7), f.backend.published[1].Nonce()) +} + +func TestPRTFallthroughRechecksAtTheNewCanonicalHead(t *testing.T) { + for _, reorg := range []bool{false, true} { + t.Run(map[bool]string{false: "head advances", true: "preparation branch orphaned"}[reorg], func(t *testing.T) { + f, _, view := preparePRTAction(t, stageTournament) + other := addDeliveryJoinCandidate(f, 8, 25) + old := f.block + refused := errors.New("first candidate's exact simulation reverted") + f.backend.onPreflight = func(_ context.Context, msg ethereum.CallMsg, block txsender.BlockRef) error { + if *msg.To == common.BigToAddress(big.NewInt(other.ID*10+3)) { + require.Equal(t, old, block) + if reorg { + f.backend.headers[old.Number].Extra = []byte("orphaned preparation") + view.sealed.EpochNumber++ + view.stage.EpochNumber++ + view.accept.EpochNumber++ + } + updatePRTView(f, old.Number+1, view) + return refused + } + require.Equal(t, f.block, block, "fallback simulation must use the fresh canonical block hash") + return nil + } + err := f.d.tick(t.Context()) + require.ErrorIs(t, err, refused) + if reorg { + require.ErrorIs(t, err, txsender.ErrIneligible) + require.Empty(t, f.backend.published, "fresh Recheck must reject the old branch's cached action") + } else { + require.Len(t, f.backend.published, 1) + require.Equal(t, f.app.IConsensusAddress, *f.backend.published[0].To()) + } + require.Equal(t, old.Number+1, f.block.Number) + require.Len(t, f.repo.pages, 4, "the head change does not rebuild the discovery window") + }) + } +} + +func TestPRTHeldStageWaitsForTournamentToFinishAgainAfterReorg(t *testing.T) { + f, call, view := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + joined := &tournamentAdapterMock{} + joined.On("CommitmentStanding", mock.Anything, [32]byte(*f.epoch.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: *f.epoch.MachineHash}, nil) + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(joined, nil) + view.stage.IsFinished = false + f.backend.headers[f.block.Number].Extra = []byte("orphaned tournament completion") + updatePRTView(f, f.block.Number-1, view) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.False(t, report.Attempted) + require.Equal(t, call.Key, report.Current.Action) + require.False(t, report.Current.Neutral) + require.Len(t, f.backend.published, 1, "a temporarily unfinished tournament does not justify cancellation") + view.stage.IsFinished = true + updatePRTView(f, 111, view) + report, err = f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, call.Key, report.Current.Action) + require.Equal(t, f.backend.raw[0], f.backend.raw[1]) +} + +func TestPRTRepeatedActionAfterNonceProgressWarnsWithoutAReceiptVerdict(t *testing.T) { + f, _, _ := preparePRTAction(t, stageTournament) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + for range 3 { + require.NoError(t, f.d.tick(t.Context())) + f.backend.nonce++ + } + require.Contains(t, logs.String(), "PRT action is still required after the signer nonce advanced") + require.Contains(t, logs.String(), f.app.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), "stage tournament result") + require.Contains(t, logs.String(), "previous_nonce=7 nonce=8") + require.Equal(t, 1, strings.Count(logs.String(), "PRT action is still required")) + require.Contains(t, logs.String(), "other signer users") + require.Len(t, f.backend.published, 3) + for i, tx := range f.backend.published { + require.Equal(t, uint64(7+i), tx.Nonce(), "externally consumed nonces cause reconstruction at the next nonce") + require.Equal(t, f.backend.published[0].Data(), tx.Data(), "nonce consumption does not prove protocol success") + } + require.False(t, f.d.publicationStopped()) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) +} + +func TestPRTPendingRefusalWarningsRetainContextWithoutANewCandidate(t *testing.T) { + for _, cancellation := range []bool{false, true} { + t.Run(map[bool]string{false: "held read unavailable", true: "cancellation lacks funds"}[cancellation], func(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + var cause error + submitPRTAction(t, f, call) + if cancellation { + f.app.Enabled = false + f.backend.balance = big.NewInt(1) + cause = txsender.ErrLimit + } else { + cause = errors.New("held epoch read unavailable") + failDeliveryEpochRead(f, f.app, cause) + } + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + for range 2 { + require.ErrorIs(t, f.d.tick(t.Context()), cause) + } + require.Len(t, f.backend.published, 1) + require.Equal(t, 1, strings.Count(logs.String(), "PRT transaction could not publish")) + require.Contains(t, logs.String(), f.app.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), "stage tournament result") + if cancellation { + require.Contains(t, logs.String(), "cancel obsolete stage tournament result") + } + require.Contains(t, logs.String(), "nonce=7") + }) + } +} + +type deadlineSigningFactory struct { + ethutil.TransactOptsFactory + blocked bool + entered bool +} + +func (f *deadlineSigningFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts, err := f.TransactOptsFactory.NewTransactOpts(ctx) + if err != nil || !f.blocked { + return opts, err + } + result := *opts + result.Signer = func(common.Address, *types.Transaction) (*types.Transaction, error) { + f.entered = true + <-ctx.Done() + return nil, ctx.Err() + } + return &result, nil +} + +func TestPRTSigningDeadlineBoundsSigningAndTheNextPassCanProgress(t *testing.T) { + f, _, _ := preparePRTAction(t, stageTournament) + factory := &deadlineSigningFactory{TransactOptsFactory: f.d.txOptsFactory, blocked: true} + f.d.deliveryPolicy.SigningTimeout = 100 * time.Millisecond + var err error + f.d.sender, err = txsender.New[actionID](f.backend, factory, 31337, f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + err = f.d.tick(t.Context()) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.True(t, factory.entered, "the signer must receive its own operation deadline") + require.Empty(t, f.backend.published) + require.False(t, f.d.publicationStopped()) + factory.blocked = false + clear(f.d.candidateCooldown) + require.NoError(t, f.d.tick(t.Context())) + require.Len(t, f.backend.published, 1) + f.backend.nonce++ + f.backend.onHeader = func(context.Context) error { + time.Sleep(75 * time.Millisecond) + return nil + } + started := time.Now() + require.NoError(t, f.d.tick(t.Context())) + require.Greater(t, time.Since(started), 100*time.Millisecond, + "the signing budget does not expire during ordinary discovery reads") + require.Len(t, f.backend.published, 2) +} + +func TestPRTPublicationShutdownDuringAccountReadDoesNotLogAnError(t *testing.T) { + f, _, _ := preparePRTAction(t, stageTournament) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + f.backend.onHeader = func(ctx context.Context) error { + cancel() + return ctx.Err() + } + err := f.d.tick(ctx) + require.ErrorIs(t, err, context.Canceled) + f.d.reportPublicationError(ctx, err) + require.NotContains(t, logs.String(), "level=ERROR") + require.Empty(t, f.backend.published) +} diff --git a/internal/prt/delivery_planner.go b/internal/prt/delivery_planner.go new file mode 100644 index 000000000..5ec690593 --- /dev/null +++ b/internal/prt/delivery_planner.go @@ -0,0 +1,238 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" +) + +// tick may skip a refused candidate, but ends at the first POST. The bound also +// prevents a failing application from making one publisher pass unbounded. +func (s *delivery) tick(ctx context.Context) (err error) { + defer func() { + if txsender.HardFailure(err) { + s.stopPublication(err) + s.reportHardPublicationFailure(ctx, err) + } + }() + if s.publicationStopped() { + return s.publicationFailure() + } + if s.sender == nil { + return errors.New("PRT submission requires a transaction sender") + } + for key, until := range s.candidateCooldown { + if !time.Now().Before(until) { + delete(s.candidateCooldown, key) + } + } + var refused error + pass := &deliveryPass{delivery: s} + for range publicationCandidateLimit { + pass.selected = nil + report, publishErr := s.sender.PublishNext(ctx, pass) + s.recordLiveProgress(report, pass.selected, publishErr) + if publishErr == nil { + return refused + } + refusedAction := pass.selected + if refusedAction == nil && report.Current != nil { + refusedAction = &report.Current.Action + } + if refusedAction != nil { + key := *refusedAction + if !errors.Is(ctx.Err(), context.Canceled) && + (!txsender.WaitOnly(publishErr) || errors.Is(publishErr, txsender.ErrLimit)) { + s.warnPublicationRefusal(key, report, publishErr) + } + kind := actionName(key.Kind) + if report.Canceling { + kind = "cancel obsolete " + kind + } + publishErr = fmt.Errorf("PRT application %s, action %s, epoch %d, nonce %d: %w", + key.Application, kind, key.Epoch, report.Nonce, publishErr) + } + if report.Attempted || pass.selected == nil || txsender.PassInvalid(publishErr) || + ctx.Err() != nil { + return errors.Join(refused, publishErr) + } + s.candidateCooldown[*pass.selected] = time.Now().Add(s.pollInterval) + refused = errors.Join(refused, publishErr) + } + return refused +} + +// A pass discovers one bounded candidate window. Each entry gets at most one +// preparation attempt, even when a slow pass outlasts the refusal cooldown. +// Requests remain subject to the sender's exact simulation and fresh Recheck. +type deliveryPass struct { + *delivery + planned bool + candidates []plannedAction + planErr error + selected *actionID + heldAttempted bool +} + +// Next owns selection. The current action is checked before optional backlog +// reads; a failed observation pass cannot stop a still-useful current request. +func (s *deliveryPass) Next(ctx context.Context, block txsender.BlockRef, + current *txsender.Current[actionID]) (txsender.Decision[actionID], error) { + var held *plannedAction + var heldErr error + var heldEligibility txsender.Eligibility + if current != nil && !current.Neutral { + held, heldEligibility, heldErr = s.decide(ctx, block, current.Action) + if heldErr != nil && (txsender.PassInvalid(heldErr) || !txsender.WaitOnly(heldErr) || ctx.Err() != nil) { + return txsender.Decision[actionID]{}, heldErr + } + } + if !s.planned { + s.planned = true + facts, err := s.observationReference(ctx) + switch { + case err != nil: + s.planErr = err + if held != nil && s.planningWarningDue(actionID{}) { + s.Logger.Warn("PRT fresh-work observation is unavailable; maintaining current work", + "block", block.Number, "error", err) + } + case facts.Number > block.Number: + s.planErr = txsender.ErrInconclusive + default: + s.candidates, s.planErr = s.delivery.candidates(ctx, block, facts) + } + } + if txsender.PassInvalid(s.planErr) { + return txsender.Decision[actionID]{}, s.planErr + } + for len(s.candidates) != 0 { + candidate := s.candidates[0] + s.candidates = s.candidates[1:] + if time.Now().Before(s.candidateCooldown[candidate.Key]) { + continue + } + // Healthy held work keeps its nonce unless a verified join has an earlier deadline. + if current != nil && !current.Neutral && heldErr == nil && heldEligibility != txsender.Obsolete { + if candidate.Key == current.Action || candidate.Deadline == 0 || + current.Action.Deadline != 0 && candidate.Deadline >= current.Action.Deadline { + continue + } + } + s.selected = new(candidate.Key) + return txsender.Publish(candidate.Key, candidate.Request), nil + } + if held != nil && heldEligibility == txsender.Useful { + if s.heldAttempted || time.Now().Before(s.candidateCooldown[held.Key]) { + return txsender.Wait[actionID]("current candidate is cooling down after a refusal"), nil + } + s.heldAttempted = true + s.selected = new(held.Key) + return txsender.Publish(held.Key, held.Request), nil + } + if current != nil && !current.Neutral { + if heldErr != nil { + return txsender.Decision[actionID]{}, heldErr + } + if heldEligibility == txsender.Obsolete { + return txsender.Cancel[actionID]("current PRT action is no longer useful"), nil + } + return txsender.Wait[actionID]("current PRT action waits for its protocol window"), nil + } + if current != nil && current.Neutral { + return txsender.Cancel[actionID]("continue cancellation of obsolete PRT action"), nil + } + if s.planErr != nil { + return txsender.Decision[actionID]{}, s.planErr + } + return txsender.Wait[actionID]("no useful PRT action"), nil +} + +// Recheck repeats exactly the selected domain decision. It does not advance a +// page, choose another candidate, or consult a second pending-request cache. +func (s *delivery) Recheck(ctx context.Context, block txsender.BlockRef, action actionID) (txsender.Eligibility, error) { + _, eligibility, err := s.decide(ctx, block, action) + return eligibility, err +} + +func (s *delivery) decide(ctx context.Context, block txsender.BlockRef, action actionID, +) (*plannedAction, txsender.Eligibility, error) { + app, err := s.repository.GetApplication(ctx, action.Application.Hex()) + if errors.Is(err, repository.ErrNotFound) || err == nil && (app == nil || app.ID != action.ApplicationID) { + return nil, txsender.Obsolete, nil + } + if err != nil { + return nil, txsender.ProtocolWait, err + } + var call *plannedAction + if action.Kind == recoverBond { + call, err = s.recoveryCall(withLiveBlock(ctx, block.Number, block.Hash), app, action.Epoch, action.Target, block, s.observedFacts) + } else { + call, err = s.planEpochAction(ctx, app, action.Kind, block, s.observedFacts, &action) + } + if errors.Is(err, errProtocolWait) { + return nil, txsender.ProtocolWait, nil + } + if errors.Is(err, txsender.ErrIneligible) { + return nil, txsender.Obsolete, nil + } + if err != nil { + return nil, txsender.ProtocolWait, err + } + if call == nil || call.Key != action { + return nil, txsender.Obsolete, nil + } + return call, txsender.Useful, nil +} + +var errProtocolWait = errors.New("PRT action waits for its protocol window") + +func (s *delivery) candidates(ctx context.Context, block, facts txsender.BlockRef) ([]plannedAction, error) { + var calls []plannedAction + var errs []error + for _, kind := range []actionKind{joinTournament, recoverBond, acceptTournament, stageTournament} { + apps, err := s.nextDeliveryApplications(ctx, kind) + if err != nil { + errs = append(errs, err) + continue + } + for _, app := range apps { + if err := ctx.Err(); err != nil { + return calls, errors.Join(append(errs, err)...) + } + call, planErr := s.planAction(ctx, app, kind, block, facts) + s.warnJoinPlanningRefusal(app, block, planErr) + s.warnPlanningPause(app, kind, block, planErr) + if planErr != nil && !errors.Is(planErr, txsender.ErrIneligible) { + errs = append(errs, fmt.Errorf("planning PRT application %s, action %s: %w", app.Name, actionName(kind), planErr)) + } + if txsender.PassInvalid(planErr) { + return calls, errors.Join(errs...) + } + if call != nil && planErr == nil { + calls = append(calls, *call) + } + } + } + slices.SortStableFunc(calls, func(a, b plannedAction) int { + if a.Deadline != b.Deadline { + if a.Deadline == 0 { + return 1 + } + if b.Deadline == 0 || a.Deadline < b.Deadline { + return -1 + } + return 1 + } + return int(a.Key.Kind) - int(b.Key.Kind) + }) + return calls, errors.Join(errs...) +} diff --git a/internal/prt/delivery_planner_test.go b/internal/prt/delivery_planner_test.go new file mode 100644 index 000000000..2088c475b --- /dev/null +++ b/internal/prt/delivery_planner_test.go @@ -0,0 +1,387 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "io" + "log/slog" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + disabledApplicationCase = "disabled" + removedApplicationCase = "removed" +) + +func preparePRTAction(t *testing.T, kind actionKind) (*deliveryFixture, plannedAction, daveConsensusSnapshot) { + t.Helper() + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, kind == acceptTournament) + view.stage.IsFinished = kind != joinTournament + view.accept.IsClaimStagingPeriodOver = true + f.snapshot(view) + f.repo.On("GetLastNonOpenEpochIndex", mock.Anything, mock.Anything).Return((*uint64)(nil), nil).Maybe() + if kind == joinTournament { + deliveryJoinAdapter(f, f.epoch, 50) + } + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, call) + return f, *call, view +} + +func updatePRTView(f *deliveryFixture, block uint64, view daveConsensusSnapshot) { + f.backend.advance(block) + f.block = txsender.BlockRef{Number: block, Hash: f.backend.headers[block].Hash()} + f.consensus.ExpectedCalls = nil + f.snapshot(view) +} + +func submitPRTAction(t *testing.T, f *deliveryFixture, call plannedAction) txsender.Report[actionID] { + t.Helper() + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, call}) + require.NoError(t, err) + require.True(t, report.Attempted) + require.NotNil(t, report.Current) + require.Equal(t, call.Key, report.Current.Action) + return report +} + +func TestPRTPublicationUsesRealSenderAndDoesNotWriteProtocolFacts(t *testing.T) { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + t.Run(actionName(kind), func(t *testing.T) { + f, call, _ := preparePRTAction(t, kind) + report := submitPRTAction(t, f, call) + require.Equal(t, uint64(7), report.Nonce) + require.Len(t, f.backend.published, 1) + tx := f.backend.published[0] + require.Equal(t, call.To, *tx.To()) + require.Equal(t, call.Data, tx.Data()) + require.Equal(t, call.Value, tx.Value()) + require.Equal(t, uint64(intrinsicGasFloor+150_000), tx.Gas()) + for _, block := range f.backend.calls { + require.Equal(t, f.block, block) + } + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Empty(t, f.repo.windows) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestPRTCurrentBytesSurviveLostReplyAndRebroadcastWithoutNewDomainWrites(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + lost := errors.New("connection reset after accepted POST") + f.backend.publishError = lost + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, call}) + require.ErrorIs(t, err, lost) + require.True(t, report.Attempted) + require.NotNil(t, report.Current) + f.backend.publishError = nil + report, err = f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Len(t, f.backend.raw, 2) + require.Equal(t, f.backend.raw[0], f.backend.raw[1]) + require.Equal(t, call.Key, report.Current.Action) + require.Empty(t, f.repo.windows) +} + +func TestPRTHeldActionEligibilityUsesCurrentContractState(t *testing.T) { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + for _, change := range []string{disabledApplicationCase, "unhealthy", "foreclosed", + removedApplicationCase, "epoch advanced", "contract foreclosed"} { + t.Run(actionName(kind)+"/"+change, func(t *testing.T) { + f, call, view := preparePRTAction(t, kind) + submitPRTAction(t, f, call) + switch change { + case disabledApplicationCase: + f.app.Enabled = false + case "unhealthy": + f.app.Status = model.ApplicationStatus_Diverged + case "foreclosed": + f.app.ForecloseBlock = 100 + case removedApplicationCase: + f.repo.apps = nil + case "epoch advanced": + view.sealed.EpochNumber++ + view.stage.EpochNumber++ + view.accept.EpochNumber++ + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), view.sealed.EpochNumber). + Return((*model.Epoch)(nil), nil) + updatePRTView(f, 111, view) + case "contract foreclosed": + f.backend.foreclosure = true + } + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.True(t, report.Current.Neutral) + require.Len(t, f.backend.published, 2) + neutral := f.backend.published[1] + require.Equal(t, f.d.txOptsFactory.From(), *neutral.To()) + require.Empty(t, neutral.Data()) + require.Zero(t, neutral.Value().Sign()) + require.Equal(t, uint64(7), neutral.Nonce()) + }) + } + } +} + +func TestPRTHeldReadFailureNeverAuthorizesCancellation(t *testing.T) { + for _, fault := range []string{"RPC failure", "future foreclosure", "future staging", "future root", + "missing tournament", "missing commitment", "missing machine hash", "missing outputs"} { + t.Run(fault, func(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + switch fault { + case "RPC failure": + f.backend.callError = errors.New("application view unavailable") + case "future foreclosure": + f.app.ForecloseBlock = 120 + case "future staging": + f.epoch.StagedAtBlock = new(uint64(120)) + case "future root": + f.repo.publishRoot(f.app, f.epoch, 120) + case "missing tournament": + f.epoch.TournamentAddress = nil + case "missing commitment": + f.epoch.Commitment = nil + case "missing machine hash": + f.epoch.MachineHash = nil + case "missing outputs": + f.epoch.TxBufferDataBlock = nil + } + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.Error(t, err) + require.False(t, report.Attempted) + require.NotNil(t, report.Current) + require.False(t, report.Current.Neutral) + require.Equal(t, call.Key, report.Current.Action) + require.Len(t, f.backend.published, 1) + }) + } +} + +func TestPRTHeldAcceptanceDistinguishesMissingStageFromTimedWait(t *testing.T) { + for _, staged := range []bool{false, true} { + t.Run(map[bool]string{false: "stage orphaned", true: "staging wait"}[staged], func(t *testing.T) { + f, call, _ := preparePRTAction(t, acceptTournament) + submitPRTAction(t, f, call) + view := resultTestSnapshot(f.epoch, staged) + if staged { + view.sealed.StagingBlockNumber = 111 + } + updatePRTView(f, 111, view) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.Equal(t, !staged, report.Attempted) + require.False(t, report.Current.Neutral) + if !staged { + require.Equal(t, stageTournament, report.Current.Action.Kind) + } + require.Len(t, f.backend.published, map[bool]int{true: 1, false: 2}[staged]) + }) + } +} + +func TestPRTFreshDiscoveryExcludesAcceptedButFixedSelectionRechecksLiveState(t *testing.T) { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + t.Run(actionName(kind), func(t *testing.T) { + f, call, view := preparePRTAction(t, kind) + f.epoch.Status = model.EpochStatus_ClaimAccepted + fresh, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.ErrorIs(t, err, txsender.ErrIneligible) + require.Nil(t, fresh) + // Fixed selection is allowed to finish if the live contract still needs it. + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.NoError(t, err) + require.Equal(t, txsender.Useful, eligibility) + // Canonical completion (the consensus advanced) suppresses the same action. + view.sealed.EpochNumber++ + view.stage.EpochNumber++ + view.accept.EpochNumber++ + updatePRTView(f, 111, view) + eligibility, err = f.d.Recheck(t.Context(), f.block, call.Key) + require.NoError(t, err) + require.Equal(t, txsender.Obsolete, eligibility) + require.Empty(t, f.backend.published) + }) + } +} + +func TestPRTHeldTerminalProjectionDoesNotCreateFreshWorkAfterReorg(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe, model.DefaultBlock_Finalized} { + t.Run(policy.String(), func(t *testing.T) { + f, call, view := preparePRTAction(t, acceptTournament) + f.d.defaultBlock = policy + submitPRTAction(t, f, call) + f.epoch.Status = model.EpochStatus_ClaimAccepted + updatePRTView(f, 111, view) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, call.Key, report.Current.Action) + f.backend.nonce = 8 + report, err = f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.False(t, report.Attempted) + require.Nil(t, report.Current, "consumed current is not restored from an inventory") + require.Equal(t, model.EpochStatus_ClaimAccepted, f.epoch.Status) + }) + } +} + +func TestPRTObservationFailureDoesNotStopUsefulCurrentRebroadcast(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, mock.Anything).Return((*types.Header)(nil), errors.New("selected head delayed")) + f.d.client = client + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, f.backend.raw[0], f.backend.raw[1]) +} + +func TestPRTUrgentJoinReplacesOnlyLaterDeadlineCurrent(t *testing.T) { + for _, allowance := range []uint64{25, 50, 80} { + t.Run(new(big.Int).SetUint64(allowance).String(), func(t *testing.T) { + f, call, _ := preparePRTAction(t, joinTournament) + submitPRTAction(t, f, call) + other := addDeliveryJoinCandidate(f, 8, allowance) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + if allowance < 50 { + require.Equal(t, other.ID, report.Current.Action.ApplicationID) + } else { + require.Equal(t, f.app.ID, report.Current.Action.ApplicationID) + require.Equal(t, f.backend.raw[0], f.backend.raw[1]) + } + require.Equal(t, uint64(7), f.backend.published[1].Nonce()) + }) + } +} + +func TestPRTNeutralReplacementIsNotSticky(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + f.app.Enabled = false + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Current.Neutral) + other := addDeliveryJoinCandidate(f, 8, 25) + other.Enabled = true + report, err = f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.False(t, report.Current.Neutral) + require.Equal(t, other.ID, report.Current.Action.ApplicationID) + require.Equal(t, uint64(7), f.backend.published[2].Nonce()) +} + +func TestPRTRecoveryExplicitGasAndUsefulReturnWithRealSender(t *testing.T) { + for _, useful := range []bool{true, false} { + t.Run(map[bool]string{true: "payment succeeds", false: "callback returns false"}[useful], func(t *testing.T) { + f := newRecoveryPortFixture(t) + f.backend.recoveryFalse = !useful + call, err := f.plan(t.Context()) + require.NoError(t, err) + require.NotNil(t, call) + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, *call}) + if useful { + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, uint64(recoveryGasLimit), f.backend.published[0].Gas()) + } else { + require.ErrorIs(t, err, txsender.ErrIneligible) + require.False(t, report.Attempted) + require.Empty(t, f.backend.published) + } + require.Empty(t, f.repo.windows) + }) + } +} + +func TestPRTUnfundedJoinFallsThroughToAffordableAction(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + other := addDeliveryJoinCandidate(f, 8, 25) + // The urgent join's bond is unaffordable; an ordinary zero-value stage fits. + f.backend.balance = big.NewInt(int64((intrinsicGasFloor + 150_000) * 3)) + require.Zero(t, call.Value.Sign()) + err := f.d.tick(t.Context()) + require.ErrorIs(t, err, txsender.ErrLimit) + require.Len(t, f.backend.published, 1) + require.Equal(t, f.app.IConsensusAddress, *f.backend.published[0].To()) + require.NotEqual(t, other.IConsensusAddress, *f.backend.published[0].To()) + require.NotEmpty(t, f.d.candidateCooldown) +} + +func TestPRTIncludedLostRacesDoNotPauseLaterEpochs(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + for range 8 { + report := submitPRTAction(t, f, call) + require.False(t, report.Current.Neutral) + f.backend.nonce++ // Included revert still consumes a nonce; no receipt breaker exists. + } + require.Len(t, f.backend.published, 8) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.False(t, f.d.publicationStopped()) +} + +func TestPRTReorgReconstructsCurrentNonceWithoutOldGenerationInventory(t *testing.T) { + f, call, view := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + f.backend.nonce = 8 + submitPRTAction(t, f, call) + f.backend.nonce = 7 + f.backend.headers[110].Extra = []byte("replacement branch") + updatePRTView(f, 111, view) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, uint64(7), f.backend.published[2].Nonce()) + require.Equal(t, call.Key, report.Current.Action) + require.Equal(t, uint64(8), f.backend.published[1].Nonce(), "old higher-nonce bytes remain distributed") +} + +func TestPRTPublicationWarningsHaveApplicationAndActionContext(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + f.d.warnPublicationRefusal(call.Key, txsender.Report[actionID]{Nonce: 7, Block: f.block}, errors.New("fee cap too low")) + first := logs.String() + require.Contains(t, first, f.app.IApplicationAddress.Hex()) + require.Contains(t, first, "stage tournament result") + require.Contains(t, first, "epoch=") + f.d.warnPublicationRefusal(call.Key, txsender.Report[actionID]{Nonce: 7, Block: f.block}, errors.New("same refusal")) + require.Equal(t, first, logs.String()) + f.d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + f.d.diagnostics.head, f.d.diagnostics.headSince = f.block, time.Now().Add(-2*frozenLiveHeadInterval) + f.d.recordLiveProgress(txsender.Report[actionID]{Block: f.block, Nonce: 7, + Current: &txsender.Current[actionID]{Action: call.Key}}, nil, nil) + require.False(t, f.d.diagnostics.warnedHead.IsZero()) +} + +func TestPRTMissingEpochEvidenceWaitsRatherThanCancelling(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + f.repo.ExpectedCalls = nil + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), f.epoch.Index).Return((*model.Epoch)(nil), nil) + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.True(t, txsender.WaitOnly(err)) + require.Equal(t, txsender.ProtocolWait, eligibility) + require.False(t, f.d.publicationStopped()) +} diff --git a/internal/prt/delivery_policy.go b/internal/prt/delivery_policy.go new file mode 100644 index 000000000..df9d1b6c6 --- /dev/null +++ b/internal/prt/delivery_policy.go @@ -0,0 +1,39 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/txsender" +) + +type actionKind uint8 + +const ( + noAction actionKind = iota + joinTournament + stageTournament + acceptTournament + recoverBond +) + +const ( + intrinsicGasFloor = 21_000 + maximumJoinGas = 2_000_000 // Read-only protocol calls, not transaction sizing. + recoveryGasLimit = 200_000 // Includes the payment callback's 50,000 gas allowance. + rootBondRecoveryPageSize = 16 +) + +func deliveryPolicy(c config.PrtConfig) (txsender.Policy, error) { + return txsender.NewPolicy(txsender.FinancialConfig{ + MaxFeePerGas: c.BlockchainTxMaxFeePerGas, Legacy: c.BlockchainLegacyEnabled, + SigningTimeout: c.BlockchainHttpRequestTimeout, + }) +} + +// A sweep visits a finite range, then starts again. It is not payment evidence. +type rootBondRecoveryScan struct { + ThroughEpoch uint64 + AfterEpoch *uint64 +} diff --git a/internal/prt/delivery_policy_regression_test.go b/internal/prt/delivery_policy_regression_test.go new file mode 100644 index 000000000..7a6bea6ea --- /dev/null +++ b/internal/prt/delivery_policy_regression_test.go @@ -0,0 +1,55 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestProvisionalStagingClockRefreshKeepsMatchingPRTResult(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe} { + for _, replacement := range []uint64{9, 11} { + t.Run(policy.String()+new(big.Int).SetUint64(replacement).String(), func(t *testing.T) { + f := newDeliveryFixture(t) + f.d.defaultBlock = policy + f.epoch.Status, f.epoch.StagedAtBlock = model.EpochStatus_ClaimStaged, new(uint64(10)) + view := resultTestSnapshot(f.epoch, true) + view.sealed.StagingBlockNumber = replacement + f.repo.On("UpdateEpochStagingBlock", mock.Anything, f.app.ID, f.epoch.Index, uint64(10), replacement). + Return(nil).Once() + require.NoError(t, f.d.recordTournamentResult(t.Context(), f.app, f.epoch, view)) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, replacement, *f.epoch.StagedAtBlock) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertExpectations(t) + }) + } + } +} + +func TestInconsistentConsensusSnapshotDoesNotPublishObservation(t *testing.T) { + f := newDeliveryFixture(t) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{f.epoch}, uint64(1), nil).Once() + view := resultTestSnapshot(f.epoch, false) + view.stage.EpochNumber++ + f.consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(view.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", mock.Anything).Return(view.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(view.accept, nil).Once() + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), f.epoch) + err := f.d.observeApplication(t.Context(), f.app, observerFacts(100)) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Contains(t, err.Error(), f.app.Name) + require.Contains(t, err.Error(), "block 100") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "StoreTournamentInterval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/prt/delivery_refusal_test.go b/internal/prt/delivery_refusal_test.go new file mode 100644 index 000000000..bdc0c9cd0 --- /dev/null +++ b/internal/prt/delivery_refusal_test.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "testing" +) + +func addSecondStageApplication(f *deliveryFixture) *model.Application { + app := *f.app + app.ID, app.Name = 8, secondApplicationName + app.IApplicationAddress, app.IConsensusAddress = common.HexToAddress("0x801"), common.HexToAddress("0x802") + f.repo.apps = append(f.repo.apps, &app) + admitTestDeployment(f.d.Service, f.repo.prtRepositoryMock, &app) + f.backend.apps = f.repo.apps + epoch := *f.epoch + epoch.ApplicationID = app.ID + f.repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index).Return(&epoch, nil) + f.repo.publishRoot(&app, &epoch, app.LastTournamentCheckBlock) + view := resultTestSnapshot(&epoch, false) + adapter := newCompatibleDaveConsensusAdapterMock() + adapter.On("GetCurrentSealedEpoch", mock.Anything).Return(view.sealed, nil) + adapter.On("CanStageTournamentResult", mock.Anything).Return(view.stage, nil) + adapter.On("CanAcceptStagedTournamentResult", mock.Anything).Return(view.accept, nil) + f.factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(adapter, nil) + return &app +} + +func TestDeliveryHardPublicationFailureLatchesReadiness(t *testing.T) { + causes := []error{txsender.ErrFinality, txsender.ErrContent, txsender.ErrUnsupported, txsender.ErrLimit, txsender.ErrStale} + for _, cause := range causes { + t.Run(cause.Error(), func(t *testing.T) { + f, client := tickFixture(t) + f.d.submissionEnabled = true + f.d.publicationReady.Store(true) + require.True(t, f.d.Ready()) + view := resultTestSnapshot(f.epoch, false) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(view.sealed, nil) + f.consensus.On("CanStageTournamentResult", opts).Return(view.stage, nil) + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(view.accept, nil) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, mock.Anything, false).Return([]*model.Epoch{}, uint64(0), nil) + tickHead(client, 100) + f.backend.headerError = cause + if errors.Is(cause, txsender.ErrFinality) || errors.Is(cause, txsender.ErrContent) { + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + } + err := f.d.tick(t.Context()) + require.ErrorIs(t, err, cause) + hard := errors.Is(cause, txsender.ErrFinality) || errors.Is(cause, txsender.ErrContent) + + require.Equal(t, !hard, f.d.Ready()) + require.Equal(t, hard, f.d.publicationStopped()) + require.Empty(t, f.backend.published) + require.Empty(t, f.d.candidateCooldown) + if hard { + require.ErrorIs(t, f.d.guardPublication(t.Context()), cause) + } + }) + } +} + +func TestDeliveryHardObservationFailureBlocksSamePassPublication(t *testing.T) { + f, client := tickFixture(t) + f.d.submissionEnabled = true + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, mock.Anything, false). + Return([]*model.Epoch(nil), uint64(0), txsender.ErrUnsupported) + tickHead(client, 100) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100)) + _, err := f.d.Tick(t.Context()) + require.ErrorIs(t, err, txsender.ErrUnsupported) + require.False(t, f.d.publicationStopped()) + require.False(t, f.d.publicationStopped()) + require.Empty(t, f.backend.headerReads) + require.Empty(t, f.backend.published) + require.Empty(t, f.d.candidateCooldown) +} diff --git a/internal/prt/delivery_reset_test.go b/internal/prt/delivery_reset_test.go new file mode 100644 index 000000000..0ba432b5b --- /dev/null +++ b/internal/prt/delivery_reset_test.go @@ -0,0 +1,205 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func resetDeliveryAdmission(f *deliveryFixture, app *model.Application) { + row := f.repo.deploymentRows[app.ID] + row.ResetGeneration++ + row.ConfirmedTier, row.DeploymentReason = nil, nil + f.repo.deploymentRows[app.ID] = row +} + +func TestRetainedResetKeepsIndependentJoinSelection(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, resetSibling := range []bool{false, true} { + name := map[bool]string{false: "disabled", true: "enabled"}[enabled] + if resetSibling { + name += "/sibling reset before final authorization" + } + t.Run(name, func(t *testing.T) { + f, held, _ := preparePRTAction(t, stageTournament) + before := submitPRTAction(t, f, held) + sibling := addDeliveryJoinCandidate(f, 8, 290) + join, err := f.d.planAction(t.Context(), sibling, joinTournament, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, join) + f.app.Enabled = enabled + resetDeliveryAdmission(f, f.app) + if resetSibling { + f.backend.onPreflight = func(context.Context, ethereum.CallMsg, txsender.BlockRef) error { + resetDeliveryAdmission(f, sibling) + return nil + } + } + err = f.d.tick(t.Context()) + if resetSibling { + require.ErrorIs(t, err, txsender.ErrInconclusive) + if enabled { + require.Len(t, f.backend.published, 1) + } else { + require.Len(t, f.backend.published, 2) + require.Empty(t, f.backend.published[1].Data()) + require.Equal(t, f.d.sender.From(), *f.backend.published[1].To()) + } + return + } + require.NoError(t, err) + require.Len(t, f.backend.published, 2) + published := f.backend.published[1] + require.Equal(t, before.Nonce, published.Nonce()) + require.Equal(t, join.To, *published.To()) + require.Equal(t, join.Data, published.Data()) + require.Equal(t, join.Value, published.Value()) + require.False(t, f.d.publicationStopped()) + }) + } + } +} + +func TestUnknownDeploymentAllowsIndependentlyObsoleteCancellation(t *testing.T) { + for _, state := range []deploymentState{deploymentNone, deploymentPending} { + t.Run(string(state), func(t *testing.T) { + f, held, _ := preparePRTAction(t, stageTournament) + before := submitPRTAction(t, f, held) + updatePRTView(f, 110, resultTestSnapshot(f.epoch, true)) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, state, nil) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.True(t, report.Current.Neutral) + require.Equal(t, before.Nonce, report.Nonce) + require.Len(t, f.backend.published, 2) + require.Empty(t, f.backend.published[1].Data()) + require.Zero(t, f.backend.published[1].Value().Sign()) + }) + } +} + +func TestUnknownHeldJoinYieldsNonceToLaterAuthorizedSibling(t *testing.T) { + f, held, _ := preparePRTAction(t, joinTournament) + before := submitPRTAction(t, f, held) + sibling := addDeliveryJoinCandidate(f, 8, 290) + join, err := f.d.planAction(t.Context(), sibling, joinTournament, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, join) + require.Greater(t, join.Deadline, held.Deadline) + resetDeliveryAdmission(f, f.app) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.False(t, report.Current.Neutral) + require.Equal(t, sibling.ID, report.Current.Action.ApplicationID) + require.Equal(t, before.Nonce, report.Nonce) + require.Len(t, f.backend.published, 2) + require.Equal(t, join.Data, f.backend.published[1].Data()) +} + +func TestUnknownHeldJoinYieldsToAuthorizedZeroDeadlineActions(t *testing.T) { + for _, kind := range []actionKind{stageTournament, recoverBond} { + t.Run(actionName(kind), func(t *testing.T) { + f, held, _ := preparePRTAction(t, joinTournament) + before := submitPRTAction(t, f, held) + sibling := addSecondStageApplication(f) + var call *plannedAction + var err error + if kind == recoverBond { + target := common.HexToAddress("0x9870") + f.factory.On("CreateTournamentAdapter", target).Return(&recoveryPortAdapter{ + recovery: canonicalBondRecovery(model.BondDispositionRecoverable, f.repo.signer, 1)}, nil) + call, err = f.d.recoveryCall(t.Context(), sibling, f.epoch.Index, target, f.block, observerFacts(100)) + } else { + call, err = f.d.planAction(t.Context(), sibling, kind, f.block, observerFacts(100)) + } + require.NoError(t, err) + require.NotNil(t, call) + require.Zero(t, call.Deadline) + resetDeliveryAdmission(f, f.app) + pass := &deliveryPass{delivery: f.d, planned: true, candidates: []plannedAction{*call}} + report, err := f.d.sender.PublishNext(t.Context(), pass) + require.NoError(t, err) + require.True(t, report.Attempted) + require.False(t, report.Current.Neutral) + require.Equal(t, sibling.ID, report.Current.Action.ApplicationID) + require.Equal(t, before.Nonce, report.Nonce) + require.Len(t, f.backend.published, 2) + require.Equal(t, call.Data, f.backend.published[1].Data()) + }) + } +} + +func TestRetainedActionBeforeStoredSealDoesNotBecomeObsolete(t *testing.T) { + for _, equal := range []bool{false, true} { + t.Run(map[bool]string{false: "older head", true: "seal head"}[equal], func(t *testing.T) { + f, call, view := preparePRTAction(t, stageTournament) + f.epoch.LastBlock = f.block.Number + if !equal { + f.epoch.LastBlock++ + view.sealed.EpochNumber-- + view.stage.EpochNumber-- + view.accept.EpochNumber-- + } + updatePRTView(f, f.block.Number, view) + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + if equal { + require.NoError(t, err) + require.Equal(t, txsender.Useful, eligibility) + } else { + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, txsender.ProtocolWait, eligibility) + } + require.Empty(t, f.backend.published) + }) + } +} + +func TestMissingDeploymentControlOrCacheRetainsUntilIndependentObsolescence(t *testing.T) { + for _, missing := range []string{"control", "cache"} { + t.Run(missing, func(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + before := submitPRTAction(t, f, call) + if missing == "control" { + delete(f.repo.deploymentRows, f.app.ID) + } else { + f.d.deployments = deploymentCache{} + } + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.False(t, report.Attempted) + require.Equal(t, before.Current, report.Current) + require.Len(t, f.backend.published, 1) + updatePRTView(f, 110, resultTestSnapshot(f.epoch, true)) + report, err = f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.True(t, report.Current.Neutral) + require.Len(t, f.backend.published, 2) + require.Empty(t, f.backend.published[1].Data()) + }) + } +} + +func TestHeldAcceptDoesNotRegressToStageAtOlderCanonicalHead(t *testing.T) { + f, call, _ := preparePRTAction(t, acceptTournament) + before := submitPRTAction(t, f, call) + require.Nil(t, f.epoch.StagedAtBlock) + older := resultTestSnapshot(f.epoch, false) + older.stage.IsFinished = true + updatePRTView(f, 109, older) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.False(t, report.Attempted) + require.Equal(t, before.Current, report.Current) + require.Len(t, f.backend.published, 1, "an older same-nonce view must not replace accept with redundant stage") +} diff --git a/internal/prt/delivery_root_prerequisite_test.go b/internal/prt/delivery_root_prerequisite_test.go new file mode 100644 index 000000000..94d1edf42 --- /dev/null +++ b/internal/prt/delivery_root_prerequisite_test.go @@ -0,0 +1,70 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "testing" +) + +func TestDeliveryNewRootRequiresItsOwnPublishedCompleteWindow(t *testing.T) { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + t.Run(fmt.Sprint(kind), func(t *testing.T) { + f := newDeliveryFixture(t) + f.epoch.LastBlock = 95 + view := resultTestSnapshot(f.epoch, kind == acceptTournament) + view.stage.IsFinished = kind != joinTournament + view.accept.IsClaimStagingPeriodOver = true + f.snapshot(view) + f.repo.publishedRoots = nil + facts := observerFacts(100) + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, facts) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Nil(t, call, "an older complete application window did not include this newly discovered root") + + f.repo.publishRoot(f.app, f.epoch, 110) + call, err = f.d.planAction(t.Context(), f.app, kind, f.block, facts) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Nil(t, call, "a projection beyond the finalized application cursor has not been published") + + f.repo.publishRoot(f.app, f.epoch, 100) + if kind == joinTournament { + deliveryJoinAdapter(f, f.epoch, 30) + } + call, err = f.d.planAction(t.Context(), f.app, kind, f.block, facts) + require.NoError(t, err) + require.NotNil(t, call) + require.Empty(t, f.backend.published) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestDeliveryPublishedRootRejectsWrongIdentityAndIncompleteHistory(t *testing.T) { + for _, corrupt := range []func(*model.Tournament){ + func(root *model.Tournament) { root.ApplicationID++ }, + func(root *model.Tournament) { root.EpochIndex++ }, + func(root *model.Tournament) { root.Address = common.HexToAddress("0xbad") }, + func(root *model.Tournament) { root.Level = 1 }, + func(root *model.Tournament) { root.ParentTournamentAddress = new(common.HexToAddress("0xbad")) }, + func(root *model.Tournament) { root.StartInstant = 101 }, + func(root *model.Tournament) { root.Snapshot.AsOfBlock = 89 }, + func(root *model.Tournament) { root.Snapshot.AsOfBlock = 0 }, + } { + f := newDeliveryFixture(t) + f.epoch.LastBlock = 90 + f.snapshot(resultTestSnapshot(f.epoch, false)) + f.repo.publishRoot(f.app, f.epoch, 100) + root := f.repo.publishedRoots[f.app.IApplicationAddress.Hex()+f.epoch.TournamentAddress.Hex()] + corrupt(root) + call, err := f.d.planAction(t.Context(), f.app, stageTournament, f.block, observerFacts(100)) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Nil(t, call) + } +} diff --git a/internal/prt/delivery_runtime_policy_test.go b/internal/prt/delivery_runtime_policy_test.go new file mode 100644 index 000000000..d922bf2b6 --- /dev/null +++ b/internal/prt/delivery_runtime_policy_test.go @@ -0,0 +1,35 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestDeliveryPolicyHasOneOptionalPriceCeiling(t *testing.T) { + cfg := prtStartupConfig(true) + cfg.BlockchainTxMaxFeePerGas = "" + policy, err := deliveryPolicy(cfg) + require.NoError(t, err) + require.Nil(t, policy.MaxFee) + cfg.BlockchainTxMaxFeePerGas = "123456789" + cfg.BlockchainLegacyEnabled = true + policy, err = deliveryPolicy(cfg) + require.NoError(t, err) + require.Equal(t, big.NewInt(123456789), policy.MaxFee) + require.True(t, policy.Legacy) +} + +func TestDeliveryPolicyRejectsInvalidPriceCeiling(t *testing.T) { + for _, value := range []string{"0", "-1", "1.5", "1e9", " 10"} { + t.Run(value, func(t *testing.T) { + cfg := prtStartupConfig(true) + cfg.BlockchainTxMaxFeePerGas = value + _, err := deliveryPolicy(cfg) + require.ErrorContains(t, err, "MAX_FEE_PER_GAS") + }) + } +} diff --git a/internal/prt/delivery_signing_test.go b/internal/prt/delivery_signing_test.go new file mode 100644 index 000000000..f7d6c624d --- /dev/null +++ b/internal/prt/delivery_signing_test.go @@ -0,0 +1,103 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "github.com/cartesi/rollups-node/internal/model" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +const forwardBlockCase = "forward block" + +type deliverySigningFactory struct { + ethutil.TransactOptsFactory + afterSign func() +} + +func (f deliverySigningFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts, err := f.TransactOptsFactory.NewTransactOpts(ctx) + if err != nil { + return nil, err + } + result := *opts + result.Signer = func(from common.Address, tx *types.Transaction) (*types.Transaction, error) { + signed, signErr := opts.Signer(from, tx) + if signErr == nil { + f.afterSign() + } + return signed, signErr + } + return &result, nil +} + +func TestPRTSlowSigningRechecksTheSameActionBeforePost(t *testing.T) { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + for _, change := range []string{disabledApplicationCase, "contract completed", forwardBlockCase} { + t.Run(actionName(kind)+"/"+change, func(t *testing.T) { + f, call, view := preparePRTAction(t, kind) + factory := deliverySigningFactory{TransactOptsFactory: f.d.txOptsFactory, afterSign: func() { + switch change { + case disabledApplicationCase: + f.app.Enabled = false + case "contract completed": + view.sealed.EpochNumber++ + view.stage.EpochNumber++ + view.accept.EpochNumber++ + updatePRTView(f, 111, view) + case forwardBlockCase: + updatePRTView(f, 111, view) + } + }} + sender, err := txsender.New[actionID](f.backend, factory, 31337, f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + report, err := sender.PublishNext(t.Context(), singleAction{f.d, call}) + if change == forwardBlockCase { + require.NoError(t, err) + require.True(t, report.Attempted, "a normal next head does not discard a useful signature") + } else { + require.ErrorIs(t, err, txsender.ErrIneligible) + require.False(t, report.Attempted) + require.Empty(t, f.backend.published) + } + }) + } + } +} + +func TestPRTRecoverySigningRechecksRecipientAndDisposition(t *testing.T) { + for _, change := range []string{disabledApplicationCase, "recovered", "foreign recipient", "recipient code"} { + t.Run(change, func(t *testing.T) { + f := newRecoveryPortFixture(t) + call, err := f.plan(t.Context()) + require.NoError(t, err) + require.NotNil(t, call) + factory := deliverySigningFactory{TransactOptsFactory: f.d.txOptsFactory, afterSign: func() { + switch change { + case disabledApplicationCase: + f.app.Enabled = false + case "recovered": + f.adapter.recovery = canonicalBondRecovery(model.BondDispositionRecovered, common.Address{}, 0) + case "foreign recipient": + f.adapter.recovery.Claimer = common.HexToAddress("0x777") + case "recipient code": + f.backend.code[f.repo.signer] = []byte{1} + } + }} + sender, err := txsender.New[actionID](f.backend, factory, 31337, f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + report, err := sender.PublishNext(t.Context(), singleAction{f.d, *call}) + require.Error(t, err) + require.False(t, report.Attempted) + require.Empty(t, f.backend.published) + }) + } +} diff --git a/internal/prt/delivery_startup_test.go b/internal/prt/delivery_startup_test.go new file mode 100644 index 000000000..daa9dc630 --- /dev/null +++ b/internal/prt/delivery_startup_test.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +const ( + startupCheckTimeoutCase = "check timeout" + startupDatabaseUnavailableCase = "database unavailable" +) + +type startupTickFixture struct { + fn func(context.Context) (bool, error) +} + +func (t *startupTickFixture) Tick(ctx context.Context) (bool, error) { return t.fn(ctx) } + +func TestPRTStartupRetriesRegistrationFailuresBeforeRunningLoops(t *testing.T) { + for _, failure := range []string{"missing observer", startupDatabaseUnavailableCase, startupCheckTimeoutCase} { + t.Run(failure, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newDeliveryFixture(t) + f.d.delivery, f.d.submissionEnabled, f.d.pollInterval = f.d, true, time.Hour + original := f.d.sender + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + attempts, failures := 0, 1 + if failure == startupDatabaseUnavailableCase { + failures = 3 + } + f.repo.onProvenance = func(check context.Context) error { + require.False(t, f.d.Ready(), "publication must remain unready throughout registration") + require.Empty(t, f.backend.published) + deadline, bounded := check.Deadline() + require.True(t, bounded) + require.LessOrEqual(t, time.Until(deadline), provenanceCheckTimeout) + attempts++ + if attempts > failures { + return nil + } + cause := repository.ErrObserverNotRegistered + switch failure { + case startupDatabaseUnavailableCase: + cause = errors.New("database connection reset") + case startupCheckTimeoutCase: + <-check.Done() + require.ErrorIs(t, check.Err(), context.DeadlineExceeded) + require.NoError(t, ctx.Err(), "the check deadline must not stop the service") + cause = check.Err() + } + return errors.Join(repository.ErrSubmitterProvenance, cause) + } + // Keep publication blocked at its first fake RPC until the observer + // checks readiness and cancels. No candidate setup is needed here. + f.backend.onHeader = func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + } + var observedReady bool + var attemptsAtStart int + tick := &startupTickFixture{fn: func(context.Context) (bool, error) { + observedReady, attemptsAtStart = f.d.Ready(), attempts + cancel() + return false, ctx.Err() + }} + require.NoError(t, service.InitTickServiceTemplate(&f.d.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Logger: f.d.Logger}, PollInterval: time.Hour, + }, tick)) + require.False(t, f.d.Ready()) + before := time.Now() + require.ErrorIs(t, f.d.Serve(ctx), context.Canceled) + require.Equal(t, failures+1, attemptsAtStart, "both loops must wait for successful registration") + require.True(t, observedReady, "successful registration must enable readiness") + elapsed := time.Duration(failures) * time.Second + if failure == startupCheckTimeoutCase { + elapsed = provenanceCheckTimeout + } + require.Equal(t, elapsed, time.Since(before), "retry must be paced without waiting for the normal hourly poll") + require.Equal(t, 1, strings.Count(logs.String(), "PRT publication waits for compatible observer registration")) + require.Contains(t, logs.String(), "level=WARN") + require.Same(t, original, f.d.sender) + require.False(t, f.d.Ready(), "shutdown must clear publication readiness") + require.Empty(t, f.backend.published) + }) + }) + } +} + +func TestPRTStartupParentStopTakesPrecedenceOverRegistrationResult(t *testing.T) { + for _, result := range []string{"joined cancellation", "successful read", "parent deadline"} { + t.Run(result, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := newDeliveryFixture(t) + f.d.delivery, f.d.submissionEnabled = f.d, true + ctx, cancel := context.WithCancel(t.Context()) + if result == "parent deadline" { + cancel() + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + } else { + go func() { + time.Sleep(time.Second) + cancel() + }() + } + defer cancel() + f.repo.onProvenance = func(check context.Context) error { + <-check.Done() + if result == "successful read" { + return nil + } + return errors.Join(repository.ErrSubmitterProvenance, check.Err()) + } + err := f.d.Serve(ctx) + require.Equal(t, ctx.Err(), err, "shutdown must return only the parent cause, without a provenance fault") + require.False(t, f.d.Ready()) + require.Empty(t, f.backend.published) + }) + }) + } +} diff --git a/internal/prt/delivery_takeover_test.go b/internal/prt/delivery_takeover_test.go new file mode 100644 index 000000000..f4f54923a --- /dev/null +++ b/internal/prt/delivery_takeover_test.go @@ -0,0 +1,48 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "math/big" +) + +func addDeliveryJoinCandidate(f *deliveryFixture, id int64, allowance uint64) *model.Application { + app := *f.app + app.ID, app.Name = id, fmt.Sprintf("candidate-%d", id) + app.IApplicationAddress = common.BigToAddress(big.NewInt(id*10 + 1)) + app.IConsensusAddress = common.BigToAddress(big.NewInt(id*10 + 2)) + epoch := *f.epoch + epoch.ApplicationID, epoch.TournamentAddress = id, new(common.BigToAddress(big.NewInt(id*10+3))) + f.repo.apps = append(f.repo.apps, &app) + admitTestDeployment(f.d.Service, f.repo.prtRepositoryMock, &app) + f.backend.apps = f.repo.apps + f.repo.On("GetEpoch", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index).Return(&epoch, nil) + f.repo.publishRoot(&app, &epoch, app.LastTournamentCheckBlock) + consensus := newCompatibleDaveConsensusAdapterMock() + view := resultTestSnapshot(&epoch, false) + view.stage.IsFinished = false + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(view.sealed, nil) + consensus.On("CanStageTournamentResult", mock.Anything).Return(view.stage, nil) + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(view.accept, nil) + f.factory.On("CreateDaveConsensusAdapter", app.IConsensusAddress).Return(consensus, nil) + deliveryJoinAdapter(f, &epoch, allowance) + return &app +} + +func deliveryJoinAdapter(f *deliveryFixture, epoch *model.Epoch, allowance uint64) { + adapter := &tournamentAdapterMock{} + adapter.On("CommitmentStanding", mock.Anything, [32]byte(*epoch.Commitment)).Return(CommitmentStanding{}, nil) + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + Level: 0, Height: model.Log2EpochComputationHashLeafCount, + Log2Stride: model.Log2RootTournamentStride, + StartInstant: 100, Allowance: allowance, + }, nil) + adapter.On("Standing", mock.Anything).Return(TournamentStanding{AcceptsJoins: true}, nil) + adapter.On("BondValue", mock.Anything).Return(big.NewInt(50), nil) + f.factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil) +} diff --git a/internal/prt/delivery_test.go b/internal/prt/delivery_test.go new file mode 100644 index 000000000..2b593c4e8 --- /dev/null +++ b/internal/prt/delivery_test.go @@ -0,0 +1,518 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "slices" + "sync" + "testing" +) + +const ( + largerContractBondCase = "larger exact contract bond" + foreclosedCase = "foreclosed" + recipientCodeCase = "recipient code" + alreadyJoinedCase = "already joined" + runningCase = "running" + getTournamentMethod = "GetTournament" +) + +type deliveryRepoFake struct { + pageMu sync.Mutex + *prtRepositoryMock + apps []*model.Application + pages []int64 + recoveries []repository.RootBondRecoveryCandidate + signer common.Address + provenanceError error + onProvenance func(context.Context) error + windows []testTournamentInterval + windowError error + targets []testTournamentScan + boundaryError error + pageError error + onBoundary func() + onPage func() + publishedRoots map[string]*model.Tournament + problems map[int64]*model.ApplicationProblem + stateRead func(context.Context, string, uint64) (*repository.EpochCompletionState, error) +} + +func (r *deliveryRepoFake) publishRoot(app *model.Application, epoch *model.Epoch, through uint64) { + if r.publishedRoots == nil { + r.publishedRoots = make(map[string]*model.Tournament) + } + r.publishedRoots[app.IApplicationAddress.Hex()+epoch.TournamentAddress.Hex()] = &model.Tournament{ + ApplicationID: app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + StartInstant: epoch.LastBlock, Snapshot: model.TournamentSnapshot{AsOfBlock: through}, + } +} + +func (r *deliveryRepoFake) GetTournament(ctx context.Context, app, address string) (*model.Tournament, error) { + for _, expectation := range r.ExpectedCalls { + if expectation.Method == getTournamentMethod && expectation.Repeatability >= 0 { + _, differences := expectation.Arguments.Diff([]any{ctx, app, address}) + if differences == 0 { + return r.prtRepositoryMock.GetTournament(ctx, app, address) + } + } + } + for _, stored := range r.storedTournaments { + if stored.Address == common.HexToAddress(address) { + return stored, nil + } + } + return r.publishedRoots[app+address], nil +} + +type testTournamentInterval struct { + ApplicationID int64 + ExpectedPreviousCursor, ThroughBlock uint64 + BlockHash common.Hash + Batches []*repository.TournamentEventBatch + CompletedScans []testTournamentScan +} +type testTournamentScan struct { + EpochIndex uint64 + Address common.Address + FromBlock, ThroughBlock uint64 +} + +func (r *deliveryRepoFake) StoreTournamentInterval(_ context.Context, id int64, expected *uint64, + batches []*repository.TournamentEventBatch) error { + interval := testTournamentInterval{ApplicationID: id, Batches: batches} + if expected != nil { + interval.ExpectedPreviousCursor = *expected + } + if len(batches) > 0 { + interval.ThroughBlock = batches[0].Tournament.Snapshot.AsOfBlock + for i, batch := range batches { + from := batch.Tournament.StartInstant + if i == 0 && expected != nil { + from = max(from, *expected+1) + } + interval.CompletedScans = append(interval.CompletedScans, testTournamentScan{EpochIndex: batch.Tournament.EpochIndex, + Address: batch.Tournament.Address, FromBlock: from, ThroughBlock: interval.ThroughBlock}) + } + } + r.windows = append(r.windows, interval) + if r.windowError != nil { + return r.windowError + } + if r.publishedRoots == nil { + r.publishedRoots = map[string]*model.Tournament{} + } + for _, app := range r.apps { + if app.ID == id { + for _, batch := range batches { + r.publishedRoots[app.IApplicationAddress.Hex()+batch.Tournament.Address.Hex()] = batch.Tournament + for i, stored := range r.storedTournaments { + if stored.Address == batch.Tournament.Address { + r.storedTournaments[i] = batch.Tournament + } + } + + } + app.LastTournamentCheckBlock = max(app.LastTournamentCheckBlock, interval.ThroughBlock) + } + } + return nil +} +func (r *deliveryRepoFake) ListTournaments(ctx context.Context, app string, filter repository.TournamentFilter, + page repository.Pagination, descending bool) ([]*model.Tournament, uint64, error) { + rows, count, err := r.prtRepositoryMock.ListTournaments(ctx, app, filter, page, descending) + if err != nil || filter != (repository.TournamentFilter{}) { + return rows, count, err + } + seen := map[common.Address]bool{} + for _, row := range rows { + seen[row.Address] = true + } + for key, row := range r.publishedRoots { + if len(key) >= len(app) && key[:len(app)] == app && !seen[row.Address] { + rows = append(rows, row) + seen[row.Address] = true + } + } + // Most callers select by name. Resolve the matching application before using + // the fake's persisted-root map, whose key contains its address. + for _, application := range r.apps { + if application.Name == app { + for key, row := range r.publishedRoots { + address := application.IApplicationAddress.Hex() + if len(key) >= len(address) && key[:len(address)] == address && !seen[row.Address] { + rows = append(rows, row) + seen[row.Address] = true + } + } + } + } + return rows, uint64(len(rows)), nil +} + +func (r *deliveryRepoFake) GetApplication(_ context.Context, address string) (*model.Application, error) { + for _, app := range r.apps { + if app.IApplicationAddress.Hex() == address { + return app, nil + } + } + return nil, repository.ErrNotFound +} +func (r *deliveryRepoFake) GetEpochCompletionState( + ctx context.Context, address string, index uint64, +) (*repository.EpochCompletionState, error) { + if r.stateRead != nil { + return r.stateRead(ctx, address, index) + } + app, err := r.GetApplication(ctx, address) + if errors.Is(err, repository.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + epoch, err := r.GetEpoch(ctx, address, index) + if err != nil { + return nil, err + } + return &repository.EpochCompletionState{Application: app, Epoch: epoch, Problem: r.problems[app.ID]}, nil +} + +func (r *deliveryRepoFake) GetApplicationIDBoundary(context.Context, repository.ApplicationFilter) (int64, error) { + if r.onBoundary != nil { + r.onBoundary() + } + var last int64 + for _, app := range r.apps { + last = max(last, app.ID) + } + return last, r.boundaryError +} +func (r *deliveryRepoFake) ListApplicationsByID( + _ context.Context, _ repository.ApplicationFilter, after, through int64, limit uint64, +) ([]*model.Application, error) { + r.pageMu.Lock() + r.pages = append(r.pages, after) + r.pageMu.Unlock() + if r.onPage != nil { + r.onPage() + } + if r.pageError != nil { + return nil, r.pageError + } + var result []*model.Application + for _, app := range r.apps { + if app.ID > after && app.ID <= through { + result = append(result, app) + } + } + slices.SortFunc(result, func(a, b *model.Application) int { + if a.ID < b.ID { + return -1 + } + if a.ID > b.ID { + return 1 + } + return 0 + }) + if uint64(len(result)) > limit { + result = result[:limit] + } + return result, nil +} +func (r *deliveryRepoFake) ListRecoverableRootBonds( + _ context.Context, _ int64, _ common.Address, _ uint64, after *uint64, through, limit uint64, +) ([]repository.RootBondRecoveryCandidate, error) { + var result []repository.RootBondRecoveryCandidate + for _, candidate := range r.recoveries { + if candidate.EpochIndex <= through && (after == nil || candidate.EpochIndex > *after) { + result = append(result, candidate) + } + } + if uint64(len(result)) > limit { + result = result[:limit] + } + return result, nil +} + +type deliveryBackendFake struct { + headerReads []string + + headers map[uint64]*types.Header + latest, nonce uint64 + balance *big.Int + published []*types.Transaction + raw [][]byte + publishError, headerError error + onPublish func(*types.Transaction) + recoveryFalse bool + preflightError error + onPreflight func(context.Context, ethereum.CallMsg, txsender.BlockRef) error + onCall func(context.Context, ethereum.CallMsg, txsender.BlockRef) error + onHeader func(context.Context) error + + txsender.Backend + app *model.Application + apps []*model.Application + code map[common.Address][]byte + calls []txsender.BlockRef + foreclosure bool + wrongConsensus bool + callError error +} + +func (b *deliveryBackendFake) Code(_ context.Context, address common.Address, _ txsender.BlockRef) ([]byte, error) { + if code, ok := b.code[address]; ok { + return code, nil + } + return []byte{1}, nil +} +func (b *deliveryBackendFake) Call( + ctx context.Context, msg ethereum.CallMsg, ref txsender.BlockRef, +) ([]byte, error) { + b.calls = append(b.calls, ref) + if b.onCall != nil { + if err := b.onCall(ctx, msg, ref); err != nil { + return nil, err + } + } + if b.callError != nil { + return nil, b.callError + } + + if len(msg.Data) < 4 { + return nil, nil + } + for _, meta := range []*bind.MetaData{itournament.ITournamentMetaData, idaveconsensus.IDaveConsensusMetaData} { + contract, err := meta.GetAbi() + if err != nil { + return nil, err + } + method, err := contract.MethodById(msg.Data[:4]) + if err == nil { + switch method.Name { + case "tryRecoveringBond": + return method.Outputs.Pack(!b.recoveryFalse) + case "joinTournament", "stageTournamentResult", "acceptStagedTournamentResult": + if b.onPreflight != nil { + return nil, b.onPreflight(ctx, msg, ref) + } + return nil, b.preflightError + } + } + } + app := b.app + for _, candidate := range b.apps { + if msg.To != nil && (*msg.To == candidate.IApplicationAddress || *msg.To == candidate.IConsensusAddress) { + app = candidate + break + } + } + appABI, err := iapplication.IApplicationMetaData.GetAbi() + if err != nil { + return nil, err + } + daveABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + if err != nil { + return nil, err + } + if method, err := appABI.MethodById(msg.Data[:4]); err == nil { + switch method.Name { + case "isForeclosed": + return method.Outputs.Pack(b.foreclosure) + case "getOutputsMerkleRootValidator": + address := app.IConsensusAddress + if b.wrongConsensus { + address = common.HexToAddress("0x999") + } + return method.Outputs.Pack(address) + } + } + method, err := daveABI.MethodById(msg.Data[:4]) + if err != nil { + return nil, err + } + switch method.Name { + case "getApplicationContract": + return method.Outputs.Pack(app.IApplicationAddress) + case "getTournamentFactory": + return method.Outputs.Pack(common.HexToAddress("0xfa")) + } + return nil, fmt.Errorf("unexpected call %s", method.Name) +} + +func TestDeliveryLatestForeclosureAndRelationshipRefusePublication(t *testing.T) { + for _, failure := range []string{foreclosedCase, "wrong consensus", "missing code", "unavailable view"} { + t.Run(failure, func(t *testing.T) { + f := newDeliveryFixture(t) + f.snapshot(resultTestSnapshot(f.epoch, false)) + switch failure { + case foreclosedCase: + f.backend.foreclosure = true + case "wrong consensus": + f.backend.wrongConsensus = true + case "missing code": + f.backend.code[f.app.IConsensusAddress] = nil + case "unavailable view": + f.backend.callError = errors.New("view unavailable") + } + call, err := f.d.planAction(t.Context(), f.app, stageTournament, f.block, txsender.BlockRef{Number: 100}) + require.Error(t, err) + require.Nil(t, call) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestDeliveryCallIdentityIncludesTargetValueAndCalldata(t *testing.T) { + f := newDeliveryFixture(t) + first := f.d.call(f.app, 3, joinTournament, common.HexToAddress("0x1"), []byte{1}, big.NewInt(1), 120) + for _, call := range []plannedAction{ + f.d.call(f.app, 3, joinTournament, common.HexToAddress("0x2"), []byte{1}, big.NewInt(1), 120), + f.d.call(f.app, 3, joinTournament, common.HexToAddress("0x1"), []byte{2}, big.NewInt(1), 120), + f.d.call(f.app, 3, joinTournament, common.HexToAddress("0x1"), []byte{1}, big.NewInt(2), 120), + } { + require.NotEqual(t, first.Key, call.Key) + } +} + +func TestDeliveryRecoveryNeedsRecoverableConfiguredCodeFreeRecipient(t *testing.T) { + for _, caseName := range []string{"recovered no-op", "foreign", recipientCodeCase, runningCase, "no winner"} { + t.Run(caseName, func(t *testing.T) { + f := newDeliveryFixture(t) + target := common.HexToAddress("0x900") + recovery := canonicalBondRecovery(model.BondDispositionRecoverable, f.repo.signer, 1) + f.backend.code[recovery.Claimer] = nil + switch caseName { + case "recovered no-op": + recovery.Disposition = model.BondDispositionRecovered + case "foreign": + recovery.Claimer = common.HexToAddress("0x700") + case recipientCodeCase: + f.backend.code[recovery.Claimer] = []byte{1} + case runningCase: + recovery.Disposition = model.BondDispositionTournamentRunning + case "no winner": + recovery.Disposition = model.BondDispositionNoWinner + } + adapter := &tournamentAdapterMock{} + adapter.On("BondRecovery", mock.Anything).Return(recovery, nil).Once() + f.factory.On("CreateTournamentAdapter", target).Return(adapter, nil).Once() + call, err := f.d.recoveryCall(withLiveBlock(t.Context(), f.block.Number, f.block.Hash), + f.app, 2, target, f.block, txsender.BlockRef{Number: 100}) + require.Nil(t, call) + if caseName == recipientCodeCase { + require.ErrorContains(t, err, "unsupported code") + } else { + require.NoError(t, err) + } + require.Empty(t, f.backend.published) + }) + } +} + +func TestDeliveryJoinHasInclusiveDeadlineAndExactBondGuards(t *testing.T) { + cases := []string{"valid", "deadline next block", largerContractBondCase, "wrong geometry", "missing target code", alreadyJoinedCase} + for _, caseName := range cases { + t.Run(caseName, func(t *testing.T) { + f := newDeliveryFixture(t) + target := *f.epoch.TournamentAddress + adapter := &tournamentAdapterMock{} + standing := CommitmentStanding{} + descriptor := TournamentDescriptor{Level: 0, Height: model.Log2EpochComputationHashLeafCount, + Log2Stride: model.Log2RootTournamentStride, + StartInstant: 100, Allowance: 20} + bond := big.NewInt(50) + switch caseName { + case "deadline next block": + descriptor.Allowance = 11 + case largerContractBondCase: + bond = big.NewInt(51) + case "wrong geometry": + descriptor.Height-- + case "missing target code": + f.backend.code[target] = nil + case alreadyJoinedCase: + standing.Joined = true + standing.FinalState = *f.epoch.MachineHash + } + adapter.On("CommitmentStanding", mock.Anything, [32]byte(*f.epoch.Commitment)).Return(standing, nil).Maybe() + adapter.On("Descriptor", mock.Anything).Return(descriptor, nil).Maybe() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{AcceptsJoins: true}, nil).Maybe() + adapter.On("BondValue", mock.Anything).Return(bond, nil).Maybe() + f.factory.On("CreateTournamentAdapter", target).Return(adapter, nil).Maybe() + call, err := f.d.joinCall(withLiveBlock(t.Context(), f.block.Number, f.block.Hash), f.app, f.epoch, f.block) + switch caseName { + case "valid", largerContractBondCase: + require.NoError(t, err) + require.Equal(t, joinTournament, call.Key.Kind) + require.Equal(t, bond, call.Value) + require.Equal(t, uint64(120), call.Deadline) + abi, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + method, err := abi.MethodById(call.Data[:4]) + require.NoError(t, err) + require.Equal(t, "joinTournament", method.Name) + case alreadyJoinedCase: + require.NoError(t, err) + require.Zero(t, call.Key.Kind) + default: + require.Error(t, err) + } + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestDeliveryActionSweepUsesIDsAndFiniteBoundary(t *testing.T) { + f := newDeliveryFixture(t) + f.repo.apps = nil + for id := int64(1); id <= 12; id++ { + f.repo.apps = append(f.repo.apps, &model.Application{ID: id, Name: fmt.Sprint(20 - id)}) + } + first, err := f.d.nextDeliveryApplications(t.Context(), recoverBond) + require.NoError(t, err) + require.Len(t, first, 8) + require.Equal(t, int64(1), first[0].ID) + f.repo.apps = append(f.repo.apps, &model.Application{ID: 13, Name: "a"}) + second, err := f.d.nextDeliveryApplications(t.Context(), recoverBond) + require.NoError(t, err) + require.Len(t, second, 4) + require.Equal(t, int64(12), second[3].ID) + third, err := f.d.nextDeliveryApplications(t.Context(), recoverBond) + require.NoError(t, err) + require.Len(t, third, 8) + require.Equal(t, int64(1), third[0].ID) + require.Equal(t, int64(13), f.d.deliverySweeps[recoverBond].through) +} + +func (r *deliveryRepoFake) CheckSubmitterProvenance(ctx context.Context, key string) error { + if key != PrtConfigKey { + return errors.New("unexpected submitter provenance key") + } + if r.onProvenance != nil { + return r.onProvenance(ctx) + } + return r.provenanceError +} + +// chargeReverts settles n matched reverted stages at nonces no test reserves. + +// dispatchRound is a test entry point for a publisher with an already selected observation reference. diff --git a/internal/prt/delivery_wait.go b/internal/prt/delivery_wait.go new file mode 100644 index 000000000..1d7aa7ac8 --- /dev/null +++ b/internal/prt/delivery_wait.go @@ -0,0 +1,30 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" +) + +// Local deferrals and changed eligibility or publication snapshots are ordinary +// waits only when every cause agrees. A joined RPC/database failure remains an error. +func deliveryWaitOnly(err error) bool { + return txsender.WaitOnly(err, repository.ErrTournamentIntervalStale, txsender.ErrLimit) +} + +func (s *Service) logDeliveryWait(err error) { + switch { + case errors.Is(err, txsender.ErrLimit): + s.Logger.Debug("PRT transaction waits for funds or the configured fee limit", "error", err) + case errors.Is(err, txsender.ErrStale): + s.Logger.Debug("PRT publication waits for the next protected head", "error", err) + case errors.Is(err, txsender.ErrIneligible): + s.Logger.Debug("PRT publication skips an action whose eligibility changed; planning will retry", "error", err) + default: + s.Logger.Debug("PRT publication waits for complete canonical observations", "error", err) + } +} diff --git a/internal/prt/deployment_acceptance.go b/internal/prt/deployment_acceptance.go new file mode 100644 index 000000000..1b473959c --- /dev/null +++ b/internal/prt/deployment_acceptance.go @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Receipt and event checks establish observed acceptance evidence. +// A transaction pointer alone is never validated acceptance knowledge. +func (s *Service) readEpochAcceptance(ctx context.Context, app *model.Application, epoch *model.Epoch, + consensus DaveConsensusAdapter, observedBlock uint64, +) (*idaveconsensus.IDaveConsensusEpochSealed, error) { + if epoch.ClaimTransactionHash == nil { + return nil, nil + } + receipt, err := s.client.TransactionReceipt(ctx, *epoch.ClaimTransactionHash) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch transaction receipt for epoch", err, "application", app.Name, + "epoch", epoch.Index, "tx", epoch.ClaimTransactionHash) + return nil, err + } + if receipt == nil { + return nil, fmt.Errorf("epoch %d: acceptance transaction receipt is nil", epoch.Index) + } + if receipt.TxHash != *epoch.ClaimTransactionHash { + return nil, fmt.Errorf("epoch %d: acceptance receipt transaction hash %s differs from observed hash %s", + epoch.Index, receipt.TxHash, epoch.ClaimTransactionHash) + } + receiptBlock, err := checkedUint64(receipt.BlockNumber, "acceptance receipt block") + if err != nil { + return nil, fmt.Errorf("epoch %d: %w", epoch.Index, err) + } + if receiptBlock > observedBlock { + s.Logger.Debug("Acceptance transaction is newer than the published tournament window", + "application", app.Name, "epoch", epoch.Index, "tx", epoch.ClaimTransactionHash, + "receipt_block", receiptBlock, "snapshot_block", observedBlock) + return nil, nil + } + if err := s.validateAcceptanceReceipt(ctx, receipt); err != nil { + return nil, fmt.Errorf("epoch %d: %w", epoch.Index, err) + } + if receipt.Status != types.ReceiptStatusSuccessful { + return nil, fmt.Errorf("epoch %d: EpochSealed transaction hash points to failed transaction", epoch.Index) + } + expectedEpoch := new(big.Int).Add(new(big.Int).SetUint64(epoch.Index), common.Big1) + for _, log := range receipt.Logs { + if log == nil || log.Address != app.IConsensusAddress || log.TxHash != *epoch.ClaimTransactionHash || + log.BlockNumber != receiptBlock || log.BlockHash != receipt.BlockHash || log.Removed { + continue + } + event, parseErr := consensus.ParseEpochSealed(*log) + if parseErr != nil || event == nil || event.EpochNumber == nil || event.EpochNumber.Cmp(expectedEpoch) != 0 { + continue + } + return event, nil + } + return nil, fmt.Errorf("%w: epoch %d: failed to find EpochSealed event in receipt logs", txsender.ErrInconclusive, epoch.Index) +} diff --git a/internal/prt/deployment_action_test.go b/internal/prt/deployment_action_test.go new file mode 100644 index 000000000..a1c221e74 --- /dev/null +++ b/internal/prt/deployment_action_test.go @@ -0,0 +1,335 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDeploymentActionPolicyUsesFreshAndDurableState(t *testing.T) { + for _, state := range []deploymentState{ + deploymentNone, deploymentPending, deploymentGeneration, deploymentTable, deploymentCompatible, + } { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + t.Run(string(state)+"/"+actionName(kind), func(t *testing.T) { + f, call, _ := preparePRTAction(t, kind) + var tier *model.DeploymentTier + if state == deploymentGeneration { + tier = new(model.DeploymentTierGeneration) + } + if state == deploymentTable { + tier = new(model.DeploymentTierTable) + } + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, state, tier) + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + switch { + case state == deploymentNone || state == deploymentPending: + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, txsender.ProtocolWait, eligibility) + case state == deploymentGeneration || state == deploymentTable && kind == joinTournament: + require.NoError(t, err) + require.Equal(t, txsender.Obsolete, eligibility) + default: + require.NoError(t, err) + require.Equal(t, txsender.Useful, eligibility) + } + }) + } + } +} + +func TestDeploymentRecheckRejectsPublicationABAAndUsesCurrentEligibility(t *testing.T) { + for _, changed := range []bool{false, true} { + t.Run(map[bool]string{false: "current disabled state", true: "equal publication ABA"}[changed], func(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + f.repo.deploymentCompletion = func(ctx context.Context, identity repository.DeploymentIdentity, index uint64) ( + *repository.DeploymentCompletionState, error, + ) { + state, err := f.repo.GetEpochCompletionState(ctx, identity.ApplicationAddress.Hex(), index) + require.NoError(t, err) + owned := *state.Application + if !changed { + owned.Enabled = false + } + completion := *state + completion.Application = &owned + row, err := f.repo.GetDeploymentAdmissionState(ctx, identity) + require.NoError(t, err) + if changed { + key := deploymentIdentity(f.app) + captured := f.d.deployments.capture(key) + f.d.deployments.publish(key, captured.snapshot) + } + return &repository.DeploymentCompletionState{Completion: &completion, Deployment: row}, nil + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + if changed { + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, txsender.ProtocolWait, eligibility) + } else { + require.NoError(t, err) + require.Equal(t, txsender.Obsolete, eligibility) + require.True(t, f.app.Enabled, "earlier planning metadata remains enabled") + } + require.Empty(t, f.backend.published) + }) + } +} + +func TestDeploymentPreparedBytesSurviveResetAfterSnapshotAndAmbiguousPOST(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 2, deploymentCompatible, nil) + preflights := 0 + f.backend.onPreflight = func(context.Context, ethereum.CallMsg, txsender.BlockRef) error { + preflights++ + if preflights == 2 { + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 3, deploymentCompatible, nil) + } + return nil + } + cause := errors.New("lost POST acknowledgment") + f.backend.publishError = cause + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, call}) + require.ErrorIs(t, err, cause) + require.Equal(t, txsender.Uncertain, report.Class) + require.Equal(t, int64(3), f.repo.deploymentRows[f.app.ID].ResetGeneration) + require.Equal(t, call.Data, f.backend.published[0].Data()) + require.Equal(t, call.To, *f.backend.published[0].To()) + require.Equal(t, call.Value, f.backend.published[0].Value()) +} + +func TestDeploymentConfirmedCancellationDoesNotNeedProjectionAndRetainsSignedBytes(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 2, deploymentGeneration, new(model.DeploymentTierGeneration)) + f.repo.publishedRoots = nil + neutralCalls := 0 + f.backend.onCall = func(_ context.Context, msg ethereum.CallMsg, _ txsender.BlockRef) error { + if len(msg.Data) == 0 { + neutralCalls++ + if neutralCalls == 2 { + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 3, deploymentPending, new(model.DeploymentTierGeneration)) + } + } + return nil + } + cause := errors.New("ambiguous neutral POST") + f.backend.publishError = cause + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.ErrorIs(t, err, cause) + require.True(t, report.Current.Neutral) + require.Equal(t, int64(3), f.repo.deploymentRows[f.app.ID].ResetGeneration) + require.Empty(t, f.backend.published[1].Data()) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 4, deploymentGeneration, new(model.DeploymentTierGeneration)) + f.backend.publishError = nil + again, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, again.Current.Neutral) + require.Equal(t, f.backend.raw[1], f.backend.raw[2]) +} + +func TestDeploymentUnknownRetainsSignedRequestWithoutCancellation(t *testing.T) { + for _, state := range []deploymentState{deploymentNone, deploymentPending} { + t.Run(string(state), func(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + before := submitPRTAction(t, f, call) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, state, new(model.DeploymentTierGeneration)) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.False(t, report.Attempted) + require.False(t, report.Current.Neutral) + require.Equal(t, before.Current, report.Current) + require.Len(t, f.backend.published, 1) + }) + } +} + +func TestDeploymentTablePublishesTrueAlternateFinalResult(t *testing.T) { + for _, kind := range []actionKind{stageTournament, acceptTournament} { + t.Run(actionName(kind), func(t *testing.T) { + f, _, view := preparePRTAction(t, kind) + view.stage.WinnerCommitment = common.HexToHash("0xdead") + updatePRTView(f, 110, view) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentTable, new(model.DeploymentTierTable)) + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, call) + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, *call}) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, kind, report.Current.Action.Kind) + require.Equal(t, call.Data, f.backend.published[0].Data()) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + }) + } +} + +func TestDeploymentVerdictGatePreservesTrueResultPolicyAndConsensusBounds(t *testing.T) { + for _, state := range []deploymentState{deploymentNone, deploymentPending, deploymentGeneration, deploymentTable} { + t.Run(string(state), func(t *testing.T) { + f, _, view := preparePRTAction(t, stageTournament) + view.stage.WinnerPostEpochMachineStateHash = common.HexToHash("0xbad") + var saved *model.DeploymentTier + if state == deploymentGeneration { + saved = new(model.DeploymentTierGeneration) + } + if state == deploymentTable { + saved = new(model.DeploymentTierTable) + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Return(nil).Once() + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app) + } + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, state, saved) + err := f.d.recordTournamentResult(t.Context(), f.app, f.epoch, view) + if state == deploymentTable { + require.Error(t, err) + f.repo.AssertExpectations(t) + } else { + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestDeploymentFinalCheckDoesNotAuthorizeCancellationFromOldApplicationMetadata(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + f.app.Enabled = false + f.repo.deploymentCompletion = func(ctx context.Context, identity repository.DeploymentIdentity, index uint64) ( + *repository.DeploymentCompletionState, error, + ) { + completion, err := f.repo.GetEpochCompletionState(ctx, identity.ApplicationAddress.Hex(), index) + require.NoError(t, err) + current := *completion.Application + current.Enabled = true + owned := *completion + owned.Application = ¤t + row, err := f.repo.GetDeploymentAdmissionState(ctx, identity) + require.NoError(t, err) + return &repository.DeploymentCompletionState{Completion: &owned, Deployment: row}, nil + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.NoError(t, err) + require.Equal(t, txsender.Useful, eligibility) + require.False(t, f.app.Enabled) +} + +func TestDeploymentConfirmedRetainedRefusalPrecedesIncompatibleProtocolReads(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + f.consensus.ExpectedCalls = nil + f.consensus.Calls = nil + f.consensus.On("GetCurrentSealedEpoch", mock.Anything). + Return(CurrentSealedEpoch{}, errors.New("incompatible static consensus view")).Maybe() + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 2, deploymentGeneration, new(model.DeploymentTierGeneration)) + f.repo.publishedRoots = nil + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.True(t, report.Current.Neutral) + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) +} + +func TestDeploymentRecoveryRemainsIndependentOfAdmission(t *testing.T) { + for _, state := range []deploymentState{deploymentNone, deploymentPending, deploymentGeneration, deploymentTable} { + t.Run(string(state), func(t *testing.T) { + f := newRecoveryPortFixture(t) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 2, state, new(model.DeploymentTierGeneration)) + call, err := f.plan(t.Context()) + require.NoError(t, err) + require.NotNil(t, call) + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, *call}) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, recoverBond, report.Current.Action.Kind) + require.Equal(t, call.Data, f.backend.published[0].Data()) + }) + } +} + +func TestDeploymentDurableRestrictionPrecedesCachePublication(t *testing.T) { + for _, tier := range []model.DeploymentTier{model.DeploymentTierGeneration, model.DeploymentTierTable} { + for _, kind := range []actionKind{joinTournament, stageTournament} { + t.Run(string(tier)+"/"+actionName(kind), func(t *testing.T) { + f, call, _ := preparePRTAction(t, kind) + row := f.repo.deploymentRows[f.app.ID] + row.ConfirmedTier, row.DeploymentReason = new(tier), new("confirmed before cache publication") + f.repo.deploymentRows[f.app.ID] = row + require.Equal(t, deploymentCompatible, f.d.deployments.capture(deploymentIdentity(f.app)).snapshot.Assessment.State) + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.NoError(t, err) + if tier == model.DeploymentTierGeneration || kind == joinTournament { + require.Equal(t, txsender.Obsolete, eligibility) + } else { + require.Equal(t, txsender.Useful, eligibility) + } + }) + } + } +} + +func TestDeploymentCompletionSnapshotSurvivesResetBeforeReadReturns(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 2, deploymentCompatible, nil) + reads := 0 + f.repo.deploymentCompletion = func(ctx context.Context, identity repository.DeploymentIdentity, index uint64) ( + *repository.DeploymentCompletionState, error, + ) { + completion, err := f.repo.GetEpochCompletionState(ctx, identity.ApplicationAddress.Hex(), index) + require.NoError(t, err) + row, err := f.repo.GetDeploymentAdmissionState(ctx, identity) + require.NoError(t, err) + reads++ + if reads == 2 { + // These returned values belong to S. Reset commits after S while + // this unchanged immutable cache publication remains current. + reset := *row + reset.ResetGeneration = 3 + f.repo.deploymentRows[f.app.ID] = reset + } + return &repository.DeploymentCompletionState{Completion: completion, Deployment: row}, nil + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.NoError(t, err) + require.Equal(t, txsender.Useful, eligibility) + require.Equal(t, int64(3), f.repo.deploymentRows[f.app.ID].ResetGeneration) + eligibility, err = f.d.Recheck(t.Context(), f.block, call.Key) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, txsender.ProtocolWait, eligibility, "the next attempt must open a fresh snapshot") +} + +func TestDeploymentFinalDatabaseReadHoldsNoPublicationLock(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 2, deploymentCompatible, nil) + reads := 0 + f.repo.deploymentCompletion = func(ctx context.Context, identity repository.DeploymentIdentity, index uint64) ( + *repository.DeploymentCompletionState, error, + ) { + if !f.d.deployments.mu.TryLock() { + return nil, errors.New("publication mutex held during SQL") + } + f.d.deployments.mu.Unlock() + reads++ + completion, err := f.repo.GetEpochCompletionState(ctx, identity.ApplicationAddress.Hex(), index) + require.NoError(t, err) + row, err := f.repo.GetDeploymentAdmissionState(ctx, identity) + require.NoError(t, err) + return &repository.DeploymentCompletionState{Completion: completion, Deployment: row}, nil + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.NoError(t, err) + require.Equal(t, txsender.Useful, eligibility) + require.Equal(t, 2, reads) +} diff --git a/internal/prt/deployment_ambiguous_commit_test.go b/internal/prt/deployment_ambiguous_commit_test.go new file mode 100644 index 000000000..793f0836e --- /dev/null +++ b/internal/prt/deployment_ambiguous_commit_test.go @@ -0,0 +1,86 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDeploymentAmbiguousRestrictionDoesNotRecoverWithinSameEpisode(t *testing.T) { + for _, test := range []struct { + name string + compatible bool + committed bool + }{ + {name: "after compatibility", compatible: true, committed: true}, + {name: "first restriction reply lost", committed: true}, + {name: "first restriction rolled back"}, + } { + t.Run(test.name, func(t *testing.T) { + s := newDeploymentObserverService() + repo := s.repository.(*prtRepositoryMock) + app := prtRevertTestApp() + reader := newCompatibleDaveConsensusAdapterMock() + if !test.committed { + repo.deploymentRows = map[int64]model.DeploymentAdmissionState{app.ID: { + ApplicationID: app.ID, ConfirmedTier: new(model.DeploymentTierGeneration), + DeploymentReason: new("saved restriction"), + }} + } + if test.compatible { + _, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(99)) + require.NoError(t, err) + } + header := &types.Header{Number: big.NewInt(100)} + first := txsender.BlockRef{Number: 100, Hash: header.Hash()} + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(header, nil).Once() + s.client = client + reader.deploymentRead = func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + return tableDeploymentEvidence(h), nil + } + _, err := s.observeDeployment(t.Context(), app, reader, first) + require.NoError(t, err) + + writeError := errors.New("restriction persistence failed") + repo.deploymentAck = func(_ context.Context, request repository.DeploymentAssessment) (*model.DeploymentAdmissionState, error) { + require.NotNil(t, request.ConfirmedTier) + if test.committed { + row := repo.deploymentRows[app.ID] + row.ConfirmedTier, row.DeploymentReason = new(*request.ConfirmedTier), new(request.Reason) + repo.deploymentRows[app.ID] = row + } + return nil, writeError + } + _, err = s.observeDeployment(t.Context(), app, reader, deploymentTestHead(101)) + require.ErrorIs(t, err, writeError) + cached := s.deployments.capture(deploymentIdentity(app)).snapshot + require.Equal(t, deploymentPending, cached.Assessment.State) + + repo.deploymentAck = nil + reader = newCompatibleDaveConsensusAdapterMock() + _, err = s.observeDeployment(t.Context(), app, reader, deploymentTestHead(102)) + require.NoError(t, err) + require.NotNil(t, repo.deploymentRows[app.ID].ConfirmedTier, + "a same-episode compatible tick must retain the restriction") + row := repo.deploymentRows[app.ID] + row.ResetGeneration++ + repo.deploymentRows[app.ID] = row + _, err = s.observeDeployment(t.Context(), app, reader, deploymentTestHead(103)) + require.NoError(t, err) + require.Nil(t, repo.deploymentRows[app.ID].ConfirmedTier, "an explicit reset permits fresh compatible recovery") + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/deployment_assessment.go b/internal/prt/deployment_assessment.go new file mode 100644 index 000000000..b43d2b43e --- /dev/null +++ b/internal/prt/deployment_assessment.go @@ -0,0 +1,65 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "slices" + + "github.com/ethereum/go-ethereum/common" +) + +// The selected head is checked separately from the decoded evidence identity. +func sameDeploymentFacts(a, b DeploymentEvidence) bool { + return a.Consensus == b.Consensus && a.FactoryKnown == b.FactoryKnown && a.Factory == b.Factory && + a.Alpha6Shape == b.Alpha6Shape && + a.Transition == b.Transition && + a.MarchID == b.MarchID && + a.Pristine == b.Pristine && + a.Count == b.Count && a.Candidate == b.Candidate && a.Cause == b.Cause && + slices.Equal(a.Rows, b.Rows) && (a.Malformed == nil && b.Malformed == nil || + a.Malformed != nil && b.Malformed != nil && *a.Malformed == *b.Malformed) +} + +// planDeploymentAssessment changes no shared state or durable data. +// A failed read updates diagnostic facts without erasing the previous conclusive evidence. +func planDeploymentAssessment( + previous deploymentSnapshot, evidence DeploymentEvidence, readErr error, previousHeadCanonical bool, +) deploymentSnapshot { + next := previous + if next.Assessment.State == "" { + next.Assessment.State = deploymentNone + } + if evidence.FactoryKnown && next.Alpha6Shape && evidence.Factory != next.ShapeFactory { + next.Alpha6Shape = false + next.ShapeFactory = common.Address{} + } + if evidence.FactoryKnown && evidence.Alpha6Shape { + next.Alpha6Shape, next.ShapeFactory = true, evidence.Factory + } + if readErr != nil || evidence.Candidate == deploymentNone || evidence.Candidate == "" || + next.Assessment.confirmed() { + return next + } + if evidence.Candidate == deploymentCompatible { + next.Assessment = deploymentAssessment{State: deploymentCompatible} + next.Head, next.Cause = evidence.Head, evidence.Cause + return next + } + if evidence.Candidate != deploymentGeneration && evidence.Candidate != deploymentTable { + return next + } + prior := next.Assessment + next.Head, next.Cause = evidence.Head, evidence.Cause + if hasDeploymentConfirmationPair(prior, evidence) && previousHeadCanonical { + next.Assessment = deploymentAssessment{State: evidence.Candidate} + } else { + next.Assessment = deploymentAssessment{State: deploymentPending, PendingEvidence: new(evidence)} + } + return next +} + +func hasDeploymentConfirmationPair(prior deploymentAssessment, evidence DeploymentEvidence) bool { + return prior.State == deploymentPending && prior.PendingEvidence != nil && + prior.PendingEvidence.Head.Hash != evidence.Head.Hash && sameDeploymentFacts(*prior.PendingEvidence, evidence) +} diff --git a/internal/prt/deployment_assessment_test.go b/internal/prt/deployment_assessment_test.go new file mode 100644 index 000000000..bcb6a34f5 --- /dev/null +++ b/internal/prt/deployment_assessment_test.go @@ -0,0 +1,159 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "slices" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func deploymentTestHead(number uint64) txsender.BlockRef { + return txsender.BlockRef{Number: number, Hash: common.BigToHash(new(big.Int).SetUint64(number + 1))} +} + +func tableDeploymentEvidence(head txsender.BlockRef) DeploymentEvidence { + e := compatibleDeploymentEvidence(head) + e.Candidate = deploymentTable + e.Rows = []DeploymentParameters{{Levels: 2, Log2Step: 44, Height: 48, ResponseBudget: 11, CommitmentBudget: 22, MaxAllowance: 33}, + {Levels: 2, Log2Step: 0, Height: 44, ResponseBudget: 11, CommitmentBudget: 22, MaxAllowance: 33}} + return e +} + +func TestDeploymentAssessmentDistinctHeadsFullEvidenceAndLatch(t *testing.T) { + first := tableDeploymentEvidence(deploymentTestHead(100)) + state := planDeploymentAssessment(deploymentSnapshot{}, first, nil, true) + require.Equal(t, deploymentPending, state.Assessment.State) + repeated := planDeploymentAssessment(state, first, nil, true) + require.Equal(t, deploymentPending, repeated.Assessment.State) + unavailable := DeploymentEvidence{Head: deploymentTestHead(101), Candidate: deploymentNone} + for _, cause := range []error{nil, errors.New("RPC read failed"), txsender.ErrUnsupported} { + next := planDeploymentAssessment(state, unavailable, cause, true) + require.Equal(t, state.Assessment, next.Assessment) + require.Equal(t, first, *next.Assessment.PendingEvidence) + } + changed := first.clone() + changed.Head = deploymentTestHead(102) + changed.Rows[1].MaxAllowance++ + restarted := planDeploymentAssessment(state, changed, nil, true) + require.Equal(t, deploymentPending, restarted.Assessment.State) + require.Equal(t, changed.Head, restarted.Assessment.PendingEvidence.Head) + require.NotEqual(t, state.Assessment.PendingEvidence, restarted.Assessment.PendingEvidence) + second := first.clone() + second.Head = deploymentTestHead(103) + failedRecheck := planDeploymentAssessment(state, second, errors.New("canonical head unavailable"), true) + require.Equal(t, state.Assessment, failedRecheck.Assessment) + require.Equal(t, state.Head, failedRecheck.Head) + confirmed := planDeploymentAssessment(state, second, nil, true) + require.Equal(t, deploymentTable, confirmed.Assessment.State) + require.Nil(t, confirmed.Assessment.PendingEvidence) + require.Equal(t, second.Head, confirmed.Head) + require.Nil(t, confirmed.Assessment.PendingEvidence) + compatible := compatibleDeploymentEvidence(deploymentTestHead(104)) + retained := planDeploymentAssessment(confirmed, compatible, nil, true) + require.Equal(t, confirmed.Assessment, retained.Assessment) + require.Equal(t, confirmed.Head, retained.Head) +} + +func TestDeploymentAssessmentCanonicalityGenesisAndEarlyShape(t *testing.T) { + first := tableDeploymentEvidence(deploymentTestHead(0)) + previous := planDeploymentAssessment(deploymentSnapshot{}, first, nil, true) + second := first.clone() + second.Head = deploymentTestHead(1) + invalid := planDeploymentAssessment(previous, second, nil, false) + require.Equal(t, deploymentPending, invalid.Assessment.State) + require.Equal(t, second.Head, invalid.Assessment.PendingEvidence.Head) + valid := planDeploymentAssessment(previous, second, nil, true) + require.Equal(t, deploymentTable, valid.Assessment.State) + partial := first.clone() + partial.Candidate = deploymentNone + partial.Rows = partial.Rows[:1] + shaped := planDeploymentAssessment(deploymentSnapshot{}, partial, errors.New("late table RPC failure"), true) + require.Equal(t, deploymentNone, shaped.Assessment.State) + require.True(t, shaped.Alpha6Shape) + missing := DeploymentEvidence{Head: deploymentTestHead(2), Candidate: deploymentNone} + retained := planDeploymentAssessment(shaped, missing, errors.New("factory read failure"), true) + require.True(t, retained.Alpha6Shape) + require.Equal(t, first.Factory, retained.ShapeFactory) + replaced := missing + replaced.FactoryKnown = true + replaced.Factory = common.HexToAddress("0x991") + other := planDeploymentAssessment(shaped, replaced, nil, true) + require.False(t, other.Alpha6Shape) + compatible := planDeploymentAssessment(deploymentSnapshot{}, compatibleDeploymentEvidence(deploymentTestHead(4)), nil, true) + require.Equal(t, deploymentCompatible, compatible.Assessment.State) + errored := planDeploymentAssessment(compatible, missing, txsender.ErrUnsupported, true) + require.Equal(t, compatible.Assessment, errored.Assessment) + require.Equal(t, compatible.Head, errored.Head) +} + +func TestDeploymentFactsCompareDecodedFieldsAndMalformedIdentity(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + evidence, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + evidence.Malformed = &DeploymentCallEvidence{ReturnLength: 1} + moved := evidence.clone() + moved.Head = deploymentTestHead(200) + require.True(t, sameDeploymentFacts(evidence, moved)) + mutations := []struct { + name string + change func(*DeploymentEvidence) + }{ + {"factory known", func(e *DeploymentEvidence) { e.FactoryKnown = false }}, + {"alpha shape", func(e *DeploymentEvidence) { e.Alpha6Shape = false }}, + {"source factory address", func(e *DeploymentEvidence) { e.Factory[0]++ }}, + {"transition", func(e *DeploymentEvidence) { e.Transition[0]++ }}, + {"marchid", func(e *DeploymentEvidence) { e.MarchID++ }}, + {"pristine", func(e *DeploymentEvidence) { e.Pristine[0]++ }}, + {"count", func(e *DeploymentEvidence) { e.Count++ }}, + {"levels", func(e *DeploymentEvidence) { e.Rows[1].Levels++ }}, + {"child stride word", func(e *DeploymentEvidence) { e.Rows[1].Log2Step++ }}, + {"child height word", func(e *DeploymentEvidence) { e.Rows[1].Height++ }}, + {"response", func(e *DeploymentEvidence) { e.Rows[1].ResponseBudget++ }}, + {"commitment budget word", func(e *DeploymentEvidence) { e.Rows[1].CommitmentBudget++ }}, + {"allowance", func(e *DeploymentEvidence) { e.Rows[1].MaxAllowance++ }}, + {"reply length", func(e *DeploymentEvidence) { e.Malformed.ReturnLength++ }}, + {"reply digest", func(e *DeploymentEvidence) { e.Malformed.ReturnHash[0]++ }}, + {"target", func(e *DeploymentEvidence) { e.Malformed.Target[0]++ }}, + {"selector", func(e *DeploymentEvidence) { e.Malformed.Selector[0]++ }}, + {"row argument flag", func(e *DeploymentEvidence) { e.Malformed.HasLevel = true }}, + {"row argument", func(e *DeploymentEvidence) { e.Malformed.Level++ }}, + {"candidate", func(e *DeploymentEvidence) { e.Candidate = deploymentTable }}, + {"cause", func(e *DeploymentEvidence) { e.Cause = deploymentCauseGeometry }}, + } + for _, tc := range mutations { + t.Run(tc.name, func(t *testing.T) { + changed := evidence.clone() + tc.change(&changed) + require.False(t, sameDeploymentFacts(evidence, changed)) + }) + } +} + +func TestDeploymentFactsExcludeProviderErrorText(t *testing.T) { + facts := make([]DeploymentEvidence, 0, 2) + for _, message := range []string{"temporary archive failure", "execution reverted: incompatible MARCHID"} { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + request := testDeploymentRequest(b.transition, testDeploymentSelector(testMarchSignature)) + b.overrides[request] = deploymentRPCReply{err: deploymentRPCError{-32000, message}} + e, err := adapter.ReadDeployment(t.Context(), b.head) + require.Error(t, err) + require.Equal(t, deploymentNone, e.Candidate) + facts = append(facts, e) + } + require.True(t, sameDeploymentFacts(facts[0], facts[1])) +} + +func (e DeploymentEvidence) clone() DeploymentEvidence { + e.Rows = slices.Clone(e.Rows) + if e.Malformed != nil { + e.Malformed = new(*e.Malformed) + } + return e +} diff --git a/internal/prt/deployment_cache.go b/internal/prt/deployment_cache.go new file mode 100644 index 000000000..ac6f5244f --- /dev/null +++ b/internal/prt/deployment_cache.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/repository" + "sync" +) + +// The observer publishes values. Readers fence their database reads with the +// revision, without holding the mutex during database or protocol calls. +type deploymentCapture struct { + revision uint64 + snapshot deploymentSnapshot +} + +type deploymentCache struct { + mu sync.RWMutex + entries map[repository.DeploymentIdentity]deploymentCapture +} + +func (c *deploymentCache) capture(key repository.DeploymentIdentity) deploymentCapture { + c.mu.RLock() + defer c.mu.RUnlock() + entry, ok := c.entries[key] + if !ok { + entry.snapshot.Assessment.State = deploymentNone + } + return entry +} + +func (c *deploymentCache) same(key repository.DeploymentIdentity, captured deploymentCapture) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.entries[key].revision == captured.revision +} + +func (c *deploymentCache) publish(key repository.DeploymentIdentity, snapshot deploymentSnapshot) { + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = make(map[repository.DeploymentIdentity]deploymentCapture) + } + c.entries[key] = deploymentCapture{revision: c.entries[key].revision + 1, snapshot: snapshot} +} diff --git a/internal/prt/deployment_cache_test.go b/internal/prt/deployment_cache_test.go new file mode 100644 index 000000000..bf03b6a29 --- /dev/null +++ b/internal/prt/deployment_cache_test.go @@ -0,0 +1,53 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeploymentCacheRevisionChangesForEqualPublications(t *testing.T) { + var cache deploymentCache + key := deploymentIdentity(prtRevertTestApp()) + original := cache.capture(key) + require.Equal(t, deploymentNone, original.snapshot.Assessment.State) + snapshot := deploymentSnapshot{Acknowledged: true, ResetGeneration: 1, Assessment: deploymentAssessment{State: deploymentCompatible}} + cache.publish(key, snapshot) + current := cache.capture(key) + require.False(t, cache.same(key, original)) + require.True(t, cache.same(key, current)) + cache.publish(key, snapshot) + require.False(t, cache.same(key, current), "an equal publication must still fence a concurrent database read") + require.Equal(t, snapshot, cache.capture(key).snapshot) +} + +func TestDeploymentCacheConcurrentPublicationAndConsumption(t *testing.T) { + var cache deploymentCache + key := deploymentIdentity(prtRevertTestApp()) + snapshot := deploymentSnapshot{Acknowledged: true, ResetGeneration: 1, Assessment: deploymentAssessment{State: deploymentCompatible}} + cache.publish(key, snapshot) + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + for range 200 { + captured := cache.capture(key) + if captured.snapshot.ResetGeneration != 1 { + t.Error("mixed cache publication") + return + } + _ = cache.same(key, captured) + } + }) + } + wg.Go(func() { + for range 200 { + cache.publish(key, snapshot) + } + }) + wg.Wait() + require.Equal(t, snapshot, cache.capture(key).snapshot) +} diff --git a/internal/prt/deployment_creation_test.go b/internal/prt/deployment_creation_test.go new file mode 100644 index 000000000..a3c15d503 --- /dev/null +++ b/internal/prt/deployment_creation_test.go @@ -0,0 +1,22 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/require" +) + +func TestDeploymentEpisodeMissingControlRefusesWithoutRepair(t *testing.T) { + s := newDeploymentObserverService() + r := s.repository.(*prtRepositoryMock) + app := prtRevertTestApp() + delete(r.deploymentRows, app.ID) + captured, err := s.deploymentEpisode(t.Context(), app) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.False(t, captured.snapshot.Acknowledged) + require.Empty(t, r.deploymentRows) +} diff --git a/internal/prt/deployment_drain.go b/internal/prt/deployment_drain.go new file mode 100644 index 000000000..14c6661c3 --- /dev/null +++ b/internal/prt/deployment_drain.go @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *Service) forecloseGenerationClaims(ctx context.Context, app *model.Application, + generation int64, +) error { + if !app.Enabled || !completionStatusAllowed(app.Status) { + return nil + } + filter := repository.EpochFilter{Status: model.NonTerminalEpochStatuses()} + epochs, _, err := s.repository.ListEpochs(ctx, app.Name, filter, repository.Pagination{}, false) + if err != nil { + return err + } + var errs []error + for _, epoch := range epochs { + if epoch.FirstBlock > app.ForecloseBlock || !slices.Contains(model.NonTerminalEpochStatuses(), epoch.Status) { + continue + } + if epoch.Status == model.EpochStatus_InputsProcessed && epoch.LastBlock < app.ForecloseBlock { + pending, eligibilityErr := s.observerCompletionEpoch(ctx, app, epoch) + if eligibilityErr != nil { + return errors.Join(append(errs, eligibilityErr)...) + } + if pending != nil { + // The normal validator must atomically publish its claim and every + // output proof. A permanent producer failure can leave this pending. + s.Logger.Info("PRT deployment drain awaits normal claim and output proofs", "application", app.Name, + "address", app.IApplicationAddress, "epoch", epoch.Index, "reset_generation", generation) + continue + } + } + if ctx.Err() != nil { + return errors.Join(append(errs, ctx.Err())...) + } + current, currentGeneration, recheckErr := s.deploymentVerdictState(ctx, app) + if recheckErr != nil { + return errors.Join(append(errs, recheckErr)...) + } + if current != deploymentGeneration || currentGeneration != generation { + return errors.Join(errs...) + } + request := repository.DeploymentDrainRequest{Identity: deploymentIdentity(app), + EpochIndex: epoch.Index, ResetGeneration: generation} + if writeErr := s.repository.ForecloseDeploymentEpoch(ctx, request); writeErr != nil { + if errors.Is(writeErr, repository.ErrNoUpdate) { + continue + } + return errors.Join(append(errs, fmt.Errorf("deployment drain application %s epoch %d: %w", + app.IApplicationAddress, epoch.Index, writeErr))...) + } + s.Logger.Info("Terminalized epoch under confirmed incompatible deployment", "application", app.Name, + "address", app.IApplicationAddress, "epoch", epoch.Index, "reset_generation", generation) + } + return errors.Join(errs...) +} diff --git a/internal/prt/deployment_drain_test.go b/internal/prt/deployment_drain_test.go new file mode 100644 index 000000000..c82d43ba2 --- /dev/null +++ b/internal/prt/deployment_drain_test.go @@ -0,0 +1,137 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDeploymentDrainWaitsForFreshConfirmationAndNormalProofProducer(t *testing.T) { + s := newDeploymentObserverService() + repo := s.repository.(*prtRepositoryMock) + app := prtRevertTestApp() + app.ForecloseBlock, app.LastEpochCheckBlock, app.LastInputCheckBlock = 100, 100, 100 + repo.deploymentRows = map[int64]model.DeploymentAdmissionState{app.ID: {ApplicationID: app.ID, + ResetGeneration: 7, ConfirmedTier: new(model.DeploymentTierGeneration), DeploymentReason: new("saved")}} + client := ðClientMock{} + expectNumericWindowHeaders(client) + s.client = client + reader := &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + return DeploymentEvidence{Head: head, Consensus: app.IConsensusAddress, Candidate: deploymentNone}, nil + }} + var writes []uint64 + repo.deploymentDrain = func(_ context.Context, request repository.DeploymentDrainRequest) error { + require.Equal(t, int64(8), request.ResetGeneration) + writes = append(writes, request.EpochIndex) + return nil + } + _, err := s.observeDeployment(t.Context(), app, reader, observerFacts(100)) + require.NoError(t, err) + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, nil, reader, 102)) + require.Empty(t, writes, "a saved generation restriction cannot authorize drain from fresh none") + reader.deploymentRead = func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + return DeploymentEvidence{Head: head, Consensus: app.IConsensusAddress, + Candidate: deploymentGeneration, Cause: deploymentCauseStaticShape}, nil + } + pending, err := s.observeDeployment(t.Context(), app, reader, observerFacts(101)) + require.NoError(t, err) + require.Equal(t, deploymentPending, pending.Assessment.State) + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, nil, reader, 102)) + require.Empty(t, writes, "the first fresh mismatch is still pending") + confirmed, err := s.observeDeployment(t.Context(), app, reader, observerFacts(102)) + require.NoError(t, err) + require.Equal(t, deploymentGeneration, confirmed.Assessment.State) + prefix := checkpointEpoch(0, "0x100") + prefix.Status, prefix.FirstBlock, prefix.LastBlock = model.EpochStatus_InputsProcessed, 0, 9 + straddler := checkpointEpoch(1, "0x101") + straddler.Status, straddler.FirstBlock, straddler.LastBlock = model.EpochStatus_Closed, 90, 110 + future := checkpointEpoch(2, "0x102") + future.Status, future.FirstBlock, future.LastBlock = model.EpochStatus_InputsProcessed, 101, 120 + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, uint64(100)).Return(false, nil).Twice() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, uint64(100)).Return(true, nil).Twice() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{prefix, straddler, future}, uint64(3), nil).Twice() + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, nil, reader, 102)) + require.Equal(t, []uint64{1}, writes, "protected IP must not stall a later eligible straddler") + // The existing normal validator owns this atomic claim/proof publication. + // This unit fixture supplies that provider result, rather than inventing a producer. + prefix.Status = model.EpochStatus_ClaimComputed + straddler.Status = model.EpochStatus_ClaimForeclosed + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, nil, reader, 102)) + require.Equal(t, []uint64{1, 0}, writes) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Equal(t, model.DeploymentTierGeneration, *repo.deploymentRows[app.ID].ConfirmedTier) + repo.AssertExpectations(t) +} + +func TestDeploymentDrainSkipsOnlyConditionalRefusal(t *testing.T) { + dependency := errors.New("independent drain dependency") + for _, tc := range []struct { + name string + fault error + writes int + }{ + {"refusal", repository.ErrNoUpdate, 2}, + {"busy", errors.Join(repository.ErrLockTimeout, dependency), 1}, + } { + t.Run(tc.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.ForecloseBlock = 100 + setTestDeployment(s, repo, app, 1, deploymentGeneration, new(model.DeploymentTierGeneration)) + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x101") + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{first, second}, uint64(2), nil).Once() + var requests []repository.DeploymentDrainRequest + repo.deploymentDrain = func(_ context.Context, request repository.DeploymentDrainRequest) error { + requests = append(requests, request) + if len(requests) == 1 { + return tc.fault + } + return nil + } + err := s.forecloseGenerationClaims(t.Context(), app, 1) + if tc.name == "refusal" { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.fault) + } + require.Len(t, requests, tc.writes) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.False(t, s.publicationStopped()) + }) + } +} + +func TestDeploymentDrainProtectsOnlyEpochsStrictlyBeforeForeclosure(t *testing.T) { + for _, last := range []uint64{99, 100, 101} { + t.Run(fmt.Sprint(last), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.ForecloseBlock = 100 + setTestDeployment(s, repo, app, 1, deploymentGeneration, new(model.DeploymentTierGeneration)) + epoch := checkpointEpoch(0, "0x100") + epoch.Status, epoch.FirstBlock, epoch.LastBlock = model.EpochStatus_InputsProcessed, 0, last + repo.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + writes := 0 + repo.deploymentDrain = func(context.Context, repository.DeploymentDrainRequest) error { writes++; return nil } + require.NoError(t, s.forecloseGenerationClaims(t.Context(), app, 1)) + expected := 1 + if last < 100 { + expected = 0 + } + require.Equal(t, expected, writes) + }) + } +} diff --git a/internal/prt/deployment_fixture_test.go b/internal/prt/deployment_fixture_test.go new file mode 100644 index 000000000..7c02628b8 --- /dev/null +++ b/internal/prt/deployment_fixture_test.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" +) + +// Existing protocol fixtures explicitly supply compatible deployment evidence. +// Reader rejection tests use the real raw-call adapter instead of this fixture. +func newCompatibleDaveConsensusAdapterMock(addresses ...common.Address) *daveConsensusAdapterMock { + address := prtRevertTestApp().IConsensusAddress + if len(addresses) != 0 { + address = addresses[0] + } + return &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + evidence := compatibleDeploymentEvidence(head) + evidence.Consensus = address + return evidence, nil + }} +} + +func compatibleDeploymentEvidence(head txsender.BlockRef) DeploymentEvidence { + return DeploymentEvidence{Head: head, Consensus: prtRevertTestApp().IConsensusAddress, + FactoryKnown: true, Factory: common.HexToAddress("0x160"), + Alpha6Shape: true, + Transition: common.HexToAddress("0x170"), + MarchID: 22, Pristine: model.UarchPristineStateHash(), Count: 2, + Rows: []DeploymentParameters{{Levels: 2, Log2Step: 38, Height: 54, ResponseBudget: 11, CommitmentBudget: 22, MaxAllowance: 33}, + {Levels: 2, Log2Step: 0, Height: 38, ResponseBudget: 11, CommitmentBudget: 22, MaxAllowance: 33}}, + Candidate: deploymentCompatible, Cause: deploymentCauseComplete} +} diff --git a/internal/prt/deployment_gate.go b/internal/prt/deployment_gate.go new file mode 100644 index 000000000..cc81911c0 --- /dev/null +++ b/internal/prt/deployment_gate.go @@ -0,0 +1,116 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" +) + +// Saved restrictions never establish fresh evidence. These explicit policy +// cases do not order the tiers or replace confirmation with a durable value. +func effectiveDeployment(snapshot deploymentSnapshot, generation int64, tier *model.DeploymentTier) deploymentState { + if !snapshot.Acknowledged || snapshot.ResetGeneration != generation { + return deploymentNone + } + state := snapshot.Assessment.State + if state == "" || state == deploymentNone { + return deploymentNone + } + if state == deploymentPending { + return state + } + if tier != nil && *tier == model.DeploymentTierGeneration { + return deploymentGeneration + } + if tier != nil && *tier == model.DeploymentTierTable && state == deploymentCompatible { + return deploymentTable + } + return state +} + +func deploymentAllowsVerdict(state deploymentState) bool { + return state == deploymentCompatible || state == deploymentTable +} + +func deploymentBlocksAction(state deploymentState, kind actionKind) bool { + return kind != recoverBond && (state == deploymentGeneration || state == deploymentTable && kind == joinTournament) +} + +// The repository closes its fresh RR transaction before returning. Identity +// confirmation therefore holds no SQL resource or lock during cache access. +func (s *Service) deploymentCompletion(ctx context.Context, app *model.Application, index uint64, +) (*repository.EpochCompletionState, deploymentState, error) { + key := deploymentIdentity(app) + captured := s.deployments.capture(key) + view, err := s.repository.GetDeploymentCompletionState(ctx, deploymentIdentity(app), index) + if err == nil && !s.deployments.same(key, captured) { + captured = s.deployments.capture(key) + view, err = s.repository.GetDeploymentCompletionState(ctx, deploymentIdentity(app), index) + } + if err != nil { + return nil, deploymentNone, err + } + if view == nil || view.Completion == nil || view.Completion.Application == nil { + return nil, deploymentNone, txsender.ErrIneligible + } + current := view.Completion.Application + if current.ConsensusType != model.Consensus_PRT { + return nil, deploymentNone, txsender.ErrIneligible + } + row := view.Deployment + // Completion facts remain usable for independent obsolescence. Missing or + // superseded admission never authorizes a useful protocol action. + if row == nil || !s.deployments.same(key, captured) { + return view.Completion, deploymentNone, nil + } + return view.Completion, effectiveDeployment(captured.snapshot, row.ResetGeneration, row.ConfirmedTier), nil +} + +func (s *delivery) checkDeploymentAction(ctx context.Context, app *model.Application, index uint64, + kind actionKind, block txsender.BlockRef, +) (*repository.EpochCompletionState, error) { + completion, state, err := s.deploymentCompletion(ctx, app, index) + if err != nil { + return nil, err + } + if completion.Epoch == nil { + return nil, txsender.ErrInconclusive + } + allowed, err := completion.AllowsEpochCompletion() + if err != nil { + return nil, fmt.Errorf("%w: %w", txsender.ErrInconclusive, err) + } + eligibilityErr := applicationEligible(completion.Application, block) + if eligibilityErr != nil && eligibilityErr != txsender.ErrIneligible { + return nil, eligibilityErr + } + if !allowed || eligibilityErr != nil || deploymentBlocksAction(state, kind) { + return nil, txsender.ErrIneligible + } + if state == deploymentNone || state == deploymentPending { + return completion, txsender.ErrInconclusive + } + return completion, nil +} + +func (s *Service) deploymentVerdictState(ctx context.Context, app *model.Application) (deploymentState, int64, error) { + key := deploymentIdentity(app) + captured := s.deployments.capture(key) + row, err := s.repository.GetDeploymentAdmissionState(ctx, deploymentIdentity(app)) + if err != nil { + return deploymentNone, 0, err + } + if !s.deployments.same(key, captured) { + return deploymentNone, 0, txsender.ErrInconclusive + } + if row == nil { + return deploymentNone, 0, nil + } + return effectiveDeployment(captured.snapshot, row.ResetGeneration, row.ConfirmedTier), row.ResetGeneration, nil +} diff --git a/internal/prt/deployment_gate_test.go b/internal/prt/deployment_gate_test.go new file mode 100644 index 000000000..a115dac09 --- /dev/null +++ b/internal/prt/deployment_gate_test.go @@ -0,0 +1,116 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/require" +) + +func TestEffectiveDeploymentRequiresFreshCurrentEvidence(t *testing.T) { + for _, tc := range []struct { + name string + state deploymentState + tier *model.DeploymentTier + acknowledged bool + generation int64 + want deploymentState + }{ + {"unacknowledged", deploymentCompatible, nil, false, 1, deploymentNone}, + {"reset generation", deploymentCompatible, nil, true, 2, deploymentNone}, + {"restart saved generation", deploymentNone, new(model.DeploymentTierGeneration), true, 1, deploymentNone}, + {"pending saved generation", deploymentPending, new(model.DeploymentTierGeneration), true, 1, deploymentPending}, + {"restart saved table", deploymentNone, new(model.DeploymentTierTable), true, 1, deploymentNone}, + {"current compatible", deploymentCompatible, nil, true, 1, deploymentCompatible}, + {"durable generation before publication", deploymentCompatible, new(model.DeploymentTierGeneration), true, 1, deploymentGeneration}, + {"durable table before publication", deploymentCompatible, new(model.DeploymentTierTable), true, 1, deploymentTable}, + {"fresh confirmed generation", deploymentGeneration, new(model.DeploymentTierGeneration), true, 1, deploymentGeneration}, + {"fresh confirmed table", deploymentTable, new(model.DeploymentTierTable), true, 1, deploymentTable}, + } { + t.Run(tc.name, func(t *testing.T) { + snapshot := deploymentSnapshot{Acknowledged: tc.acknowledged, ResetGeneration: 1, + Assessment: deploymentAssessment{State: tc.state}} + require.Equal(t, tc.want, effectiveDeployment(snapshot, tc.generation, tc.tier)) + }) + } +} + +func TestDeploymentFinalGateRetriesOnePublicationAndRetainsItsFence(t *testing.T) { + const ( + repeatedState = "ABA" + retryRestriction = "restriction on retry" + repeatedPublication = "repeated publication" + ) + for _, change := range []string{ + string(deploymentCompatible), repeatedState, string(deploymentPending), + "restriction", "reset", repeatedPublication, retryRestriction, + } { + t.Run(change, func(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + reads := 0 + f.repo.deploymentCompletion = func(ctx context.Context, key repository.DeploymentIdentity, + index uint64, + ) (*repository.DeploymentCompletionState, error) { + completion, err := f.repo.GetEpochCompletionState(ctx, key.ApplicationAddress.Hex(), index) + require.NoError(t, err) + row, err := f.repo.GetDeploymentAdmissionState(ctx, key) + require.NoError(t, err) + reads++ + if reads == 2 || reads == 3 && (change == repeatedPublication || change == retryRestriction) { + switch change { + case string(deploymentPending): + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentPending, nil) + case "restriction": + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, + deploymentGeneration, new(model.DeploymentTierGeneration)) + case "reset": + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 2, deploymentNone, nil) + case repeatedState: + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentPending, nil) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentCompatible, nil) + case retryRestriction: + if reads == 3 { + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, + deploymentGeneration, new(model.DeploymentTierGeneration)) + } else { + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentCompatible, nil) + } + default: + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentCompatible, nil) + } + } + return &repository.DeploymentCompletionState{Completion: completion, Deployment: row}, nil + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + if change == string(deploymentCompatible) || change == repeatedState { + require.NoError(t, err) + require.Equal(t, txsender.Useful, eligibility) + } else { + require.NotEqual(t, txsender.Useful, eligibility) + } + require.Equal(t, 3, reads, "only the raced final gate gets one fresh read") + }) + } +} + +func TestDeploymentCompletionDoesNotRetryRepositoryErrors(t *testing.T) { + f := newDeliveryFixture(t) + cause := errors.New("completion read failed") + reads := 0 + f.repo.deploymentCompletion = func(context.Context, repository.DeploymentIdentity, uint64) ( + *repository.DeploymentCompletionState, error, + ) { + reads++ + return nil, cause + } + _, _, err := f.d.deploymentCompletion(t.Context(), f.app, f.epoch.Index) + require.ErrorIs(t, err, cause) + require.Equal(t, 1, reads) +} diff --git a/internal/prt/deployment_lifecycle.go b/internal/prt/deployment_lifecycle.go new file mode 100644 index 000000000..2879beb0e --- /dev/null +++ b/internal/prt/deployment_lifecycle.go @@ -0,0 +1,111 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "log/slog" + "slices" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" +) + +const deploymentUnknownHeadLimit = 5 + +func deploymentIdentity(app *model.Application) repository.DeploymentIdentity { + return repository.DeploymentIdentity{ApplicationID: app.ID, + ApplicationAddress: app.IApplicationAddress, ConsensusAddress: app.IConsensusAddress} +} + +// A process acknowledges its startup fence before reading fresh evidence. +// Later explicit resets already advanced that fence and request a new episode. +func (s *Service) deploymentEpisode(ctx context.Context, app *model.Application) (deploymentCapture, error) { + identity := deploymentIdentity(app) + captured := s.deployments.capture(identity) + row, err := s.repository.GetDeploymentAdmissionState(ctx, identity) + if err != nil { + return captured, err + } + if row == nil { + return captured, fmt.Errorf("deployment control is unavailable: %w", txsender.ErrInconclusive) + } + if captured.snapshot.Acknowledged && captured.snapshot.ResetGeneration == row.ResetGeneration { + return captured, nil + } + if !captured.snapshot.Acknowledged { + row, err = s.repository.BeginDeploymentStartup(ctx, identity, row.ResetGeneration) + } + if err != nil { + return captured, err + } + if row == nil { + return captured, fmt.Errorf("deployment episode was not acknowledged: %w", txsender.ErrInconclusive) + } + fresh := deploymentSnapshot{Acknowledged: true, ResetGeneration: row.ResetGeneration, Recovery: true, + Assessment: deploymentAssessment{State: deploymentNone}, + Alpha6Shape: captured.snapshot.Alpha6Shape, ShapeFactory: captured.snapshot.ShapeFactory} + s.deployments.publish(identity, fresh) + return s.deployments.capture(identity), nil +} + +func (s *Service) persistDeployment(ctx context.Context, app *model.Application, next deploymentSnapshot) error { + request := repository.DeploymentAssessment{Identity: deploymentIdentity(app), ResetGeneration: next.ResetGeneration} + if next.Assessment.confirmed() { + request.ConfirmedTier = new(model.DeploymentTier(next.Assessment.State)) + request.Reason = string(next.Cause) + } else if next.Assessment.State != deploymentCompatible || !next.Recovery { + return nil + } + previous, err := s.repository.AcknowledgeDeploymentAssessment(ctx, request) + if err != nil { + return err + } + if request.ConfirmedTier == nil && previous.ConfirmedTier != nil { + s.Logger.Info("Recovered saved PRT deployment restriction", "application", app.Name, + "address", app.IApplicationAddress, "reset_generation", request.ResetGeneration, + "previous_tier", *previous.ConfirmedTier, "reason", next.Cause) + } + return nil +} + +func (s *Service) deploymentNotice(ctx context.Context, app *model.Application, + previous deploymentSnapshot, next *deploymentSnapshot, evidence DeploymentEvidence, cause error, +) { + if txsender.ShutdownOnly(ctx, cause) { + return + } + head := evidence.Head + state := next.Assessment.State + if state == deploymentNone { + if head.Hash == (common.Hash{}) || slices.Contains(next.UnknownHeadIDs[:next.UnknownHeads], head.Hash) { + return + } + firstError := false + if next.UnknownHeads < deploymentUnknownHeadLimit { + next.UnknownHeadIDs[next.UnknownHeads] = head.Hash + next.UnknownHeads++ + firstError = next.UnknownHeads == deploymentUnknownHeadLimit + } + if !warningDue(next.LastNotice) && !firstError { + return + } + next.LastNotice = time.Now() + level := slog.LevelWarn + if next.UnknownHeads >= deploymentUnknownHeadLimit { + level = slog.LevelError + } + s.Logger.Log(ctx, level, "PRT deployment evidence is unavailable", "application", app.Name, + "address", app.IApplicationAddress, "block", head.Number, "block_hash", head.Hash, + "distinct_heads", next.UnknownHeads, "reason", evidence.Cause, + "factory", evidence.Factory, "state_transition", evidence.Transition, "error", cause) + } else if state != previous.Assessment.State { + s.Logger.Info("PRT deployment assessment changed", "application", app.Name, "address", app.IApplicationAddress, + "state", state, "reset_generation", next.ResetGeneration, "reason", next.Cause) + } +} diff --git a/internal/prt/deployment_lifecycle_test.go b/internal/prt/deployment_lifecycle_test.go new file mode 100644 index 000000000..fe3c6c6e5 --- /dev/null +++ b/internal/prt/deployment_lifecycle_test.go @@ -0,0 +1,127 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/require" +) + +func TestDeploymentStartupRetainsSavedRestrictionUntilFreshRecovery(t *testing.T) { + for _, tier := range []model.DeploymentTier{model.DeploymentTierGeneration, model.DeploymentTierTable} { + t.Run(string(tier), func(t *testing.T) { + s := newDeploymentObserverService() + repo := s.repository.(*prtRepositoryMock) + app := prtRevertTestApp() + repo.deploymentRows = map[int64]model.DeploymentAdmissionState{app.ID: {ApplicationID: app.ID, + ResetGeneration: 7, ConfirmedTier: new(tier), DeploymentReason: new("saved evidence")}} + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + cause := errors.New("temporary deployment read") + reader := &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, head txsender.BlockRef) ( + DeploymentEvidence, error, + ) { + return DeploymentEvidence{Head: head, Consensus: app.IConsensusAddress, Candidate: deploymentNone}, cause + }} + first, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(100)) + require.ErrorIs(t, err, cause) + require.Equal(t, int64(8), first.ResetGeneration) + require.Equal(t, deploymentNone, first.Assessment.State) + require.NotNil(t, repo.deploymentRows[app.ID].ConfirmedTier) + require.Equal(t, tier, *repo.deploymentRows[app.ID].ConfirmedTier) + second, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(101)) + require.ErrorIs(t, err, cause) + require.Equal(t, int64(8), second.ResetGeneration, "transient retries do not start another episode") + reader.deploymentRead = func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + return compatibleDeploymentEvidence(head), nil + } + current, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(102)) + require.NoError(t, err) + require.Equal(t, deploymentCompatible, current.Assessment.State) + require.Nil(t, repo.deploymentRows[app.ID].ConfirmedTier) + require.Nil(t, repo.deploymentRows[app.ID].DeploymentReason) + require.Contains(t, logs.String(), "Recovered saved PRT deployment restriction") + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.False(t, s.publicationStopped()) + }) + } +} + +func TestDeploymentStalePendingPublicationCannotCarryAcrossReset(t *testing.T) { + s := newDeploymentObserverService() + repo := s.repository.(*prtRepositoryMock) + app := prtRevertTestApp() + reset := true + reader := &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, head txsender.BlockRef) ( + DeploymentEvidence, error, + ) { + if reset { + row := repo.deploymentRows[app.ID] + row.ResetGeneration++ + repo.deploymentRows[app.ID] = row + reset = false + } + return tableDeploymentEvidence(head), nil + }} + _, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(100)) + require.NoError(t, err) + captured := s.deployments.capture(deploymentIdentity(app)) + require.Equal(t, int64(1), captured.snapshot.ResetGeneration) + require.Equal(t, deploymentPending, captured.snapshot.Assessment.State) + next, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(101)) + require.NoError(t, err) + require.Equal(t, int64(2), next.ResetGeneration) + require.Equal(t, deploymentPending, next.Assessment.State) + require.Equal(t, deploymentTestHead(101), next.Assessment.PendingEvidence.Head) + require.Nil(t, repo.deploymentRows[app.ID].ConfirmedTier) +} + +func TestDeploymentFailedAcknowledgmentDoesNotPublishCandidate(t *testing.T) { + s := newDeploymentObserverService() + repo := s.repository.(*prtRepositoryMock) + app := prtRevertTestApp() + _, err := s.deploymentEpisode(t.Context(), app) + require.NoError(t, err) + before := s.deployments.capture(deploymentIdentity(app)) + cause := errors.New("ambiguous assessment commit") + repo.deploymentAck = func(context.Context, repository.DeploymentAssessment) (*model.DeploymentAdmissionState, error) { + return nil, cause + } + reader := newCompatibleDaveConsensusAdapterMock() + _, err = s.observeDeployment(t.Context(), app, reader, deploymentTestHead(100)) + require.ErrorIs(t, err, cause) + require.True(t, s.deployments.same(deploymentIdentity(app), before)) + require.Equal(t, deploymentNone, before.snapshot.Assessment.State) +} + +func TestDeploymentUnknownEscalationCountsDistinctHeads(t *testing.T) { + s := newDeploymentObserverService() + app := prtRevertTestApp() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + reader := &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, head txsender.BlockRef) ( + DeploymentEvidence, error, + ) { + return DeploymentEvidence{Head: head, Consensus: app.IConsensusAddress, Candidate: deploymentNone}, nil + }} + for repeat := range 6 { + _, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(100+uint64(repeat%2))) + require.NoError(t, err) + } + require.NotContains(t, logs.String(), "level=ERROR") + for head := uint64(102); head < 105; head++ { + _, err := s.observeDeployment(t.Context(), app, reader, deploymentTestHead(head)) + require.NoError(t, err) + } + require.Contains(t, logs.String(), "level=ERROR") + require.Contains(t, logs.String(), "distinct_heads=5") +} diff --git a/internal/prt/deployment_notice_regression_test.go b/internal/prt/deployment_notice_regression_test.go new file mode 100644 index 000000000..cdaec9b53 --- /dev/null +++ b/internal/prt/deployment_notice_regression_test.go @@ -0,0 +1,51 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestProtectionRepeatedDeploymentFailureIsThrottled(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + clock := prtprotection.Clock{EpochIndex: 1, Root: common.HexToAddress("0x123"), ClosingBlock: new(uint64(200))} + providerErr := errors.New("provider unavailable") + for _, number := range []uint64{100, 101} { + head := observerFacts(number) + // deploymentReader.call uses this exact wrapper for every failed pinned call. + readErr := fmt.Errorf("deployment %s at %s, block %d hash %s: %w", + "CM_MARCHID", common.HexToAddress("0x456"), head.Number, head.Hash, providerErr) + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, head, deploymentNone, 0, readErr) + } + require.Equal(t, 1, strings.Count(output.String(), "PRT protection stopped"), output.String()) +} + +func TestDeploymentMissingDependencyWarningIsThrottled(t *testing.T) { + adapter, backend := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + backend.absent[backend.factory] = true + service := newDeploymentObserverService() + var logs bytes.Buffer + service.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := prtRevertTestApp() + app.IConsensusAddress = backend.consensus + for range 2 { + snapshot, err := service.observeDeployment(t.Context(), app, adapter, backend.head) + require.NoError(t, err) + require.Equal(t, deploymentNone, snapshot.Assessment.State) + } + require.Equal(t, 1, strings.Count(logs.String(), "PRT deployment evidence is unavailable"), logs.String()) + require.Contains(t, logs.String(), "reason=empty_return") + require.Contains(t, logs.String(), backend.factory.Hex()) +} diff --git a/internal/prt/deployment_observe.go b/internal/prt/deployment_observe.go new file mode 100644 index 000000000..12901575f --- /dev/null +++ b/internal/prt/deployment_observe.go @@ -0,0 +1,54 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" +) + +func (s *Service) observeDeployment( + ctx context.Context, app *model.Application, consensus DaveConsensusAdapter, head txsender.BlockRef, +) (deploymentSnapshot, error) { + key := deploymentIdentity(app) + captured, episodeErr := s.deploymentEpisode(ctx, app) + if episodeErr != nil { + return captured.snapshot, episodeErr + } + if captured.snapshot.Assessment.confirmed() || + captured.snapshot.Assessment.State == deploymentCompatible && captured.snapshot.Head == head { + return captured.snapshot, nil + } + evidence, readErr := consensus.ReadDeployment(ctx, head) + previousCanonical := true + prior := captured.snapshot.Assessment + if readErr == nil && hasDeploymentConfirmationPair(prior, evidence) { + previous, err := s.client.HeaderByNumber(ctx, new(big.Int).SetUint64(prior.PendingEvidence.Head.Number)) + if err != nil { + readErr = fmt.Errorf("rechecking deployment confirmation head: %w", err) + } else { + previousCanonical = previous != nil && previous.Hash() == prior.PendingEvidence.Head.Hash + } + } + next := planDeploymentAssessment(captured.snapshot, evidence, readErr, previousCanonical) + persistErr := s.persistDeployment(ctx, app, next) + if persistErr != nil { + if next.Assessment.confirmed() && captured.snapshot.Recovery { + captured.snapshot.Recovery = false + s.deployments.publish(key, captured.snapshot) + } + return captured.snapshot, errors.Join(readErr, persistErr) + } + if next.Assessment.State == deploymentCompatible || next.Assessment.confirmed() { + next.Recovery = false + } + s.deploymentNotice(ctx, app, captured.snapshot, &next, evidence, readErr) + s.deployments.publish(key, next) + return next, readErr +} diff --git a/internal/prt/deployment_observe_test.go b/internal/prt/deployment_observe_test.go new file mode 100644 index 000000000..b206b4e43 --- /dev/null +++ b/internal/prt/deployment_observe_test.go @@ -0,0 +1,468 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDeploymentObserverReadsOutsideCacheLockAndRetainsKnownShape(t *testing.T) { + service := newDeploymentObserverService() + service.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + app := prtRevertTestApp() + head := deploymentTestHead(100) + cause := errors.New("late row unavailable") + calls := 0 + consensus := &daveConsensusAdapterMock{ + deploymentRead: func(_ context.Context, requested txsender.BlockRef) (DeploymentEvidence, error) { + calls++ + unlocked := service.deployments.mu.TryLock() + if unlocked { + service.deployments.mu.Unlock() + } + require.True(t, unlocked, "RPC was called while the publication lock was held") + evidence := tableDeploymentEvidence(requested) + evidence.Candidate = deploymentNone + evidence.Rows = evidence.Rows[:1] + return evidence, cause + }} + snapshot, err := service.observeDeployment(t.Context(), app, consensus, head) + require.ErrorIs(t, err, cause) + require.Equal(t, 1, calls) + require.Equal(t, deploymentNone, snapshot.Assessment.State) + require.True(t, snapshot.Alpha6Shape) + consensus.deploymentRead = func(_ context.Context, requested txsender.BlockRef) (DeploymentEvidence, error) { + return DeploymentEvidence{Head: requested, Consensus: app.IConsensusAddress, Candidate: deploymentNone}, txsender.ErrUnsupported + } + next, err := service.observeDeployment(t.Context(), app, consensus, deploymentTestHead(101)) + require.ErrorIs(t, err, txsender.ErrUnsupported) + require.True(t, next.Alpha6Shape) + require.Equal(t, deploymentNone, next.Assessment.State) + require.False(t, service.publicationStopped()) +} + +func TestDeploymentObserverConfirmsOnlyCanonicalDistinctHeads(t *testing.T) { + for _, canonical := range []bool{true, false} { + t.Run(map[bool]string{true: "canonical", false: "reorged first head"}[canonical], func(t *testing.T) { + service := newDeploymentObserverService() + service.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + app := prtRevertTestApp() + oldHeader := &types.Header{Number: big.NewInt(100)} + newHeader := &types.Header{Number: big.NewInt(101)} + first := txsender.BlockRef{Number: 100, Hash: oldHeader.Hash()} + second := txsender.BlockRef{Number: 101, Hash: newHeader.Hash()} + client := ðClientMock{} + service.client = client + returned := types.CopyHeader(oldHeader) + if !canonical { + returned.Extra = []byte("another branch") + } + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(returned, nil).Once() + reads := 0 + consensus := &daveConsensusAdapterMock{ + deploymentRead: func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + reads++ + return tableDeploymentEvidence(h), nil + }} + snapshot, err := service.observeDeployment(t.Context(), app, consensus, first) + require.NoError(t, err) + require.Equal(t, deploymentPending, snapshot.Assessment.State) + same, err := service.observeDeployment(t.Context(), app, consensus, first) + require.NoError(t, err) + require.Equal(t, deploymentPending, same.Assessment.State) + final, err := service.observeDeployment(t.Context(), app, consensus, second) + require.NoError(t, err) + expected := deploymentPending + if canonical { + expected = deploymentTable + } + require.Equal(t, expected, final.Assessment.State) + if canonical { + _, err = service.observeDeployment(t.Context(), app, consensus, deploymentTestHead(102)) + require.NoError(t, err) + require.Equal(t, 3, reads) + } + client.AssertExpectations(t) + }) + } +} + +func TestDeploymentObservationNeverBecomesWholeApplicationTierGate(t *testing.T) { + for _, confirmed := range []bool{false, true} { + t.Run(map[bool]string{false: "pending old shape", true: "confirmed generation"}[confirmed], func(t *testing.T) { + f := newDeliveryIntervalFixture(t) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + f.repo.publishedRoots = nil + f.checkpoint.epochs(f.epoch) + f.block = f.facts + f.snapshot(resultTestSnapshot(f.epoch, false)) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + reads := 0 + f.consensus.deploymentRead = func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + reads++ + e := compatibleDeploymentEvidence(h) + e.Candidate = deploymentGeneration + e.Cause = deploymentCauseStaticShape + e.Alpha6Shape = false + e.Rows = nil + return e, nil + } + if confirmed { + key := deploymentIdentity(f.app) + snapshot := deploymentSnapshot{Acknowledged: true, ResetGeneration: 1, + Assessment: deploymentAssessment{State: deploymentGeneration}} + f.d.deployments.publish(key, snapshot) + } + require.NoError(t, f.d.observeApplication(t.Context(), f.app, f.facts)) + f.consensus.AssertCalled(t, "CanStageTournamentResult", mock.Anything) + f.consensus.AssertCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + f.consensus.AssertNotCalled(t, "TournamentLevelCount", mock.Anything) + require.Empty(t, f.repo.windows) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.False(t, f.d.publicationStopped()) + require.Contains(t, logs.String(), "join_close_block=310") + require.Contains(t, logs.String(), "clock_available=true") + expected := 1 + if confirmed { + expected = 0 + } + require.Equal(t, expected, reads) + }) + } +} + +func TestDeploymentObservationSkipsDisabledApplicationBeforeRPC(t *testing.T) { + f := newDeliveryFixture(t) + f.app.Enabled = false + before := f.d.deployments.capture(deploymentIdentity(f.app)) + require.NoError(t, f.d.observeApplication(t.Context(), f.app, f.block)) + require.True(t, f.d.deployments.same(deploymentIdentity(f.app), before)) + f.factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) +} + +func TestDeploymentObserverMissingDependencyWarnsWithoutVerdict(t *testing.T) { + for _, missing := range []string{"factory", "transition"} { + t.Run(missing, func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + if missing == "factory" { + b.absent[b.factory] = true + } else { + b.absent[b.transition] = true + } + service := newDeploymentObserverService() + var logs bytes.Buffer + service.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + app := prtRevertTestApp() + app.IConsensusAddress = b.consensus + snapshot, err := service.observeDeployment(t.Context(), app, adapter, b.head) + require.NoError(t, err) + require.Equal(t, deploymentNone, snapshot.Assessment.State) + require.Contains(t, logs.String(), "level=WARN") + require.Contains(t, logs.String(), "reason=empty_return") + require.Contains(t, logs.String(), b.factory.Hex()) + require.False(t, service.publicationStopped()) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + }) + } +} + +func TestDeploymentObserverCanonicalReadErrorKeepsThePendingPair(t *testing.T) { + service := newDeploymentObserverService() + service.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + app := prtRevertTestApp() + firstHeader := &types.Header{Number: big.NewInt(100)} + head := txsender.BlockRef{Number: 100, Hash: firstHeader.Hash()} + client := ðClientMock{} + service.client = client + cause := errors.New("historical header unavailable") + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return((*types.Header)(nil), cause).Once() + reader := &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + return tableDeploymentEvidence(h), nil + }} + first, err := service.observeDeployment(t.Context(), app, reader, head) + require.NoError(t, err) + next, err := service.observeDeployment(t.Context(), app, reader, deploymentTestHead(101)) + require.ErrorIs(t, err, cause) + require.Equal(t, first.Assessment, next.Assessment) + require.Equal(t, first.Assessment.PendingEvidence, next.Assessment.PendingEvidence) + require.Equal(t, deploymentPending, next.Assessment.State) + client.AssertExpectations(t) +} + +func TestDeploymentObserverConfirmsActualGenerationAndTableEvidence(t *testing.T) { + const wantPending = "pending" + for _, tc := range []struct { + name string + configure func(*deploymentViewRPC) + want string + }{ + { + name: "old static shape", + configure: func(b *deploymentViewRPC) { + request := testDeploymentRequest(b.factory, testDeploymentRowInput(0)) + b.overrides[request] = deploymentRPCReply{data: testDeploymentWords(2, 38, 54, 17, 19)} + }, + want: wantGenerationDeployment, + }, + { + name: "marchid mismatch", + configure: func(b *deploymentViewRPC) { b.march = 21 }, + want: wantGenerationDeployment, + }, + { + name: "pristine mismatch", + configure: func(b *deploymentViewRPC) { b.pristine = common.HexToHash("0x123") }, + want: wantGenerationDeployment, + }, + { + name: "invalid geometry", + configure: func(b *deploymentViewRPC) { b.rows[1][2] = 37 }, + want: wantGenerationDeployment, + }, + { + name: "alternate two levels", + configure: func(b *deploymentViewRPC) { + b.rows = [][]uint64{{2, 44, 48, 17, 19, 23}, {2, 0, 44, 17, 19, 23}} + }, + want: wantTableDeployment, + }, + { + name: "alternate three levels", + configure: func(b *deploymentViewRPC) { + b.rows = [][]uint64{{3, 44, 48, 17, 19, 23}, {3, 27, 17, 17, 19, 23}, {3, 0, 27, 17, 19, 23}} + b.count = 3 + }, + want: wantTableDeployment, + }, + } { + t.Run(tc.name, func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, [][]uint64{{2, 38, 54, 17, 19, 23}, {2, 0, 38, 17, 19, 23}}, 2) + tc.configure(b) + firstHeader := &types.Header{Number: big.NewInt(100)} + secondHeader := &types.Header{Number: big.NewInt(101)} + b.head = txsender.BlockRef{Number: 100, Hash: firstHeader.Hash()} + client := ðClientMock{} + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(firstHeader, nil).Once() + service := newDeploymentObserverService() + service.client = client + service.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + app := prtRevertTestApp() + app.IConsensusAddress = b.consensus + key := deploymentIdentity(app) + + first, err := service.observeDeployment(t.Context(), app, adapter, b.head) + require.NoError(t, err) + require.Equal(t, wantPending, string(first.Assessment.State)) + require.Equal(t, tc.want, string(first.Assessment.PendingEvidence.Candidate)) + require.Equal(t, wantPending, string(service.deployments.capture(key).snapshot.Assessment.State)) + + repeated, err := service.observeDeployment(t.Context(), app, adapter, b.head) + require.NoError(t, err) + require.Equal(t, wantPending, string(repeated.Assessment.State)) + require.Equal(t, wantPending, string(service.deployments.capture(key).snapshot.Assessment.State)) + + b.head = txsender.BlockRef{Number: 101, Hash: secondHeader.Hash()} + actual, err := service.observeDeployment(t.Context(), app, adapter, b.head) + require.NoError(t, err) + require.Equal(t, tc.want, string(actual.Assessment.State), "the actual tier must become confirmed") + published := service.deployments.capture(key).snapshot + require.Equal(t, tc.want, string(published.Assessment.State)) + require.Equal(t, b.head, actual.Head) + require.Equal(t, b.head, published.Head) + require.False(t, service.publicationStopped()) + client.AssertExpectations(t) + }) + } +} + +func TestCompatibleDeploymentReadFailureDoesNotReportProtectionStop(t *testing.T) { + f := newDeliveryIntervalFixture(t) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + f.checkpoint.epochs(f.epoch) + f.block = f.facts + f.snapshot(resultTestSnapshot(f.epoch, false)) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + cause := errors.New("deployment read unavailable") + f.consensus.deploymentRead = func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + return DeploymentEvidence{Head: h, Consensus: f.app.IConsensusAddress}, cause + } + require.ErrorIs(t, f.d.observeApplication(t.Context(), f.app, f.facts), cause) + state, _, err := f.d.deploymentVerdictState(t.Context(), f.app) + require.NoError(t, err) + require.Equal(t, deploymentCompatible, state) + require.NotContains(t, logs.String(), "PRT protection stopped") +} + +func TestDeploymentReadShutdownDoesNotReportProtectionStop(t *testing.T) { + f := newDeliveryIntervalFixture(t) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + f.checkpoint.epochs(f.epoch) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + f.consensus.deploymentRead = func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + cancel() + return DeploymentEvidence{Head: h, Consensus: f.app.IConsensusAddress}, context.Canceled + } + require.ErrorIs(t, f.d.observeApplication(ctx, f.app, f.facts), context.Canceled) + require.NotContains(t, logs.String(), "PRT protection stopped") +} + +func TestDeploymentResetAcknowledgementFailureReportsEffectiveAdmission(t *testing.T) { + f := newDeliveryIntervalFixture(t) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + f.checkpoint.epochs(f.epoch) + f.block = f.facts + f.snapshot(resultTestSnapshot(f.epoch, false)) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + row := f.repo.deploymentRows[f.app.ID] + row.ResetGeneration++ + f.repo.deploymentRows[f.app.ID] = row + cause := errors.New("episode read unavailable") + f.repo.deploymentRead = func(context.Context, repository.DeploymentIdentity) (*model.DeploymentAdmissionState, error) { + return nil, cause + } + require.ErrorIs(t, f.d.observeApplication(t.Context(), f.app, f.facts), cause) + require.Equal(t, deploymentCompatible, f.d.deployments.capture(deploymentIdentity(f.app)).snapshot.Assessment.State) + f.repo.deploymentRead = nil + effective, _, err := f.d.deploymentVerdictState(t.Context(), f.app) + require.NoError(t, err) + require.Equal(t, deploymentNone, effective) + require.Contains(t, logs.String(), "PRT protection stopped") + require.Contains(t, logs.String(), "join_close_block=310") +} + +func TestUnknownDeploymentShutdownReportsOnlyIndependentFailure(t *testing.T) { + for _, mixed := range []bool{false, true} { + t.Run(map[bool]string{false: "shutdown only", true: "independent failure"}[mixed], func(t *testing.T) { + f := newDeliveryIntervalFixture(t) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentNone, nil) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + f.checkpoint.epochs(f.epoch) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + dependency := errors.New("independent deployment failure") + f.consensus.deploymentRead = func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + cancel() + cause := error(context.Canceled) + if mixed { + cause = errors.Join(cause, dependency) + } + return DeploymentEvidence{Head: h, Consensus: f.app.IConsensusAddress}, cause + } + err := f.d.observeApplication(ctx, f.app, f.facts) + require.ErrorIs(t, err, context.Canceled) + if mixed { + require.ErrorIs(t, err, dependency) + require.Contains(t, logs.String(), "PRT protection stopped") + } else { + require.NotContains(t, logs.String(), "PRT protection stopped") + } + }) + } +} + +func TestDeploymentAssessmentRetainsMalformedIdentityAndDecodedZeroFactory(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + first, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + previous := planDeploymentAssessment(deploymentSnapshot{}, first, nil, true) + b.head = deploymentTestHead(101) + b.absent[common.Address{}] = true + factoryRequest := testDeploymentRequest(b.consensus, testDeploymentSelector(testFactorySignature)) + b.overrides[factoryRequest] = deploymentRPCReply{data: testDeploymentAddress(common.Address{})} + zero, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + require.True(t, zero.FactoryKnown) + require.False(t, planDeploymentAssessment(previous, zero, nil, true).Alpha6Shape) + request := testDeploymentRequest(b.factory, testDeploymentRowInput(0)) + delete(b.overrides, testDeploymentRequest(b.consensus, testDeploymentSelector(testFactorySignature))) + b.overrides[request] = deploymentRPCReply{data: []byte{1}} + malformed, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + pending := planDeploymentAssessment(deploymentSnapshot{}, malformed, nil, true) + b.head = deploymentTestHead(102) + b.overrides[request] = deploymentRPCReply{data: []byte{2, 3}} + changed, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + require.Equal(t, deploymentPending, planDeploymentAssessment(pending, changed, nil, true).Assessment.State) + b.overrides[request] = deploymentRPCReply{data: []byte{2}} + sameLength, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + require.Equal(t, deploymentPending, planDeploymentAssessment(pending, sameLength, nil, true).Assessment.State) + +} + +func TestCompatibleDeploymentCacheUsesOnlyCurrentPinnedHeadAndEpisode(t *testing.T) { + s := newDeploymentObserverService() + app := prtRevertTestApp() + reads := 0 + reader := &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + reads++ + evidence := compatibleDeploymentEvidence(h) + if reads == 2 { + evidence.Candidate, evidence.Cause = deploymentGeneration, deploymentCauseMarchID + evidence.MarchID-- + } + return evidence, nil + }} + head := deploymentTestHead(100) + _, err := s.observeDeployment(t.Context(), app, reader, head) + require.NoError(t, err) + _, err = s.observeDeployment(t.Context(), app, reader, head) + require.NoError(t, err) + require.Equal(t, 1, reads) + repo := s.repository.(*prtRepositoryMock) + row := repo.deploymentRows[app.ID] + row.ResetGeneration++ + repo.deploymentRows[app.ID] = row + snapshot, err := s.observeDeployment(t.Context(), app, reader, head) + require.NoError(t, err) + require.Equal(t, 2, reads, "a same-head reset must read new evidence before using the compatible cache") + require.Equal(t, deploymentPending, snapshot.Assessment.State) + _, err = s.observeDeployment(t.Context(), app, reader, deploymentTestHead(101)) + require.NoError(t, err) + require.Equal(t, 3, reads) +} + +func TestDeploymentSameHeadRetriesNoneAndPending(t *testing.T) { + for _, candidate := range []deploymentState{deploymentNone, deploymentGeneration} { + t.Run(string(candidate), func(t *testing.T) { + s := newDeploymentObserverService() + app := prtRevertTestApp() + reads := 0 + reader := &daveConsensusAdapterMock{deploymentRead: func(_ context.Context, h txsender.BlockRef) (DeploymentEvidence, error) { + reads++ + return DeploymentEvidence{Head: h, Consensus: app.IConsensusAddress, + Candidate: candidate, Cause: deploymentCauseStaticShape}, nil + }} + head := deploymentTestHead(100) + _, err := s.observeDeployment(t.Context(), app, reader, head) + require.NoError(t, err) + _, err = s.observeDeployment(t.Context(), app, reader, head) + require.NoError(t, err) + require.Equal(t, 2, reads) + }) + } +} diff --git a/internal/prt/deployment_race_test.go b/internal/prt/deployment_race_test.go new file mode 100644 index 000000000..b203c9b3a --- /dev/null +++ b/internal/prt/deployment_race_test.go @@ -0,0 +1,48 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDeploymentPublicationChangeDuringCompletionBlocksUsefulAction(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + f.repo.deploymentCompletion = func(ctx context.Context, identity repository.DeploymentIdentity, index uint64) ( + *repository.DeploymentCompletionState, error, + ) { + completion, err := f.repo.GetEpochCompletionState(ctx, identity.ApplicationAddress.Hex(), index) + require.NoError(t, err) + row, err := f.repo.GetDeploymentAdmissionState(ctx, identity) + require.NoError(t, err) + setTestDeployment(f.d.Service, f.repo.prtRepositoryMock, f.app, 1, deploymentPending, nil) + return &repository.DeploymentCompletionState{Completion: completion, Deployment: row}, nil + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, txsender.ProtocolWait, eligibility) +} +func TestDeploymentResetDuringProtocolReadsBlocksUsefulAction(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + for _, expected := range f.consensus.ExpectedCalls { + if expected.Method == "GetCurrentSealedEpoch" { + expected.Run(func(mock.Arguments) { + row := f.repo.deploymentRows[f.app.ID] + row.ResetGeneration = 2 + row.ConfirmedTier = nil + row.DeploymentReason = nil + f.repo.deploymentRows[f.app.ID] = row + }) + } + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, txsender.ProtocolWait, eligibility) +} diff --git a/internal/prt/deployment_reader.go b/internal/prt/deployment_reader.go new file mode 100644 index 000000000..d0f37bbe5 --- /dev/null +++ b/internal/prt/deployment_reader.go @@ -0,0 +1,250 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/cartesistatetransition" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/imultileveltournamentfactory" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" +) + +const ( + deploymentABIWordBytes = 32 + deploymentUint64Bytes = 8 + deploymentMethodMissingCode = -32601 + deploymentRulerBits = model.Log2MaxAdvanceStatesPerEpoch + model.Log2MaxExecutionCycles + model.Log2MaxUarchCyclesPerMCycle + deploymentWindowBits = model.Log2MaxExecutionCycles + model.Log2MaxUarchCyclesPerMCycle +) + +const ( + deploymentLevelsWord = iota + deploymentStrideWord + deploymentHeightWord + deploymentResponseWord + deploymentCommitmentWord + deploymentAllowanceWord +) + +type deploymentReadStatus uint8 + +const ( + deploymentReadAbsent deploymentReadStatus = iota + deploymentReadValue + deploymentReadMismatch +) + +type deploymentReader struct { + ctx context.Context + backend readOnlyBackend + consensus abi.ABI + factory abi.ABI + transition abi.ABI + evidence DeploymentEvidence +} + +// ReadDeployment reads one selected hash without applying action or integrity-status policy. +func (a *DaveConsensusAdapterImpl) ReadDeployment(ctx context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + evidence := DeploymentEvidence{Head: head, Consensus: a.address, Candidate: deploymentNone, Cause: deploymentCauseNone} + if head.Hash == (common.Hash{}) { + return evidence, fmt.Errorf("deployment observation requires an explicit block hash: %w", txsender.ErrInconclusive) + } + consensus, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + if err != nil { + return evidence, err + } + factory, err := imultileveltournamentfactory.IMultiLevelTournamentFactoryMetaData.GetAbi() + if err != nil { + return evidence, err + } + transition, err := cartesistatetransition.CartesiStateTransitionMetaData.GetAbi() + if err != nil { + return evidence, err + } + r := deploymentReader{ctx: withLiveBlock(ctx, head.Number, head.Hash), backend: readOnlyBackend{Client: a.client}, + consensus: *consensus, factory: *factory, transition: *transition, evidence: evidence} + return r.read() +} + +func (r *deploymentReader) call( + address common.Address, contractABI abi.ABI, name string, args ...any, +) ([]byte, deploymentReadStatus, error) { + method, exists := contractABI.Methods[name] + if !exists { + return nil, deploymentReadAbsent, fmt.Errorf("deployment ABI has no method %s", name) + } + words, err := staticABIWordCount(method.Outputs) + if err != nil { + return nil, deploymentReadAbsent, err + } + input, err := contractABI.Pack(name, args...) + if err != nil { + return nil, deploymentReadAbsent, err + } + contract := bind.NewBoundContract(address, contractABI, r.backend, nil, nil) + output, err := contract.CallRaw(pinnedCallOpts(r.ctx, r.evidence.Head.Number), input) + if errors.Is(err, bind.ErrNoCode) { + r.evidence.Cause = deploymentCauseEmpty + return nil, deploymentReadAbsent, nil + } + if err != nil { + var rpcError rpc.Error + if errors.Is(err, bind.ErrNoBlockHashState) || + (errors.As(err, &rpcError) && rpcError.ErrorCode() == deploymentMethodMissingCode) { + err = errors.Join(txsender.ErrUnsupported, err) + } + return nil, deploymentReadAbsent, fmt.Errorf("deployment %s at %s, block %d hash %s: %w", + name, address, r.evidence.Head.Number, r.evidence.Head.Hash, err) + } + if len(output) == 0 { + r.evidence.Cause = deploymentCauseEmpty + return nil, deploymentReadAbsent, nil + } + malformed := func() ([]byte, deploymentReadStatus, error) { + call := DeploymentCallEvidence{Target: address, Selector: [4]byte(method.ID), + ReturnLength: uint64(len(output)), ReturnHash: crypto.Keccak256Hash(output)} + if len(args) != 0 { + call.HasLevel, call.Level = true, args[0].(uint64) + } + r.evidence.Malformed = &call + r.evidence.Candidate, r.evidence.Cause = deploymentGeneration, deploymentCauseStaticShape + return nil, deploymentReadMismatch, nil + } + if len(output) != words*deploymentABIWordBytes { + return malformed() + } + _, err = method.Outputs.Unpack(output) + if err == nil { + for i, argument := range method.Outputs { + if argument.Type.T == abi.AddressTy { + start := i * deploymentABIWordBytes + for _, padding := range output[start : start+deploymentABIWordBytes-common.AddressLength] { + if padding != 0 { + return malformed() + } + } + } + } + return output, deploymentReadValue, nil + } + + return malformed() +} + +func deploymentWord(output []byte, index int) uint64 { + end := (index + 1) * deploymentABIWordBytes + return binary.BigEndian.Uint64(output[end-deploymentUint64Bytes : end]) +} + +func deploymentParameters(output []byte) DeploymentParameters { + return DeploymentParameters{ + Levels: deploymentWord(output, deploymentLevelsWord), + Log2Step: deploymentWord(output, deploymentStrideWord), + Height: deploymentWord(output, deploymentHeightWord), + ResponseBudget: deploymentWord(output, deploymentResponseWord), + CommitmentBudget: deploymentWord(output, deploymentCommitmentWord), + MaxAllowance: deploymentWord(output, deploymentAllowanceWord), + } +} + +func (r *deploymentReader) read() (DeploymentEvidence, error) { + e := &r.evidence + output, status, err := r.call(e.Consensus, r.consensus, "getTournamentFactory") + if status != deploymentReadValue { + return *e, err + } + e.FactoryKnown, e.Factory = true, common.BytesToAddress(output) + output, status, err = r.call(e.Factory, r.factory, "tournamentParameters", uint64(0)) + if status != deploymentReadValue { + return *e, err + } + e.Alpha6Shape = true + e.Rows = append(e.Rows, deploymentParameters(output)) + output, status, err = r.call(e.Factory, r.factory, "stateTransition") + if status != deploymentReadValue { + return *e, err + } + e.Transition = common.BytesToAddress(output) + output, status, err = r.call(e.Transition, r.transition, "CM_MARCHID") + if status != deploymentReadValue { + return *e, err + } + e.MarchID = deploymentWord(output, 0) + if e.MarchID != model.CartesiMachineMarchID { + e.Candidate, e.Cause = deploymentGeneration, deploymentCauseMarchID + return *e, nil + } + output, status, err = r.call(e.Transition, r.transition, "UARCH_PRISTINE_STATE_HASH") + if status != deploymentReadValue { + return *e, err + } + e.Pristine = common.BytesToHash(output) + if e.Pristine != model.UarchPristineStateHash() { + e.Candidate, e.Cause = deploymentGeneration, deploymentCausePristine + return *e, nil + } + output, status, err = r.call(e.Factory, r.factory, "tournamentLevelCount") + if status != deploymentReadValue { + return *e, err + } + e.Count = deploymentWord(output, 0) + if e.Count == 0 || e.Count > deploymentRulerBits { + e.Candidate, e.Cause = deploymentGeneration, deploymentCauseCount + return *e, nil + } + for level := uint64(1); level < e.Count; level++ { + output, status, err = r.call(e.Factory, r.factory, "tournamentParameters", level) + if status != deploymentReadValue { + return *e, err + } + e.Rows = append(e.Rows, deploymentParameters(output)) + } + e.Candidate, e.Cause = classifyDeploymentGeometry(e.Rows, e.Count), deploymentCauseComplete + if e.Candidate == deploymentGeneration { + e.Cause = deploymentCauseGeometry + } + return *e, nil +} + +func classifyDeploymentGeometry(rows []DeploymentParameters, count uint64) deploymentState { + if count == 0 || count > deploymentRulerBits || uint64(len(rows)) != count { + return deploymentGeneration + } + for i, row := range rows { + if row.Levels != count || row.Height == 0 || row.Log2Step > deploymentRulerBits || + row.Height > deploymentRulerBits || row.Log2Step+row.Height > deploymentRulerBits { + return deploymentGeneration + } + if i == 0 && row.Log2Step+row.Height != deploymentRulerBits { + return deploymentGeneration + } + if i != 0 && row.Log2Step+row.Height != rows[i-1].Log2Step { + return deploymentGeneration + } + } + if rows[len(rows)-1].Log2Step != 0 || rows[0].Log2Step < model.Log2MaxUarchCyclesPerMCycle || + rows[0].Log2Step > deploymentWindowBits { + return deploymentGeneration + } + if count != model.TournamentLevelCount { + return deploymentTable + } + for i, row := range rows { + if !model.IsCanonicalTournamentLevel(uint64(i), row.Height, row.Log2Step) { + return deploymentTable + } + } + return deploymentCompatible +} diff --git a/internal/prt/deployment_reader_test.go b/internal/prt/deployment_reader_test.go new file mode 100644 index 000000000..f0547670b --- /dev/null +++ b/internal/prt/deployment_reader_test.go @@ -0,0 +1,429 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + "sync" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +const ( + testDeploymentWordBytes = 32 + testFactorySignature = "getTournamentFactory()" + testParametersSignature = "tournamentParameters(uint64)" + testMarchSignature = "CM_MARCHID()" + wantTableDeployment = "table" + wantGenerationDeployment = "generation" +) + +var testDeploymentPristine = common.HexToHash("0x558f38f2350970a0a566b2450afa64250059c1cff66c9a55560e5b93fa783926") + +type deploymentRPCReply struct { + data []byte + err error +} +type deploymentRPCError struct { + code int + message string +} + +func (e deploymentRPCError) Error() string { return e.message } +func (e deploymentRPCError) ErrorCode() int { return e.code } + +type deploymentRPCRecord struct { + address common.Address + input []byte + block rpc.BlockNumberOrHash +} +type deploymentViewRPC struct { + mu sync.Mutex + consensus, factory, transition common.Address + head txsender.BlockRef + rows [][]uint64 + count uint64 + march uint64 + pristine common.Hash + absent map[common.Address]bool + overrides map[string]deploymentRPCReply + records []deploymentRPCRecord + rowCalls []uint64 + codeErr error +} + +func testDeploymentWords(values ...uint64) []byte { + out := make([]byte, testDeploymentWordBytes*len(values)) + for i, value := range values { + binary.BigEndian.PutUint64(out[(i+1)*testDeploymentWordBytes-8:], value) + } + return out +} +func testDeploymentAddress(address common.Address) []byte { + out := make([]byte, testDeploymentWordBytes) + copy(out[testDeploymentWordBytes-common.AddressLength:], address[:]) + return out +} +func testDeploymentSelector(signature string) []byte { return crypto.Keccak256([]byte(signature))[:4] } +func testDeploymentRequest(address common.Address, input []byte) string { + return address.Hex() + hexutil.Encode(input) +} +func testDeploymentRowInput(level uint64) []byte { + return append(testDeploymentSelector(testParametersSignature), testDeploymentWords(level)...) +} + +func newDeploymentViewRPC(t *testing.T, rows [][]uint64, count uint64) (*DaveConsensusAdapterImpl, *deploymentViewRPC) { + t.Helper() + backend := &deploymentViewRPC{consensus: common.HexToAddress("0x101"), factory: common.HexToAddress("0x202"), + transition: common.HexToAddress("0x303"), head: txsender.BlockRef{Number: 100, Hash: common.HexToHash("0x104")}, + rows: rows, count: count, march: 22, pristine: testDeploymentPristine, absent: make(map[common.Address]bool), + overrides: make(map[string]deploymentRPCReply)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewDaveConsensusAdapter(backend.consensus, client) + require.NoError(t, err) + concrete, ok := adapter.(*DaveConsensusAdapterImpl) + require.True(t, ok) + return concrete, backend +} +func (b *deploymentViewRPC) record(address common.Address, input []byte, block rpc.BlockNumberOrHash) error { + if block.BlockHash == nil || *block.BlockHash != b.head.Hash || !block.RequireCanonical || block.BlockNumber != nil { + return errors.New("read did not use the selected canonical hash") + } + b.records = append(b.records, deploymentRPCRecord{address: address, input: slices.Clone(input), block: block}) + return nil +} +func (b *deploymentViewRPC) GetCode(_ context.Context, address common.Address, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + b.mu.Lock() + defer b.mu.Unlock() + if err := b.record(address, nil, block); err != nil { + return nil, err + } + if b.codeErr != nil { + return nil, b.codeErr + } + if b.absent[address] { + return nil, nil + } + return hexutil.Bytes{1}, nil +} +func (b *deploymentViewRPC) Call(_ context.Context, fields map[string]json.RawMessage, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + b.mu.Lock() + defer b.mu.Unlock() + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(fields["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(fields["input"], &input); err != nil { + return nil, err + } + if err := b.record(address, input, block); err != nil { + return nil, err + } + if b.absent[address] { + return nil, nil + } + if response, ok := b.overrides[testDeploymentRequest(address, input)]; ok { + return response.data, response.err + } + if address == b.consensus && slices.Equal(input, testDeploymentSelector(testFactorySignature)) { + return testDeploymentAddress(b.factory), nil + } + if address == b.factory { + switch { + case slices.Equal(input, testDeploymentSelector("stateTransition()")): + return testDeploymentAddress(b.transition), nil + case slices.Equal(input, testDeploymentSelector("tournamentLevelCount()")): + return testDeploymentWords(b.count), nil + case len(input) == testDeploymentWordBytes+4 && slices.Equal(input[:4], testDeploymentSelector(testParametersSignature)): + level := binary.BigEndian.Uint64(input[len(input)-8:]) + b.rowCalls = append(b.rowCalls, level) + if level >= uint64(len(b.rows)) { + return nil, errors.New("unexpected table row") + } + return testDeploymentWords(b.rows[level]...), nil + } + } + if address == b.transition { + if slices.Equal(input, testDeploymentSelector(testMarchSignature)) { + return testDeploymentWords(b.march), nil + } + if slices.Equal(input, testDeploymentSelector("UARCH_PRISTINE_STATE_HASH()")) { + return b.pristine[:], nil + } + } + return nil, fmt.Errorf("unexpected deployment read at %s: %x", address, input) +} + +func canonicalDeploymentRows() [][]uint64 { + return [][]uint64{{2, 38, 54, 11, 22, 33}, {2, 0, 38, 11, 22, 33}} +} +func alternateDeploymentRows() [][]uint64 { + return [][]uint64{{3, 44, 48, 11, 22, 33}, {3, 27, 17, 11, 22, 33}, {3, 0, 27, 11, 22, 33}} +} + +func TestDeploymentReaderCompleteGeometryAndAllRowWords(t *testing.T) { + maxValid := [][]uint64{{69, 68, 24, 11, 22, 33}} + for stride := uint64(67); ; stride-- { + maxValid = append(maxValid, []uint64{69, stride, 1, 11, 22, 33}) + if stride == 0 { + break + } + } + maxCount := [][]uint64{{92, 38, 54, 11, 22, 33}, {92, 0, 38, 11, 22, 33}} + for len(maxCount) < 92 { + maxCount = append(maxCount, []uint64{92, 0, 1, 11, 22, 33}) + } + cases := []struct { + name string + rows [][]uint64 + count uint64 + want string + fetched int + }{ + {"canonical", canonicalDeploymentRows(), 2, "compatible", 2}, + {"canonical zero budgets", [][]uint64{{2, 38, 54, 0, 0, 0}, {2, 0, 38, 0, 0, 0}}, 2, "compatible", 2}, + {"four levels at lower bound", + [][]uint64{ + {4, 20, 72, 11, 22, 33}, + {4, 12, 8, 11, 22, 33}, + {4, 4, 8, 11, 22, 33}, + {4, 0, 4, 11, 22, 33}, + }, 4, wantTableDeployment, 4}, + {"same count alternate", [][]uint64{{2, 44, 48, 11, 22, 33}, {2, 0, 44, 11, 22, 33}}, 2, wantTableDeployment, 2}, + {"three levels", alternateDeploymentRows(), 3, wantTableDeployment, 3}, + {"invalid final row", + [][]uint64{{3, 44, 48, 11, 22, 33}, {3, 27, 17, 11, 22, 33}, {3, 0, 26, 11, 22, 33}}, 3, wantGenerationDeployment, 3}, + {"wrong repeated count", + [][]uint64{{3, 44, 48, 11, 22, 33}, {3, 27, 17, 11, 22, 33}, {2, 0, 27, 11, 22, 33}}, 3, wantGenerationDeployment, 3}, + {"zero count", canonicalDeploymentRows(), 0, wantGenerationDeployment, 1}, + {"excessive count", canonicalDeploymentRows(), 93, wantGenerationDeployment, 1}, + {"one level", [][]uint64{{1, 0, 92, 11, 22, 33}}, 1, wantGenerationDeployment, 1}, + {"maximum valid", maxValid, 69, wantTableDeployment, 69}, + {"maximum calls", maxCount, 92, wantGenerationDeployment, 92}, + {"overflow root", [][]uint64{{2, math.MaxUint64, 93, 11, 22, 33}, {2, 0, 38, 11, 22, 33}}, 2, wantGenerationDeployment, 2}, + {"overflow child", [][]uint64{{2, 38, 54, 11, 22, 33}, {2, math.MaxUint64, 39, 11, 22, 33}}, 2, wantGenerationDeployment, 2}, + {"zero height", + [][]uint64{{4, 44, 48, 11, 22, 33}, + {4, 27, 17, 11, 22, 33}, + {4, 27, 0, 11, 22, 33}, + {4, 0, 27, 11, 22, 33}}, 4, wantGenerationDeployment, 4}, + {"incomplete ruler", [][]uint64{{2, 44, 47, 11, 22, 33}, {2, 0, 44, 11, 22, 33}}, 2, wantGenerationDeployment, 2}, + {"root below bounds", [][]uint64{{2, 19, 73, 11, 22, 33}, {2, 0, 19, 11, 22, 33}}, 2, wantGenerationDeployment, 2}, + {"root above bounds", [][]uint64{{2, 69, 23, 11, 22, 33}, {2, 0, 69, 11, 22, 33}}, 2, wantGenerationDeployment, 2}, + {"nonzero leaf stride", [][]uint64{{2, 44, 48, 11, 22, 33}, {2, 1, 43, 11, 22, 33}}, 2, wantGenerationDeployment, 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, tc.rows, tc.count) + evidence, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + require.Equal(t, tc.want, string(evidence.Candidate)) + require.Equal(t, b.head, evidence.Head) + require.True(t, evidence.FactoryKnown) + require.Equal(t, b.factory, evidence.Factory) + require.Equal(t, b.transition, evidence.Transition) + require.True(t, evidence.Alpha6Shape) + require.Equal(t, tc.count, evidence.Count) + require.Len(t, evidence.Rows, tc.fetched) + require.Len(t, b.rowCalls, tc.fetched) + for i, row := range evidence.Rows { + actual := []uint64{row.Levels, row.Log2Step, row.Height, row.ResponseBudget, row.CommitmentBudget, row.MaxAllowance} + require.Equal(t, tc.rows[i], actual) + } + }) + } +} + +func TestDeploymentReaderRejectsOnlyConclusiveStaticFailures(t *testing.T) { + for _, tc := range []struct { + name, method string + factory bool + data []byte + want deploymentState + shape bool + }{ + {"factory short", testFactorySignature, false, make([]byte, 31), deploymentGeneration, false}, + {"factory extra word", testFactorySignature, false, make([]byte, 64), deploymentGeneration, false}, + {"factory empty", testFactorySignature, false, nil, deploymentNone, false}, + {"five words", testParametersSignature, true, testDeploymentWords(2, 38, 54, 11, 22), deploymentGeneration, false}, + {"seven words", testParametersSignature, true, testDeploymentWords(2, 38, 54, 11, 22, 33, 44), deploymentGeneration, false}, + {"empty row", testParametersSignature, true, nil, deploymentNone, false}, + } { + t.Run(tc.name, func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + target := b.consensus + input := testDeploymentSelector(tc.method) + if tc.factory { + target = b.factory + input = testDeploymentRowInput(0) + } + b.overrides[testDeploymentRequest(target, input)] = deploymentRPCReply{data: tc.data} + evidence, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + require.Equal(t, tc.want, evidence.Candidate) + require.Equal(t, tc.factory, evidence.FactoryKnown) + require.Equal(t, tc.shape, evidence.Alpha6Shape) + require.Empty(t, evidence.Rows) + call := evidence.Malformed + if tc.want != deploymentGeneration { + require.Nil(t, call) + return + } + require.NotNil(t, call) + require.Equal(t, uint64(len(tc.data)), call.ReturnLength) + require.Equal(t, crypto.Keccak256Hash(tc.data), call.ReturnHash) + }) + } + t.Run("noncanonical address padding", func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + raw := testDeploymentAddress(b.factory) + raw[0] = 1 + b.overrides[testDeploymentRequest(b.consensus, testDeploymentSelector(testFactorySignature))] = deploymentRPCReply{data: raw} + evidence, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + require.Equal(t, deploymentGeneration, evidence.Candidate) + require.False(t, evidence.FactoryKnown) + }) + t.Run("uint64 overflow in row", func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + raw := testDeploymentWords(2, 38, 54, 11, 22, 33) + raw[0] = 1 + b.overrides[testDeploymentRequest(b.factory, testDeploymentRowInput(0))] = deploymentRPCReply{data: raw} + evidence, err := adapter.ReadDeployment(t.Context(), b.head) + require.NoError(t, err) + require.Equal(t, deploymentGeneration, evidence.Candidate) + require.True(t, evidence.FactoryKnown) + require.False(t, evidence.Alpha6Shape) + }) +} + +func TestDeploymentReaderPartialIdentityAndUnavailableSources(t *testing.T) { + for _, tc := range []struct { + name string + change func(*deploymentViewRPC) + want deploymentState + cause deploymentCause + rowCount int + transition, pristine, count bool + wantError bool + }{ + {"marchid mismatch", func(b *deploymentViewRPC) { b.march = 21 }, + deploymentGeneration, deploymentCauseMarchID, 1, true, false, false, false}, + {"pristine mismatch", func(b *deploymentViewRPC) { b.pristine = common.HexToHash("0x123") }, + deploymentGeneration, deploymentCausePristine, 1, true, true, false, false}, + {"consensus code absent", func(b *deploymentViewRPC) { b.absent[b.consensus] = true }, + deploymentNone, deploymentCauseEmpty, 0, false, false, false, false}, + {"factory code absent", func(b *deploymentViewRPC) { b.absent[b.factory] = true }, + deploymentNone, deploymentCauseEmpty, 0, false, false, false, false}, + {"transition code absent", func(b *deploymentViewRPC) { b.absent[b.transition] = true }, + deploymentNone, deploymentCauseEmpty, 1, true, false, false, false}, + {"late read failure", func(b *deploymentViewRPC) { + b.rows = alternateDeploymentRows() + b.count = 3 + b.overrides[testDeploymentRequest(b.factory, testDeploymentRowInput(2))] = deploymentRPCReply{ + err: deploymentRPCError{-32000, "temporary state unavailable"}} + }, deploymentNone, deploymentCauseNone, 2, true, true, true, true}, + {"revert text is not mismatch", func(b *deploymentViewRPC) { + b.overrides[testDeploymentRequest(b.transition, testDeploymentSelector(testMarchSignature))] = deploymentRPCReply{ + err: deploymentRPCError{3, "execution reverted: MARCHID incompatible"}} + }, deploymentNone, deploymentCauseNone, 1, true, false, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + tc.change(b) + evidence, err := adapter.ReadDeployment(t.Context(), b.head) + if tc.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.want, evidence.Candidate) + require.Equal(t, tc.cause, evidence.Cause) + require.Equal(t, tc.name != "consensus code absent", evidence.FactoryKnown, "retain factory on every early identity/count exit") + require.Len(t, evidence.Rows, tc.rowCount) + if tc.pristine { + require.Equal(t, b.pristine, evidence.Pristine) + } else { + require.Zero(t, evidence.Pristine) + } + if tc.count { + require.Equal(t, b.count, evidence.Count) + } else { + require.Zero(t, evidence.Count) + } + if evidence.FactoryKnown { + require.Equal(t, b.factory, evidence.Factory) + } + if tc.transition { + require.Equal(t, b.transition, evidence.Transition) + } + }) + } + t.Run("unsupported code capability", func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + request := testDeploymentRequest(b.consensus, testDeploymentSelector(testFactorySignature)) + b.overrides[request] = deploymentRPCReply{err: deploymentRPCError{-32601, "method eth_call unavailable"}} + evidence, err := adapter.ReadDeployment(t.Context(), b.head) + require.ErrorIs(t, err, txsender.ErrUnsupported) + require.True(t, txsender.PassInvalid(err)) + require.False(t, txsender.HardFailure(err)) + require.Equal(t, deploymentNone, evidence.Candidate) + require.Nil(t, evidence.Malformed) + }) + t.Run("hash required", func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + evidence, err := adapter.ReadDeployment(t.Context(), txsender.BlockRef{Number: b.head.Number}) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.Equal(t, deploymentNone, evidence.Candidate) + require.Empty(t, b.records) + }) +} + +func TestDeploymentReaderTypedCapabilityAndAmbiguousViewErrors(t *testing.T) { + for _, tc := range []struct { + name string + code int + message string + unsupported bool + }{ + {"missing RPC method", -32601, "method unavailable", true}, + {"ambiguous schema failure", -32602, "unknown field requireCanonical", false}, + {"revert mentioning capability", 3, "execution reverted: requireCanonical unsupported", false}, + {"read error mentioning mismatch", -32000, "MARCHID incompatible", false}, + } { + t.Run(tc.name, func(t *testing.T) { + adapter, b := newDeploymentViewRPC(t, canonicalDeploymentRows(), 2) + b.overrides[testDeploymentRequest(b.consensus, testDeploymentSelector(testFactorySignature))] = deploymentRPCReply{ + err: deploymentRPCError{tc.code, tc.message}, + } + e, err := adapter.ReadDeployment(t.Context(), b.head) + require.Error(t, err) + require.Equal(t, "none", string(e.Candidate)) + require.False(t, txsender.HardFailure(err)) + require.Equal(t, tc.unsupported, errors.Is(err, txsender.ErrUnsupported)) + require.Equal(t, tc.unsupported, txsender.PassInvalid(err)) + var rpcError rpc.Error + require.ErrorAs(t, err, &rpcError) + require.Equal(t, tc.code, rpcError.ErrorCode()) + }) + } +} diff --git a/internal/prt/deployment_repository_fixture_test.go b/internal/prt/deployment_repository_fixture_test.go new file mode 100644 index 000000000..4593e0fa4 --- /dev/null +++ b/internal/prt/deployment_repository_fixture_test.go @@ -0,0 +1,145 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func cloneDeploymentRow(row model.DeploymentAdmissionState) model.DeploymentAdmissionState { + if row.ConfirmedTier != nil { + row.ConfirmedTier = new(*row.ConfirmedTier) + } + if row.DeploymentReason != nil { + row.DeploymentReason = new(*row.DeploymentReason) + } + return row +} + +func (m *prtRepositoryMock) ForecloseDeploymentEpoch(ctx context.Context, request repository.DeploymentDrainRequest) error { + if m.deploymentDrain != nil { + return m.deploymentDrain(ctx, request) + } + return m.Called(ctx, request).Error(0) +} + +func (m *prtRepositoryMock) GetDeploymentAdmissionState(ctx context.Context, + identity repository.DeploymentIdentity, +) (*model.DeploymentAdmissionState, error) { + if m.deploymentRead != nil { + return m.deploymentRead(ctx, identity) + } + m.deploymentMu.Lock() + defer m.deploymentMu.Unlock() + row, ok := m.deploymentRows[identity.ApplicationID] + if !ok { + return nil, nil + } + owned := cloneDeploymentRow(row) + return &owned, nil +} + +func (m *prtRepositoryMock) BeginDeploymentStartup(_ context.Context, identity repository.DeploymentIdentity, + expected int64, +) (*model.DeploymentAdmissionState, error) { + m.deploymentMu.Lock() + defer m.deploymentMu.Unlock() + row, ok := m.deploymentRows[identity.ApplicationID] + if !ok || row.ResetGeneration != expected { + return nil, repository.ErrNoUpdate + } + row.ResetGeneration++ + m.deploymentRows[identity.ApplicationID] = row + owned := cloneDeploymentRow(row) + return &owned, nil +} + +func (m *prtRepositoryMock) AcknowledgeDeploymentAssessment(ctx context.Context, + request repository.DeploymentAssessment, +) (*model.DeploymentAdmissionState, error) { + if m.deploymentAck != nil { + return m.deploymentAck(ctx, request) + } + m.deploymentMu.Lock() + defer m.deploymentMu.Unlock() + row, ok := m.deploymentRows[request.Identity.ApplicationID] + if !ok || row.ResetGeneration != request.ResetGeneration { + return nil, repository.ErrNoUpdate + } + previous := cloneDeploymentRow(row) + if request.ConfirmedTier != nil { + row.ConfirmedTier, row.DeploymentReason = new(*request.ConfirmedTier), new(request.Reason) + } else { + row.ConfirmedTier, row.DeploymentReason = nil, nil + } + m.deploymentRows[request.Identity.ApplicationID] = row + return &previous, nil +} + +func (m *prtRepositoryMock) GetDeploymentCompletionState(ctx context.Context, + identity repository.DeploymentIdentity, epoch uint64, +) (*repository.DeploymentCompletionState, error) { + if m.deploymentCompletion != nil { + return m.deploymentCompletion(ctx, identity, epoch) + } + completion, err := m.GetEpochCompletionState(ctx, identity.ApplicationAddress.Hex(), epoch) + if err != nil { + return nil, err + } + row, err := m.GetDeploymentAdmissionState(ctx, identity) + return &repository.DeploymentCompletionState{Completion: completion, Deployment: row}, err +} + +func admitTestDeployment(s *Service, repo *prtRepositoryMock, app *model.Application) { + row := model.DeploymentAdmissionState{ApplicationID: app.ID, ResetGeneration: 1} + if repo.deploymentRows == nil { + repo.deploymentRows = map[int64]model.DeploymentAdmissionState{} + } + repo.deploymentRows[app.ID] = row + key := deploymentIdentity(app) + s.deployments.publish(key, deploymentSnapshot{Acknowledged: true, ResetGeneration: 1, + Assessment: deploymentAssessment{State: deploymentCompatible}}) +} + +func newDeploymentObserverService() *Service { + s, repo := newPRTServiceMock() + s.deployments = deploymentCache{} + app := prtRevertTestApp() + repo.deploymentRows = map[int64]model.DeploymentAdmissionState{app.ID: {ApplicationID: app.ID}} + return s +} + +func setTestDeployment(s *Service, repo *prtRepositoryMock, app *model.Application, + generation int64, state deploymentState, saved *model.DeploymentTier, +) { + repo.deploymentMu.Lock() + repo.deploymentRows[app.ID] = model.DeploymentAdmissionState{ApplicationID: app.ID, + ResetGeneration: generation, ConfirmedTier: saved} + if saved != nil { + row := repo.deploymentRows[app.ID] + row.DeploymentReason = new("confirmed fixture evidence") + repo.deploymentRows[app.ID] = row + } + repo.deploymentMu.Unlock() + key := deploymentIdentity(app) + s.deployments.publish(key, deploymentSnapshot{Acknowledged: true, ResetGeneration: generation, + Assessment: deploymentAssessment{State: state}}) +} + +func (r *deliveryRepoFake) GetDeploymentCompletionState(ctx context.Context, + identity repository.DeploymentIdentity, epoch uint64, +) (*repository.DeploymentCompletionState, error) { + if r.deploymentCompletion != nil { + return r.deploymentCompletion(ctx, identity, epoch) + } + completion, err := r.GetEpochCompletionState(ctx, identity.ApplicationAddress.Hex(), epoch) + if err != nil { + return nil, err + } + row, err := r.GetDeploymentAdmissionState(ctx, identity) + return &repository.DeploymentCompletionState{Completion: completion, Deployment: row}, err +} diff --git a/internal/prt/deployment_types.go b/internal/prt/deployment_types.go new file mode 100644 index 000000000..dd1774f12 --- /dev/null +++ b/internal/prt/deployment_types.go @@ -0,0 +1,98 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" +) + +type deploymentState string + +const ( + deploymentNone deploymentState = "none" + deploymentPending deploymentState = "pending" + deploymentCompatible deploymentState = "compatible" + deploymentGeneration deploymentState = deploymentState(model.DeploymentTierGeneration) + deploymentTable deploymentState = deploymentState(model.DeploymentTierTable) +) + +type deploymentCause string + +const ( + deploymentCauseNone deploymentCause = "none" + deploymentCauseStaticShape deploymentCause = "static_shape" + deploymentCauseMarchID deploymentCause = "marchid" + deploymentCausePristine deploymentCause = "pristine_hash" + deploymentCauseCount deploymentCause = "level_count" + deploymentCauseGeometry deploymentCause = "geometry" + deploymentCauseComplete deploymentCause = "complete" + deploymentCauseEmpty deploymentCause = "empty_return" +) + +// DeploymentParameters retains every field in the factory's static alpha.6 tuple. +// The three budgets are EVM block counts, rather than wall-clock durations. +type DeploymentParameters struct { + Levels uint64 + Log2Step uint64 + Height uint64 + ResponseBudget uint64 + CommitmentBudget uint64 + MaxAllowance uint64 +} + +// DeploymentCallEvidence identifies a malformed response without retaining unbounded bytes. +type DeploymentCallEvidence struct { + Target common.Address + Selector [4]byte + HasLevel bool + Level uint64 + ReturnLength uint64 + ReturnHash common.Hash +} + +// DeploymentEvidence contains facts from one explicit block hash, including partial successful reads. +type DeploymentEvidence struct { + Head txsender.BlockRef + Consensus common.Address + + FactoryKnown bool + Factory common.Address + Alpha6Shape bool + Transition common.Address + MarchID uint64 + Pristine common.Hash + Count uint64 + Rows []DeploymentParameters + Malformed *DeploymentCallEvidence + Candidate deploymentState + Cause deploymentCause +} + +type deploymentAssessment struct { + State deploymentState + PendingEvidence *DeploymentEvidence +} + +func (a deploymentAssessment) confirmed() bool { + return a.State == deploymentGeneration || a.State == deploymentTable +} + +// Pending evidence is immutable after publication and is discarded after confirmation. +type deploymentSnapshot struct { + ResetGeneration int64 + Acknowledged bool + Recovery bool + UnknownHeads uint64 + UnknownHeadIDs [deploymentUnknownHeadLimit]common.Hash + LastNotice time.Time + Assessment deploymentAssessment + Head txsender.BlockRef + Cause deploymentCause + Alpha6Shape bool + ShapeFactory common.Address +} diff --git a/internal/prt/deployment_visibility_test.go b/internal/prt/deployment_visibility_test.go new file mode 100644 index 000000000..8da2b6bb0 --- /dev/null +++ b/internal/prt/deployment_visibility_test.go @@ -0,0 +1,142 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func newInitialVisibilityFixture(t *testing.T) (*observerCheckpointFixture, *bytes.Buffer, *int) { + t.Helper() + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 0 + f.s.deployments = deploymentCache{} + f.repo.deploymentRows[f.app.ID] = model.DeploymentAdmissionState{ApplicationID: f.app.ID} + var logs bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + reads := 0 + f.consensus.deploymentRead = func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + reads++ + return DeploymentEvidence{Head: head, Consensus: f.app.IConsensusAddress, + Candidate: deploymentNone, Cause: deploymentCauseEmpty}, nil + } + return f, &logs, &reads +} + +func TestObservationWaitsForInitialDeploymentAtSelectedHead(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Finalized, model.DefaultBlock_Safe, model.DefaultBlock_Latest} { + t.Run(policy.String(), func(t *testing.T) { + f, logs, reads := newInitialVisibilityFixture(t) + f.s.defaultBlock = policy + before := f.repo.deploymentRows[f.app.ID] + for head := uint64(100); head < 109; head++ { + facts := observerFacts(head) + f.epochs() + f.consensus.On("GetCurrentSealedEpoch", mock.Anything).Run(func(args mock.Arguments) { + opts := args.Get(0).(*bind.CallOpts) + require.Equal(t, facts.Hash, opts.BlockHash) + require.Nil(t, opts.BlockNumber) + require.NotNil(t, opts.Context) + require.False(t, opts.Pending) + }).Return(CurrentSealedEpoch{}, bind.ErrNoCode).Once() + require.NoError(t, f.s.observeApplication(t.Context(), f.app, facts)) + } + require.Zero(t, *reads) + require.Equal(t, before, f.repo.deploymentRows[f.app.ID], "absence must not acknowledge or clear an episode") + require.Empty(t, f.s.deployments.entries) + require.Empty(t, f.s.protectionNotices) + require.NotContains(t, logs.String(), "level=ERROR") + require.NotContains(t, logs.String(), "deployment evidence is unavailable") + facts := observerFacts(109) + root := common.HexToAddress("0x777") + f.epochs() + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)). + Return(CurrentSealedEpoch{Tournament: root}, nil).Twice() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", root).Return(adapter, nil).Once() + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{StartInstant: 109, Allowance: 300}, nil).Once() + f.consensus.deploymentRead = func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + (*reads)++ + return compatibleDeploymentEvidence(head), nil + } + require.NoError(t, f.s.observeApplication(t.Context(), f.app, facts)) + require.Equal(t, 1, *reads, "visibility must resume deployment assessment") + require.Equal(t, int64(1), f.repo.deploymentRows[f.app.ID].ResetGeneration) + require.Equal(t, deploymentCompatible, f.s.deployments.capture(deploymentIdentity(f.app)).snapshot.Assessment.State) + require.NotContains(t, logs.String(), "level=ERROR") + require.Zero(t, f.app.LastTournamentCheckBlock) + adapter.AssertExpectations(t) + }) + } +} + +func TestObservationInitialVisibilityDoesNotHideRealErrors(t *testing.T) { + const inventoryErrorCase = "epoch read" + for _, name := range []string{"transport", "observed cursor", "failed application", inventoryErrorCase} { + t.Run(name, func(t *testing.T) { + f, logs, reads := newInitialVisibilityFixture(t) + facts := observerFacts(100) + cause := bind.ErrNoCode + calls := 1 + switch name { + case "transport": + cause = errors.New("selected state transport unavailable") + calls = 2 + case "observed cursor": + f.app.LastTournamentCheckBlock = 90 + case "failed application": + f.app.Status = model.ApplicationStatus_Failed + case inventoryErrorCase: + // A failed query is not evidence that the application has no obligation. + queryErr := errors.New("epoch inventory unavailable") + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch(nil), uint64(0), queryErr).Once() + } + if name != inventoryErrorCase { + f.epochs() + } + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(CurrentSealedEpoch{}, cause).Times(calls) + err := f.s.observeApplication(t.Context(), f.app, facts) + if name == inventoryErrorCase { + require.ErrorContains(t, err, "epoch inventory unavailable") + } else { + require.NoError(t, err) + } + require.Equal(t, 1, *reads, "real failures must continue through assessment and diagnostics") + require.Contains(t, logs.String(), "level=ERROR msg=\"PRT protection deadline discovery unavailable\"") + require.Contains(t, logs.String(), cause.Error()) + }) + } +} + +func TestObservationMissingCodeKeepsKnownRootDiagnostics(t *testing.T) { + f, logs, reads := newInitialVisibilityFixture(t) + facts := observerFacts(100) + epoch := checkpointEpoch(0, "0x777") + f.epochs(epoch) + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(CurrentSealedEpoch{}, bind.ErrNoCode).Twice() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{}, bind.ErrNoCode).Once() + require.NoError(t, f.s.observeApplication(t.Context(), f.app, facts)) + require.Equal(t, 1, *reads) + require.Contains(t, logs.String(), "PRT protection stopped") + require.Contains(t, logs.String(), "epoch_index=0 root_tournament="+epoch.TournamentAddress.Hex()) + require.Contains(t, logs.String(), "clock_available=false") + require.Contains(t, logs.String(), bind.ErrNoCode.Error()) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/event_batch_test.go b/internal/prt/event_batch_test.go new file mode 100644 index 000000000..4768b192c --- /dev/null +++ b/internal/prt/event_batch_test.go @@ -0,0 +1,193 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type eventBatchFixture struct { + *observerCheckpointFixture + epoch *Epoch + tournament *Tournament + adapter *tournamentAdapterMock +} + +func newEventBatchFixture(t *testing.T, commitments []*Commitment, matches []*Match) *eventBatchFixture { + t.Helper() + s, repo := newPRTServiceMock() + base := &observerCheckpointFixture{t: t, s: s, repo: repo, app: prtRevertTestApp()} + epoch := checkpointEpoch(0, "0x100") + f := &eventBatchFixture{observerCheckpointFixture: base, epoch: epoch, + tournament: &Tournament{Address: *epoch.TournamentAddress, Height: 256, Kind: TournamentKindLeaf}, + adapter: &tournamentAdapterMock{}} + address := f.tournament.Address.Hex() + repo.On("ListCommitments", mock.Anything, base.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return(commitments, uint64(len(commitments)), nil).Once() + repo.On("ListMatches", mock.Anything, base.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return(matches, uint64(len(matches)), nil).Once() + t.Cleanup(func() { repo.AssertExpectations(t); f.adapter.AssertExpectations(t) }) + return f +} + +func (f *eventBatchFixture) project(events *TournamentEvents) (*repository.TournamentEventBatch, error) { + return f.s.tournamentEventBatch(f.t.Context(), f.app, f.epoch, f.tournament, f.adapter, events, 100, nil) +} + +func TestTournamentEventBatchKeepsFactsAndCurrentViewsSeparate(t *testing.T) { + f := newEventBatchFixture(t, nil, nil) + one, two, matchID := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + finalOne, finalTwo := common.HexToHash("0x44"), common.HexToHash("0x55") + tx := common.HexToHash("0x900") + logAt := func(index uint) types.Log { return types.Log{BlockNumber: 100, TxHash: tx, Index: index} } + large := new(big.Int).Lsh(big.NewInt(1), 200) + events := &TournamentEvents{ + CommitmentJoined: []*itournament.ITournamentCommitmentJoined{ + {Commitment: one, FinalStateHash: finalOne, Submitter: common.HexToAddress("0xa"), Raw: logAt(0)}, + {Commitment: two, FinalStateHash: finalTwo, Submitter: common.HexToAddress("0xb"), Raw: logAt(1)}, + }, + MatchCreated: []*itournament.ITournamentMatchCreated{{ + MatchIdHash: matchID, One: one, Two: two, LeftOfTwo: finalTwo, EliminableAt: 120, Raw: logAt(2), + }}, + MatchAdvanced: []*itournament.ITournamentMatchAdvanced{ + {MatchIdHash: matchID, One: one, Two: two, OtherParent: finalOne, LeftNode: one, SegmentStartPosition: large, EliminableAt: 121, + Raw: logAt(3)}, + {MatchIdHash: matchID, One: one, Two: two, OtherParent: finalOne, LeftNode: two, + SegmentStartPosition: big.NewInt(8), EliminableAt: 122, Raw: logAt(4)}, + }, + LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{MatchIdHash: matchID, One: one, Two: two, + DivergencePosition: big.NewInt(0), EliminableAt: 123, Raw: logAt(5)}}, + MatchDeleted: []*itournament.ITournamentMatchDeleted{{MatchIdHash: matchID, One: one, Two: two, Reason: 1, Raw: logAt(6)}}, + PartialBondRefund: []*itournament.ITournamentPartialBondRefund{{ + Recipient: common.HexToAddress("0xc"), Value: large, Success: false, Raw: logAt(7), + }}, + BondRecovered: []*itournament.ITournamentBondRecovered{{ + Commitment: one, Claimer: common.HexToAddress("0xa"), Payment: big.NewInt(0), Burned: big.NewInt(7), Raw: logAt(8), + }}, + } + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.adapter.On("CommitmentStanding", opts, [32]byte(one)).Return(CommitmentStanding{ + Joined: true, FinalState: finalOne, ClockRunning: true, ClockDeadline: 110, ClockAllowance: 30, + }, nil).Once() + f.adapter.On("CommitmentStanding", opts, [32]byte(two)).Return(CommitmentStanding{ + Joined: true, FinalState: finalTwo, ClockAllowance: 20, + }, nil).Once() + f.adapter.On("MatchSnapshot", opts, [32]byte(one), [32]byte(two)).Return(ObservedMatchSnapshot{ + Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, + }, nil).Once() + batch, err := f.project(events) + require.NoError(t, err) + require.Len(t, batch.Commitments, 2) + require.Equal(t, common.HexToAddress("0xa"), batch.Commitments[0].SubmitterAddress) + require.Zero(t, batch.Commitments[0].Snapshot.Claimer, "recovery clears the current claimer, not the join submitter") + require.True(t, batch.Commitments[0].Snapshot.ClockRunning, "raw retained clocks are not a liveness classification") + require.Equal(t, uint64(100), batch.Commitments[0].Snapshot.AsOfBlock) + require.Len(t, batch.MatchAdvances, 2, "the same parent hash can occur in distinct events") + require.Equal(t, uint64(3), batch.MatchAdvances[0].LogIndex) + require.Equal(t, uint64(4), batch.MatchAdvances[1].LogIndex) + require.Equal(t, large, batch.MatchAdvances[0].SegmentStartPosition.ToBig()) + require.Len(t, batch.Matches, 1) + match := batch.Matches[0] + require.Equal(t, uint64(120), match.EliminableAt, "creation data must not become a later deadline") + require.Equal(t, uint64(123), match.LeafSeal.EliminableAt) + require.Equal(t, uint64(5), match.LeafSeal.LogIndex) + require.Equal(t, tx, *match.DeletionTxHash) + require.Equal(t, uint64(6), *match.DeletionLogIndex) + require.Equal(t, MatchDeletionReason_TIMEOUT, match.DeletionReason) + require.Equal(t, MatchPhaseUninitialized, match.Snapshot.Phase) + require.Nil(t, match.Snapshot.Bisection) + require.Nil(t, match.Snapshot.Sealed) + require.Len(t, batch.BondEvents, 2) + require.False(t, batch.BondEvents[0].Refund.Success) + require.Nil(t, batch.BondEvents[0].Recovery) + require.Nil(t, batch.BondEvents[1].Refund) + require.Zero(t, batch.BondEvents[1].Recovery.Payment.ToBig().Sign()) + large.SetUint64(0) + events.MatchDeleted[0].Raw.TxHash = common.Hash{} + require.Equal(t, 201, batch.MatchAdvances[0].SegmentStartPosition.ToBig().BitLen()) + require.Equal(t, 201, batch.BondEvents[0].Refund.Value.ToBig().BitLen()) + require.Equal(t, tx, *match.DeletionTxHash) + f.repo.AssertNotCalled(t, "StoreTournamentInterval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentEventBatchRejectsUnknownOrContradictoryDeletion(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + name string + reason, winner uint8 + badPair bool + }{ + {name: "unknown reason", reason: 255}, {name: "unknown winner", winner: 3}, {name: "different pair", badPair: true}, + } { + t.Run(test.name, func(t *testing.T) { + stored := &Match{IDHash: common.HexToHash("0x33"), CommitmentOne: one, CommitmentTwo: two, + DeletionReason: MatchDeletionReason_NOT_DELETED} + before := *stored + f := newEventBatchFixture(t, nil, []*Match{stored}) + event := &itournament.ITournamentMatchDeleted{MatchIdHash: stored.IDHash, One: one, Two: two, + Reason: test.reason, WinnerCommitment: test.winner, Raw: types.Log{BlockNumber: 100}} + if test.badPair { + event.Two = one + } + _, err := f.project(&TournamentEvents{MatchDeleted: []*itournament.ITournamentMatchDeleted{event}}) + require.Error(t, err) + require.Equal(t, before, *stored) + require.Empty(t, f.adapter.Calls) + }) + } +} + +func TestTournamentEventBatchReadFailuresDoNotMutateStoredRows(t *testing.T) { + for _, failed := range []string{"commitment", "match"} { + t.Run(failed, func(t *testing.T) { + commitment := &Commitment{Commitment: common.HexToHash("0x11"), FinalStateHash: common.HexToHash("0x22")} + match := &Match{IDHash: common.HexToHash("0x33"), CommitmentOne: commitment.Commitment} + beforeCommitment, beforeMatch := *commitment, *match + f := newEventBatchFixture(t, []*Commitment{commitment}, []*Match{match}) + cause := errors.New("snapshot unavailable") + var commitmentError error + if failed == "commitment" { + commitmentError = cause + } + f.adapter.On("CommitmentStanding", mock.Anything, [32]byte(commitment.Commitment)).Return( + CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 7}, commitmentError).Once() + if commitmentError == nil { + f.adapter.On("MatchSnapshot", mock.Anything, [32]byte(match.CommitmentOne), [32]byte(match.CommitmentTwo)). + Return(ObservedMatchSnapshot{}, cause).Once() + } + _, err := f.project(&TournamentEvents{}) + require.ErrorIs(t, err, cause) + require.Equal(t, beforeCommitment, *commitment) + require.Equal(t, beforeMatch, *match) + }) + } +} + +func TestTournamentCreationEventChecksCloneStartAndKeepsIdentity(t *testing.T) { + child := &Tournament{Address: common.HexToAddress("0x100"), StartInstant: 50, BondValue: Uint256{1}} + event := &TournamentCreation{Address: child.Address, BondValue: Uint256{1}, + Descriptor: TournamentDescriptor{BaseCycle: new(big.Int), StartInstant: 50}, + Event: TournamentCreationEvent{BlockNumber: 50, TxHash: common.HexToHash("0x22"), LogIndex: 4}} + require.NoError(t, applyTournamentCreationEvent(child, event)) + require.Equal(t, uint64(4), child.CreationEvent.LogIndex) + before := *child.CreationEvent + require.NoError(t, applyTournamentCreationEvent(child, event), "an exact repeated fact is harmless") + event.Event.LogIndex++ + require.ErrorContains(t, applyTournamentCreationEvent(child, event), "conflicting creation events") + require.Equal(t, before, *child.CreationEvent) + event.Event.BlockNumber++ + require.ErrorContains(t, applyTournamentCreationEvent(child, event), "descriptor or bond does not match") +} diff --git a/internal/prt/event_decoder_test.go b/internal/prt/event_decoder_test.go new file mode 100644 index 000000000..30f0403a3 --- /dev/null +++ b/internal/prt/event_decoder_test.go @@ -0,0 +1,279 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/binary" + "errors" + "io" + "log/slog" + "math/big" + "net/http/httptest" + "reflect" + "slices" + "sync" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +func TestStaticABIWordCount(t *testing.T) { + components := []abi.ArgumentMarshaling{{Name: "root", Type: "bytes32"}, {Name: "values", Type: "uint64[2]"}} + for _, test := range []struct { + kind string + components []abi.ArgumentMarshaling + words int + }{ + {kind: "uint256", words: 1}, {kind: "bytes32[3]", words: 3}, + {kind: "tuple", components: components, words: 3}, {kind: "tuple[2]", components: components, words: 6}, + {kind: "string"}, {kind: "bytes"}, {kind: "uint64[]"}, + {kind: "tuple", components: []abi.ArgumentMarshaling{{Name: "payload", Type: "bytes"}}}, + } { + t.Run(test.kind, func(t *testing.T) { + value, err := abi.NewType(test.kind, "", test.components) + require.NoError(t, err) + words, err := staticABIWordCount(abi.Arguments{{Type: value}}) + if test.words == 0 { + require.ErrorContains(t, err, "not static") + return + } + require.NoError(t, err) + require.Equal(t, test.words, words) + }) + } +} + +func TestTournamentEventDecoderRejectsIncorrectABIWidths(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + binding, err := itournament.NewITournament(common.Address{}, nil) + require.NoError(t, err) + adapter := &ITournamentAdapterImpl{tournament: binding} + for _, entry := range currentTournamentLogs(t) { + definition, err := contractABI.EventByID(entry.Topics[0]) + require.NoError(t, err) + for _, mutation := range []string{"truncated", "trailing word", testMissingTopicMutation, "extra topic"} { + t.Run(definition.Name+"/"+mutation, func(t *testing.T) { + invalid := entry + invalid.Data, invalid.Topics = slices.Clone(entry.Data), slices.Clone(entry.Topics) + switch mutation { + case "truncated": + invalid.Data = invalid.Data[:len(invalid.Data)-1] + case "trailing word": + invalid.Data = append(invalid.Data, make([]byte, 32)...) + case testMissingTopicMutation: + invalid.Topics = invalid.Topics[:len(invalid.Topics)-1] + case "extra topic": + invalid.Topics = append(invalid.Topics, common.Hash{}) + } + require.Error(t, adapter.appendTournamentEvent(contractABI, invalid, &TournamentEvents{})) + }) + } + } +} + +func TestTournamentEventDecoderChecksStatelessFields(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + binding, err := itournament.NewITournament(common.Address{}, nil) + require.NoError(t, err) + adapter := &ITournamentAdapterImpl{tournament: binding} + fixtures := make(map[string]types.Log) + for _, entry := range currentTournamentLogs(t) { + definition, err := contractABI.EventByID(entry.Topics[0]) + require.NoError(t, err) + fixtures[definition.Name] = entry + } + for _, test := range []struct { + name, event string + word int + value uint64 + topic bool + valid bool + }{ + {name: "created pair", event: testMatchCreatedEvent, topic: true}, + {name: "advance pair", event: testMatchAdvancedEvent}, + {name: "leaf pair", event: testLeafMatchSealedEvent}, + {name: "leaf first maximum", event: testLeafMatchSealedEvent, word: 6, value: 22, valid: true}, + {name: "child pair", event: testNewInnerTournamentEvent}, + {name: "child kind", event: testNewInnerTournamentEvent, word: 9, value: 2}, + } { + t.Run(test.name, func(t *testing.T) { + entry := fixtures[test.event] + entry.Data, entry.Topics = slices.Clone(entry.Data), slices.Clone(entry.Topics) + if test.topic { + entry.Topics[1] = common.Hash{} + } else { + offset := test.word * 32 + clear(entry.Data[offset : offset+32]) + binary.BigEndian.PutUint64(entry.Data[offset+24:offset+32], test.value) + } + err := adapter.appendTournamentEvent(contractABI, entry, &TournamentEvents{}) + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + t.Run("first deadline maximum", func(t *testing.T) { + entry := fixtures[testLeafMatchSealedEvent] + entry.Data = slices.Clone(entry.Data) + binary.BigEndian.PutUint64(entry.Data[6*32+24:7*32], 23) + binary.BigEndian.PutUint64(entry.Data[8*32+24:9*32], 23) + require.NoError(t, adapter.appendTournamentEvent(contractABI, entry, &TournamentEvents{})) + }) + for _, test := range []struct { + name string + dangling, parent common.Hash + result, expiry uint64 + valid bool + }{ + {name: "root result", result: 10, valid: true}, + {name: "inner result", dangling: common.Hash{1}, parent: common.Hash{2}, result: 10, expiry: 12, valid: true}, + {name: "zero allowance", dangling: common.Hash{1}, parent: common.Hash{2}, result: 10, expiry: 10, valid: true}, + } { + t.Run(test.name, func(t *testing.T) { + entry := fixtures[testStandingChangedEvent] + entry.Data, err = contractABI.Events[testStandingChangedEvent].Inputs.NonIndexed().Pack( + new(big.Int), test.dangling, test.parent, test.result, test.expiry) + require.NoError(t, err) + err := adapter.appendTournamentEvent(contractABI, entry, &TournamentEvents{}) + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +type tournamentRangeQuery struct { + FromBlock *hexutil.Big `json:"fromBlock"` + ToBlock *hexutil.Big `json:"toBlock"` + Topics [][]common.Hash `json:"topics"` +} + +type segmentedTournamentLogs struct { + mu sync.Mutex + logs []types.Log + queries [][2]uint64 + failAt *uint64 +} + +func (s *segmentedTournamentLogs) GetLogs(_ context.Context, query tournamentRangeQuery) ([]types.Log, error) { + from, to := query.FromBlock.ToInt().Uint64(), query.ToBlock.ToInt().Uint64() + s.mu.Lock() + s.queries = append(s.queries, [2]uint64{from, to}) + s.mu.Unlock() + if s.failAt != nil && from <= *s.failAt && *s.failAt <= to { + return nil, errors.New("interval unavailable") + } + logs := []types.Log{} + for _, entry := range s.logs { + if from <= entry.BlockNumber && entry.BlockNumber <= to && slices.Contains(query.Topics[0], entry.Topics[0]) { + logs = append(logs, entry) + } + } + return logs, nil +} + +func TestRetrieveTournamentEventsRetainsEverySliceAcrossChunks(t *testing.T) { + var reference *TournamentEvents + for _, maximum := range []*big.Int{nil, big.NewInt(1)} { + source := &segmentedTournamentLogs{} + for _, block := range []uint64{12, 10} { + for _, entry := range currentTournamentLogs(t) { + entry.BlockNumber = block + entry.BlockHash = common.BigToHash(new(big.Int).SetUint64(block)) + entry.TxHash = common.BigToHash(new(big.Int).SetUint64(block + 100)) + source.logs = append(source.logs, entry) + } + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + client, err := ethutil.DialEthClient(t.Context(), httpServer.URL) + require.NoError(t, err) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(common.HexToAddress("0x1234"), client, + ethutil.Filter{MaxChunkSize: maximum, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + require.NoError(t, err) + end := uint64(12) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.NoError(t, err) + fields := reflect.ValueOf(events).Elem() + for index := range fields.NumField() { + field := fields.Field(index) + require.Equal(t, reflect.Slice, field.Kind()) + require.Equal(t, 2, field.Len(), fields.Type().Field(index).Name) + for position, block := range []uint64{10, 12} { + raw := field.Index(position).Elem().FieldByName("Raw").Interface().(types.Log) + require.Equal(t, block, raw.BlockNumber) + } + } + source.mu.Lock() + queries := slices.Clone(source.queries) + source.mu.Unlock() + if maximum == nil { + reference = events + require.Equal(t, [][2]uint64{{10, 12}}, queries) + } else { + require.Equal(t, reference, events) + require.Equal(t, [][2]uint64{{10, 10}, {11, 11}, {12, 12}}, queries) + } + } +} + +func TestRetrieveTournamentEventsRejectsTerminalIteratorError(t *testing.T) { + source := &segmentedTournamentLogs{logs: currentTournamentLogs(t), failAt: new(uint64(11))} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + httpServer := httptest.NewServer(server) + t.Cleanup(httpServer.Close) + client, err := ethutil.DialEthClient(t.Context(), httpServer.URL) + require.NoError(t, err) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(common.HexToAddress("0x1234"), client, + ethutil.Filter{MaxChunkSize: big.NewInt(1), Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + require.NoError(t, err) + end := uint64(12) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.ErrorContains(t, err, "interval unavailable") + require.Nil(t, events) +} + +func TestTournamentEventCountsRequiresEveryStandingChange(t *testing.T) { + events := &TournamentEvents{CommitmentJoined: []*itournament.ITournamentCommitmentJoined{{}, {}}, + MatchDeleted: []*itournament.ITournamentMatchDeleted{{}}} + before := zeroStructuralEventCounts() + before.CommitmentJoined.SetUint64(9) + before.MatchDeleted.SetUint64(17) + after := zeroStructuralEventCounts() + after.CommitmentJoined.SetUint64(11) + after.MatchDeleted.SetUint64(18) + for count := 2; count <= 4; count++ { + events.StandingChanged = make([]*itournament.ITournamentStandingChanged, count) + err := validateTournamentEventCounts(before, after, events) + if count == 3 { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.ErrorContains(t, err, "StandingChanged event count mismatch") + } + } +} diff --git a/internal/prt/event_filter_test.go b/internal/prt/event_filter_test.go new file mode 100644 index 000000000..d4004d0b8 --- /dev/null +++ b/internal/prt/event_filter_test.go @@ -0,0 +1,307 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "io" + "log/slog" + "maps" + "math/big" + "slices" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +// Keep expected event names independent of the production filter list. +const ( + testCommitmentJoinedEvent = "CommitmentJoined" + testMatchCreatedEvent = "MatchCreated" + testMatchAdvancedEvent = "MatchAdvanced" + testNewInnerTournamentEvent = "NewInnerTournament" + testStandingChangedEvent = "StandingChanged" + testMissingTopicMutation = "missing topic" + testLeafMatchSealedEvent = "LeafMatchSealed" +) + +func TestAllTournamentEventsFilter(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + end := uint64(20) + query, err := buildAllEventsFilterQuery(&bind.FilterOpts{Start: 10, End: &end}, address, contractABI) + require.NoError(t, err) + require.Equal(t, []common.Address{address}, query.Addresses) + require.Equal(t, uint64(10), query.FromBlock.Uint64()) + require.Equal(t, end, query.ToBlock.Uint64()) + require.Len(t, query.Topics, 1) + names := []string{testCommitmentJoinedEvent, testMatchAdvancedEvent, testMatchCreatedEvent, "MatchDeleted", + testNewInnerTournamentEvent, testLeafMatchSealedEvent, "PartialBondRefund", "BondRecovered", testStandingChangedEvent} + expected := make([]common.Hash, 0, len(names)) + for _, name := range names { + expected = append(expected, contractABI.Events[name].ID) + t.Run("missing "+name, func(t *testing.T) { + incompleteABI := *contractABI + incompleteABI.Events = maps.Clone(contractABI.Events) + delete(incompleteABI.Events, name) + _, err := buildAllEventsFilterQuery(&bind.FilterOpts{}, address, &incompleteABI) + require.ErrorContains(t, err, "missing monitored event "+name) + }) + } + require.Equal(t, expected, query.Topics[0]) +} + +type tournamentLogsSource struct { + logs []types.Log +} + +func (s *tournamentLogsSource) GetLogs(context.Context, map[string]any) ([]types.Log, error) { + return s.logs, nil +} + +func currentTournamentLogs(t *testing.T) []types.Log { + t.Helper() + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + one := common.HexToHash("0x11") + two := common.HexToHash("0x22") + three := common.HexToHash("0x33") + pair := crypto.Keccak256Hash(two[:], three[:]) + source := &tournamentLogsSource{} + large := new(big.Int).Lsh(big.NewInt(1), 200) + for index, event := range []struct { + name string + indexed []common.Hash + data []any + }{ + {name: testCommitmentJoinedEvent, indexed: []common.Hash{one, two}, data: []any{three}}, + {name: testMatchCreatedEvent, indexed: []common.Hash{pair, two, three}, data: []any{one, uint64(19), uint64(20)}}, + {name: testMatchAdvancedEvent, indexed: []common.Hash{pair}, + data: []any{two, three, two, three, large, uint64(5), uint64(20), uint64(21)}}, + {name: "MatchDeleted", indexed: []common.Hash{pair, two, three}, data: []any{uint8(1), uint8(2)}}, + {name: testNewInnerTournamentEvent, indexed: []common.Hash{pair, two}, data: []any{two, three, one, two, + itournament.ITournamentTournamentDescriptor{InitialHash: one, BaseCycle: new(big.Int), Height: 38, + Level: 1, StartInstant: 10, Allowance: 20}, big.NewInt(9)}}, + {name: testLeafMatchSealedEvent, indexed: []common.Hash{pair}, + data: []any{two, three, one, large, one, two, uint64(21), uint64(22), uint64(22)}}, + {name: "PartialBondRefund", indexed: []common.Hash{two, common.HexToHash("0x01")}, data: []any{large}}, + {name: "BondRecovered", indexed: []common.Hash{one, two}, data: []any{large, big.NewInt(7)}}, + {name: testStandingChangedEvent, data: []any{big.NewInt(1), common.Hash{}, common.Hash{}, uint64(0), uint64(0)}}, + } { + definition := contractABI.Events[event.name] + data, err := definition.Inputs.NonIndexed().Pack(event.data...) + require.NoError(t, err) + source.logs = append(source.logs, types.Log{ + Address: address, BlockNumber: 10, Topics: append([]common.Hash{definition.ID}, event.indexed...), Data: data, + BlockHash: three, TxHash: two, Index: uint(index), + }) + } + return source.logs +} + +func TestRetrieveAllTournamentEventsParsesCurrentABI(t *testing.T) { + address := common.HexToAddress("0x1234") + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + pair := crypto.Keccak256Hash(two[:], three[:]) + large := new(big.Int).Lsh(big.NewInt(1), 200) + source := &tournamentLogsSource{logs: currentTournamentLogs(t)} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(10) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.NoError(t, err) + require.Len(t, events.CommitmentJoined, 1) + require.Len(t, events.MatchCreated, 1) + require.Len(t, events.MatchAdvanced, 1) + require.Len(t, events.MatchDeleted, 1) + require.Len(t, events.NewInnerTournament, 1) + require.Len(t, events.LeafMatchSealed, 1) + require.Len(t, events.PartialBondRefund, 1) + require.Len(t, events.BondRecovered, 1) + require.Equal(t, [32]byte(one), events.CommitmentJoined[0].Commitment) + require.Equal(t, [32]byte(three), events.CommitmentJoined[0].FinalStateHash) + require.Equal(t, [32]byte(two), events.MatchCreated[0].One) + require.Equal(t, [32]byte(three), events.MatchCreated[0].Two) + require.Equal(t, [32]byte(one), events.MatchCreated[0].LeftOfTwo) + require.Equal(t, uint64(20), events.MatchCreated[0].EliminableAt) + require.Equal(t, [32]byte(two), events.MatchAdvanced[0].OtherParent) + require.Equal(t, [32]byte(three), events.MatchAdvanced[0].LeftNode) + require.Equal(t, large, events.MatchAdvanced[0].SegmentStartPosition) + require.Equal(t, uint64(21), events.MatchAdvanced[0].EliminableAt) + require.Equal(t, uint8(1), events.MatchDeleted[0].Reason) + require.Equal(t, uint8(2), events.MatchDeleted[0].WinnerCommitment) + require.Equal(t, common.BytesToAddress(two[:]), events.NewInnerTournament[0].ChildTournament) + require.Equal(t, uint64(22), events.LeafMatchSealed[0].EliminableAt) + require.Equal(t, [32]byte(pair), events.LeafMatchSealed[0].MatchIdHash) + require.Equal(t, common.BytesToAddress(two[:]), events.PartialBondRefund[0].Recipient) + require.True(t, events.PartialBondRefund[0].Success) + require.Equal(t, large, events.PartialBondRefund[0].Value) + require.Equal(t, [32]byte(one), events.BondRecovered[0].Commitment) + require.Equal(t, common.BytesToAddress(two[:]), events.BondRecovered[0].Claimer) + require.Equal(t, large, events.BondRecovered[0].Payment) + require.Equal(t, big.NewInt(7), events.BondRecovered[0].Burned) + require.Equal(t, source.logs[7], events.BondRecovered[0].Raw) + require.Equal(t, uint64(19), events.MatchCreated[0].ResponderDeadline) + require.Equal(t, [32]byte(two), events.MatchAdvanced[0].One) + require.Equal(t, [32]byte(three), events.MatchAdvanced[0].Two) + require.Equal(t, uint64(5), events.MatchAdvanced[0].CurrentHeight) + require.Equal(t, uint64(20), events.MatchAdvanced[0].ResponderDeadline) + require.Equal(t, [32]byte(two), events.LeafMatchSealed[0].One) + require.Equal(t, [32]byte(three), events.LeafMatchSealed[0].Two) + require.Equal(t, [32]byte(one), events.LeafMatchSealed[0].AgreeState) + require.Equal(t, large, events.LeafMatchSealed[0].DivergencePosition) + require.Equal(t, [32]byte(one), events.LeafMatchSealed[0].FinalStateOne) + require.Equal(t, [32]byte(two), events.LeafMatchSealed[0].FinalStateTwo) + require.Equal(t, uint64(21), events.LeafMatchSealed[0].DeadlineOne) + require.Equal(t, uint64(22), events.LeafMatchSealed[0].DeadlineTwo) + require.Equal(t, [32]byte(two), events.NewInnerTournament[0].One) + require.Equal(t, [32]byte(three), events.NewInnerTournament[0].Two) + require.Equal(t, [32]byte(one), events.NewInnerTournament[0].ContestedFinalStateOne) + require.Equal(t, [32]byte(two), events.NewInnerTournament[0].ContestedFinalStateTwo) + descriptor := events.NewInnerTournament[0].Descriptor + require.NotNil(t, descriptor.BaseCycle) + require.Zero(t, descriptor.BaseCycle.Sign()) + descriptor.BaseCycle = nil + require.Equal(t, itournament.ITournamentTournamentDescriptor{InitialHash: one, + Height: 38, Level: 1, StartInstant: 10, Allowance: 20}, descriptor) + require.Equal(t, big.NewInt(9), events.NewInnerTournament[0].BondValue) + require.Len(t, events.StandingChanged, 1) + require.Equal(t, big.NewInt(1), events.StandingChanged[0].MatchCount) + require.Equal(t, source.logs[8], events.StandingChanged[0].Raw) +} + +func TestRetrieveTournamentEventsRejectsInvalidLogs(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + definition := contractABI.Events["MatchDeleted"] + data, err := definition.Inputs.NonIndexed().Pack(uint8(1), uint8(2)) + require.NoError(t, err) + valid := types.Log{ + Address: address, BlockNumber: 10, BlockHash: common.HexToHash("0xb0"), TxHash: common.HexToHash("0xc0"), + Topics: []common.Hash{definition.ID, common.HexToHash("0x1"), common.HexToHash("0x2"), common.HexToHash("0x3")}, + Data: data, + } + for _, test := range []struct { + name string + mutate func(*types.Log) + duplicate bool + }{ + {name: "wrong address", mutate: func(log *types.Log) { log.Address = common.Address{} }}, + {name: "removed", mutate: func(log *types.Log) { log.Removed = true }}, + {name: "before range", mutate: func(log *types.Log) { log.BlockNumber = 9 }}, + {name: "after range", mutate: func(log *types.Log) { log.BlockNumber = 11 }}, + {name: "missing transaction", mutate: func(log *types.Log) { log.TxHash = common.Hash{} }}, + {name: "missing block hash", mutate: func(log *types.Log) { log.BlockHash = common.Hash{} }}, + {name: testMissingTopicMutation, mutate: func(log *types.Log) { log.Topics = nil }}, + {name: "unexpected topic", mutate: func(log *types.Log) { log.Topics[0] = common.Hash{} }}, + {name: "bad payload", mutate: func(log *types.Log) { log.Data = nil }}, + {name: "unknown reason", mutate: func(log *types.Log) { log.Data[31] = 3 }}, + {name: "unknown winner", mutate: func(log *types.Log) { log.Data[63] = 3 }}, + {name: "node sentinel is not a contract reason", mutate: func(log *types.Log) { log.Data[31] = 255 }}, + {name: "maximum unknown winner", mutate: func(log *types.Log) { log.Data[63] = 255 }}, + {name: "duplicate identity", duplicate: true}, + } { + t.Run(test.name, func(t *testing.T) { + entry := valid + entry.Data = slices.Clone(valid.Data) + entry.Topics = slices.Clone(valid.Topics) + if test.mutate != nil { + test.mutate(&entry) + } + source := &tournamentLogsSource{logs: []types.Log{entry}} + if test.duplicate { + source.logs = append(source.logs, entry) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", source)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(10) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + require.Error(t, err) + require.Nil(t, events, "a rejected range must not return partial events") + }) + } +} + +func TestRetrieveTournamentEventsOrdersLogsAndChecksCrossLogIdentity(t *testing.T) { + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x1234") + definition := contractABI.Events["PartialBondRefund"] + value := big.NewInt(17) + data, err := definition.Inputs.NonIndexed().Pack(value) + require.NoError(t, err) + first := types.Log{Address: address, BlockNumber: 10, BlockHash: common.HexToHash("0xb0"), TxHash: common.HexToHash("0xc0"), + Topics: []common.Hash{definition.ID, common.HexToHash("0x22"), {}}, Data: data} + second := first + second.Index = 1 + for _, test := range []struct { + name string + mutate func(*types.Log) + wantError string + }{ + {name: "same block order"}, + {name: "different block hashes", mutate: func(log *types.Log) { log.BlockHash = common.HexToHash("0xb1") }, + wantError: "disagree on the block hash"}, + {name: "same transaction identity in different blocks", mutate: func(log *types.Log) { + log.BlockNumber = 11 + log.Index = 0 + }, wantError: "transaction log identity"}, + } { + t.Run(test.name, func(t *testing.T) { + entry := second + if test.mutate != nil { + test.mutate(&entry) + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", &tournamentLogsSource{logs: []types.Log{entry, first}})) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + require.NoError(t, err) + end := uint64(11) + events, err := adapter.RetrieveAllEvents(&bind.FilterOpts{Context: t.Context(), Start: 10, End: &end}) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.Nil(t, events) + return + } + require.NoError(t, err) + require.Len(t, events.PartialBondRefund, 2) + require.Equal(t, first, events.PartialBondRefund[0].Raw) + require.Equal(t, second, events.PartialBondRefund[1].Raw) + for _, refund := range events.PartialBondRefund { + require.False(t, refund.Success) + require.Equal(t, value, refund.Value, "failed refund retains its requested amount") + } + }) + } +} diff --git a/internal/prt/final_result_test.go b/internal/prt/final_result_test.go new file mode 100644 index 000000000..7ed4cc690 --- /dev/null +++ b/internal/prt/final_result_test.go @@ -0,0 +1,192 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + finalMachineResultComponent = "machine" + finalOutputsResultComponent = "outputs" + matchingFinalResult = "matching" +) + +func TestTournamentFinalResultPlansForeignCommitment(t *testing.T) { + for _, kind := range []actionKind{stageTournament, acceptTournament} { + t.Run(actionName(kind), func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, kind == acceptTournament) + view.stage.WinnerCommitment = common.HexToHash("0x600") + view.accept.IsClaimStagingPeriodOver = true + f.snapshot(view) + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, call) + require.Equal(t, kind, call.Key.Kind) + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + method, err := contractABI.MethodById(call.Data[:4]) + require.NoError(t, err) + if kind == stageTournament { + require.Equal(t, "stageTournamentResult", method.Name) + proof, err := f.epoch.StateProof() + require.NoError(t, err) + require.Equal(t, *f.epoch.MachineHash, proof.MachineHash) + require.Equal(t, *f.epoch.TxBufferDataBlock, proof.TxBufferDataBlock) + } else { + require.Equal(t, "acceptStagedTournamentResult", method.Name) + } + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, *call}) + require.NoError(t, err) + require.True(t, report.Attempted) + require.NotEmpty(t, f.backend.published) + require.Equal(t, call.Data, f.backend.published[0].Data()) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestTournamentFinalResultObserverIgnoresCommitmentIdentity(t *testing.T) { + for _, falseState := range []bool{false, true} { + t.Run(map[bool]string{false: "matching state", true: "false state"}[falseState], func(t *testing.T) { + service, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + foreign, final := common.HexToHash("0x600"), *epoch.MachineHash + if falseState { + final[0] ^= 1 + } + root := &model.Tournament{Snapshot: model.TournamentSnapshot{ + AsOfBlock: 100, FinishedAtBlock: 90, WinnerCommitment: &foreign, FinalStateHash: &final, + }} + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(root, nil).Once() + statusWrite := repo.On("UpdateApplicationStatus", mock.Anything, app.ID, + model.ApplicationStatus_Diverged, mock.Anything).Return(nil) + if falseState { + statusWrite.Once() + } else { + statusWrite.Maybe() + } + if falseState { + expectStoredProtection(t, repo, app, epoch) + } + deferred, err := service.reconcileAcceptedEpochs(t.Context(), app, []*model.Epoch{epoch}, nil, 100) + if falseState { + require.ErrorContains(t, err, "inconsistent machine hash") + require.True(t, deferred) + require.Equal(t, model.ApplicationStatus_Diverged, app.Status) + } else { + require.NoError(t, err) + require.False(t, deferred) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + repo.AssertExpectations(t) + }) + } +} + +func TestTournamentFinalResultObservationChecksBothComponents(t *testing.T) { + for _, component := range []string{matchingFinalResult, finalMachineResultComponent, finalOutputsResultComponent} { + t.Run(component, func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, true) + view.stage.WinnerCommitment = common.HexToHash("0x600") + switch component { + case finalMachineResultComponent: + view.stage.WinnerPostEpochMachineStateHash[0] ^= 1 + view.sealed.StagedPostEpochMachineStateHash = view.stage.WinnerPostEpochMachineStateHash + view.accept.StagedPostEpochMachineStateHash = view.stage.WinnerPostEpochMachineStateHash + case finalOutputsResultComponent: + view.sealed.StagedPostEpochOutputsMerkleRoot[0] ^= 1 + view.accept.StagedPostEpochOutputsMerkleRoot = view.sealed.StagedPostEpochOutputsMerkleRoot + } + require.NoError(t, validateDaveConsensusSnapshot(view, 100)) + if component == matchingFinalResult { + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, + view.sealed.StagingBlockNumber).Return(nil).Once() + } else { + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, + model.ApplicationStatus_Diverged, mock.Anything).Return(nil).Once() + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, + view.sealed.StagingBlockNumber).Return(nil).Maybe() + } + if component != matchingFinalResult { + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + } + err := f.d.recordTournamentResult(t.Context(), f.app, f.epoch, view) + if component == matchingFinalResult { + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimStaged, f.epoch.Status) + } else { + require.ErrorContains(t, err, "inconsistent") + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + f.repo.AssertExpectations(t) + }) + } +} + +func TestTournamentFinalResultAcceptsForeignCommitment(t *testing.T) { + f := newAcceptanceReceiptFixture(t) + foreign := common.HexToHash("0x600") + f.root.Snapshot.WinnerCommitment = &foreign + f.expectReceipt() + f.expectAcceptance() + deferred, err := f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.NoError(t, err) + require.False(t, deferred) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentFinalResultRechecksAfterSigning(t *testing.T) { + for _, kind := range []actionKind{stageTournament, acceptTournament} { + t.Run(actionName(kind), func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, kind == acceptTournament) + view.stage.WinnerCommitment = common.HexToHash("0x600") + view.accept.IsClaimStagingPeriodOver = true + f.snapshot(view) + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, call) + factory := deliverySigningFactory{TransactOptsFactory: f.d.txOptsFactory, afterSign: func() { + f.app.Status = model.ApplicationStatus_Failed + }} + sender, err := txsender.New[actionID](f.backend, factory, 31337, f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + report, err := sender.PublishNext(t.Context(), singleAction{f.d, *call}) + require.Error(t, err) + require.False(t, report.Attempted) + require.Empty(t, f.backend.published) + require.Equal(t, model.ApplicationStatus_Failed, f.app.Status) + }) + } +} + +func TestTournamentFinalResultPlansRejectFalseState(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, false) + view.stage.WinnerCommitment = common.HexToHash("0x600") + view.stage.WinnerPostEpochMachineStateHash[0] ^= 1 + f.snapshot(view) + call, err := f.d.planAction(t.Context(), f.app, stageTournament, f.block, observerFacts(100)) + require.ErrorContains(t, err, "inconsistent machine hash") + require.Nil(t, call) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/prt/fixture_test.go b/internal/prt/fixture_test.go new file mode 100644 index 000000000..5d397b97e --- /dev/null +++ b/internal/prt/fixture_test.go @@ -0,0 +1,588 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "io" + "log/slog" + "math/big" + "sync" + "time" +) + +type prtRepositoryMock struct { + mock.Mock + repository.DeliveryRepository + storedTournaments []*model.Tournament + committedTournaments map[common.Address]*model.Tournament + eventCounts map[common.Address]repository.TournamentEventCounts + eventCountError error + eventCountRead func(int64, uint64, common.Address, uint64) + deploymentMu sync.Mutex + deploymentRows map[int64]model.DeploymentAdmissionState + deploymentRead func(context.Context, repository.DeploymentIdentity) (*model.DeploymentAdmissionState, error) + deploymentAck func(context.Context, repository.DeploymentAssessment) (*model.DeploymentAdmissionState, error) + deploymentCompletion func(context.Context, repository.DeploymentIdentity, uint64) (*repository.DeploymentCompletionState, error) + deploymentDrain func(context.Context, repository.DeploymentDrainRequest) error +} + +func (m *prtRepositoryMock) GetMatch(ctx context.Context, app string, epoch uint64, tournament, id string) (*model.Match, error) { + args := m.Called(ctx, app, epoch, tournament, id) + match, _ := args.Get(0).(*model.Match) + return match, args.Error(1) +} + +func (m *prtRepositoryMock) GetTournamentEventCounts( + _ context.Context, appID int64, epoch uint64, tournament common.Address, through uint64, +) (repository.TournamentEventCounts, error) { + if m.eventCountRead != nil { + m.eventCountRead(appID, epoch, tournament, through) + } + return m.eventCounts[tournament], m.eventCountError +} + +func (m *prtRepositoryMock) GetApplication(ctx context.Context, address string) (*model.Application, error) { + args := m.Called(ctx, address) + app, _ := args.Get(0).(*model.Application) + return app, args.Error(1) +} + +func (m *prtRepositoryMock) GetEpochCompletionState( + ctx context.Context, address string, index uint64, +) (*repository.EpochCompletionState, error) { + args := m.Called(ctx, address, index) + state, _ := args.Get(0).(*repository.EpochCompletionState) + return state, args.Error(1) +} + +func (m *prtRepositoryMock) GetApplicationIDBoundary(ctx context.Context, filter repository.ApplicationFilter) (int64, error) { + args := m.Called(ctx, filter) + return args.Get(0).(int64), args.Error(1) +} + +func (m *prtRepositoryMock) ListApplicationsByID( + ctx context.Context, filter repository.ApplicationFilter, after, through int64, limit uint64, +) ([]*model.Application, error) { + args := m.Called(ctx, filter, after, through, limit) + apps, _ := args.Get(0).([]*model.Application) + return apps, args.Error(1) +} + +func (m *prtRepositoryMock) StoreTournamentInterval(ctx context.Context, id int64, _ *uint64, + batches []*repository.TournamentEventBatch) error { + var head uint64 + if len(batches) > 0 { + head = batches[0].Tournament.Snapshot.AsOfBlock + } + args := m.MethodCalled("StoreTournamentInterval", ctx, id, batches, head) + if args.Error(0) == nil { + if m.committedTournaments == nil { + m.committedTournaments = map[common.Address]*model.Tournament{} + } + for _, batch := range batches { + m.committedTournaments[batch.Tournament.Address] = batch.Tournament + } + } + return args.Error(0) +} + +var _ prtRepository = (*prtRepositoryMock)(nil) + +func (m *prtRepositoryMock) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, +) ([]*model.Application, uint64, error) { + args := m.Called(ctx, f, p, descending) + apps, _ := args.Get(0).([]*model.Application) + return apps, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) UpdateApplicationStatus( + ctx context.Context, + appID int64, + status model.ApplicationStatus, + reason *string, +) error { + args := m.Called(ctx, appID, status, reason) + return args.Error(0) +} + +func (m *prtRepositoryMock) HasUndrainedEpochsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *prtRepositoryMock) HasUnreconciledClaimsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + args := m.Called(ctx, appID, blockBound) + return args.Bool(0), args.Error(1) +} + +func (m *prtRepositoryMock) UpdateEpochWithForeclosedClaim( + ctx context.Context, + applicationID int64, + index uint64, +) error { + args := m.Called(ctx, applicationID, index) + return args.Error(0) +} + +func (m *prtRepositoryMock) ListEpochs( + ctx context.Context, + nameOrAddress string, + f repository.EpochFilter, + p repository.Pagination, + descending bool, +) ([]*model.Epoch, uint64, error) { + args := m.Called(ctx, nameOrAddress, f, p, descending) + epochs, _ := args.Get(0).([]*model.Epoch) + return epochs, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) GetEpoch( + ctx context.Context, + nameOrAddress string, + index uint64, +) (*model.Epoch, error) { + args := m.Called(ctx, nameOrAddress, index) + epoch, _ := args.Get(0).(*model.Epoch) + return epoch, args.Error(1) +} + +func (m *prtRepositoryMock) GetLastNonOpenEpochIndex(ctx context.Context, nameOrAddress string) (*uint64, error) { + args := m.Called(ctx, nameOrAddress) + index, _ := args.Get(0).(*uint64) + return index, args.Error(1) +} + +func (m *prtRepositoryMock) ListRecoverableRootBonds( + ctx context.Context, appID int64, claimer common.Address, observedBlock uint64, + afterEpoch *uint64, throughEpoch, limit uint64, +) ([]repository.RootBondRecoveryCandidate, error) { + args := m.Called(ctx, appID, claimer, observedBlock, afterEpoch, throughEpoch, limit) + values, _ := args.Get(0).([]repository.RootBondRecoveryCandidate) + return values, args.Error(1) +} + +func (m *prtRepositoryMock) UpdateEpochStagingBlock( + ctx context.Context, applicationID int64, index, expectedOldBlock, newBlock uint64, +) error { + return m.Called(ctx, applicationID, index, expectedOldBlock, newBlock).Error(0) +} + +func (m *prtRepositoryMock) UpdateEpochReconciledStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, +) error { + args := m.Called(ctx, applicationID, index, stagedAtBlock) + return args.Error(0) +} + +func (m *prtRepositoryMock) UpdateEpochWithAcceptedClaim( + ctx context.Context, + applicationID int64, + index uint64, + txHash *common.Hash, +) error { + args := m.Called(ctx, applicationID, index, txHash) + return args.Error(0) +} + +func (m *prtRepositoryMock) GetTournament( + ctx context.Context, + nameOrAddress string, + address string, +) (*model.Tournament, error) { + if committed := m.committedTournaments[common.HexToAddress(address)]; committed != nil { + active := false + for _, call := range m.ExpectedCalls { + if call.Method == getTournamentMethod && call.Repeatability >= 0 { + _, diff := call.Arguments.Diff([]any{ctx, nameOrAddress, address}) + if diff == 0 { + active = true + break + } + } + } + if !active { + return committed, nil + } + } + active := false + for _, call := range m.ExpectedCalls { + if call.Method == getTournamentMethod && call.Repeatability >= 0 { + _, diff := call.Arguments.Diff([]any{ctx, nameOrAddress, address}) + if diff == 0 { + active = true + break + } + } + } + if !active { + for _, stored := range m.storedTournaments { + if stored.Address == common.HexToAddress(address) { + return stored, nil + } + } + } + args := m.Called(ctx, nameOrAddress, address) + tournament, _ := args.Get(0).(*model.Tournament) + if args.Error(1) == nil { + if committed := m.committedTournaments[common.HexToAddress(address)]; committed != nil { + return committed, nil + } + } + return tournament, args.Error(1) +} + +func (m *prtRepositoryMock) ListTournaments( + ctx context.Context, + nameOrAddress string, + f repository.TournamentFilter, + p repository.Pagination, + descending bool, +) ([]*model.Tournament, uint64, error) { + if f == (repository.TournamentFilter{}) { + explicit := false + for _, call := range m.ExpectedCalls { + if call.Method == "ListTournaments" { + _, diff := call.Arguments.Diff([]any{ctx, nameOrAddress, f, p, descending}) + if diff == 0 { + explicit = true + break + } + } + } + if !explicit { + stored := append([]*model.Tournament(nil), m.storedTournaments...) + if len(stored) == 0 { + for _, call := range m.ExpectedCalls { + if call.Method == getTournamentMethod && len(call.ReturnArguments) > 0 { + if t, ok := call.ReturnArguments[0].(*model.Tournament); ok && t != nil && t.Address != (common.Address{}) && + t.Snapshot.AsOfBlock > 0 { + stored = append(stored, t) + } + } + } + } + for i, row := range stored { + if committed := m.committedTournaments[row.Address]; committed != nil { + stored[i] = committed + } + } + return stored, uint64(len(stored)), nil + } + } + args := m.Called(ctx, nameOrAddress, f, p, descending) + tournaments, _ := args.Get(0).([]*model.Tournament) + return tournaments, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) StoreTournamentEvents( + ctx context.Context, + appID int64, + batches []*repository.TournamentEventBatch, + blockNumber uint64, +) error { + args := m.Called(ctx, appID, batches, blockNumber) + return args.Error(0) +} + +func (m *prtRepositoryMock) GetCommitment( + ctx context.Context, + nameOrAddress string, + epochIndex uint64, + tournamentAddress string, + commitment string, +) (*model.Commitment, error) { + args := m.Called(ctx, nameOrAddress, epochIndex, tournamentAddress, commitment) + c, _ := args.Get(0).(*model.Commitment) + return c, args.Error(1) +} + +func (m *prtRepositoryMock) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *prtRepositoryMock) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + args := m.Called(ctx, key, rawJSON) + return args.Error(0) +} + +func (m *prtRepositoryMock) ListCommitments(ctx context.Context, app string, filter repository.CommitmentFilter, + page repository.Pagination, descending bool) ([]*model.Commitment, uint64, error) { + args := m.Called(ctx, app, filter, page, descending) + rows, _ := args.Get(0).([]*model.Commitment) + return rows, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) ListMatches(ctx context.Context, app string, filter repository.MatchFilter, + page repository.Pagination, descending bool) ([]*model.Match, uint64, error) { + args := m.Called(ctx, app, filter, page, descending) + rows, _ := args.Get(0).([]*model.Match) + return rows, args.Get(1).(uint64), args.Error(2) +} + +func (m *prtRepositoryMock) LoadNodeConfigRaw( + ctx context.Context, + key string, +) ([]byte, time.Time, time.Time, error) { + args := m.Called(ctx, key) + raw, _ := args.Get(0).([]byte) + return raw, args.Get(1).(time.Time), args.Get(2).(time.Time), args.Error(3) +} + +type ethClientMock struct { + mock.Mock +} + +var _ EthClientInterface = (*ethClientMock)(nil) + +func (m *ethClientMock) SuggestGasPrice(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + price, _ := args.Get(0).(*big.Int) + return price, args.Error(1) +} + +func (m *ethClientMock) TransactionReceipt( + ctx context.Context, + txHash common.Hash, +) (*types.Receipt, error) { + args := m.Called(ctx, txHash) + receipt, _ := args.Get(0).(*types.Receipt) + return receipt, args.Error(1) +} + +func (m *ethClientMock) ChainID(ctx context.Context) (*big.Int, error) { + args := m.Called(ctx) + chainID, _ := args.Get(0).(*big.Int) + return chainID, args.Error(1) +} + +func (m *ethClientMock) BlockNumber(ctx context.Context) (uint64, error) { + args := m.Called(ctx) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *ethClientMock) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { + args := m.Called(ctx, number) + if header, ok := args.Get(0).(func(context.Context, *big.Int) *types.Header); ok { + return header(ctx, number), args.Error(1) + } + header, _ := args.Get(0).(*types.Header) + return header, args.Error(1) +} + +func (m *ethClientMock) TransactionByHash( + ctx context.Context, + hash common.Hash, +) (*types.Transaction, bool, error) { + args := m.Called(ctx, hash) + tx, _ := args.Get(0).(*types.Transaction) + return tx, args.Bool(1), args.Error(2) +} + +type adapterFactoryMock struct { + mock.Mock +} + +var _ AdapterFactory = (*adapterFactoryMock)(nil) + +func (m *adapterFactoryMock) CreateTournamentAdapter(addr common.Address) (TournamentAdapter, error) { + args := m.Called(addr) + adapter, _ := args.Get(0).(TournamentAdapter) + return adapter, args.Error(1) +} + +func (m *adapterFactoryMock) CreateDaveConsensusAdapter(addr common.Address) (DaveConsensusAdapter, error) { + args := m.Called(addr) + adapter, _ := args.Get(0).(DaveConsensusAdapter) + return adapter, args.Error(1) +} + +type daveConsensusAdapterMock struct { + mock.Mock + deploymentRead func(context.Context, txsender.BlockRef) (DeploymentEvidence, error) +} + +var _ DaveConsensusAdapter = (*daveConsensusAdapterMock)(nil) + +func (m *daveConsensusAdapterMock) ReadDeployment(ctx context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + return m.deploymentRead(ctx, head) +} + +func (m *daveConsensusAdapterMock) EpochSealedAt( + ctx context.Context, blockHash common.Hash, epoch uint64, +) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) { + args := m.Called(ctx, blockHash, epoch) + events, _ := args.Get(0).([]*idaveconsensus.IDaveConsensusEpochSealed) + return events, args.Error(1) +} + +func (m *daveConsensusAdapterMock) ParseEpochSealed( + log types.Log, +) (*idaveconsensus.IDaveConsensusEpochSealed, error) { + args := m.Called(log) + event, _ := args.Get(0).(*idaveconsensus.IDaveConsensusEpochSealed) + return event, args.Error(1) +} + +func (m *daveConsensusAdapterMock) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { + args := m.Called(opts) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *daveConsensusAdapterMock) GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CurrentSealedEpoch) + return result, args.Error(1) +} + +func (m *daveConsensusAdapterMock) CanStageTournamentResult( + opts *bind.CallOpts, +) (CanStageTournamentResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CanStageTournamentResult) + return result, args.Error(1) +} + +func (m *daveConsensusAdapterMock) CanAcceptStagedTournamentResult( + opts *bind.CallOpts, +) (CanAcceptStagedTournamentResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(CanAcceptStagedTournamentResult) + return result, args.Error(1) +} + +type tournamentAdapterMock struct { + mock.Mock +} + +var _ TournamentAdapter = (*tournamentAdapterMock)(nil) + +func (m *tournamentAdapterMock) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { + args := m.Called(opts) + events, _ := args.Get(0).(*TournamentEvents) + return events, args.Error(1) +} + +func (m *tournamentAdapterMock) Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) { + args := m.Called(opts) + result, _ := args.Get(0).(TournamentDescriptor) + return result, args.Error(1) +} + +func (m *tournamentAdapterMock) Standing(opts *bind.CallOpts) (TournamentStanding, error) { + args := m.Called(opts) + result, _ := args.Get(0).(TournamentStanding) + return result, args.Error(1) +} + +func (m *tournamentAdapterMock) MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) { + args := m.Called(opts, one, two) + result, _ := args.Get(0).(ObservedMatchSnapshot) + return result, args.Error(1) +} + +func (m *tournamentAdapterMock) InnerResult(opts *bind.CallOpts) (InnerResult, error) { + args := m.Called(opts) + result, _ := args.Get(0).(InnerResult) + return result, args.Error(1) +} + +func (m *tournamentAdapterMock) StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) { + args := m.Called(opts) + result, _ := args.Get(0).(StructuralEventCounts) + return result, args.Error(1) +} + +func (m *tournamentAdapterMock) BondValue(opts *bind.CallOpts) (*big.Int, error) { + args := m.Called(opts) + value, _ := args.Get(0).(*big.Int) + return value, args.Error(1) +} + +func (m *tournamentAdapterMock) BondRecovery(opts *bind.CallOpts) (BondRecovery, error) { + args := m.Called(opts) + recovery, _ := args.Get(0).(BondRecovery) + return recovery, args.Error(1) +} + +func (m *tournamentAdapterMock) CommitmentStanding( + opts *bind.CallOpts, + commitmentRoot [32]byte, +) (CommitmentStanding, error) { + args := m.Called(opts, commitmentRoot) + result, _ := args.Get(0).(CommitmentStanding) + return result, args.Error(1) +} + +func observeEpochsForTest(ctx context.Context, s *Service, app *model.Application, head uint64) (bool, error) { + epochs, _, err := getTournamentObservationEpochs(ctx, s.repository, app.Name) + if err != nil { + return true, err + } + consensus, err := s.adapterFactory.CreateDaveConsensusAdapter(app.IConsensusAddress) + if err != nil { + return true, err + } + err = s.observeTournamentState(ctx, app, epochs, consensus, observerFacts(head)) + if err != nil { + return true, err + } + if app.Status != model.ApplicationStatus_OK { + return true, nil + } + if app.IsForeclosed() { + return true, s.drainForeclosedClaims(ctx, app, epochs, consensus, head) + } + return s.reconcileAcceptedEpochs(ctx, app, epochs, consensus, head) +} + +func (s *delivery) observeWindow(ctx context.Context, app *model.Application, epochs []*model.Epoch, + consensus DaveConsensusAdapter, facts txsender.BlockRef) error { + return s.observeTournamentState(ctx, app, epochs, consensus, facts) +} + +func newPRTServiceMock() (*Service, *prtRepositoryMock) { + repo := &prtRepositoryMock{} + s := &Service{ + TickServiceTemplate: service.TickServiceTemplate{ + BaseTemplate: service.BaseTemplate{ + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }, + }, + repository: repo, + disputeWarnings: map[common.Address]struct{}{}, + zeroStagingWarnings: map[int64]struct{}{}, + observationFailures: map[int64]tournamentObservationFailure{}, + } + s.delivery = &delivery{Service: s, deliveryPolicy: txsender.RuntimePolicy()} + // Protocol fixtures supply acknowledged compatible admission explicitly. + // Deployment lifecycle tests replace this with an empty process cache. + admitTestDeployment(s, repo, prtRevertTestApp()) + return s, repo +} diff --git a/internal/prt/foreclosure_no_update_test.go b/internal/prt/foreclosure_no_update_test.go new file mode 100644 index 000000000..2c5996dbe --- /dev/null +++ b/internal/prt/foreclosure_no_update_test.go @@ -0,0 +1,101 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "encoding/json" + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func TestForeclosePendingClaimEpochs_NoUpdateContinuesAndFreshPrefixIsPreserved(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + app := prtForeclosedApp(1, 100) + stale := &model.Epoch{ + ApplicationID: app.ID, Index: 2, Status: model.EpochStatus_Closed, FirstBlock: 80, LastBlock: 90, + } + suffix := &model.Epoch{ + ApplicationID: app.ID, Index: 3, Status: model.EpochStatus_Closed, FirstBlock: 90, LastBlock: 110, + } + fresh := *stale + fresh.Status = model.EpochStatus_InputsProcessed + filter := repository.EpochFilter{Status: model.NonTerminalEpochStatuses()} + firstRead := r.On("ListEpochs", mock.Anything, app.Name, filter, repository.Pagination{}, false). + Return([]*model.Epoch{stale, suffix}, uint64(2), nil).Once() + noUpdate := r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, stale.Index). + Return(repository.ErrNoUpdate).Once() + success := r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, suffix.Index).Return(nil).Once() + secondRead := r.On("ListEpochs", mock.Anything, app.Name, filter, repository.Pagination{}, false). + Return([]*model.Epoch{&fresh}, uint64(1), nil).Once() + mock.InOrder(firstRead, noUpdate, success, secondRead) + + // The conditional update saw a newer row than the CLOSED snapshot. Another + // eligible epoch must still progress, without claiming the no-op succeeded. + require.NoError(t, s.foreclosePendingClaimEpochs(t.Context(), app)) + assert.Equal(t, []uint64{suffix.Index}, loggedForeclosedEpochs(t, &logs)) + + // On the next observation the healthy, fully pre-foreclosure IP epoch is + // visible and must retain its validator path without another update or log. + require.NoError(t, s.foreclosePendingClaimEpochs(t.Context(), app)) + assert.Equal(t, []uint64{suffix.Index}, loggedForeclosedEpochs(t, &logs)) + r.AssertNumberOfCalls(t, "UpdateEpochWithForeclosedClaim", 2) +} + +func TestForeclosePendingClaimEpochs_NoUpdateDoesNotHideLaterWriteError(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + app := prtForeclosedApp(1, 100) + dbErr := errors.New("terminalization write failed") + stale := &model.Epoch{ + ApplicationID: app.ID, Index: 2, Status: model.EpochStatus_Closed, FirstBlock: 80, LastBlock: 90, + } + next := &model.Epoch{ + ApplicationID: app.ID, Index: 3, Status: model.EpochStatus_Closed, FirstBlock: 90, LastBlock: 110, + } + r.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{stale, next}, uint64(2), nil).Once() + noUpdate := r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, stale.Index). + Return(repository.ErrNoUpdate).Once() + writeFailure := r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, next.Index).Return(dbErr).Once() + mock.InOrder(noUpdate, writeFailure) + + err := s.foreclosePendingClaimEpochs(t.Context(), app) + assert.ErrorIs(t, err, dbErr) + assert.Empty(t, loggedForeclosedEpochs(t, &logs)) +} + +func loggedForeclosedEpochs(t *testing.T, logs *bytes.Buffer) []uint64 { + t.Helper() + var epochs []uint64 + for _, line := range bytes.Split(logs.Bytes(), []byte("\n")) { + if len(line) == 0 { + continue + } + var record struct { + Message string `json:"msg"` + Level string `json:"level"` + EpochIndex uint64 `json:"epoch_index"` + } + require.NoError(t, json.Unmarshal(line, &record)) + if record.Message == "Terminalized unaccepted epoch of foreclosed application" { + assert.Equal(t, "INFO", record.Level) + epochs = append(epochs, record.EpochIndex) + } + } + return epochs +} diff --git a/internal/prt/foreclosure_recovery_drain_test.go b/internal/prt/foreclosure_recovery_drain_test.go new file mode 100644 index 000000000..939922d1d --- /dev/null +++ b/internal/prt/foreclosure_recovery_drain_test.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestForeclosedClaimsDrainWhenRecoveryDiscoveryFails(t *testing.T) { + f := newDeliveryFixture(t) + f.d.submissionEnabled = true + f.d.defaultBlock = model.DefaultBlock_Finalized + f.app.ForecloseBlock = 90 + f.app.LastInputCheckBlock, f.app.LastEpochCheckBlock = 100, 100 + client := ðClientMock{} + header := &types.Header{Number: big.NewInt(100)} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(header, nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(header, nil).Once() + f.d.client = client + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch(nil), uint64(0), nil).Once() + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, uint64(90)).Return(false, nil).Once() + f.repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, f.app.ID, uint64(90)).Return(true, nil).Once() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{{Index: 3}}, uint64(1), nil).Once() + f.repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, f.app.ID, uint64(3)).Return(nil).Once() + f.repo.On("GetLastNonOpenEpochIndex", mock.Anything, f.app.IApplicationAddress.Hex()).Return(new(uint64(3)), nil) + failure := errors.New("recovery query unavailable") + f.d.repository = &recoveryRepositoryPort{deliveryRepoFake: f.repo, err: failure} + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100)) + observationErr := f.d.observeApplication(t.Context(), f.app, observerFacts(100)) + require.ErrorIs(t, errors.Join(observationErr, f.d.tick(t.Context())), failure) + f.repo.AssertExpectations(t) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) +} + +func TestForeclosedDrainRetainsRecoveryErrorOnEveryReturn(t *testing.T) { + const ( + observationError = "observation error" + ingestionIncomplete = "ingestion incomplete" + inputsPending = "inputs pending" + inputQueryError = "input query error" + claimsDrained = "claims drained" + claimQueryError = "claim query error" + epochQueryError = "epoch query error" + epochWriteError = "epoch write error" + claimTerminalized = "claim terminalized" + shutdown = "shutdown" + ) + for _, path := range []string{observationError, ingestionIncomplete, inputsPending, inputQueryError, + claimsDrained, claimQueryError, epochQueryError, epochWriteError, claimTerminalized, shutdown} { + t.Run(path, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + f := newDeliveryFixture(t) + f.d.submissionEnabled = true + f.d.defaultBlock = model.DefaultBlock_Finalized + f.app.ForecloseBlock = 90 + f.app.LastInputCheckBlock, f.app.LastEpochCheckBlock = 100, 100 + client := ðClientMock{} + header := &types.Header{Number: big.NewInt(100)} + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(header, nil).Once() + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(header, nil).Once() + f.d.client = client + drainErr := errors.New("drain failed") + var observeErr error + if path == observationError { + observeErr = drainErr + } + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch(nil), uint64(0), observeErr).Once() + if path == ingestionIncomplete { + f.app.LastInputCheckBlock = 1 + } + if path != observationError && path != ingestionIncomplete { + var inputErr error + if path == inputQueryError { + inputErr = drainErr + } + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, uint64(90)). + Return(path == inputsPending, inputErr).Once() + if path != inputsPending && inputErr == nil { + var claimErr error + if path == claimQueryError { + claimErr = drainErr + } + f.repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, f.app.ID, uint64(90)). + Return(path != claimsDrained, claimErr).Once() + if path != claimsDrained && claimErr == nil { + var epochErr error + if path == epochQueryError { + epochErr = drainErr + } + f.repo.On("ListEpochs", mock.Anything, f.app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{{Index: 3}}, uint64(1), epochErr).Once() + if epochErr == nil { + if path == epochWriteError { + epochErr = drainErr + } + f.repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, f.app.ID, uint64(3)).Return(epochErr).Once() + } + } + } + } + f.repo.On("GetLastNonOpenEpochIndex", mock.Anything, f.app.IApplicationAddress.Hex()).Return(new(uint64(3)), nil) + failure := errors.New("recovery provider unavailable") + r := &recoveryRepositoryPort{deliveryRepoFake: f.repo, err: failure} + if path == shutdown { + r.beforeQuery = cancel + r.err = errors.Join(failure, context.Canceled) + } + f.d.repository = r + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100)) + observationErr := f.d.observeApplication(ctx, f.app, observerFacts(100)) + err := errors.Join(observationErr, f.d.tick(ctx)) + require.ErrorIs(t, err, failure) + if path == shutdown { + require.ErrorIs(t, err, context.Canceled) + } + if path == observationError || path == inputQueryError || path == claimQueryError || + path == epochQueryError || path == epochWriteError { + require.ErrorIs(t, err, drainErr) + } + f.repo.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/handle_foreclosed_test.go b/internal/prt/handle_foreclosed_test.go new file mode 100644 index 000000000..dbf648644 --- /dev/null +++ b/internal/prt/handle_foreclosed_test.go @@ -0,0 +1,289 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "log/slog" + "math/big" + "testing" +) + +func prtForeclosedApp(id int64, block uint64) *model.Application { + txHash := common.HexToHash("0xcafe") + return &model.Application{ + ID: id, + Name: "prt-app", + IApplicationAddress: common.BigToAddress(common.Big1), + ConsensusType: model.Consensus_PRT, + Enabled: true, + Status: model.ApplicationStatus_OK, + ForecloseBlock: block, + ForecloseTransaction: &txHash, + // Both DaveConsensus observation cursors default to the foreclose + // block so callers that don't care about the bootstrap guard skip it. + // Tests that exercise the guard override one cursor explicitly. + LastEpochCheckBlock: block, + LastInputCheckBlock: block, + } +} + +func TestOrdinaryApplicationObservationDoesNotRunForeclosureDrain(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, false) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(view.sealed, nil) + f.consensus.On("CanStageTournamentResult", opts).Return(view.stage, nil) + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(view.accept, nil) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, mock.Anything, false).Return([]*model.Epoch{}, uint64(0), nil) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100)) + require.NoError(t, f.d.observeApplication(t.Context(), f.app, observerFacts(100))) + f.repo.AssertNotCalled(t, "HasUndrainedEpochsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) +} + +func TestObserveRetainsTerminalApplicationStatus(t *testing.T) { + statuses := []model.ApplicationStatus{ + model.ApplicationStatus_Failed, model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted, + } + for _, status := range statuses { + t.Run(status.String(), func(t *testing.T) { + f, client := tickFixture(t) + f.app.Status = status + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, mock.Anything, false).Return([]*model.Epoch{}, uint64(0), nil) + f.repo.publishedRoots = nil + tickHead(client, 110) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(110)) + require.NoError(t, f.d.observe(t.Context())) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock, "empty observation does not invent coverage") + require.Equal(t, status, f.app.Status) + require.Empty(t, f.repo.windows) + require.Empty(t, f.backend.published) + }) + } +} + +func TestForeclosureDrainDefersNewerSavedForeclosure(t *testing.T) { + f := newDeliveryFixture(t) + f.app.ForecloseBlock = 120 + f.app.LastEpochCheckBlock, f.app.LastInputCheckBlock = 120, 120 + f.repo.publishedRoots = nil + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, mock.Anything, false). + Return([]*model.Epoch{}, uint64(0), nil).Once() + + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100)) + err := f.d.observeApplication(t.Context(), f.app, observerFacts(100)) + require.ErrorIs(t, err, txsender.ErrInconclusive) + f.repo.AssertNotCalled(t, "HasUndrainedEpochsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertExpectations(t) +} + +// TestHandleForeclosedApp_DefersWhenUndrained verifies the +// pre-foreclosure-work guard. While the advancer/validator have epochs to +// process before the foreclose block, the PRT app must keep its current +// status. Marking it terminal early would lose the last machine state needed +// to process its last pre-foreclosure epoch. +func TestHandleForeclosedApp_DefersWhenUndrained(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(true, nil).Once() + // No UpdateApplicationStatus expectation — see TestProcessForeclosedApps_DefersWhenUndrained + // in the claimer suite for the equivalent reasoning. + + require.NoError(t, s.drainForeclosedClaims(context.Background(), app, nil, nil, 120)) +} + +// A completed local drain must not stop passive tournament observation. With +// no roots, this pass only queries eligibility and checks the existing gates. +func TestHandleForeclosedApp_ObservesAfterLocalDrain(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + r.On("HasUnreconciledClaimsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + + require.NoError(t, s.drainForeclosedClaims(context.Background(), app, nil, nil, 120)) +} + +// TestHandleForeclosedApp_SurfacesDrainCheckError verifies the surrounding +// behavior on transient repository failures: the error must propagate so +// the Tick's err slice marks the app as in trouble; the app keeps its current +// status for retry on the next tick. +func TestHandleForeclosedApp_SurfacesDrainCheckError(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + dbErr := errors.New("connection refused") + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, dbErr).Once() + + err := s.drainForeclosedClaims(context.Background(), app, nil, nil, 120) + require.Error(t, err) + assert.ErrorIs(t, err, dbErr) +} + +// TestHandleForeclosedApp_DefersWhenStillBackfilling verifies the +// bootstrap-readiness guard. When a freshly registered PRT app encounters +// an already-foreclosed contract, evmreader sets ForecloseBlock before +// checkForEpochsAndInputs has ingested any historical sealed epochs. The +// drain gate would then see an empty input table and incorrectly return +// false, making the app look drained before any pre-foreclosure epoch is +// observed locally. The guard must defer the drain check until +// both DaveConsensus observation cursors reach ForecloseBlock. +// +// The mock has no HasUndrainedEpochsBeforeBlock or UpdateApplicationStatus +// expectation registered; testify/mock panics on an unexpected call, so +// either reach attempt fails the test loudly. +func TestHandleForeclosedApp_DefersWhenStillBackfilling(t *testing.T) { + for _, test := range []struct { + name string + epochCursor uint64 + inputCursor uint64 + }{ + {name: "sealed epochs still landing", epochCursor: 50, inputCursor: 100}, + {name: "same-block open inputs still landing", epochCursor: 100, inputCursor: 99}, + } { + t.Run(test.name, func(t *testing.T) { + s, r := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + app.LastEpochCheckBlock = test.epochCursor + app.LastInputCheckBlock = test.inputCursor + + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, nil, nil, 120)) + require.Contains(t, output.String(), "sealed epochs and inputs") + require.Contains(t, output.String(), "last_epoch_check_block="+new(big.Int).SetUint64(test.epochCursor).String()) + require.Contains(t, output.String(), "last_input_check_block="+new(big.Int).SetUint64(test.inputCursor).String()) + require.Contains(t, output.String(), "foreclose_block=100") + r.AssertNotCalled(t, "HasUndrainedEpochsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + r.AssertNotCalled(t, "HasUnreconciledClaimsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + r.AssertExpectations(t) + }) + } +} + +// TestHandleForeclosedApp_SurfacesReconciliationCheckError verifies the +// epoch-level completion gate's error propagates so the Tick retries on the +// next pass and the app keeps its status meanwhile. +func TestHandleForeclosedApp_SurfacesReconciliationCheckError(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + dbErr := errors.New("connection refused") + r.On("HasUndrainedEpochsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, nil).Once() + r.On("HasUnreconciledClaimsBeforeBlock", + mock.Anything, app.ID, app.ForecloseBlock, + ).Return(false, dbErr).Once() + + err := s.drainForeclosedClaims(context.Background(), app, nil, nil, 120) + require.Error(t, err) + assert.ErrorIs(t, err, dbErr) +} + +// TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass models +// an epoch that appears between the observation query and the local drain +// query. An on-chain EpochSealed transaction hash requires reconciliation on +// the next pass; the local drain must not mark that epoch CLAIM_FORECLOSED. +func TestHandleForeclosedApp_LeavesClaimedEpochForNextReconciliationPass(t *testing.T) { + s, r := newPRTServiceMock() + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + claimTx := common.HexToHash("0xbeef") + r.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + r.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + r.On("ListEpochs", mock.Anything, app.Name, + repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{{Index: 1, ClaimTransactionHash: &claimTx}}, uint64(1), nil).Once() + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, nil, nil, 120)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(1)) + r.AssertExpectations(t) +} + +// Foreclosure terminalizes only epochs whose claim path is finished; earlier +// INPUTS_PROCESSED epochs still require validation, and sealed observations stay +// available for acceptance reconciliation. +func TestForeclosePendingClaimEpochsPreservesValidationAndSealedClaims(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + claimTx := common.HexToHash("0xbeef") + r.On("ListEpochs", + mock.Anything, app.Name, + repository.EpochFilter{Status: []model.EpochStatus{ + model.EpochStatus_Open, model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, + model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged, + }}, + repository.Pagination{}, false, + ).Return([]*model.Epoch{ + {Index: 0, Status: model.EpochStatus_Open, FirstBlock: 100, LastBlock: 150}, + {Index: 1, Status: model.EpochStatus_Closed}, + {Index: 2, Status: model.EpochStatus_InputsProcessed}, + {Index: 3}, + {Index: 4, Status: model.EpochStatus_ClaimStaged}, + {Index: 5, ClaimTransactionHash: &claimTx}, + {Index: 6, Status: model.EpochStatus_ClaimSubmitted}, + {Index: 7, Status: model.EpochStatus_Open, FirstBlock: 101}, + }, uint64(8), nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(0)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(1)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(3)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(4)).Return(nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(6)).Return(nil).Once() + + require.NoError(t, s.foreclosePendingClaimEpochs(context.Background(), app)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(2)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(5)) + r.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(7)) +} + +// TestForeclosePendingClaimEpochs_PropagatesUpdateError verifies a failed +// terminalization surfaces so the Tick retries rather than silently dropping +// a still-non-terminal epoch. +func TestForeclosePendingClaimEpochs_PropagatesUpdateError(t *testing.T) { + s, r := newPRTServiceMock() + defer r.AssertExpectations(t) + + app := prtForeclosedApp(1, 100) + admitTestDeployment(s, r, app) + dbErr := errors.New("write failed") + r.On("ListEpochs", mock.Anything, app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{{Index: 7}}, uint64(1), nil).Once() + r.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, uint64(7)).Return(dbErr).Once() + + err := s.foreclosePendingClaimEpochs(context.Background(), app) + require.Error(t, err) + assert.ErrorIs(t, err, dbErr) +} diff --git a/internal/prt/idaveconsensus_adapter.go b/internal/prt/idaveconsensus_adapter.go new file mode 100644 index 000000000..d6623c715 --- /dev/null +++ b/internal/prt/idaveconsensus_adapter.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/imultileveltournamentfactory" +) + +// DaveConsensusAdapterImpl wraps the generated IDaveConsensus binding. +type DaveConsensusAdapterImpl struct { + consensus *idaveconsensus.IDaveConsensus + client *ethclient.Client + address common.Address +} + +// NewDaveConsensusAdapter creates a new DaveConsensusAdapter backed by the IDaveConsensus contract. +func NewDaveConsensusAdapter( + addr common.Address, + client *ethclient.Client, +) (DaveConsensusAdapter, error) { + consensus, err := idaveconsensus.NewIDaveConsensus(addr, readOnlyBackend{Client: client}) + if err != nil { + return nil, err + } + return &DaveConsensusAdapterImpl{consensus: consensus, client: client, address: addr}, nil +} + +func (a *DaveConsensusAdapterImpl) ParseEpochSealed(log types.Log) (*idaveconsensus.IDaveConsensusEpochSealed, error) { + return a.consensus.ParseEpochSealed(log) +} + +// Query only the exact accepting block, consensus and next-epoch event. A lost +// publication race must not require one receipt RPC per unrelated transaction. +func (a *DaveConsensusAdapterImpl) EpochSealedAt( + ctx context.Context, blockHash common.Hash, epoch uint64, +) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) { + contract, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + if err != nil { + return nil, err + } + logs, err := a.client.FilterLogs(ctx, ethereum.FilterQuery{BlockHash: &blockHash, + Addresses: []common.Address{a.address}, + Topics: [][]common.Hash{{contract.Events["EpochSealed"].ID}, {common.BigToHash(new(big.Int).SetUint64(epoch))}}, + }) + if err != nil { + return nil, err + } + events := make([]*idaveconsensus.IDaveConsensusEpochSealed, 0, len(logs)) + for _, log := range logs { + event, err := a.ParseEpochSealed(log) + if err != nil { + return nil, err + } + events = append(events, event) + } + return events, nil +} + +func (a *DaveConsensusAdapterImpl) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { + factoryAddress, err := a.consensus.GetTournamentFactory(opts) + if err != nil { + return 0, err + } + factory, err := imultileveltournamentfactory.NewIMultiLevelTournamentFactory(factoryAddress, readOnlyBackend{Client: a.client}) + if err != nil { + return 0, err + } + levelCount, err := factory.TournamentLevelCount(opts) + if err != nil { + return 0, err + } + if levelCount == 0 { + return 0, errors.New("tournament level count is zero") + } + return levelCount, nil +} + +func checkedUint64(value *big.Int, field string) (uint64, error) { + if value == nil || value.Sign() < 0 || !value.IsUint64() { + return 0, fmt.Errorf("%s is not a uint64", field) + } + return value.Uint64(), nil +} + +func (a *DaveConsensusAdapterImpl) GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) { + result, err := a.consensus.GetCurrentSealedEpoch(opts) + if err != nil { + return CurrentSealedEpoch{}, err + } + epoch, err := checkedUint64(result.EpochNumber, "sealed epoch number") + if err != nil { + return CurrentSealedEpoch{}, err + } + lowerBound, err := checkedUint64(result.InputIndexLowerBound, "sealed epoch input lower bound") + if err != nil { + return CurrentSealedEpoch{}, err + } + upperBound, err := checkedUint64(result.InputIndexUpperBound, "sealed epoch input upper bound") + if err != nil { + return CurrentSealedEpoch{}, err + } + stagingBlock, err := checkedUint64(result.StagingBlockNumber, "sealed epoch staging block") + if err != nil { + return CurrentSealedEpoch{}, err + } + return CurrentSealedEpoch{ + EpochNumber: epoch, + InputIndexLowerBound: lowerBound, + InputIndexUpperBound: upperBound, + Tournament: result.Tournament, + IsTournamentResultStaged: result.IsTournamentResultStaged, + StagingBlockNumber: stagingBlock, + StagedPostEpochMachineStateHash: result.StagedPostEpochMachineStateHash, + StagedPostEpochOutputsMerkleRoot: result.StagedPostEpochOutputsMerkleRoot, + }, nil +} + +func (a *DaveConsensusAdapterImpl) CanStageTournamentResult( + opts *bind.CallOpts, +) (CanStageTournamentResult, error) { + result, err := a.consensus.CanStageTournamentResult(opts) + if err != nil { + return CanStageTournamentResult{}, err + } + epoch, err := checkedUint64(result.EpochNumber, "stage readiness epoch number") + if err != nil { + return CanStageTournamentResult{}, err + } + return CanStageTournamentResult{ + IsFinished: result.IsFinished, + IsTournamentFailed: result.IsTournamentFailed, + IsTournamentResultStaged: result.IsTournamentResultStaged, + EpochNumber: epoch, + WinnerCommitment: result.WinnerCommitment, + WinnerPostEpochMachineStateHash: result.WinnerPostEpochMachineStateHash, + }, nil +} + +func (a *DaveConsensusAdapterImpl) CanAcceptStagedTournamentResult( + opts *bind.CallOpts, +) (CanAcceptStagedTournamentResult, error) { + result, err := a.consensus.CanAcceptStagedTournamentResult(opts) + if err != nil { + return CanAcceptStagedTournamentResult{}, err + } + epoch, err := checkedUint64(result.EpochNumber, "accept readiness epoch number") + if err != nil { + return CanAcceptStagedTournamentResult{}, err + } + return CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: result.IsTournamentResultStaged, + DoAllSentriesAgreeWithStagedTournamentResult: result.DoAllSentriesAgreeWithStagedTournamentResult, + IsClaimStagingPeriodOver: result.IsClaimStagingPeriodOver, + EpochNumber: epoch, + StagedPostEpochMachineStateHash: result.StagedPostEpochMachineStateHash, + StagedPostEpochOutputsMerkleRoot: result.StagedPostEpochOutputsMerkleRoot, + }, nil +} + +func daveMachineValidityProof(proof model.StateProof) idaveconsensus.MachineValidityProof { + return idaveconsensus.MachineValidityProof{ + IflagsYProof: idaveconsensus.LeafProof{ + DataBlock: proof.IflagsYDataBlock, + Siblings: append([][32]byte(nil), proof.IflagsYProof...), + }, + HtifTohostProof: idaveconsensus.LeafProof{ + DataBlock: proof.HtifTohostDataBlock, + Siblings: append([][32]byte(nil), proof.HtifTohostProof...), + }, + TxBufferProof: idaveconsensus.LeafProof{ + DataBlock: proof.TxBufferDataBlock, + Siblings: append([][32]byte(nil), proof.TxBufferProof...), + }, + } +} diff --git a/internal/prt/idaveconsensus_adapter_test.go b/internal/prt/idaveconsensus_adapter_test.go new file mode 100644 index 000000000..ed7e50417 --- /dev/null +++ b/internal/prt/idaveconsensus_adapter_test.go @@ -0,0 +1,148 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type daveConsensusViewRPC struct{ mock.Mock } + +type daveEventQuery struct { + BlockHash common.Hash `json:"blockHash"` + Address []common.Address `json:"address"` + Topics [][]common.Hash `json:"topics"` + FromBlock json.RawMessage `json:"fromBlock"` + ToBlock json.RawMessage `json:"toBlock"` +} + +func (m *daveConsensusViewRPC) GetLogs(_ context.Context, query daveEventQuery) ([]types.Log, error) { + args := m.Called(query) + logs, _ := args.Get(0).([]types.Log) + return logs, args.Error(1) +} + +func TestDaveConsensusAdapterFiltersAcceptanceByExactBlockHashContractAndEpoch(t *testing.T) { + contract, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + address, block := common.HexToAddress("0x100"), common.HexToHash("0x110") + event := contract.Events["EpochSealed"] + epoch := uint64(4) + machine, outputs := common.HexToHash("0x400"), common.HexToHash("0x500") + data, err := event.Inputs.NonIndexed().Pack(big.NewInt(5), big.NewInt(9), machine, outputs, common.HexToAddress("0x200")) + require.NoError(t, err) + log := types.Log{Address: address, BlockHash: block, BlockNumber: 110, TxHash: common.HexToHash("0x999"), + Topics: []common.Hash{event.ID, common.BigToHash(new(big.Int).SetUint64(epoch))}, Data: data} + backend := &daveConsensusViewRPC{} + backend.On("GetLogs", daveEventQuery{BlockHash: block, Address: []common.Address{address}, + Topics: [][]common.Hash{{event.ID}, {common.BigToHash(new(big.Int).SetUint64(epoch))}}, + }).Return([]types.Log{log}, nil).Once() + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewDaveConsensusAdapter(address, client) + require.NoError(t, err) + events, err := adapter.EpochSealedAt(t.Context(), block, epoch) + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, epoch, events[0].EpochNumber.Uint64()) + require.Equal(t, [32]byte(machine), events[0].InitialMachineStateHash) + require.Equal(t, [32]byte(outputs), events[0].OutputsMerkleRoot) + require.Equal(t, log, events[0].Raw) + backend.AssertExpectations(t) +} + +func (m *daveConsensusViewRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +func TestDaveConsensusAdapterDecodesContractViews(t *testing.T) { + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + address := common.HexToAddress("0x100") + tournament := common.HexToAddress("0x11") + winner := common.HexToHash("0x22") + machineHash := common.HexToHash("0x33") + outputsRoot := common.HexToHash("0x44") + block := big.NewInt(20) + + for i, name := range []string{"first flag", "second flag", "third flag"} { + t.Run(name, func(t *testing.T) { + var flags [3]bool + flags[i] = true + backend := &daveConsensusViewRPC{} + for _, view := range []struct { + method string + values []any + }{ + {"getCurrentSealedEpoch", []any{ + big.NewInt(1), big.NewInt(2), big.NewInt(3), tournament, flags[0], big.NewInt(4), machineHash, outputsRoot, + }}, + {"canStageTournamentResult", []any{flags[0], flags[1], flags[2], big.NewInt(5), winner, machineHash}}, + {"canAcceptStagedTournamentResult", []any{flags[0], flags[1], flags[2], big.NewInt(6), machineHash, outputsRoot}}, + } { + method := contractABI.Methods[view.method] + encoded, err := method.Outputs.Pack(view.values...) + require.NoError(t, err) + backend.On("Call", address, method.ID, rpc.BlockNumber(block.Int64())).Return(encoded, nil).Once() + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + adapter, err := NewDaveConsensusAdapter(address, client) + require.NoError(t, err) + opts := &bind.CallOpts{Context: t.Context(), BlockNumber: block} + + sealed, err := adapter.GetCurrentSealedEpoch(opts) + require.NoError(t, err) + require.Equal(t, CurrentSealedEpoch{ + EpochNumber: 1, InputIndexLowerBound: 2, InputIndexUpperBound: 3, Tournament: tournament, + IsTournamentResultStaged: flags[0], StagingBlockNumber: 4, + StagedPostEpochMachineStateHash: machineHash, StagedPostEpochOutputsMerkleRoot: outputsRoot, + }, sealed) + stage, err := adapter.CanStageTournamentResult(opts) + require.NoError(t, err) + require.Equal(t, CanStageTournamentResult{ + IsFinished: flags[0], IsTournamentFailed: flags[1], IsTournamentResultStaged: flags[2], + EpochNumber: 5, WinnerCommitment: winner, WinnerPostEpochMachineStateHash: machineHash, + }, stage) + accept, err := adapter.CanAcceptStagedTournamentResult(opts) + require.NoError(t, err) + require.Equal(t, CanAcceptStagedTournamentResult{ + IsTournamentResultStaged: flags[0], DoAllSentriesAgreeWithStagedTournamentResult: flags[1], + IsClaimStagingPeriodOver: flags[2], EpochNumber: 6, + StagedPostEpochMachineStateHash: machineHash, StagedPostEpochOutputsMerkleRoot: outputsRoot, + }, accept) + backend.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/itournament_adapter.go b/internal/prt/itournament_adapter.go new file mode 100644 index 000000000..0cc701813 --- /dev/null +++ b/internal/prt/itournament_adapter.go @@ -0,0 +1,554 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "cmp" + "errors" + "fmt" + "math/big" + "slices" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" +) + +var ( + errInvalidBondRecovery = errors.New("invalid tournament bond recovery state") + errTournamentMatchPhaseChanged = errors.New("tournament match phase changed within pinned observation") + errUnpinnedTournamentRead = errors.New("tournament observation requires a pinned block") +) + +// ITournament Wrapper +type ITournamentAdapterImpl struct { + tournament *itournament.ITournament + client *ethclient.Client + tournamentAddress common.Address + filter ethutil.Filter +} + +func NewITournamentAdapter( + tournamentAddress common.Address, + client *ethclient.Client, + filter ethutil.Filter, +) (TournamentAdapter, error) { + tournamentContract, err := itournament.NewITournament(tournamentAddress, readOnlyBackend{Client: client}) + if err != nil { + return nil, err + } + return &ITournamentAdapterImpl{ + tournament: tournamentContract, + tournamentAddress: tournamentAddress, + client: client, + filter: filter, + }, nil +} + +func (a *ITournamentAdapterImpl) Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) { + descriptor, err := a.tournament.TournamentDescriptor(opts) + if err != nil { + return TournamentDescriptor{}, err + } + return tournamentDescriptorFromBinding(descriptor) +} + +func tournamentDescriptorFromBinding(descriptor itournament.ITournamentTournamentDescriptor) (TournamentDescriptor, error) { + kind, err := tournamentEnum("kind", descriptor.Kind, model.TournamentKindLeaf, model.TournamentKindNonLeaf) + if err != nil { + return TournamentDescriptor{}, err + } + return TournamentDescriptor{ + InitialHash: descriptor.InitialHash, + BaseCycle: descriptor.BaseCycle, + Log2Stride: descriptor.Log2Stride, + Height: descriptor.Height, + Level: descriptor.Level, + Kind: kind, + StartInstant: descriptor.StartInstant, + Allowance: descriptor.Allowance, + }, nil +} + +func (a *ITournamentAdapterImpl) Standing(opts *bind.CallOpts) (TournamentStanding, error) { + standing, err := a.tournament.TournamentStanding(opts) + if err != nil { + return TournamentStanding{}, err + } + return tournamentStandingFromBinding(standing) +} + +func tournamentStandingFromBinding(standing itournament.ITournamentTournamentStandingView) (TournamentStanding, error) { + state, err := tournamentEnum("standing", standing.Standing, + model.TournamentStandingMatchesActive, model.TournamentStandingAwaitingClosure, + model.TournamentStandingRootWinner, model.TournamentStandingRootFailed, model.TournamentStandingInnerWinner, + model.TournamentStandingInnerEliminableNoWinner, model.TournamentStandingInnerEliminableWinnerExpired) + if err != nil { + return TournamentStanding{}, err + } + return TournamentStanding{ + State: state, + AcceptsJoins: standing.AcceptsJoins, + HasCandidate: standing.HasCandidate, + Candidate: standing.Candidate, + FinalState: standing.FinalState, + ParentCommitment: standing.ParentCommitment, + FinishedAt: standing.FinishedAt, + WinnerExpiresAt: standing.WinnerExpiresAt, + }, nil +} + +func (a *ITournamentAdapterImpl) BondValue(opts *bind.CallOpts) (*big.Int, error) { + value, err := a.tournament.BondValue(opts) + if err != nil { + return nil, err + } + return value, nil +} + +func (a *ITournamentAdapterImpl) BondRecovery(opts *bind.CallOpts) (BondRecovery, error) { + recovery, err := a.tournament.BondRecovery(opts) + if err != nil { + return BondRecovery{}, err + } + return bondRecoveryFromBinding(recovery.Disposition, recovery.Claimer, recovery.Payment) +} + +func bondRecoveryFromBinding( + disposition uint8, + claimer common.Address, + payment *big.Int, +) (BondRecovery, error) { + d, err := tournamentEnum("bond disposition", disposition, model.BondDispositionTournamentRunning, + model.BondDispositionNoWinner, model.BondDispositionRecoverable, model.BondDispositionRecovered) + if err != nil { + return BondRecovery{}, fmt.Errorf("%w: %w", errInvalidBondRecovery, err) + } + + if d == model.BondDispositionRecoverable { + if claimer == (common.Address{}) { + return BondRecovery{}, fmt.Errorf("%w: recoverable bond has zero claimer", errInvalidBondRecovery) + } + } else if claimer != (common.Address{}) || payment.Sign() != 0 { + return BondRecovery{}, fmt.Errorf( + "%w: inactive disposition %s has claimer %s or payment %s", + errInvalidBondRecovery, + d, + claimer, + payment, + ) + } + + return BondRecovery{ + Disposition: d, + Claimer: claimer, + Payment: payment, + }, nil +} + +func (a *ITournamentAdapterImpl) CommitmentStanding( + opts *bind.CallOpts, commitmentRoot [32]byte, +) (CommitmentStanding, error) { + standing, err := a.tournament.CommitmentStanding(opts, commitmentRoot) + if err != nil { + return CommitmentStanding{}, err + } + return commitmentStandingFromBinding(standing), nil +} + +func commitmentStandingFromBinding(standing itournament.ITournamentCommitmentStandingView) CommitmentStanding { + return CommitmentStanding{ + Joined: standing.Joined, + FinalState: standing.FinalState, + Claimer: standing.Claimer, + ClockRunning: standing.ClockRunning, + ClockDeadline: standing.ClockDeadline, + ClockAllowance: standing.ClockAllowance, + } +} + +// tournamentEnum maps ABI ordinal values to the shared model vocabulary. The +// explicit order at each call site follows the deployed Solidity declaration. +func tournamentEnum[T ~string](name string, raw uint8, values ...T) (T, error) { + if int(raw) >= len(values) { + var zero T + return zero, fmt.Errorf("tournament has unknown %s %d", name, raw) + } + return values[raw], nil +} + +const tournamentIntegerBits = 256 + +func tournamentUint256(name string, value *big.Int) (*big.Int, error) { + if value == nil { + return nil, fmt.Errorf("tournament %s is nil", name) + } + if value.Sign() < 0 { + return nil, fmt.Errorf("tournament %s is negative", name) + } + if value.BitLen() > tournamentIntegerBits { + return nil, fmt.Errorf("tournament %s exceeds uint256", name) + } + return new(big.Int).Set(value), nil +} + +func requirePinnedTournamentRead(opts *bind.CallOpts) error { + if opts == nil || opts.Pending { + return errUnpinnedTournamentRead + } + if opts.BlockHash != (common.Hash{}) && opts.BlockNumber == nil { + return nil + } + if opts.BlockNumber == nil || !opts.BlockNumber.IsUint64() || opts.BlockHash != (common.Hash{}) { + return errUnpinnedTournamentRead + } + return nil +} + +func (a *ITournamentAdapterImpl) MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) { + if err := requirePinnedTournamentRead(opts); err != nil { + return ObservedMatchSnapshot{}, err + } + timeout, err := a.tournament.ClassifyMatchTimeout(opts, itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}) + if err != nil { + return ObservedMatchSnapshot{}, err + } + phase, err := tournamentEnum("match phase", timeout.ActualPhase, + model.MatchPhaseUninitialized, model.MatchPhaseBisecting, model.MatchPhaseReadyToSeal, model.MatchPhaseSealed) + if err != nil { + return ObservedMatchSnapshot{}, err + } + outcome, err := tournamentEnum("match timeout outcome", timeout.Outcome, + model.MatchTimeoutNone, model.MatchTimeoutOneWins, model.MatchTimeoutTwoWins, model.MatchTimeoutEliminateBoth) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if (outcome == model.MatchTimeoutNone || outcome == model.MatchTimeoutEliminateBoth) && timeout.DeferredCharge != 0 { + return ObservedMatchSnapshot{}, errors.New("tournament timeout has an inactive deferred charge") + } + snapshot := ObservedMatchSnapshot{Phase: phase, TimeoutOutcome: outcome, DeferredCharge: timeout.DeferredCharge} + matchHash := crypto.Keccak256Hash(one[:], two[:]) + switch phase { + case model.MatchPhaseUninitialized: + if outcome != model.MatchTimeoutNone { + return ObservedMatchSnapshot{}, errors.New("absent tournament match has a timeout outcome") + } + case model.MatchPhaseBisecting: + view, err := a.tournament.BisectingMatch(opts, matchHash) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged + } + value := view.Value + position, cycle, side, err := bisectionFields(value.SegmentStartPosition, value.SegmentStartCycle, value.Responder) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if value.CurrentHeight <= 1 { + return ObservedMatchSnapshot{}, errors.New("bisecting tournament match has height below two") + } + snapshot.Bisecting = &BisectingMatch{RevealingParent: value.RevealingParent, + WaitingLeft: value.WaitingLeft, WaitingRight: value.WaitingRight, SegmentStartPosition: position, + SegmentStartCycle: cycle, CurrentHeight: value.CurrentHeight, Responder: side} + case model.MatchPhaseReadyToSeal: + view, err := a.tournament.ReadyToSealMatch(opts, matchHash) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged + } + value := view.Value + position, cycle, side, err := bisectionFields(value.SegmentStartPosition, value.SegmentStartCycle, value.Responder) + if err != nil { + return ObservedMatchSnapshot{}, err + } + snapshot.ReadyToSeal = &ReadyToSealMatch{RevealingParent: value.RevealingParent, + WaitingLeft: value.WaitingLeft, WaitingRight: value.WaitingRight, SegmentStartPosition: position, + SegmentStartCycle: cycle, Responder: side} + case model.MatchPhaseSealed: + view, err := a.tournament.SealedMatch(opts, matchHash) + if err != nil { + return ObservedMatchSnapshot{}, err + } + if view.ActualPhase != timeout.ActualPhase { + return ObservedMatchSnapshot{}, errTournamentMatchPhaseChanged + } + value := view.Value + if timeout.DeferredCharge != 0 { + return ObservedMatchSnapshot{}, errors.New("sealed tournament match has a deferred charge") + } + snapshot.Sealed = &SealedMatch{AgreeState: value.AgreeState, DivergencePosition: value.DivergencePosition, + DivergenceCycle: value.DivergenceCycle, FinalStateOne: value.FinalStateOne, FinalStateTwo: value.FinalStateTwo} + } + return snapshot, nil +} + +func bisectionFields(position, cycle *big.Int, rawSide uint8) (*big.Int, *big.Int, model.CommitmentSide, error) { + side, err := tournamentEnum("responder", rawSide, model.CommitmentSideOne, model.CommitmentSideTwo) + return position, cycle, side, err +} + +func (a *ITournamentAdapterImpl) InnerResult(opts *bind.CallOpts) (InnerResult, error) { + value, err := a.tournament.InnerResult(opts) + if err != nil { + return InnerResult{}, err + } + disposition, err := tournamentEnum("inner disposition", value.Disposition, + model.InnerTournamentUnsettled, model.InnerTournamentWinner, model.InnerTournamentEliminable) + if err != nil { + return InnerResult{}, err + } + if disposition != model.InnerTournamentWinner && (value.ParentCommitment != [32]byte{} || value.PausedAllowance != 0) { + return InnerResult{}, errors.New("inactive tournament inner result has winner fields") + } + return InnerResult{Disposition: disposition, ParentCommitment: value.ParentCommitment, PausedAllowance: value.PausedAllowance}, nil +} + +func (a *ITournamentAdapterImpl) StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) { + if err := requirePinnedTournamentRead(opts); err != nil { + return StructuralEventCounts{}, err + } + var counts StructuralEventCounts + for _, counter := range []struct { + name string + read func(*bind.CallOpts) (*big.Int, error) + value **big.Int + }{ + {tournamentEventCommitmentJoined, a.tournament.GetCommitmentJoinedCount, &counts.CommitmentJoined}, + {tournamentEventMatchCreated, a.tournament.GetMatchCreatedCount, &counts.MatchCreated}, + {tournamentEventMatchAdvanced, a.tournament.GetMatchAdvancedCount, &counts.MatchAdvanced}, + {tournamentEventLeafMatchSealed, a.tournament.GetLeafMatchSealedCount, &counts.LeafMatchSealed}, + {tournamentEventMatchDeleted, a.tournament.GetMatchDeletedCount, &counts.MatchDeleted}, + {tournamentEventNewInnerTournament, a.tournament.GetNewInnerTournamentCount, &counts.NewInnerTournament}, + } { + value, err := counter.read(opts) + if err != nil { + return StructuralEventCounts{}, fmt.Errorf("reading %s count: %w", counter.name, err) + } + *counter.value = value + } + return counts, nil +} + +var tournamentEventNames = [...]string{ + "CommitmentJoined", "MatchAdvanced", "MatchCreated", "MatchDeleted", + "NewInnerTournament", "LeafMatchSealed", "PartialBondRefund", "BondRecovered", tournamentEventStandingChanged, +} + +func buildAllEventsFilterQuery( + opts *bind.FilterOpts, + tournamentAddress common.Address, + contractABI *abi.ABI, +) (ethereum.FilterQuery, error) { + if opts == nil { + return ethereum.FilterQuery{}, errors.New("tournament event filter is nil") + } + topics := make([]common.Hash, 0, len(tournamentEventNames)) + for _, name := range tournamentEventNames { + event, ok := contractABI.Events[name] + if !ok { + return ethereum.FilterQuery{}, fmt.Errorf("ITournament ABI is missing monitored event %s", name) + } + topics = append(topics, event.ID) + } + q := ethereum.FilterQuery{ + Addresses: []common.Address{tournamentAddress}, + FromBlock: new(big.Int).SetUint64(opts.Start), + Topics: [][]common.Hash{topics}, + } + if opts.End != nil { + q.ToBlock = new(big.Int).SetUint64(*opts.End) + } + return q, nil +} + +func (a *ITournamentAdapterImpl) RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) { + if opts == nil || opts.End == nil || opts.Start > *opts.End { + return nil, errors.New("tournament events require a fixed, nonempty block range") + } + contractABI, err := itournament.ITournamentMetaData.GetAbi() + if err != nil { + return nil, err + } + query, err := buildAllEventsFilterQuery(opts, a.tournamentAddress, contractABI) + if err != nil { + return nil, err + } + iterator, err := a.filter.ChunkedFilterLogs(opts.Context, a.client, query) + if err != nil { + return nil, err + } + type logPosition struct { + block uint64 + index uint + } + type eventIdentity struct { + transaction common.Hash + index uint + } + positions := make(map[logPosition]struct{}) + identities := make(map[eventIdentity]struct{}) + blockHashes := make(map[uint64]common.Hash) + var logs []types.Log + for entry, err := range iterator { + if err != nil { + return nil, err + } + if entry == nil || entry.Address != a.tournamentAddress || entry.Removed { + return nil, errors.New("tournament event has an invalid address or removed flag") + } + if entry.BlockNumber < opts.Start || entry.BlockNumber > *opts.End { + return nil, errors.New("tournament event is outside the requested block range") + } + if entry.TxHash == (common.Hash{}) || entry.BlockHash == (common.Hash{}) { + return nil, errors.New("tournament event has an incomplete log identity") + } + position := logPosition{entry.BlockNumber, entry.Index} + identity := eventIdentity{entry.TxHash, entry.Index} + if _, exists := positions[position]; exists { + return nil, errors.New("tournament event repeats a block log position") + } + if _, exists := identities[identity]; exists { + return nil, errors.New("tournament event repeats a transaction log identity") + } + if hash, exists := blockHashes[entry.BlockNumber]; exists && hash != entry.BlockHash { + return nil, errors.New("tournament events disagree on the block hash") + } + positions[position] = struct{}{} + identities[identity] = struct{}{} + blockHashes[entry.BlockNumber] = entry.BlockHash + logs = append(logs, *entry) + } + slices.SortFunc(logs, func(one, two types.Log) int { + if order := cmp.Compare(one.BlockNumber, two.BlockNumber); order != 0 { + return order + } + return cmp.Compare(one.Index, two.Index) + }) + events := &TournamentEvents{} + for _, entry := range logs { + if err := a.appendTournamentEvent(contractABI, entry, events); err != nil { + return nil, err + } + } + return events, nil +} + +func (a *ITournamentAdapterImpl) appendTournamentEvent(contractABI *abi.ABI, entry types.Log, events *TournamentEvents) error { + if len(entry.Topics) == 0 { + return errors.New("tournament event has no topic") + } + definition, err := contractABI.EventByID(entry.Topics[0]) + if err != nil { + return fmt.Errorf("tournament event has unexpected topic %s: %w", entry.Topics[0], err) + } + // UnpackLog skips decoding empty data, so validate its exact size first. + const abiWordBytes = 32 + dataArguments := definition.Inputs.NonIndexed() + words, err := staticABIWordCount(dataArguments) + if err != nil { + return fmt.Errorf("tournament %s ABI: %w", definition.Name, err) + } + if len(entry.Data) != words*abiWordBytes || len(entry.Topics) != len(definition.Inputs)-len(dataArguments)+1 { + return fmt.Errorf("tournament %s event has invalid ABI field lengths", definition.Name) + } + switch entry.Topics[0] { + case contractABI.Events["CommitmentJoined"].ID: + event, err := a.tournament.ParseCommitmentJoined(entry) + if err != nil { + return err + } + events.CommitmentJoined = append(events.CommitmentJoined, event) + case contractABI.Events["MatchAdvanced"].ID: + event, err := a.tournament.ParseMatchAdvanced(entry) + if err != nil { + return err + } + if err := validateTournamentEventPair(event.MatchIdHash, event.One, event.Two); err != nil { + return err + } + events.MatchAdvanced = append(events.MatchAdvanced, event) + case contractABI.Events["MatchCreated"].ID: + event, err := a.tournament.ParseMatchCreated(entry) + if err != nil { + return err + } + if err := validateTournamentEventPair(event.MatchIdHash, event.One, event.Two); err != nil { + return err + } + events.MatchCreated = append(events.MatchCreated, event) + case contractABI.Events["MatchDeleted"].ID: + event, err := a.tournament.ParseMatchDeleted(entry) + if err != nil { + return err + } + if _, err := model.MatchDeletionReasonFromUint8(event.Reason); err != nil { + return fmt.Errorf("tournament deletion reason: %w", err) + } + if _, err := model.WinnerCommitmentFromUint8(event.WinnerCommitment); err != nil { + return fmt.Errorf("tournament deletion winner: %w", err) + } + events.MatchDeleted = append(events.MatchDeleted, event) + case contractABI.Events["NewInnerTournament"].ID: + event, err := a.tournament.ParseNewInnerTournament(entry) + if err != nil { + return err + } + if err := validateTournamentEventPair(event.MatchIdHash, event.One, event.Two); err != nil { + return err + } + if _, err := tournamentDescriptorFromBinding(event.Descriptor); err != nil { + return err + } + events.NewInnerTournament = append(events.NewInnerTournament, event) + case contractABI.Events["LeafMatchSealed"].ID: + event, err := a.tournament.ParseLeafMatchSealed(entry) + if err != nil { + return err + } + if err := validateTournamentEventPair(event.MatchIdHash, event.One, event.Two); err != nil { + return err + } + events.LeafMatchSealed = append(events.LeafMatchSealed, event) + case contractABI.Events["PartialBondRefund"].ID: + event, err := a.tournament.ParsePartialBondRefund(entry) + if err != nil { + return err + } + events.PartialBondRefund = append(events.PartialBondRefund, event) + case contractABI.Events["BondRecovered"].ID: + event, err := a.tournament.ParseBondRecovered(entry) + if err != nil { + return err + } + events.BondRecovered = append(events.BondRecovered, event) + case contractABI.Events[tournamentEventStandingChanged].ID: + event, err := a.tournament.ParseStandingChanged(entry) + if err != nil { + return err + } + events.StandingChanged = append(events.StandingChanged, event) + default: + return fmt.Errorf("tournament event has unexpected topic %s", entry.Topics[0]) + } + return nil +} + +func validateTournamentEventPair(id, one, two [32]byte) error { + if crypto.Keccak256Hash(one[:], two[:]) != id { + return errors.New("tournament event pair does not match its identity") + } + return nil +} diff --git a/internal/prt/join_gas_anvil_test.go b/internal/prt/join_gas_anvil_test.go new file mode 100644 index 000000000..2ba04d486 --- /dev/null +++ b/internal/prt/join_gas_anvil_test.go @@ -0,0 +1,236 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package prt + +import ( + "context" + "log/slog" + "math/big" + "os" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +// The peer's join lands after our last simulation and before our POST. The same +// execution branch can change when the peer precedes us within one block. +type joinGasBackend struct { + txsender.ProtectedClient + beforePublish func() +} + +func (b *joinGasBackend) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + if b.beforePublish != nil { + hook := b.beforePublish + b.beforePublish = nil + hook() + } + return b.ProtectedClient.Publish(ctx, raw) +} + +type joinGasPlanner struct{ request txsender.Request } + +func (p joinGasPlanner) Next(context.Context, txsender.BlockRef, *txsender.Current[uint8]) (txsender.Decision[uint8], error) { + return txsender.Publish(uint8(1), p.request), nil +} + +func (joinGasPlanner) Recheck(_ context.Context, _ txsender.BlockRef, _ uint8) (txsender.Eligibility, error) { + return txsender.Useful, nil +} + +func TestAnvilJoinGasAfterPeerJoin(t *testing.T) { + if os.Getenv("CARTESI_PRT_ANVIL_TEST") != "1" { + t.Skip("set CARTESI_PRT_ANVIL_TEST=1 for the isolated Anvil release fixture") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + client, err := ethutil.DialEthClient(ctx, os.Getenv("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT")) + require.NoError(t, err) + t.Cleanup(client.Close) + factoryAddress := os.Getenv("CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS") + if factoryAddress == "" { + factoryAddress = "0xd34BEC37Fa5816ABA2f87BdaD2E13dd1B161370f" + } + factory, err := idaveappfactory.NewIDaveAppFactory(common.HexToAddress(factoryAddress), client) + require.NoError(t, err) + for _, fixture := range []struct { + name string + peerFirst bool + estimate bool + halfOnly bool + }{ + {name: "estimate_only_without_peer", estimate: true}, + {name: "estimate_only_loses_pairing_race", peerFirst: true, estimate: true}, + {name: "half_padding_alone_loses_pairing_race", peerFirst: true, halfOnly: true}, + {name: "padded_estimate_survives_pairing_race", peerFirst: true}, + } { + t.Run(fixture.name, func(t *testing.T) { + ours, peer := joinGasSigner(ctx, t, client), joinGasSigner(ctx, t, client) + salt := crypto.Keccak256Hash(ours.From.Bytes()) + addresses, err := factory.CalculateDaveAppAddress(&bind.CallOpts{Context: ctx}, salt, + big.NewInt(1000), ours.From, nil, idaveappfactory.WithdrawalConfig{}, salt) + require.NoError(t, err) + deployment, err := factory.NewDaveApp(ours, salt, big.NewInt(1000), ours.From, + nil, idaveappfactory.WithdrawalConfig{}, salt) + require.NoError(t, err) + joinGasMined(ctx, t, client, deployment, types.ReceiptStatusSuccessful) + consensus, err := NewDaveConsensusAdapter(addresses.DaveConsensusAddress, client) + require.NoError(t, err) + sealed, err := consensus.GetCurrentSealedEpoch(&bind.CallOpts{Context: ctx}) + require.NoError(t, err) + tournament, err := itournament.NewITournament(sealed.Tournament, client) + require.NoError(t, err) + bond, err := tournament.BondValue(&bind.CallOpts{Context: ctx}) + require.NoError(t, err) + machine := common.HexToHash("0x0a") + epoch := &model.Epoch{MachineHash: &machine, CommitmentProof: make([]common.Hash, model.Log2EpochComputationHashLeafCount)} + data, err := joinData(epoch) + require.NoError(t, err) + request := txsender.Request{To: sealed.Tournament, Data: data, Value: bond} + estimated, err := client.EstimateGas(ctx, ethereum.CallMsg{From: ours.From, To: &request.To, Data: data, Value: bond}) + require.NoError(t, err) + var controlGas uint64 + if fixture.estimate { + controlGas = estimated + } + if fixture.halfOnly { + controlGas = estimated + estimated/2 + } + backend := &joinGasBackend{ProtectedClient: txsender.ProtectedClient{Client: client}} + if fixture.peerFirst { + backend.beforePublish = func() { + peerHash := common.HexToHash("0x0b") + proof := make([]common.Hash, model.Log2EpochComputationHashLeafCount) + position := uint64(1< failure.lastHead + failure.fromBlock = fromBlock + failure.lastHead = head + reachedThreshold := failure.failedHeads == tournamentObservationFailureThreshold-1 + if increasing && failure.failedHeads < tournamentObservationFailureThreshold { + failure.failedHeads++ + } + if reachedThreshold || failure.failedHeads >= tournamentObservationFailureThreshold && warningDue(failure.warnedAt) { + failure.warnedAt = time.Now() + s.Logger.Error("PRT tournament observation is stalled; incomplete intervals will retry", + "application", app.Name, "address", app.IApplicationAddress, + "operation", "tournament_observation", "from_block", failure.fromBlock, + "through_block", head, "configured_head", head, "failed_heads", failure.failedHeads, + "error", err) + } + s.observationFailures[app.ID] = failure +} + +// A pass with completed intervals and no failed target, or explicit disable, +// clears a failure. A successful sibling alone cannot prove recovery. +func (s *Service) clearTournamentObservationFailure(appID int64) { + delete(s.observationFailures, appID) +} diff --git a/internal/prt/observation_health_test.go b/internal/prt/observation_health_test.go new file mode 100644 index 000000000..5faf96150 --- /dev/null +++ b/internal/prt/observation_health_test.go @@ -0,0 +1,257 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "log/slog" + "strings" + "sync" + "testing" +) + +func failTournamentObservation(ctx context.Context, f *observerCheckpointFixture, epoch *model.Epoch, head uint64, cause error) { + f.t.Helper() + f.epochs(epoch) + windowEnd := head + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(windowEnd))). + Return(uint64(0), cause).Once() + deferActions, err := observeEpochsForTest(ctx, f.s, f.app, head) + require.ErrorIs(f.t, err, cause) + require.True(f.t, deferActions) + require.Equal(f.t, uint64(50), f.app.LastTournamentCheckBlock) + require.Equal(f.t, model.ApplicationStatus_OK, f.app.Status) +} + +func TestTournamentObservationHealthCountsIncreasingHeads(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + for i, test := range []struct { + head uint64 + count uint8 + }{ + {100, 1}, {100, 1}, {99, 1}, {101, 2}, {102, 3}, {103, 4}, {104, 5}, {105, 5}, + } { + // Neither a new error message nor a changing head resets the failure. + failTournamentObservation(t.Context(), f, epoch, test.head, fmt.Errorf("provider response %d at head %d", i, test.head)) + require.Equal(t, test.count, f.s.observationFailures[f.app.ID].failedHeads) + require.True(t, f.s.Ready()) + } + require.Equal(t, uint64(10), f.s.observationFailures[f.app.ID].fromBlock) + require.Equal(t, 1, strings.Count(output.String(), "PRT tournament observation is stalled")) + require.Contains(t, output.String(), "application="+f.app.Name) + require.Contains(t, output.String(), "operation=tournament_observation") + require.Contains(t, output.String(), "from_block=10") + require.Contains(t, output.String(), "configured_head=104") + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentObservationHealthClearsAfterAnotherObserverRetiresTarget(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.observationFailures[f.app.ID] = tournamentObservationFailure{fromBlock: 51, lastHead: 105, failedHeads: 5} + f.repo.storedTournaments = []*model.Tournament{{Address: common.HexToAddress("0x100"), + Snapshot: model.TournamentSnapshot{AsOfBlock: 105, FinishedAtBlock: 90, Standing: model.TournamentStandingRootWinner, + BondRecovery: model.TournamentBondRecovery{Disposition: model.BondDispositionRecovered}}}} + require.NoError(t, f.s.observeTournamentState(t.Context(), f.app, nil, f.consensus, observerFacts(106))) + require.Empty(t, f.s.observationFailures) + f.s.recordTournamentObservationFailure(t.Context(), f.app, 107, 80, errors.New("new target unavailable")) + require.Equal(t, uint8(1), f.s.observationFailures[f.app.ID].failedHeads) + f.s.recordTournamentObservationFailure(t.Context(), f.app, 108, 95, errors.New("another target unavailable")) + require.Equal(t, uint64(95), f.s.observationFailures[f.app.ID].fromBlock) +} + +func TestTournamentObservationHealthClearsOnlyAfterPublication(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + for head := uint64(100); head < 105; head++ { + failTournamentObservation(t.Context(), f, epoch, head, errors.New("observation unavailable")) + } + require.True(t, f.s.Ready()) + + // A head with no new observation window does not prove recovery. + f.epochs() + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 50) + require.NoError(t, err) + require.True(t, f.s.Ready()) + require.Equal(t, uint8(5), f.s.observationFailures[f.app.ID].failedHeads) + + // An unrelated query failure and an empty result cannot clear it either. + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), errors.New("epoch query unavailable")).Once() + _, err = observeEpochsForTest(t.Context(), f.s, f.app, 106) + require.ErrorContains(t, err, "epoch query unavailable") + f.epochs() + _, err = observeEpochsForTest(t.Context(), f.s, f.app, 50) + require.NoError(t, err) + require.True(t, f.s.Ready()) + + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 90) + _, err = observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.True(t, f.s.Ready()) + require.Empty(t, f.s.observationFailures) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestTournamentObservationHealthIncludesFailedPublication(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + cause := errors.New("no match found for update") + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(cause).Once() + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.ErrorIs(t, err, cause) + require.Equal(t, uint8(1), f.s.observationFailures[f.app.ID].failedHeads) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) +} + +func TestTournamentObservationHealthIgnoresAcceptanceFailure(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(1), nil).Once() + tournament := f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + cause := errors.New("acceptance write unavailable") + f.acceptance(epoch, tournament, cause) + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.ErrorIs(t, err, cause) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Empty(t, f.s.observationFailures) + require.True(t, f.s.Ready()) +} + +func TestTournamentObservationHealthKeepsHealthyApplicationsRunning(t *testing.T) { + f, client := tickFixture(t) + healthy := *f.app + healthy.ID++ + healthy.Name = "healthy-app" + healthy.IApplicationAddress = common.HexToAddress("0x999") + healthy.IConsensusAddress = common.HexToAddress("0x998") + f.repo.apps = append(f.repo.apps, &healthy) + ready := resultTestSnapshot(f.epoch, false) + consensus := newCompatibleDaveConsensusAdapterMock(healthy.IConsensusAddress) + consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(ready.sealed, nil) + consensus.On("CanStageTournamentResult", mock.Anything).Return(ready.stage, nil) + consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(ready.accept, nil) + f.factory.On("CreateDaveConsensusAdapter", healthy.IConsensusAddress).Return(consensus, nil) + f.repo.On("GetEpoch", mock.Anything, healthy.IApplicationAddress.Hex(), f.epoch.Index).Return((*model.Epoch)(nil), nil) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{f.epoch}, uint64(1), nil) + f.repo.On("ListEpochs", mock.Anything, healthy.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), nil) + f.consensus.On("GetCurrentSealedEpoch", mock.Anything).Return(ready.sealed, nil) + f.consensus.On("CanStageTournamentResult", mock.Anything).Return(ready.stage, nil) + f.consensus.On("CanAcceptStagedTournamentResult", mock.Anything).Return(ready.accept, nil) + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(0), errors.New("invalid descriptor")) + for head := uint64(101); head < 107; head++ { + tickHead(client, head) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(head), f.epoch) + expectProtectionReads(t, f.factory, consensus, observerFacts(head)) + _, err := f.d.Tick(t.Context()) + require.ErrorContains(t, err, "invalid descriptor") + require.Equal(t, uint64(100), healthy.LastTournamentCheckBlock, "a healthy app with no tournament has no new coverage to record") + } + require.True(t, f.d.Ready(), "per-app observation cannot change hard prerequisite readiness") + require.Len(t, f.d.observationFailures, 1) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.ApplicationStatus_OK, healthy.Status) +} + +func TestTournamentObservationHealthClearsDisabledApplications(t *testing.T) { + f := newDeliveryFixture(t) + f.app.Enabled = false + f.app.Status = model.ApplicationStatus_Failed + f.d.observationFailures[f.app.ID] = tournamentObservationFailure{failedHeads: tournamentObservationFailureThreshold} + facts := txsender.BlockRef{Number: f.app.LastTournamentCheckBlock} + require.NoError(t, f.d.observeApplication(t.Context(), f.app, facts)) + require.Empty(t, f.d.observationFailures) + f.repo.AssertNotCalled(t, "ListEpochs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) +} + +func TestTournamentObservationHealthIgnoresShutdownCancellation(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + stopping bool + cause error + counted bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"joined shutdown cancellations", true, errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), false}, + {"shutdown database failure", true, dbErr, true}, + {"shutdown mixed database failure", true, fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {"shutdown joined deadline", true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + failTournamentObservation(ctx, f, checkpointEpoch(0, "0x100"), 100, test.cause) + require.Equal(t, test.counted, len(f.s.observationFailures) == 1) + }) + } +} + +func TestTournamentObservationReadinessConcurrentWithUpdates(t *testing.T) { + s, _ := newPRTServiceMock() + app := prtRevertTestApp() + cause := errors.New("observation unavailable") + var readers sync.WaitGroup + readers.Go(func() { + for range 1000 { + s.Ready() + } + }) + for head := uint64(1); head <= 1000; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, cause) + s.clearTournamentObservationFailure(app.ID) + } + readers.Wait() + require.True(t, s.Ready()) +} + +func TestTournamentObservationHealthIncludesLocalFailureStates(t *testing.T) { + for _, status := range []model.ApplicationStatus{ + model.ApplicationStatus_OK, model.ApplicationStatus_Failed, + model.ApplicationStatus_Corrupted, model.ApplicationStatus_Diverged, + } { + t.Run(status.String(), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.Status = status + for head := uint64(1); head <= tournamentObservationFailureThreshold; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, errors.New("window unavailable")) + } + require.True(t, s.Ready()) + require.Equal(t, status, app.Status) + require.Empty(t, repo.Calls) + }) + } +} diff --git a/internal/prt/observation_independence_test.go b/internal/prt/observation_independence_test.go new file mode 100644 index 000000000..f49f15d8f --- /dev/null +++ b/internal/prt/observation_independence_test.go @@ -0,0 +1,61 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +type slowObservationRepository struct { + *deliveryRepoFake + delay time.Duration +} + +func (r *slowObservationRepository) ListEpochs( + ctx context.Context, _ string, _ repository.EpochFilter, _ repository.Pagination, _ bool, +) ([]*model.Epoch, uint64, error) { + timer := time.NewTimer(r.delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return nil, 0, ctx.Err() + case <-timer.C: + return nil, 0, nil + } +} + +func TestObservationHasNoPublicationTimeShare(t *testing.T) { + f, client := tickFixture(t) + f.app.Status = model.ApplicationStatus_Failed + f.repo.publishedRoots = nil + f.repo.storedTournaments = nil + f.d.repository = &slowObservationRepository{deliveryRepoFake: f.repo, delay: 40 * time.Millisecond} + tickHead(client, 110) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(110)) + _, err := f.d.Tick(t.Context()) + require.NoError(t, err) + require.Empty(t, f.backend.headerReads) + require.Empty(t, f.repo.windows, "an empty observation must not invent tournament coverage") + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) +} + +func TestObservationStillHonorsServiceCancellation(t *testing.T) { + f, client := tickFixture(t) + f.app.Status = model.ApplicationStatus_Failed + f.repo.publishedRoots = nil + f.repo.storedTournaments = nil + f.d.repository = &slowObservationRepository{deliveryRepoFake: f.repo, delay: time.Second} + tickHead(client, 110) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + _, err := f.d.Tick(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Empty(t, f.repo.windows) + require.Empty(t, f.backend.published) +} diff --git a/internal/prt/observation_projection.go b/internal/prt/observation_projection.go new file mode 100644 index 000000000..136adda21 --- /dev/null +++ b/internal/prt/observation_projection.go @@ -0,0 +1,322 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "slices" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +// tournamentEventBatch folds immutable event facts into copies of the stored +// participants, then refreshes mutable views at the same pinned block. +func (s *Service) tournamentEventBatch( + ctx context.Context, app *Application, epoch *Epoch, tournament *Tournament, + adapter TournamentAdapter, events *TournamentEvents, block uint64, parent *Match, +) (*repository.TournamentEventBatch, error) { + address := tournament.Address.Hex() + commitments, _, err := s.repository.ListCommitments(ctx, app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false) + if err != nil { + return nil, fmt.Errorf("loading tournament commitments: %w", err) + } + matches, _, err := s.repository.ListMatches(ctx, app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false) + if err != nil { + return nil, fmt.Errorf("loading tournament matches: %w", err) + } + commitmentByHash := make(map[common.Hash]*Commitment, len(commitments)+len(events.CommitmentJoined)) + for _, stored := range commitments { + projection := *stored + commitmentByHash[stored.Commitment] = &projection + } + matchByHash := make(map[common.Hash]*Match, len(matches)+len(events.MatchCreated)) + unchangedDeletedMatches := make(map[common.Hash]bool, len(matches)) + for _, stored := range matches { + projection := *stored + matchByHash[stored.IDHash] = &projection + if deletedMatchObservationComplete(stored, tournament.Snapshot.AsOfBlock, block) { + unchangedDeletedMatches[stored.IDHash] = true + } + } + batch := &repository.TournamentEventBatch{Tournament: tournament} + for _, event := range events.CommitmentJoined { + joined := &Commitment{ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Commitment: event.Commitment, FinalStateHash: event.FinalStateHash, SubmitterAddress: event.Submitter, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index)} + if previous := commitmentByHash[joined.Commitment]; previous != nil { + if previous.FinalStateHash != joined.FinalStateHash || previous.SubmitterAddress != joined.SubmitterAddress || + previous.BlockNumber != joined.BlockNumber || previous.TxHash != joined.TxHash || previous.LogIndex != joined.LogIndex { + return nil, fmt.Errorf("commitment %s has conflicting join events", joined.Commitment) + } + } else { + commitmentByHash[joined.Commitment] = joined + } + } + for _, event := range events.MatchCreated { + delete(unchangedDeletedMatches, event.MatchIdHash) + created := &Match{ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + IDHash: event.MatchIdHash, CommitmentOne: event.One, CommitmentTwo: event.Two, LeftOfTwo: event.LeftOfTwo, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + ResponderDeadline: event.ResponderDeadline, EliminableAt: event.EliminableAt, Winner: WinnerCommitment_NONE, + DeletionReason: MatchDeletionReason_NOT_DELETED} + if previous := matchByHash[created.IDHash]; previous != nil { + if previous.CommitmentOne != created.CommitmentOne || previous.CommitmentTwo != created.CommitmentTwo || + previous.LeftOfTwo != created.LeftOfTwo || previous.BlockNumber != created.BlockNumber || + previous.TxHash != created.TxHash || previous.LogIndex != created.LogIndex || + previous.EliminableAt != created.EliminableAt || previous.ResponderDeadline != created.ResponderDeadline { + return nil, fmt.Errorf("match %s has conflicting creation events", created.IDHash) + } + } else { + matchByHash[created.IDHash] = created + } + } + for _, event := range events.MatchAdvanced { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if match == nil || match.CommitmentOne != event.One || match.CommitmentTwo != event.Two { + return nil, fmt.Errorf("match advance does not match its creation pair for %x", event.MatchIdHash) + } + if event.CurrentHeight >= tournament.Height { + return nil, fmt.Errorf("advance current height is outside tournament height") + } + position, err := Uint256FromBig(event.SegmentStartPosition) + if err != nil { + return nil, fmt.Errorf("match advance position: %w", err) + } + batch.MatchAdvances = append(batch.MatchAdvances, &MatchAdvanced{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + IDHash: event.MatchIdHash, OtherParent: event.OtherParent, LeftNode: event.LeftNode, + SegmentStartPosition: position, CurrentHeight: event.CurrentHeight, ResponderDeadline: event.ResponderDeadline, + EliminableAt: event.EliminableAt, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + }) + } + for _, event := range events.LeafMatchSealed { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if match == nil || match.CommitmentOne != event.One || match.CommitmentTwo != event.Two { + return nil, fmt.Errorf("leaf seal does not match its creation pair for %x", event.MatchIdHash) + } + if tournament.Kind != TournamentKindLeaf { + return nil, fmt.Errorf("leaf seal requires a leaf tournament") + } + position, err := Uint256FromBig(event.DivergencePosition) + if err != nil || !divergencePositionWithinHeight(position, tournament.Height) { + return nil, fmt.Errorf("seal divergence position is outside tournament height") + } + seal := &LeafMatchSeal{AgreeState: event.AgreeState, DivergencePosition: position, FinalStateOne: event.FinalStateOne, + FinalStateTwo: event.FinalStateTwo, DeadlineOne: event.DeadlineOne, DeadlineTwo: event.DeadlineTwo, + EliminableAt: event.EliminableAt, + BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index)} + if match.LeafSeal != nil && *match.LeafSeal != *seal { + return nil, fmt.Errorf("match %s has conflicting leaf seal events", match.IDHash) + } + match.LeafSeal = seal + } + for _, event := range events.NewInnerTournament { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if tournament.Kind != TournamentKindNonLeaf { + return nil, fmt.Errorf("child creation requires a non-leaf tournament") + } + if match == nil || match.CommitmentOne != event.One || match.CommitmentTwo != event.Two { + return nil, fmt.Errorf("child creation does not match its creation pair") + } + } + for _, event := range events.MatchDeleted { + delete(unchangedDeletedMatches, event.MatchIdHash) + match := matchByHash[event.MatchIdHash] + if match == nil || match.CommitmentOne != event.One || match.CommitmentTwo != event.Two { + return nil, fmt.Errorf("match deletion does not match its creation pair for %x", event.MatchIdHash) + } + reason, err := MatchDeletionReasonFromUint8(event.Reason) + if err != nil { + return nil, err + } + winner, err := WinnerCommitmentFromUint8(event.WinnerCommitment) + if err != nil { + return nil, err + } + if match.DeletionTxHash != nil && (*match.DeletionTxHash != event.Raw.TxHash || + match.DeletionLogIndex == nil || *match.DeletionLogIndex != uint64(event.Raw.Index) || + match.DeletionBlockNumber != event.Raw.BlockNumber || match.DeletionReason != reason || match.Winner != winner) { + return nil, fmt.Errorf("match %s has conflicting deletion events", match.IDHash) + } + match.DeletionReason, match.Winner = reason, winner + match.DeletionBlockNumber, match.DeletionTxHash = event.Raw.BlockNumber, new(event.Raw.TxHash) + match.DeletionLogIndex = new(uint64(event.Raw.Index)) + } + if err := appendTournamentBondEvents(batch, app, epoch, events); err != nil { + return nil, err + } + for _, commitment := range commitmentByHash { + standing, err := adapter.CommitmentStanding(pinnedCallOpts(ctx, block), commitment.Commitment) + if err != nil { + return nil, fmt.Errorf("reading commitment %s standing: %w", commitment.Commitment, err) + } + if !standing.Joined || standing.FinalState != commitment.FinalStateHash { + return nil, fmt.Errorf("commitment %s standing does not match its join event", commitment.Commitment) + } + commitment.Snapshot = CommitmentSnapshot{AsOfBlock: block, Claimer: standing.Claimer, + ClockRunning: standing.ClockRunning, ClockDeadline: standing.ClockDeadline, ClockAllowance: standing.ClockAllowance} + batch.Commitments = append(batch.Commitments, commitment) + } + for _, match := range matchByHash { + if unchangedDeletedMatches[match.IDHash] { + // Keep the stored certification block. There is no new match view to publish. + continue + } + view, err := adapter.MatchSnapshot(pinnedCallOpts(ctx, block), match.CommitmentOne, match.CommitmentTwo) + if err != nil { + return nil, fmt.Errorf("reading match %s snapshot: %w", match.IDHash, err) + } + match.Snapshot, err = projectMatchSnapshot(view, block, match.DeletionTxHash != nil) + if err != nil { + return nil, fmt.Errorf("match %s: %w", match.IDHash, err) + } + batch.Matches = append(batch.Matches, match) + } + if len(events.StandingChanged) > 0 && tournament.Level != uint64(RootLevel) && parent == nil { + if tournament.ParentTournamentAddress == nil || tournament.ParentMatchIDHash == nil { + return nil, fmt.Errorf("inner standing has no parent identity") + } + parent, err = s.repository.GetMatch(ctx, app.Name, epoch.Index, tournament.ParentTournamentAddress.Hex(), + tournament.ParentMatchIDHash.Hex()) + if err != nil { + return nil, err + } + if parent == nil { + return nil, fmt.Errorf("inner standing has no parent match") + } + } + if err := appendTournamentStandingEvents(batch, app, epoch, events, matchByHash, parent); err != nil { + return nil, err + } + // Stable ordering makes the batch deterministic without changing event chronology. + slices.SortFunc(batch.Commitments, func(a, b *Commitment) int { return a.Commitment.Cmp(b.Commitment) }) + slices.SortFunc(batch.Matches, func(a, b *Match) int { return a.IDHash.Cmp(b.IDHash) }) + return batch, nil +} + +// Deleted matches have no mutable contract state. Reuse only a snapshot that +// was validated after deletion and committed within the published observation. +func deletedMatchObservationComplete(match *Match, cursor, head uint64) bool { + if match.DeletionTxHash == nil || *match.DeletionTxHash == (common.Hash{}) || match.DeletionLogIndex == nil || + match.DeletionBlockNumber == 0 || match.DeletionBlockNumber > match.Snapshot.AsOfBlock || + match.Snapshot.AsOfBlock > cursor || cursor > head || + match.DeletionReason == MatchDeletionReason_NOT_DELETED || !slices.Contains(MatchDeletionReasonAllValues, match.DeletionReason) || + !slices.Contains(WinnerCommitmentAllValues, match.Winner) { + return false + } + return match.Snapshot == (MatchSnapshot{AsOfBlock: match.Snapshot.AsOfBlock, + Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone}) +} + +func appendTournamentBondEvents(batch *repository.TournamentEventBatch, app *Application, epoch *Epoch, events *TournamentEvents) error { + for _, event := range events.PartialBondRefund { + value, err := Uint256FromBig(event.Value) + if err != nil { + return fmt.Errorf("partial bond refund value: %w", err) + } + batch.BondEvents = append(batch.BondEvents, &BondEvent{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: batch.Tournament.Address, + Type: BondEventPartialRefund, BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + Refund: &PartialBondRefund{Recipient: event.Recipient, Value: value, Success: event.Success}, + }) + } + for _, event := range events.BondRecovered { + payment, err := Uint256FromBig(event.Payment) + if err != nil { + return fmt.Errorf("bond recovery payment: %w", err) + } + burned, err := Uint256FromBig(event.Burned) + if err != nil { + return fmt.Errorf("bond recovery burn: %w", err) + } + batch.BondEvents = append(batch.BondEvents, &BondEvent{ + ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: batch.Tournament.Address, + Type: BondEventRecovered, BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, LogIndex: uint64(event.Raw.Index), + Recovery: &BondRecovered{Commitment: event.Commitment, Claimer: event.Claimer, Payment: payment, Burned: burned}, + }) + } + return nil +} + +func projectMatchSnapshot(view ObservedMatchSnapshot, block uint64, deleted bool) (MatchSnapshot, error) { + result := MatchSnapshot{AsOfBlock: block, Phase: view.Phase, + TimeoutOutcome: view.TimeoutOutcome, DeferredCharge: view.DeferredCharge} + if deleted && view.Phase != MatchPhaseUninitialized { + return MatchSnapshot{}, fmt.Errorf("deleted match has phase %s", view.Phase) + } + switch view.Phase { + case MatchPhaseBisecting: + if view.Bisecting == nil || view.ReadyToSeal != nil || view.Sealed != nil { + return MatchSnapshot{}, fmt.Errorf("invalid bisecting match payload") + } + v := view.Bisecting + position, err := Uint256FromBig(v.SegmentStartPosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.SegmentStartCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Bisection = &MatchBisectionSnapshot{RevealingParent: v.RevealingParent, WaitingLeft: v.WaitingLeft, + WaitingRight: v.WaitingRight, SegmentStartPosition: position, SegmentStartCycle: cycle, + CurrentHeight: new(v.CurrentHeight), Responder: v.Responder} + case MatchPhaseReadyToSeal: + if view.ReadyToSeal == nil || view.Bisecting != nil || view.Sealed != nil { + return MatchSnapshot{}, fmt.Errorf("invalid ready-to-seal match payload") + } + v := view.ReadyToSeal + position, err := Uint256FromBig(v.SegmentStartPosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.SegmentStartCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Bisection = &MatchBisectionSnapshot{RevealingParent: v.RevealingParent, WaitingLeft: v.WaitingLeft, + WaitingRight: v.WaitingRight, SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: v.Responder} + case MatchPhaseSealed: + if view.Sealed == nil || view.Bisecting != nil || view.ReadyToSeal != nil { + return MatchSnapshot{}, fmt.Errorf("invalid sealed match payload") + } + v := view.Sealed + position, err := Uint256FromBig(v.DivergencePosition) + if err != nil { + return MatchSnapshot{}, err + } + cycle, err := Uint256FromBig(v.DivergenceCycle) + if err != nil { + return MatchSnapshot{}, err + } + result.Sealed = &MatchSealedSnapshot{AgreeState: v.AgreeState, DivergencePosition: position, DivergenceCycle: cycle, + FinalStateOne: v.FinalStateOne, FinalStateTwo: v.FinalStateTwo} + case MatchPhaseUninitialized: + if !deleted || view.Bisecting != nil || view.ReadyToSeal != nil || view.Sealed != nil || + view.TimeoutOutcome != MatchTimeoutNone || view.DeferredCharge != 0 { + return MatchSnapshot{}, fmt.Errorf("uninitialized match has no matching deletion or has live payload") + } + default: + return MatchSnapshot{}, fmt.Errorf("unknown match phase %s", view.Phase) + } + return result, nil +} + +const tournamentPositionBits = 256 + +func divergencePositionWithinHeight(position Uint256, height uint64) bool { + if height >= tournamentPositionBits { + return true + } + return position.ToBig().BitLen() <= int(height) +} diff --git a/internal/prt/observation_retirement_test.go b/internal/prt/observation_retirement_test.go new file mode 100644 index 000000000..a40d9aef9 --- /dev/null +++ b/internal/prt/observation_retirement_test.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentRetirementRequiresImmutableCommittedSnapshot(t *testing.T) { + base := Tournament{Snapshot: TournamentSnapshot{AsOfBlock: 50, + FinishedAtBlock: 40, + Standing: TournamentStandingRootWinner, BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}}} + for _, test := range []struct { + name string + change func(*Tournament) + retired bool + }{ + {"recovered root", func(*Tournament) {}, true}, + {"failed root", func(v *Tournament) { + v.Snapshot.Standing, v.Snapshot.BondRecovery.Disposition = TournamentStandingRootFailed, BondDispositionNoWinner + }, true}, + {"unfinished", func(v *Tournament) { v.Snapshot.FinishedAtBlock = 0 }, false}, + {"finish not covered", func(v *Tournament) { v.Snapshot.FinishedAtBlock = 51 }, false}, + {"snapshot not committed", func(v *Tournament) { v.Snapshot.AsOfBlock = 51 }, false}, + {"recoverable bond", func(v *Tournament) { v.Snapshot.BondRecovery.Disposition = BondDispositionRecoverable }, false}, + {"inner winner still expires", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerWinner + }, false}, + {"expired winner recovered", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerEliminableWinnerExpired + }, true}, + {"missing child creation", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, nil, TournamentStandingInnerEliminableNoWinner + }, false}, + {"child no winner", func(v *Tournament) { + v.Level, v.CreationEvent, v.Snapshot.Standing = 1, &TournamentCreationEvent{}, TournamentStandingInnerEliminableNoWinner + v.Snapshot.BondRecovery.Disposition = BondDispositionNoWinner + }, true}, + } { + t.Run(test.name, func(t *testing.T) { + value := base + test.change(&value) + require.Equal(t, test.retired, tournamentObservationComplete(&value, 50, 100)) + }) + } + require.False(t, tournamentObservationComplete(&base, 50, 49), "a later snapshot cannot decide retirement at an older head") +} + +func TestRetiredParentStillObservesMutableChildren(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.Status = EpochStatus_ClaimAccepted + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := &Tournament{Address: *epoch.TournamentAddress, MaxLevel: 2, + Snapshot: TournamentSnapshot{ + AsOfBlock: 50, FinishedAtBlock: 40, Standing: TournamentStandingRootWinner, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}, + }} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), parent.Address.Hex()).Return(parent, nil).Maybe() + child := f.tournament(epoch, common.HexToAddress("0x101"), 1, 2, 45, 100, &TournamentEvents{}, nil) + retiredChild := &Tournament{Address: common.HexToAddress("0x102"), Level: 1, MaxLevel: 2, + CreationEvent: &TournamentCreationEvent{BlockNumber: 10}, Snapshot: TournamentSnapshot{ + AsOfBlock: 50, FinishedAtBlock: 45, Standing: TournamentStandingInnerEliminableNoWinner, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionNoWinner}, + }} + f.children(epoch, parent, child, retiredChild) + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), retiredChild.Address.Hex()).Return(retiredChild, nil).Maybe() + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Address == child.Address && + batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(50), parent.Snapshot.AsOfBlock) + require.Equal(t, uint64(50), retiredChild.Snapshot.AsOfBlock) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", parent.Address) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", retiredChild.Address) +} diff --git a/internal/prt/observation_shutdown_test.go b/internal/prt/observation_shutdown_test.go new file mode 100644 index 000000000..525bbef6f --- /dev/null +++ b/internal/prt/observation_shutdown_test.go @@ -0,0 +1,234 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "log/slog" + "math/big" + "strings" + "testing" +) + +const ( + shutdownCancellationCase = "shutdown cancellation" + shutdownDeadlineCase = "shutdown deadline" + shutdownMixedDeadlineCase = "shutdown cancellation and deadline" + runningCancellationCase = "running cancellation" +) + +const standingReadName = "standing" + +func TestPRTObserverShutdownKeepsJoinedRealFailuresVisible(t *testing.T) { + failure := errors.New("database unavailable") + for _, mixed := range []bool{false, true} { + s, repo := newPRTServiceMock() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + causes := []error{repository.ErrTournamentIntervalStale, txsender.ErrInconclusive, context.Canceled} + if mixed { + causes = append(causes, failure) + } + repo.On("GetApplicationIDBoundary", mock.Anything, mock.Anything). + Run(func(mock.Arguments) { cancel() }).Return(int64(0), errors.Join(causes...)).Once() + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if mixed { + require.ErrorIs(t, err, failure) + } else { + require.NoError(t, err, "shutdown waits must not reach the tick template as operational errors") + } + repo.AssertExpectations(t) + } +} + +func TestTournamentObservationShutdownLogs(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, test := range []struct { + name string + stopping bool + cause error + wantLog bool + }{ + {shutdownCancellationCase, true, context.Canceled, false}, + {"joined shutdown cancellations", true, errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), false}, + {"shutdown database failure", true, dbErr, true}, + {"shutdown mixed database failure", true, fmt.Errorf("read: %w", errors.Join(context.Canceled, dbErr)), true}, + {shutdownDeadlineCase, true, context.DeadlineExceeded, true}, + {shutdownMixedDeadlineCase, true, errors.Join(context.Canceled, context.DeadlineExceeded), true}, + {runningCancellationCase, false, context.Canceled, true}, + } { + for _, operation := range []string{ + "new descriptor", "stored descriptor", "new standing", "stored standing", "new result", "stored result", + "projection load", "participant list", "epochs list", "acceptance receipt", "acceptance header", "acceptance update", + } { + t.Run(test.name+"/"+operation, func(t *testing.T) { + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, factory: &adapterFactoryMock{}, + consensus: newCompatibleDaveConsensusAdapterMock(), client: ðClientMock{}, app: prtRevertTestApp()} + f.app.LastTournamentCheckBlock = 50 + f.s.adapterFactory, f.s.client = f.factory, f.client + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.client.AssertExpectations(t) + }) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if test.stopping { + cancel() + } + err := runObservationWithIOError(ctx, t, f, operation, test.cause) + f.s.logErrorUnlessShutdown(ctx, "PRT observation", err, "application", f.app.Name) + require.ErrorIs(t, err, test.cause) + require.Equal(t, test.wantLog, strings.Contains(output.String(), "level=ERROR"), output.String()) + if test.wantLog { + require.Contains(t, output.String(), "application="+f.app.Name) + } + }) + } + } +} + +func runObservationWithIOError(ctx context.Context, t *testing.T, f *observerCheckpointFixture, operation string, cause error) error { + t.Helper() + epoch := checkpointEpoch(0, "0x100") + if operation == "epochs list" { + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{}, uint64(0), cause).Once() + _, err := observeEpochsForTest(ctx, f.s, f.app, 100) + return err + } + if strings.HasPrefix(operation, "acceptance") { + if operation == "acceptance header" { + header := &types.Header{Number: big.NewInt(50)} + f.client.On("HeaderByNumber", mock.Anything, header.Number).Return((*types.Header)(nil), cause).Once() + return f.s.validateAcceptanceReceipt(ctx, &types.Receipt{BlockHash: header.Hash(), BlockNumber: header.Number}) + } + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + tournament := &model.Tournament{Address: *epoch.TournamentAddress, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: 50, AsOfBlock: 50}} + if operation == "acceptance update" { + f.app.LastTournamentCheckBlock = 100 + f.acceptance(epoch, tournament, cause) + } else { + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Once() + f.client.On("TransactionReceipt", mock.Anything, *epoch.ClaimTransactionHash). + Return((*types.Receipt)(nil), cause).Once() + } + _, err := f.s.reconcileAcceptedEpochs(ctx, f.app, []*model.Epoch{epoch}, f.consensus, 100) + return err + } + + address := *epoch.TournamentAddress + adapter := &tournamentAdapterMock{} + t.Cleanup(func() { adapter.AssertExpectations(t) }) + var stored *model.Tournament + if !strings.HasPrefix(operation, "new ") { + stored = &model.Tournament{Address: address, Level: 1, MaxLevel: 3, Kind: model.TournamentKindNonLeaf, + Snapshot: model.TournamentSnapshot{AsOfBlock: 50}} + } + var loadError error + if operation == "projection load" { + loadError = cause + } + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(stored, loadError).Once() + if loadError == nil { + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + descriptor := TournamentDescriptor{BaseCycle: big.NewInt(0), Level: 1, Kind: model.TournamentKindNonLeaf} + var descriptorError error + if strings.HasSuffix(operation, "descriptor") { + descriptorError = cause + } + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(descriptor, descriptorError).Once() + if descriptorError == nil { + if stored == nil { + adapter.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + } + var standingError error + if strings.HasSuffix(operation, standingReadName) { + standingError = cause + } + standing := TournamentStanding{State: model.TournamentStandingAwaitingClosure} + if strings.HasSuffix(operation, "result") { + standing = TournamentStanding{State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: 90} + adapter.On("InnerResult", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(InnerResult{}, cause).Once() + } + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(standing, standingError).Once() + } + if operation == "participant list" { + expectTournamentAuxiliaryReads(adapter, mock.MatchedBy(resultCallOptsAtBlock(100)), 1, model.TournamentStandingAwaitingClosure) + adapter.On("RetrieveAllEvents", mock.Anything).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(zeroStructuralEventCounts(), nil).Once() + addressString := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &addressString}, repository.Pagination{}, false). + Return([]*model.Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &addressString}, repository.Pagination{}, false). + Return([]*model.Match{}, uint64(0), cause).Once() + } + } + _, err := f.s.gatherTournamentData(ctx, f.app, epoch, 1, nil, nil, address, 3, 100, nil, nil) + return err +} + +func TestTournamentJoinReadsPreserveDependencyErrors(t *testing.T) { + for _, operation := range []string{"commitment standing", "descriptor", standingReadName, "bond value"} { + for _, cause := range []error{context.Canceled, context.DeadlineExceeded, errors.New("database unavailable")} { + t.Run(operation+"/"+cause.Error(), func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, false) + view.stage.IsFinished = false + f.snapshot(view) + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(adapter, nil) + var readErr error + if operation == "commitment standing" { + readErr = cause + } + adapter.On("CommitmentStanding", mock.Anything, [32]byte(*f.epoch.Commitment)).Return(CommitmentStanding{}, readErr) + if readErr == nil { + if operation == "descriptor" { + readErr = cause + } + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{Height: model.Log2EpochComputationHashLeafCount, + Log2Stride: model.Log2RootTournamentStride, + StartInstant: 100, Allowance: 30}, readErr) + } + if readErr == nil { + if operation == standingReadName { + readErr = cause + } + adapter.On("Standing", mock.Anything).Return(TournamentStanding{AcceptsJoins: true}, readErr) + } + if readErr == nil { + adapter.On("BondValue", mock.Anything).Return((*big.Int)(nil), cause) + } + call, err := f.d.planAction(t.Context(), f.app, joinTournament, f.block, observerFacts(100)) + require.ErrorIs(t, err, cause) + require.Nil(t, call) + require.Empty(t, f.backend.published) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } + } +} diff --git a/internal/prt/observer_checkpoint_test.go b/internal/prt/observer_checkpoint_test.go new file mode 100644 index 000000000..e308d1cf9 --- /dev/null +++ b/internal/prt/observer_checkpoint_test.go @@ -0,0 +1,520 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math/big" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type observerCheckpointFixture struct { + t *testing.T + s *Service + repo *prtRepositoryMock + factory *adapterFactoryMock + consensus *daveConsensusAdapterMock + client *ethClientMock + app *Application +} + +func newObserverCheckpointFixture(t *testing.T) *observerCheckpointFixture { + t.Helper() + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, factory: &adapterFactoryMock{}, + consensus: newCompatibleDaveConsensusAdapterMock(), client: ðClientMock{}, app: prtRevertTestApp()} + f.app.LastTournamentCheckBlock = 50 + f.app.LastEpochCheckBlock = 100 + s.adapterFactory, s.client = f.factory, f.client + expectNumericWindowHeaders(f.client) + f.factory.On("CreateDaveConsensusAdapter", f.app.IConsensusAddress).Return(f.consensus, nil).Maybe() + t.Cleanup(func() { + f.repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + f.consensus.AssertExpectations(t) + f.client.AssertExpectations(t) + }) + return f +} + +func expectNumericWindowHeaders(client *ethClientMock) { + client.On("HeaderByNumber", mock.Anything, + mock.MatchedBy(func(block *big.Int) bool { return block != nil && block.Sign() >= 0 })). + Return(func(_ context.Context, block *big.Int) *types.Header { + return &types.Header{Number: new(big.Int).Set(block)} + }, nil).Maybe() +} + +func observerFacts(block uint64) txsender.BlockRef { + header := &types.Header{Number: new(big.Int).SetUint64(block)} + return txsender.BlockRef{Number: block, Hash: header.Hash()} +} + +func (f *observerCheckpointFixture) epochs(epochs ...*Epoch) { + f.t.Helper() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return(epochs, uint64(len(epochs)), nil).Once() +} + +func checkpointEpoch(index uint64, address string) *Epoch { + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + epoch.Index = index + epoch.LastBlock = 10 + epoch.TournamentAddress = new(common.HexToAddress(address)) + return epoch +} + +func (f *observerCheckpointFixture) tournament( + epoch *Epoch, address common.Address, level, levels, finish, scanEnd uint64, events *TournamentEvents, scanError error, +) *Tournament { + f.t.Helper() + previous := f.app.LastTournamentCheckBlock + var old *Tournament + for _, known := range f.repo.storedTournaments { + if known.Address == address { + old = known + break + } + } + if committed := f.repo.committedTournaments[address]; committed != nil { + old = committed + } + if old != nil { + previous = old.Snapshot.AsOfBlock + } + tournament := &Tournament{ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: address, + Level: level, MaxLevel: levels, StartInstant: epoch.LastBlock, + Snapshot: TournamentSnapshot{FinishedAtBlock: finish, AsOfBlock: previous}} + if old != nil { + tournament.ParentTournamentAddress = old.ParentTournamentAddress + tournament.ParentMatchIDHash = old.ParentMatchIDHash + tournament.CreationEvent = old.CreationEvent + } + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return(tournament, nil).Once() + replaced := false + for i, known := range f.repo.storedTournaments { + if known.Address == address { + f.repo.storedTournaments[i] = tournament + replaced = true + break + } + } + if !replaced { + f.repo.storedTournaments = append(f.repo.storedTournaments, tournament) + } + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + kind := TournamentKindNonLeaf + if level+1 == levels { + kind = TournamentKindLeaf + } + tournament.Kind = kind + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(scanEnd))). + Return(TournamentDescriptor{BaseCycle: big.NewInt(0), Level: level, Kind: kind, StartInstant: epoch.LastBlock}, nil).Once() + finalState := common.Hash{} + if epoch.MachineHash != nil { + finalState = *epoch.MachineHash + } + candidate := common.HexToHash("0x777") + if epoch.Commitment != nil { + candidate = *epoch.Commitment + } + state := TournamentStandingRootWinner + if level != uint64(RootLevel) { + state = TournamentStandingInnerWinner + } + if len(events.MatchCreated) > 0 && len(events.MatchDeleted) == 0 { + state = TournamentStandingMatchesActive + } + finishedAt := finish + if finishedAt == 0 { + finishedAt = scanEnd + } + if state == TournamentStandingMatchesActive { + finishedAt = 0 + candidate = common.Hash{} + } + adapter.On("Standing", mock.MatchedBy(resultCallOptsAtBlock(scanEnd))).Return(TournamentStanding{ + State: state, HasCandidate: state != TournamentStandingMatchesActive, Candidate: candidate, FinalState: finalState, + FinishedAt: finishedAt, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.MatchedBy(resultCallOptsAtBlock(scanEnd)), TournamentLevel(level), state) + end := scanEnd + start := max(epoch.LastBlock, tournament.Snapshot.AsOfBlock+1) + if start <= end { + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts != nil && opts.Context != nil && opts.Start == start && opts.End != nil && *opts.End == end + })).Return(events, scanError).Once() + } + if scanError == nil { + adapter.On("StructuralEventCounts", mock.MatchedBy(resultCallOptsAtBlock(end))). + Return(structuralCountsForEvents(events), nil).Once() + f.emptyParticipants(epoch, address) + } + f.t.Cleanup(func() { adapter.AssertExpectations(f.t) }) + return tournament +} + +func expectTournamentAuxiliaryReads(adapter *tournamentAdapterMock, opts any, level TournamentLevel, state TournamentStandingState) { + disposition := BondDispositionTournamentRunning + if isTerminalTournamentStanding(state) { + disposition = BondDispositionRecoverable + if state == TournamentStandingRootFailed || state == TournamentStandingInnerEliminableNoWinner { + disposition = BondDispositionNoWinner + } + } + if level != RootLevel { + result := InnerResult{Disposition: InnerTournamentUnsettled} + switch state { + case TournamentStandingInnerWinner: + result = InnerResult{Disposition: InnerTournamentWinner, PausedAllowance: 1} + case TournamentStandingInnerEliminableWinnerExpired, TournamentStandingInnerEliminableNoWinner: + result.Disposition = InnerTournamentEliminable + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, TournamentStandingRootFailed: + } + adapter.On("InnerResult", opts).Return(result, nil).Once() + } + adapter.On("BondRecovery", opts).Return(canonicalBondRecovery(disposition, common.HexToAddress("0x777"), 0), nil).Once() +} + +func (f *observerCheckpointFixture) emptyParticipants(epoch *Epoch, address common.Address) { + f.t.Helper() + filterAddress := address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &filterAddress}, repository.Pagination{}, false). + Return([]*Match{}, uint64(0), nil).Once() +} + +func (f *observerCheckpointFixture) children(epoch *Epoch, parent *Tournament, children ...*Tournament) { + f.t.Helper() + for _, child := range children { + child.ParentTournamentAddress = &parent.Address + child.ParentMatchIDHash = new(common.HexToHash("0xabc")) + } + + for _, value := range append([]*Tournament{parent}, children...) { + found := false + for _, known := range f.repo.storedTournaments { + if known.Address == value.Address { + found = true + break + } + } + if !found { + f.repo.storedTournaments = append(f.repo.storedTournaments, value) + } + } + +} + +func (f *observerCheckpointFixture) acceptance(epoch *Epoch, tournament *Tournament, writeError error) { + f.t.Helper() + observed := *tournament + observed.Snapshot.AsOfBlock = 100 + tournament = &observed + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Once() + log := types.Log{Address: f.app.IConsensusAddress, TxHash: *epoch.ClaimTransactionHash, + BlockNumber: 100, BlockHash: observerFacts(100).Hash} + receipt := &types.Receipt{TxHash: *epoch.ClaimTransactionHash, BlockNumber: big.NewInt(100), + BlockHash: log.BlockHash, Status: types.ReceiptStatusSuccessful, Logs: []*types.Log{&log}} + expectNumericWindowHeaders(f.client) + f.client.On("TransactionReceipt", mock.Anything, *epoch.ClaimTransactionHash).Return(receipt, nil).Once() + f.consensus.On("ParseEpochSealed", log).Return(&idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: new(big.Int).SetUint64(epoch.Index + 1), InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, Raw: log, + }, nil).Once() + f.repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, f.app.ID, epoch.Index, epoch.ClaimTransactionHash). + Run(func(mock.Arguments) { require.Equal(f.t, uint64(100), f.app.LastTournamentCheckBlock) }).Return(writeError).Once() +} + +func (f *observerCheckpointFixture) unaccepted(epoch *Epoch, finish uint64) { + f.t.Helper() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return( + &Tournament{Address: *epoch.TournamentAddress, Snapshot: TournamentSnapshot{ + FinishedAtBlock: finish, WinnerCommitment: epoch.Commitment, + }}, nil).Once() +} + +func TestObserverCheckpointCommitsAllStoredRootsAndChildrenIndependently(t *testing.T) { + f := newObserverCheckpointFixture(t) + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + first.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(first, second) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := f.tournament(first, *first.TournamentAddress, 0, 2, 90, 100, &TournamentEvents{}, nil) + child := f.tournament(first, common.HexToAddress("0x101"), 1, 2, 88, 100, &TournamentEvents{}, nil) + f.children(first, parent, child) + secondRoot := f.tournament(second, *second.TournamentAddress, 0, 2, 95, 100, &TournamentEvents{}, nil) + lastChild := f.tournament(second, common.HexToAddress("0x201"), 1, 2, 89, 100, &TournamentEvents{}, nil) + f.children(second, secondRoot, lastChild) + var committed []common.Address + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 1) + committed = append(committed, batches[0].Tournament.Address) + }).Return(nil).Times(4) + f.acceptance(first, parent, nil) + f.unaccepted(second, 95) + deferred, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.NoError(t, err) + require.False(t, deferred) + require.ElementsMatch(t, []common.Address{parent.Address, child.Address, secondRoot.Address, lastChild.Address}, committed) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) +} + +func TestObserverCheckpointKeepsFinishedSubtreesCurrent(t *testing.T) { + for _, finish := range []uint64{0, 49, 50, 51} { + t.Run(new(big.Int).SetUint64(finish).String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + parent := f.tournament(epoch, *epoch.TournamentAddress, 0, 2, finish, 100, &TournamentEvents{}, nil) + child := f.tournament(epoch, common.HexToAddress("0x101"), 1, 2, finish, 100, &TournamentEvents{}, nil) + f.children(epoch, parent, child) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 + }), uint64(100)).Return(nil).Twice() + f.unaccepted(epoch, finish) + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + finishedLogs := strings.Count(output.String(), "Found finished tournament") + if finish == 0 { + require.Equal(t, 2, finishedLogs, "each newly finished clone reports its transition") + } else { + require.Zero(t, finishedLogs, "a current refresh must not repeat the finish announcement") + } + }) + } +} + +func TestGatherTournamentDataKeepsNewFinishedProjectionBelowCursor(t *testing.T) { + // This checks the gather helper's contract. Normal atomic publication does + // not advance a cursor without the corresponding tournament projection. + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 50 + epoch := checkpointEpoch(0, "0x100") + address := *epoch.TournamentAddress + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), address.Hex()). + Return((*Tournament)(nil), nil).Once() + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: TournamentKindLeaf, StartInstant: epoch.LastBlock, + } + adapter.On("Descriptor", opts).Return(descriptor, nil).Once() + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: TournamentStandingRootWinner, HasCandidate: true, Candidate: *epoch.Commitment, + FinalState: *epoch.MachineHash, FinishedAt: 49, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, TournamentStandingRootWinner) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == epoch.LastBlock && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, app: app} + f.emptyParticipants(epoch, address) + factory := &adapterFactoryMock{} + factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + s.adapterFactory = factory + + batches, err := s.gatherTournamentData(t.Context(), app, epoch, RootLevel, nil, nil, address, 1, 100, creationFixture(t, address, + descriptor), nil) + require.NoError(t, err) + require.Len(t, batches, 1, "a new projection must reach the caller even when no event scan is needed") + require.Equal(t, address, batches[0].Tournament.Address) + require.Equal(t, uint64(49), batches[0].Tournament.Snapshot.FinishedAtBlock) + require.Equal(t, epoch.Commitment, batches[0].Tournament.Snapshot.WinnerCommitment) + require.Equal(t, epoch.MachineHash, batches[0].Tournament.Snapshot.FinalStateHash) + require.Equal(t, uint64(50), app.LastTournamentCheckBlock) + require.Len(t, repo.Calls, 3, "gather reads projections but must not publish them or their cursor") + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + factory.AssertExpectations(t) +} + +const intervalTransactionFailure = "transaction" + +func TestObserverCheckpointRetriesOnlyFailedTournament(t *testing.T) { + for _, fault := range []string{"child RPC", "root RPC", intervalTransactionFailure} { + t.Run(fault, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + first.Status, second.Status = EpochStatus_ClaimAccepted, EpochStatus_ClaimAccepted + failure := errors.New(fault) + var failedAddress common.Address + for attempt := range 2 { + f.epochs(first, second) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + if attempt == 0 { + var rootErr, childErr error + if fault == "root RPC" { + rootErr = failure + failedAddress = *first.TournamentAddress + } + if fault == "child RPC" { + childErr = failure + failedAddress = common.HexToAddress("0x101") + } + parent := f.tournament(first, *first.TournamentAddress, 0, 2, 0, 100, &TournamentEvents{}, rootErr) + child := f.tournament(first, common.HexToAddress("0x101"), 1, 2, 90, 100, &TournamentEvents{}, childErr) + f.children(first, parent, child) + f.tournament(second, *second.TournamentAddress, 0, 2, 95, 100, &TournamentEvents{}, nil) + if fault == intervalTransactionFailure { + failedAddress = *first.TournamentAddress + } + } else { + level := uint64(0) + epoch := first + if failedAddress != *first.TournamentAddress { + level = 1 + } + f.tournament(epoch, failedAddress, level, 2, 90, 100, &TournamentEvents{}, nil) + } + writes := 2 + if attempt == 1 { + writes = 1 + } + if fault == intervalTransactionFailure && attempt == 0 { + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(b []*repository.TournamentEventBatch) bool { + return len(b) == 1 && b[0].Tournament.Address == failedAddress + }), uint64(100)).Return(failure).Once() + } + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(b []*repository.TournamentEventBatch) bool { + return len(b) == 1 && (attempt == 1 || b[0].Tournament.Address != failedAddress) + }), uint64(100)).Return(nil).Times(writes) + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + if attempt == 0 { + require.ErrorIs(t, err, failure) + } else { + require.NoError(t, err) + } + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock, + "successful independent units publish despite another failure") + } + }) + } +} + +func TestObserverCheckpointObservesRootsBeyondIngestionHead(t *testing.T) { + for _, status := range []EpochStatus{EpochStatus_Closed, EpochStatus_InputsProcessed} { + t.Run(status.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + ready, unprepared := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + unprepared.Status, unprepared.LastBlock = status, 80 + unprepared.Commitment, unprepared.MachineHash, unprepared.TxBufferDataBlock = nil, nil, nil + f.epochs(ready, unprepared) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(120))).Return(uint64(1), nil).Once() + f.tournament(ready, *ready.TournamentAddress, 0, 1, 0, 120, &TournamentEvents{}, nil) + f.tournament(unprepared, *unprepared.TournamentAddress, 0, 1, 0, 120, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(120)).Return(nil).Twice() + f.unaccepted(ready, 120) + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 120) + require.NoError(t, err) + require.Equal(t, uint64(120), f.app.LastTournamentCheckBlock) + }) + } + t.Run("prepared roots continue beyond reader head", func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(120))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 0, 120, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(120)).Return(nil).Once() + f.unaccepted(epoch, 120) + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 120) + require.NoError(t, err) + require.Equal(t, uint64(120), f.app.LastTournamentCheckBlock) + }) +} + +func TestObserverCheckpointRetriesAcceptanceWithoutNewWindow(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + tournament := f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + failure := errors.New("acceptance write failed") + f.acceptance(epoch, tournament, failure) + deferActions, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.ErrorIs(t, err, failure) + require.True(t, deferActions) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.epochs(epoch) + f.acceptance(epoch, tournament, nil) + deferActions, err = observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.NoError(t, err) + require.False(t, deferActions) + f.repo.AssertNumberOfCalls(t, "StoreTournamentInterval", 1) + f.consensus.AssertNumberOfCalls(t, "TournamentLevelCount", 1) +} + +func TestObserverCheckpointIncludesUnpreparedCreationBlock(t *testing.T) { + for _, creation := range []uint64{0, 80} { + t.Run(new(big.Int).SetUint64(creation).String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 0 + ready, unprepared := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x200") + ready.LastBlock = creation + unprepared.Status, unprepared.LastBlock = EpochStatus_Closed, creation + unprepared.Commitment, unprepared.MachineHash, unprepared.TxBufferDataBlock = nil, nil, nil + f.epochs(ready, unprepared) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(ready, *ready.TournamentAddress, 0, 1, 100, 100, &TournamentEvents{}, nil) + f.tournament(unprepared, *unprepared.TournamentAddress, 0, 1, 100, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 + }), uint64(100)).Return(nil).Twice() + f.unaccepted(ready, 100) + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }) + } +} + +func TestObserverCheckpointDoesNotMoveBackward(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 100 + epoch := checkpointEpoch(0, "0x100") + epoch.Status = EpochStatus_ClaimAccepted + f.repo.storedTournaments = []*Tournament{{ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + Snapshot: TournamentSnapshot{AsOfBlock: 100}}} + f.epochs(epoch) + _, err := observeEpochsForTest(t.Context(), f.s, f.app, 90) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + f.repo.AssertNotCalled(t, "StoreTournamentInterval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNotCalled(t, "TournamentLevelCount", mock.Anything) +} diff --git a/internal/prt/observer_descendant_interval_test.go b/internal/prt/observer_descendant_interval_test.go new file mode 100644 index 000000000..bc33df085 --- /dev/null +++ b/internal/prt/observer_descendant_interval_test.go @@ -0,0 +1,206 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewDescendantFirstScanCommitsWithItsDiscoveringInterval(t *testing.T) { + for _, fault := range []string{"none", "child read", "transaction"} { + t.Run(fault, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + child := common.HexToAddress("0x200") + rootEvents := descendantParticipantEvents(51, false) + rootEvents.NewInnerTournament = []*itournament.ITournamentNewInnerTournament{{ + MatchIdHash: rootEvents.MatchCreated[0].MatchIdHash, ChildTournament: child, + One: rootEvents.MatchCreated[0].One, Two: rootEvents.MatchCreated[0].Two, + Descriptor: itournament.ITournamentTournamentDescriptor{BaseCycle: big.NewInt(0), Level: 1, Kind: 0, StartInstant: 60}, + BondValue: big.NewInt(1), + Raw: types.Log{Address: *epoch.TournamentAddress, BlockNumber: 60, TxHash: common.HexToHash("0xa0"), Index: 0}, + }} + rootEvents.PartialBondRefund = []*itournament.ITournamentPartialBondRefund{{ + Value: big.NewInt(0), Raw: types.Log{BlockNumber: 60, TxHash: common.HexToHash("0xa0"), Index: 1}, + }} + root := f.tournament(epoch, *epoch.TournamentAddress, 0, 2, 100, 100, rootEvents, nil) + // The fixture created this adapter immediately before returning root. + rootAdapter := f.factory.ExpectedCalls[len(f.factory.ExpectedCalls)-1].ReturnArguments.Get(0).(*tournamentAdapterMock) + expectDescendantParticipantViews(rootAdapter, rootEvents) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(2), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), child.Hex()). + Return((*model.Tournament)(nil), nil).Twice() + + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", child).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: 1, Kind: model.TournamentKindLeaf, StartInstant: 60, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: model.TournamentStandingMatchesActive, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, TournamentLevel(1), model.TournamentStandingMatchesActive) + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + childEvents := descendantParticipantEvents(61, true) + failure := errors.New("descendant interval unavailable") + var readErr error + if fault == "child read" { + readErr = failure + } + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == 60 && opts.End != nil && *opts.End == 100 + })).Return(childEvents, readErr).Once() + if readErr == nil { + adapter.On("StructuralEventCounts", opts).Return(structuralCountsForEvents(childEvents), nil).Once() + f.emptyParticipants(epoch, child) + expectDescendantParticipantViews(adapter, childEvents) + var storeErr error + if fault == "transaction" { + storeErr = failure + } + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + if len(batches) != 2 { + return false + } + parent, descendant := batches[0].Tournament, batches[1].Tournament + return parent.Address == root.Address && parent.Snapshot.AsOfBlock == 100 && + descendant.Address == child && descendant.Snapshot.AsOfBlock == 100 && + descendant.ParentTournamentAddress != nil && *descendant.ParentTournamentAddress == root.Address && + descendant.CreationEvent != nil && descendant.CreationEvent.BlockNumber == 60 + }), uint64(100)).Return(storeErr).Once() + } + + err := f.s.observeTournamentState(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, observerFacts(100)) + if fault == "none" { + require.NoError(t, err) + require.Equal(t, uint64(100), f.repo.committedTournaments[root.Address].Snapshot.AsOfBlock) + require.Equal(t, uint64(100), f.repo.committedTournaments[child].Snapshot.AsOfBlock) + } else { + require.ErrorIs(t, err, failure) + require.Empty(t, f.repo.committedTournaments, "a failed descendant never publishes a parent prefix") + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + } + require.Equal(t, uint64(50), root.Snapshot.AsOfBlock, "the stored input row remains unchanged") + adapter.AssertExpectations(t) + }) + } +} + +func descendantParticipantEvents(start uint64, leaf bool) *TournamentEvents { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + matchID := common.HexToHash("0x33") + events := &TournamentEvents{ + CommitmentJoined: []*itournament.ITournamentCommitmentJoined{ + {Commitment: one, FinalStateHash: one, Raw: types.Log{BlockNumber: start}}, + {Commitment: two, FinalStateHash: two, Raw: types.Log{BlockNumber: start + 1}}, + }, + MatchCreated: []*itournament.ITournamentMatchCreated{{ + MatchIdHash: matchID, One: one, Two: two, Raw: types.Log{BlockNumber: start + 4}, + }}, + } + events.StandingChanged = []*itournament.ITournamentStandingChanged{ + {MatchCount: new(big.Int), Dangling: one, ResultAt: 100, Raw: types.Log{BlockNumber: start, Index: 1}}, + {MatchCount: big.NewInt(1), Raw: types.Log{BlockNumber: start + 1, Index: 1}}, + } + if leaf { + events.LeafMatchSealed = []*itournament.ITournamentLeafMatchSealed{{ + MatchIdHash: matchID, One: one, Two: two, DivergencePosition: big.NewInt(0), Raw: types.Log{BlockNumber: 80}, + }} + events.PartialBondRefund = []*itournament.ITournamentPartialBondRefund{{Value: big.NewInt(0), Raw: types.Log{BlockNumber: 80}}} + } + return events +} + +func expectDescendantParticipantViews(adapter *tournamentAdapterMock, events *TournamentEvents) { + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + for _, event := range events.CommitmentJoined { + adapter.On("CommitmentStanding", opts, event.Commitment). + Return(CommitmentStanding{Joined: true, FinalState: event.FinalStateHash}, nil).Once() + } + match := events.MatchCreated[0] + adapter.On("MatchSnapshot", opts, match.One, match.Two).Return(ObservedMatchSnapshot{ + Phase: model.MatchPhaseSealed, Sealed: &SealedMatch{DivergencePosition: big.NewInt(0), DivergenceCycle: big.NewInt(0)}, + }, nil).Once() +} + +func TestFinishedNoWinnerTreeKeepsHistoricalChildAfterParentDeletion(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0xa0") + epoch.Status = model.EpochStatus_Closed + events := descendantParticipantEvents(51, false) + pair := events.MatchCreated[0] + childAddress := common.HexToAddress("0xa1") + events.NewInnerTournament = []*itournament.ITournamentNewInnerTournament{{MatchIdHash: pair.MatchIdHash, One: pair.One, Two: pair.Two, + ChildTournament: childAddress, ContestedFinalStateTwo: common.HexToHash("0x77"), BondValue: big.NewInt(1), + Descriptor: itournament.ITournamentTournamentDescriptor{BaseCycle: big.NewInt(0), Level: 1, Kind: 0, Height: 68, StartInstant: 60, + Allowance: 20}, + Raw: types.Log{Address: *epoch.TournamentAddress, BlockNumber: 60, TxHash: common.HexToHash("0x66"), Index: 1}}} + events.MatchDeleted = []*itournament.ITournamentMatchDeleted{{MatchIdHash: pair.MatchIdHash, One: pair.One, Two: pair.Two, + Reason: 2, WinnerCommitment: 0, + Raw: types.Log{BlockNumber: 90, TxHash: common.HexToHash("0x88"), Index: 2}}} + events.StandingChanged = append(events.StandingChanged, &itournament.ITournamentStandingChanged{MatchCount: big.NewInt(0), ResultAt: 90, + Raw: types.Log{BlockNumber: 90, TxHash: common.HexToHash("0x88"), Index: 3}}) + events.PartialBondRefund = []*itournament.ITournamentPartialBondRefund{{Value: big.NewInt(0)}, {Value: big.NewInt(0)}} + root := f.tournament(epoch, *epoch.TournamentAddress, 0, 2, 90, 100, events, nil) + rootAdapter := f.factory.ExpectedCalls[len(f.factory.ExpectedCalls)-1].ReturnArguments.Get(0).(*tournamentAdapterMock) + for _, call := range rootAdapter.ExpectedCalls { + switch call.Method { + case "Standing": + call.Return(TournamentStanding{State: model.TournamentStandingRootFailed, FinishedAt: 90}, nil) + case "BondRecovery": + call.Return(canonicalBondRecovery(model.BondDispositionNoWinner, common.Address{}, 0), nil) + } + } + for _, joined := range events.CommitmentJoined { + rootAdapter.On("CommitmentStanding", mock.Anything, joined.Commitment).Return(CommitmentStanding{Joined: true, + FinalState: joined.FinalStateHash}, nil).Once() + } + rootAdapter.On("MatchSnapshot", mock.Anything, pair.One, + pair.Two).Return(ObservedMatchSnapshot{Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone}, nil).Once() + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(2), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), childAddress.Hex()).Return((*model.Tournament)(nil), + nil).Twice() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", childAddress).Return(adapter, nil).Once() + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{BaseCycle: big.NewInt(0), Level: 1, + Kind: model.TournamentKindLeaf, Height: 68, StartInstant: 60, Allowance: 20}, nil).Once() + adapter.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: model.TournamentStandingInnerEliminableNoWinner, + FinishedAt: 80}, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, 1, model.TournamentStandingInnerEliminableNoWinner) + adapter.On("RetrieveAllEvents", mock.Anything).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", mock.Anything).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, childAddress) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 2) + require.Equal(t, model.TournamentStandingRootFailed, batches[0].Tournament.Snapshot.Standing) + require.Nil(t, batches[0].Tournament.Snapshot.Candidate) + require.Equal(t, model.MatchPhaseUninitialized, batches[0].Matches[0].Snapshot.Phase) + child := batches[1].Tournament + require.NotNil(t, child.CreationEvent) + require.NotNil(t, child.ContestedFinalStateOne) + require.Zero(t, *child.ContestedFinalStateOne) + require.Equal(t, common.HexToHash("0x77"), *child.ContestedFinalStateTwo) + require.Equal(t, model.TournamentStandingInnerEliminableNoWinner, child.Snapshot.Standing) + require.Nil(t, child.Snapshot.Candidate) + }).Return(nil).Once() + require.NoError(t, f.s.observeTournamentState(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, observerFacts(100))) + require.Equal(t, uint64(100), f.repo.committedTournaments[root.Address].Snapshot.AsOfBlock) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/observer_projection_test.go b/internal/prt/observer_projection_test.go new file mode 100644 index 000000000..1322caafc --- /dev/null +++ b/internal/prt/observer_projection_test.go @@ -0,0 +1,300 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "encoding/json" + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +const ( + unknownEnumValue = "UNKNOWN" + projectionCreateCase = "create" + projectionUpdateCase = "update" + unknownStandingCase = "unknown standing" +) + +func TestTournamentProjectionPreservesWinnerPresence(t *testing.T) { + for _, test := range []struct { + name string + state model.TournamentStandingState + level TournamentLevel + winner bool + }{ + {name: "failed root", state: model.TournamentStandingRootFailed}, + {name: "root result exists", state: model.TournamentStandingRootWinner, winner: true}, + {name: "inner without winner", state: model.TournamentStandingInnerEliminableNoWinner, level: 1}, + {name: "inner result exists", state: model.TournamentStandingInnerWinner, level: 1, winner: true}, + {name: "expired inner winner", state: model.TournamentStandingInnerEliminableWinnerExpired, level: 1}, + } { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(test.name+"/"+operation, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo := newPRTServiceMock() + descriptor := TournamentDescriptor{BaseCycle: big.NewInt(0), Level: uint64(test.level), Height: 7, + Kind: model.TournamentKindNonLeaf} + hasCandidate := test.winner || test.state == model.TournamentStandingInnerEliminableWinnerExpired + standing := TournamentStanding{State: test.state, FinishedAt: 9, HasCandidate: hasCandidate} + if hasCandidate { + standing.Candidate = *epoch.Commitment + } + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(descriptor, nil).Once() + adapter.On("Standing", opts).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, test.level, test.state) + var stored *model.Tournament + if update { + tournament := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, + Level: uint64(test.level), Height: descriptor.Height, + Kind: model.TournamentKindNonLeaf, Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + require.NoError(t, service.refreshTournament(t.Context(), app, epoch, test.level, adapter, tournament, 10)) + stored = tournament + } else { + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + tournament, err := service.readTournament(t.Context(), app, epoch, test.level, + nil, nil, *epoch.TournamentAddress, adapter, 3, 10) + require.NoError(t, err) + require.NotNil(t, tournament) + stored = tournament + } + require.NotNil(t, stored) + require.Equal(t, uint64(9), stored.Snapshot.FinishedAtBlock) + data, err := json.Marshal(stored) + require.NoError(t, err) + var projection map[string]any + require.NoError(t, json.Unmarshal(data, &projection)) + projection = projection["snapshot"].(map[string]any) + require.Equal(t, uint64(10), stored.Snapshot.AsOfBlock) + require.Equal(t, test.state, stored.Snapshot.Standing) + if hasCandidate { + require.Equal(t, epoch.Commitment, stored.Snapshot.Candidate) + } else { + require.Nil(t, stored.Snapshot.Candidate) + } + if test.winner { + require.Equal(t, epoch.Commitment, stored.Snapshot.WinnerCommitment) + require.NotNil(t, stored.Snapshot.FinalStateHash, "a zero final-state hash is a value, not absence") + require.Zero(t, *stored.Snapshot.FinalStateHash) + require.Equal(t, epoch.Commitment.Hex(), projection["winner_commitment"]) + require.Equal(t, (common.Hash{}).Hex(), projection["final_state_hash"]) + } else { + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + require.Nil(t, projection["winner_commitment"]) + require.Nil(t, projection["final_state_hash"]) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls, "projection reads must not write tournament state") + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } + } +} + +func TestTournamentProjectionClearsNonTerminalResult(t *testing.T) { + for _, test := range []struct { + name string + state model.TournamentStandingState + }{ + {name: "active matches", state: model.TournamentStandingMatchesActive}, + {name: "awaiting closure", state: model.TournamentStandingAwaitingClosure}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Log2Step: 20, Height: 7, + Kind: model.TournamentKindNonLeaf, Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{State: test.state}, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, test.state) + + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10)) + require.Nil(t, projection.Snapshot.WinnerCommitment) + require.Nil(t, projection.Snapshot.FinalStateHash) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentProjectionRejectsChangedStoredGeometry(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*model.Tournament) + }{ + {name: "level", mutate: func(p *model.Tournament) { p.Level++ }}, + {name: "stride", mutate: func(p *model.Tournament) { p.Log2Step++ }}, + {name: "height", mutate: func(p *model.Tournament) { p.Height++ }}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Log2Step: 20, Height: 7} + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.MatchedBy(resultCallOptsAtBlock(10))).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + test.mutate(projection) + before := *projection + + err := s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10) + require.ErrorContains(t, err, "descriptor does not match stored geometry") + require.Equal(t, before, *projection) + require.Empty(t, repo.Calls) + adapter.AssertNotCalled(t, "Standing", mock.Anything) + adapter.AssertExpectations(t) + }) + } +} + +func TestTournamentProjectionLeavesReadErrorsUnchanged(t *testing.T) { + for _, test := range []struct { + name string + standing TournamentStanding + readErr error + }{ + {name: "standing RPC error", readErr: errors.New("standing RPC unavailable")}, + {name: unknownStandingCase, standing: TournamentStanding{State: unknownEnumValue}}, + {name: "future result", standing: TournamentStanding{State: model.TournamentStandingRootWinner, FinishedAt: 11}}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + projection := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, + Log2Step: 20, Height: 7, Kind: model.TournamentKindNonLeaf, + Snapshot: model.TournamentSnapshot{ + WinnerCommitment: new(common.HexToHash("0x111")), FinalStateHash: new(common.HexToHash("0x222"))}} + before := *projection + adapter := &tournamentAdapterMock{} + opts := mock.MatchedBy(resultCallOptsAtBlock(10)) + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, + Log2Stride: projection.Log2Step, Height: projection.Height, + }, nil).Once() + adapter.On("Standing", opts).Return(test.standing, test.readErr).Once() + + err := s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, projection, 10) + require.Error(t, err) + if test.readErr != nil { + require.ErrorIs(t, err, test.readErr) + } + require.Equal(t, before, *projection) + require.Same(t, before.Snapshot.WinnerCommitment, projection.Snapshot.WinnerCommitment) + require.Same(t, before.Snapshot.FinalStateHash, projection.Snapshot.FinalStateHash) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } +} + +func TestRootWinnerMismatchDoesNotChangeLocalHealthDuringObservation(t *testing.T) { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(operation, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + service, repo := newPRTServiceMock() + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindNonLeaf, + }, nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{ + State: model.TournamentStandingRootWinner, FinishedAt: 9, HasCandidate: true, Candidate: common.HexToHash("0xbad"), + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, model.TournamentStandingRootWinner) + if update { + err := service.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, + &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 3, Kind: model.TournamentKindNonLeaf}, 10) + require.NoError(t, err) + } else { + adapter.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + tournament, err := service.readTournament(t.Context(), app, epoch, RootLevel, + nil, nil, *epoch.TournamentAddress, adapter, 3, 10) + require.NoError(t, err) + require.Equal(t, common.HexToHash("0xbad"), *tournament.Snapshot.WinnerCommitment) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + repo.AssertExpectations(t) + adapter.AssertExpectations(t) + }) + } +} + +func TestJoinChecksSupportedRootGeometry(t *testing.T) { + for _, test := range []struct { + name string + level, height, stride uint64 + allowed bool + observed string + }{ + {name: "supported", level: 0, height: 54, stride: 38, allowed: true}, + {name: "level", level: 1, height: 54, stride: 38, observed: "level=1 height=54 stride=38"}, + {name: "height", level: 0, height: 53, stride: 38, observed: "level=0 height=53 stride=38"}, + {name: "stride", level: 0, height: 54, stride: 44, observed: "level=0 height=54 stride=44"}, + } { + t.Run(test.name, func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, false) + view.stage.IsFinished = false + view.stage.WinnerCommitment = common.Hash{} + view.stage.WinnerPostEpochMachineStateHash = common.Hash{} + f.snapshot(view) + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *f.epoch.TournamentAddress).Return(adapter, nil).Once() + opts := mock.MatchedBy(resultCallOptsAtBlock(f.block.Number)) + adapter.On("CommitmentStanding", opts, [32]byte(*f.epoch.Commitment)).Return(CommitmentStanding{}, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{ + Level: test.level, Height: test.height, Log2Stride: test.stride, StartInstant: 100, Allowance: 20, + }, nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{AcceptsJoins: true}, nil).Maybe() + adapter.On("BondValue", opts).Return(big.NewInt(0), nil).Maybe() + call, err := f.d.planAction(t.Context(), f.app, joinTournament, f.block, observerFacts(100)) + if test.allowed { + require.NoError(t, err) + require.NotNil(t, call) + require.Equal(t, joinTournament, call.Key.Kind) + } else { + require.ErrorIs(t, err, txsender.ErrIneligible) + require.ErrorContains(t, err, test.observed) + require.ErrorContains(t, err, "expected level=0 height=54 stride=38") + require.Nil(t, call) + require.Empty(t, f.backend.published) + adapter.AssertNotCalled(t, "Standing", mock.Anything) + adapter.AssertNotCalled(t, "BondValue", mock.Anything) + } + assertPRTPlanDidNotWrite(t, f) + adapter.AssertExpectations(t) + f.repo.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/observer_test.go b/internal/prt/observer_test.go new file mode 100644 index 000000000..5bd949bc9 --- /dev/null +++ b/internal/prt/observer_test.go @@ -0,0 +1,356 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentDescriptorFromBinding_MapsDecodedFields(t *testing.T) { + baseCycle := big.NewInt(123) + binding := itournament.ITournamentTournamentDescriptor{ + BaseCycle: baseCycle, + Log2Stride: 20, + Height: 48, + Level: 2, + Kind: 0, + } + + descriptor, err := tournamentDescriptorFromBinding(binding) + require.NoError(t, err) + require.Equal(t, int64(123), descriptor.BaseCycle.Int64()) + require.Equal(t, binding.Log2Stride, descriptor.Log2Stride) + require.Equal(t, binding.Height, descriptor.Height) + require.Equal(t, binding.Level, descriptor.Level) + require.Equal(t, model.TournamentKindLeaf, descriptor.Kind) + +} + +func TestTournamentStandingFromBinding_MapsAllFields(t *testing.T) { + for _, flags := range [][2]bool{{true, false}, {false, true}} { + binding := itournament.ITournamentTournamentStandingView{ + Standing: 4, + AcceptsJoins: flags[0], HasCandidate: flags[1], + Candidate: common.HexToHash("0x21"), FinalState: common.HexToHash("0x22"), FinishedAt: 101, + } + standing, err := tournamentStandingFromBinding(binding) + require.NoError(t, err) + require.Equal(t, TournamentStanding{ + State: model.TournamentStandingInnerWinner, AcceptsJoins: flags[0], HasCandidate: flags[1], + Candidate: common.HexToHash("0x21"), FinalState: common.HexToHash("0x22"), FinishedAt: 101, + }, standing) + } +} + +func TestCommitmentStandingFromBinding_MapsAllFields(t *testing.T) { + binding := itournament.ITournamentCommitmentStandingView{ + Joined: true, + FinalState: common.HexToHash("0x31"), + Claimer: common.HexToAddress("0x32"), + } + + standing := commitmentStandingFromBinding(binding) + + require.Equal(t, binding.Joined, standing.Joined) + require.Equal(t, common.Hash(binding.FinalState), standing.FinalState) + require.Equal(t, binding.Claimer, standing.Claimer) +} + +func TestValidateTournamentDescriptor_LevelCountAndKind(t *testing.T) { + valid := func(level uint64, kind model.TournamentKind) TournamentDescriptor { + return TournamentDescriptor{ + BaseCycle: big.NewInt(0), + Level: level, + Kind: kind, + } + } + tests := []struct { + name string + descriptor TournamentDescriptor + expectedLevel TournamentLevel + levelCount uint64 + wantError string + }{ + {name: "root of three levels", descriptor: valid(0, model.TournamentKindNonLeaf), levelCount: 3}, + {name: "dynamic last level", descriptor: valid(3, model.TournamentKindLeaf), expectedLevel: 3, levelCount: 4}, + {name: "zero level count", descriptor: valid(0, model.TournamentKindLeaf), wantError: "level count is zero"}, + { + name: "nil base cycle", + descriptor: TournamentDescriptor{Level: 0, Kind: model.TournamentKindNonLeaf}, + levelCount: 2, + wantError: "invalid base cycle", + }, + { + name: "negative base cycle", + descriptor: TournamentDescriptor{BaseCycle: big.NewInt(-1), Level: 0, Kind: model.TournamentKindNonLeaf}, + levelCount: 2, + wantError: "invalid base cycle", + }, + { + name: "unexpected level", + descriptor: valid(1, model.TournamentKindNonLeaf), + expectedLevel: 2, + levelCount: 3, + wantError: "does not match expected level", + }, + { + name: "level outside count", + descriptor: valid(2, model.TournamentKindLeaf), + expectedLevel: 2, + levelCount: 2, + wantError: "outside level count", + }, + { + name: "unknown kind", + descriptor: valid(0, model.TournamentKind("UNKNOWN")), + levelCount: 2, + wantError: "unknown kind", + }, + { + name: "leaf before last level", + descriptor: valid(0, model.TournamentKindLeaf), + levelCount: 3, + wantError: "expected NON_LEAF", + }, + { + name: "non-leaf at last level", + descriptor: valid(2, model.TournamentKindNonLeaf), + expectedLevel: 2, + levelCount: 3, + wantError: "expected LEAF", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTournamentDescriptor(test.descriptor, test.expectedLevel, test.levelCount) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantError) + }) + } +} + +func TestValidateTournamentStanding_AllStates(t *testing.T) { + candidate := common.HexToHash("0x41") + tests := []struct { + name string + descriptor TournamentDescriptor + standing TournamentStanding + terminal bool + }{ + { + name: "matches active", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{State: model.TournamentStandingMatchesActive}, + }, + { + name: "matches active and accepts joins", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingMatchesActive, AcceptsJoins: true, + }, + }, + { + name: "matches active with candidate", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingMatchesActive, HasCandidate: true, Candidate: candidate, + }, + }, + { + name: "awaiting closure", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + }, + }, + { + name: "root winner", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{ + State: model.TournamentStandingRootWinner, HasCandidate: true, Candidate: candidate, FinishedAt: 9, + }, + terminal: true, + }, + { + name: "root failed", + descriptor: TournamentDescriptor{Level: 0}, + standing: TournamentStanding{State: model.TournamentStandingRootFailed, FinishedAt: 9}, + terminal: true, + }, + { + name: "inner winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{ + State: model.TournamentStandingInnerWinner, HasCandidate: true, Candidate: candidate, + FinishedAt: 9, + }, + terminal: true, + }, + { + name: "inner eliminable without winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{State: model.TournamentStandingInnerEliminableNoWinner, FinishedAt: 9}, + terminal: true, + }, + { + name: "inner eliminable with expired winner", + descriptor: TournamentDescriptor{Level: 1}, + standing: TournamentStanding{ + State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: candidate, FinishedAt: 9, + }, + terminal: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, validateTournamentStanding(test.standing, 10)) + require.Equal(t, test.terminal, isTerminalTournamentStanding(test.standing.State)) + if test.terminal { + require.NotZero(t, test.standing.FinishedAt) + } else { + require.Zero(t, test.standing.FinishedAt) + } + }) + } +} + +func TestValidateTournamentStanding_RejectsInvalidStoredBoundary(t *testing.T) { + for _, test := range []struct { + name string + standing TournamentStanding + wantError string + }{ + {name: unknownStandingCase, standing: TournamentStanding{State: unknownEnumValue}, wantError: unknownStandingCase}, + {name: "terminal without finished block", standing: TournamentStanding{State: model.TournamentStandingRootFailed}, + wantError: "zero finished block"}, + {name: "finished after selected head", standing: TournamentStanding{State: model.TournamentStandingRootWinner, FinishedAt: 11}, + wantError: "after observed block"}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorContains(t, validateTournamentStanding(test.standing, 10), test.wantError) + }) + } +} + +func TestReadTournament_PinsDescriptorAndStandingReads(t *testing.T) { + const mostRecentBlock uint64 = 100 + app := &model.Application{ID: 1, Name: "app", IApplicationAddress: common.HexToAddress("0x91")} + epoch := &model.Epoch{Index: 3} + tournamentAddress := common.HexToAddress("0x92") + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: uint64(RootLevel), Kind: model.TournamentKindNonLeaf, + } + standing := TournamentStanding{State: model.TournamentStandingMatchesActive} + pinned := mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts != nil && opts.Context != nil && opts.BlockNumber != nil && + opts.BlockNumber.Uint64() == mostRecentBlock + }) + + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", pinned).Return(descriptor, nil).Once() + adapter.On("Standing", pinned).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, pinned, RootLevel, standing.State) + s, repo := newPRTServiceMock() + + adapter.On("BondValue", pinned).Return(big.NewInt(1), nil).Once() + tournament, err := s.readTournament( + context.Background(), app, epoch, RootLevel, nil, nil, tournamentAddress, adapter, 2, mostRecentBlock, + ) + require.NoError(t, err) + require.Equal(t, tournamentAddress, tournament.Address) + require.Equal(t, uint64(2), tournament.MaxLevel) + require.Equal(t, uint64(RootLevel), tournament.Level) + require.Empty(t, repo.Calls, "reading a projection must not write it") + repo.AssertExpectations(t) + adapter.AssertExpectations(t) +} + +func TestGatherTournamentData_UsesDynamicLastLevel(t *testing.T) { + for _, test := range []struct { + name string + level TournamentLevel + wantChildScan bool + }{ + {name: "last level from four-level factory", level: 3}, + {name: "level before last", level: 2, wantChildScan: true}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo := newPRTServiceMock() + f := &observerCheckpointFixture{t: t, s: s, repo: repo, app: prtRevertTestApp(), factory: &adapterFactoryMock{}} + s.adapterFactory = f.factory + epoch := checkpointEpoch(3, "0x92") + tournament := f.tournament(epoch, *epoch.TournamentAddress, uint64(test.level), 4, 100, 100, &TournamentEvents{}, nil) + _ = tournament // Known children are scheduled independently. + + batches, err := s.gatherTournamentData(t.Context(), f.app, epoch, test.level, nil, nil, + *epoch.TournamentAddress, 4, 100, nil, nil) + require.NoError(t, err) + require.Len(t, batches, 1) + require.Equal(t, uint64(test.level), batches[0].Tournament.Level) + repo.AssertExpectations(t) + f.factory.AssertExpectations(t) + }) + } +} + +func TestObservedResultRequiresLocalMachineHash(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, false) + f.epoch.MachineHash = nil + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, "missing required fields") + })).Return(nil).Once() + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + err := f.d.recordTournamentResult(t.Context(), f.app, f.epoch, view) + require.ErrorContains(t, err, "missing required fields") + require.Equal(t, model.ApplicationStatus_Corrupted, f.app.Status) + f.repo.AssertExpectations(t) +} + +func TestObservedResultChecksLocalMachineState(t *testing.T) { + for _, mismatch := range []bool{false, true} { + t.Run(fmt.Sprint(mismatch), func(t *testing.T) { + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, true) + if mismatch { + view.sealed.StagedPostEpochMachineStateHash[0] ^= 1 + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, + mock.Anything).Return(nil).Once() + } else { + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, f.epoch.Index, + view.sealed.StagingBlockNumber).Return(nil).Once() + } + if mismatch { + expectStoredProtection(t, f.repo.prtRepositoryMock, f.app, f.epoch) + } + err := f.d.recordTournamentResult(t.Context(), f.app, f.epoch, view) + if mismatch { + require.Error(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + } else { + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + } + f.repo.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/passive_observer_test.go b/internal/prt/passive_observer_test.go new file mode 100644 index 000000000..a7c7f4b96 --- /dev/null +++ b/internal/prt/passive_observer_test.go @@ -0,0 +1,231 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" +) + +func TestPassiveObservationIncludesLocalFailuresAndTerminalEpochs(t *testing.T) { + for _, status := range model.ApplicationStatusAllValues { + for _, epochStatus := range []model.EpochStatus{ + model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimStaged, model.EpochStatus_ClaimAccepted, model.EpochStatus_ClaimForeclosed, + } { + t.Run(status.String()+"/"+epochStatus.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.app.Status = status + // These applications must remain passive even with signing enabled. + f.s.submissionEnabled = status != model.ApplicationStatus_OK + epoch := checkpointEpoch(0, "0x100") + epoch.Status = epochStatus + pendingClaim := epochStatus == model.EpochStatus_ClaimComputed || epochStatus == model.EpochStatus_ClaimStaged + if pendingClaim { + // A complete claim ensures that invalid local data cannot be + // the reason an unhealthy application avoids submission. + applyPRTStateProof(epoch, repotest.KeccakStateProof(common.HexToHash("0x1234"))) + _, err := epoch.StateProof() + require.NoError(t, err) + } + snapshot := resultTestSnapshot(epoch, epochStatus == model.EpochStatus_ClaimStaged) + if epochStatus == model.EpochStatus_ClaimStaged { + epoch.StagedAtBlock = new(uint64(95)) + snapshot.sealed.StagingBlockNumber = *epoch.StagedAtBlock + snapshot.accept.IsClaimStagingPeriodOver = true + } + if !pendingClaim { + epoch.Commitment, epoch.MachineHash, epoch.TxBufferDataBlock = nil, nil, nil + } + if status.IsExecutionTerminal() || status == model.ApplicationStatus_InvalidOutputsRoot { + problem := &model.ApplicationProblem{EpochIndex: 0, Kind: status} + if status.IsExecutionTerminal() { + problem.InputIndex = new(uint64(0)) + } + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index). + Return(&repository.EpochCompletionState{Application: f.app, Epoch: epoch, Problem: problem}, nil).Maybe() + } + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + if status == model.ApplicationStatus_OK && pendingClaim { + if pendingClaim { + f.unaccepted(epoch, 90) + } + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + } + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + err := f.s.delivery.observeApplication(t.Context(), f.app, observerFacts(100)) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, status, f.app.Status) + require.Equal(t, epochStatus, epoch.Status) + require.Empty(t, f.s.delivery.sender) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + if status != model.ApplicationStatus_OK { + // Tick must stop after observation, before any result-action + // query. An unexpected broadcast also fails the strict mocks. + f.consensus.AssertNumberOfCalls(t, "GetCurrentSealedEpoch", 1) + f.consensus.AssertNotCalled(t, "CanStageTournamentResult", mock.Anything) + f.consensus.AssertNotCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + } + }) + } + } +} + +func TestForeclosedInvalidOutputsRootObservesWhileInputDrainPending(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = true + f.app.Status = model.ApplicationStatus_InvalidOutputsRoot + f.app.Reason = new("epoch 0: declared outputs root does not match the calculated root") + f.app.ForecloseBlock = 90 + f.app.LastEpochCheckBlock, f.app.LastInputCheckBlock = 100, 100 + require.True(t, f.app.ForeclosureScanCaughtUp(), "input drain still applies after scanner readiness") + epoch := checkpointEpoch(0, "0x100") + applyPRTStateProof(epoch, repotest.KeccakStateProof(common.HexToHash("0x1234"))) + _, err := epoch.StateProof() + require.NoError(t, err, "claim data must not be the reason submission is blocked") + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 && + batches[0].Tournament.Snapshot.BondRecovery.Disposition == model.BondDispositionRecoverable + }), uint64(100)).Return(nil).Once() + + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, f.app.ForecloseBlock).Return(true, nil).Once() + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index). + Return(&repository.EpochCompletionState{Application: f.app, Epoch: epoch, Problem: prtTestProblem(f.app.Status, 0)}, nil).Once() + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + err = f.s.delivery.observeApplication(t.Context(), f.app, observerFacts(100)) + + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, model.ApplicationStatus_InvalidOutputsRoot, f.app.Status) + require.Equal(t, "epoch 0: declared outputs root does not match the calculated root", *f.app.Reason) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Empty(t, f.s.delivery.sender) + require.Empty(t, f.s.delivery.rootBondRecoveryScans) + f.repo.AssertNotCalled(t, "HasUnreconciledClaimsBeforeBlock", mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "GetLastNonOpenEpochIndex", mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + // The strict tournament mocks permit observation only. A join or recovery + // broadcast would fail, as would a new latest-block read for either action. + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.consensus.AssertNumberOfCalls(t, "GetCurrentSealedEpoch", 1) +} + +func TestHealthyReaderPublishesBeforeRecordingStagedClaim(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + f.s.submissionEnabled = false + epoch := checkpointEpoch(0, "0x100") + snapshot := resultTestSnapshot(epoch, true) + snapshot.sealed.StagingBlockNumber = 95 + snapshot.accept.IsClaimStagingPeriodOver = true + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, + mock.MatchedBy(func(batches []*repository.TournamentEventBatch) bool { + return len(batches) == 1 && batches[0].Tournament.Snapshot.AsOfBlock == 100 + }), uint64(100)).Return(nil).Once() + f.unaccepted(epoch, 90) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(95)). + Run(func(mock.Arguments) { require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) }).Return(nil).Once() + + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + err := f.s.delivery.observeApplication(t.Context(), f.app, observerFacts(100)) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(95)), epoch.StagedAtBlock) + require.Empty(t, f.s.delivery.sender) + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateEpochWithAcceptedClaim", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestFalseFinalStatePublishesBeforeLocalDivergence(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + foreign := common.HexToHash("0xbad") + falseState := common.HexToHash("0xdead") + address := *epoch.TournamentAddress + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", address).Return(adapter, nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()).Return((*model.Tournament)(nil), nil).Once() + descriptor := TournamentDescriptor{ + BaseCycle: big.NewInt(0), Kind: model.TournamentKindLeaf, StartInstant: 10, + } + adapter.On("Descriptor", opts).Return(descriptor, nil).Once() + adapter.On("BondValue", opts).Return(big.NewInt(1), nil).Once() + adapter.On("Standing", opts).Return(TournamentStanding{ + State: model.TournamentStandingRootWinner, HasCandidate: true, Candidate: foreign, FinalState: falseState, FinishedAt: 90, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, model.TournamentStandingRootWinner) + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + // A newly found clone includes its full history, even below the app cursor. + return opts.Start == 10 && opts.End != nil && *opts.End == 100 + })).Return(&TournamentEvents{}, nil).Once() + adapter.On("StructuralEventCounts", opts).Return(zeroStructuralEventCounts(), nil).Once() + f.emptyParticipants(epoch, address) + var published *model.Tournament + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(t, batches, 1) + published = batches[0].Tournament + require.Equal(t, foreign, *published.Snapshot.WinnerCommitment) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + }).Return(nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), address.Hex()). + Return(&model.Tournament{Snapshot: model.TournamentSnapshot{ + FinishedAtBlock: 90, WinnerCommitment: &foreign, FinalStateHash: &falseState, + }}, nil).Once() + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Run(func(mock.Arguments) { + require.NotNil(t, published) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + }).Return(nil).Once() + expectStoredProtection(t, f.repo, f.app, epoch) + deferActions, err := observeEpochsForTest(t.Context(), f.s, f.app, 100) + require.ErrorContains(t, err, "inconsistent machine hash") + require.True(t, deferActions) + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + require.Empty(t, f.s.delivery.sender) + adapter.AssertExpectations(t) +} diff --git a/internal/prt/prefix_drain_test.go b/internal/prt/prefix_drain_test.go new file mode 100644 index 000000000..b2bacaf91 --- /dev/null +++ b/internal/prt/prefix_drain_test.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "testing" +) + +func TestPRTForeclosurePreservesPrefixAwaitingValidator(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_OK, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.Status = status + app.ForecloseBlock, app.LastEpochCheckBlock, app.LastInputCheckBlock = 100, 100, 100 + epoch := checkpointEpoch(0, "0x100") + epoch.Status = model.EpochStatus_InputsProcessed + if status != model.ApplicationStatus_OK { + repo.On("GetEpochCompletionState", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index). + Return(&repository.EpochCompletionState{Application: app, Epoch: epoch, Problem: prtTestProblem(status, 1)}, nil).Once() + } + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, []*model.Epoch{epoch}, nil, 100)) + repo.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestPRTTerminalPrefixAcceptancePrecedesForeclosureDrain(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_MachineHalted + f.app.ForecloseBlock, f.app.LastEpochCheckBlock, f.app.LastInputCheckBlock = 100, 100, 100 + f.app.LastTournamentCheckBlock = 100 + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0xabc")) + problem := prtTestProblem(f.app.Status, 1) + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index). + Return(&repository.EpochCompletionState{Application: f.app, Epoch: epoch, Problem: problem}, nil).Once() + tournament := &model.Tournament{ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + Snapshot: model.TournamentSnapshot{FinishedAtBlock: 90, AsOfBlock: 100, WinnerCommitment: epoch.Commitment}} + f.acceptance(epoch, tournament, nil) + var order []string + for _, call := range f.repo.ExpectedCalls { + if call.Method == "UpdateEpochWithAcceptedClaim" { + call.Run(func(mock.Arguments) { order = append(order, "accepted earlier epoch") }) + } + } + suffix := &model.Epoch{ApplicationID: f.app.ID, Index: 1, FirstBlock: 20, LastBlock: 30, Status: model.EpochStatus_Closed} + f.repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, f.app.ID, uint64(100)).Return(false, nil).Once() + f.repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, f.app.ID, uint64(100)).Return(true, nil).Once() + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{epoch, suffix}, uint64(2), nil).Once() + f.repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, f.app.ID, suffix.Index). + Run(func(mock.Arguments) { order = append(order, "foreclosed stopped suffix") }).Return(nil).Once() + + require.NoError(t, f.s.drainForeclosedClaims(t.Context(), f.app, []*model.Epoch{epoch, suffix}, f.consensus, 100)) + + require.Equal(t, []string{"accepted earlier epoch", "foreclosed stopped suffix"}, order) + f.repo.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, f.app.ID, epoch.Index) + require.Equal(t, model.ApplicationStatus_MachineHalted, f.app.Status) +} + +func TestPRTTerminalPrefixDrainWaitsForOwningRootObservation(t *testing.T) { + for _, missing := range []bool{false, true} { + t.Run(map[bool]string{false: "root interval incomplete", true: "root absent"}[missing], func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.Status = model.ApplicationStatus_MachineHalted + app.ForecloseBlock, app.LastEpochCheckBlock, app.LastInputCheckBlock = 100, 100, 100 + epoch := checkpointEpoch(0, "0x100") + repo.On("GetEpochCompletionState", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index). + Return(&repository.EpochCompletionState{Application: app, Epoch: epoch, Problem: prtTestProblem(app.Status, 1)}, nil).Once() + var root *model.Tournament + if !missing { + root = &model.Tournament{Address: *epoch.TournamentAddress, + Snapshot: model.TournamentSnapshot{AsOfBlock: 99, FinishedAtBlock: 90, WinnerCommitment: epoch.Commitment}} + } + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return(root, nil).Once() + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, uint64(100)).Return(false, nil).Once() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, uint64(100)).Return(true, nil).Once() + + require.NoError(t, s.drainForeclosedClaims(t.Context(), app, []*model.Epoch{epoch}, nil, 100)) + + repo.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "ListEpochs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false) + repo.AssertExpectations(t) + }) + } +} + +func TestPRTForeclosureDoesNotTreatGlobalStopAsCutoffEvidence(t *testing.T) { + for _, change := range []string{disabledApplicationCase, failedApplicationCase, divergedApplicationCase, + corruptedApplicationCase, removedApplicationCase} { + t.Run(change, func(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.Status = model.ApplicationStatus_MachineHalted + app.ForecloseBlock = 100 + epoch := checkpointEpoch(0, "0x100") + epoch.Status = model.EpochStatus_InputsProcessed + current := *app + state := &repository.EpochCompletionState{Application: ¤t, Epoch: epoch, Problem: prtTestProblem(app.Status, 1)} + switch change { + case disabledApplicationCase: + current.Enabled = false + case failedApplicationCase: + current.Status = model.ApplicationStatus_Failed + case divergedApplicationCase: + current.Status = model.ApplicationStatus_Diverged + case corruptedApplicationCase: + current.Status = model.ApplicationStatus_Corrupted + case removedApplicationCase: + state = nil + } + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetEpochCompletionState", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index).Return(state, nil).Once() + + require.ErrorIs(t, s.foreclosePendingClaimEpochs(t.Context(), app), txsender.ErrIneligible) + + repo.AssertNotCalled(t, "UpdateEpochWithForeclosedClaim", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/prefix_regression_test.go b/internal/prt/prefix_regression_test.go new file mode 100644 index 000000000..49479cc83 --- /dev/null +++ b/internal/prt/prefix_regression_test.go @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/require" +) + +func TestPRTFreshSealedEpochWaitsForIngestionWithoutPlanningError(t *testing.T) { + f := newDeliveryFixture(t) + f.snapshot(resultTestSnapshot(f.epoch, false)) + f.repo.stateRead = func(context.Context, string, uint64) (*repository.EpochCompletionState, error) { + return &repository.EpochCompletionState{Application: f.app}, nil + } + call, err := f.d.planAction(t.Context(), f.app, joinTournament, f.block, observerFacts(100)) + require.Nil(t, call) + require.ErrorIs(t, err, txsender.ErrIneligible, "a sealed epoch can precede its database row during normal ingestion") +} + +func TestPRTMissingEpochRetainsSignedWorkUntilIngestion(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + f.repo.stateRead = func(context.Context, string, uint64) (*repository.EpochCompletionState, error) { + return &repository.EpochCompletionState{Application: f.app}, nil + } + eligibility, err := f.d.Recheck(t.Context(), f.block, call.Key) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.True(t, txsender.WaitOnly(err)) + require.Equal(t, txsender.ProtocolWait, eligibility) + // This read supplies no independent evidence for a priority replacement. + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.ErrorIs(t, err, txsender.ErrInconclusive) + require.True(t, txsender.WaitOnly(err)) + require.False(t, report.Attempted) + require.Equal(t, call.Key, report.Current.Action) + require.False(t, report.Current.Neutral) + require.Len(t, f.backend.published, 1) + f.repo.stateRead = nil + report, err = f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Len(t, f.backend.published, 2) + require.Equal(t, f.backend.raw[0], f.backend.raw[1]) +} diff --git a/internal/prt/prefix_test.go b/internal/prt/prefix_test.go new file mode 100644 index 000000000..9b0da0dad --- /dev/null +++ b/internal/prt/prefix_test.go @@ -0,0 +1,216 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + snapshotReadFailedCase = "snapshot read failed" + divergedApplicationCase = "diverged" + failedApplicationCase = "failed" + corruptedApplicationCase = "corrupted" + missingBoundaryCase = "missing boundary" +) + +func prtTestProblem(kind model.ApplicationStatus, cutoff uint64) *model.ApplicationProblem { + p := &model.ApplicationProblem{EpochIndex: cutoff, Kind: kind} + if kind.IsExecutionTerminal() { + p.InputIndex = new(uint64(99)) + } + return p +} + +func setPRTTestStop(f *deliveryFixture, kind model.ApplicationStatus, cutoff uint64) { + f.app.Status = kind + f.repo.problems = map[int64]*model.ApplicationProblem{f.app.ID: prtTestProblem(kind, cutoff)} +} + +func TestPRTCompletesTerminalEpochPrefix(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_GuestException, + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_McycleOverflow, + model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot} { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + t.Run(status.String()+"/"+actionName(kind), func(t *testing.T) { + f, _, _ := preparePRTAction(t, kind) + setPRTTestStop(f, status, f.epoch.Index+1) + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, call, "the complete earlier epoch remains eligible") + submitPRTAction(t, f, *call) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Len(t, f.backend.raw, 2) + require.Equal(t, f.backend.raw[0], f.backend.raw[1]) + // Defensive case: a single ordered validator cannot tighten + // to a previously computed epoch. The consumer still refuses + // it if that premise is violated. + f.repo.problems[f.app.ID].EpochIndex = f.epoch.Index + callAtCut, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.ErrorIs(t, err, txsender.ErrIneligible) + require.Nil(t, callAtCut) + report, err = f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.True(t, report.Current.Neutral, "positive cutoff evidence makes the held action obsolete") + require.Len(t, f.backend.published, 3) + require.Empty(t, f.backend.published[2].Data()) + require.Equal(t, status, f.app.Status) + }) + } + } +} + +func TestPRTUnknownStopEvidenceAloneDoesNotJustifyReplacement(t *testing.T) { + for _, fault := range []string{missingBoundaryCase, snapshotReadFailedCase} { + t.Run(fault, func(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + submitPRTAction(t, f, call) + setPRTTestStop(f, model.ApplicationStatus_MachineHalted, f.epoch.Index+1) + cause := errors.New("application epoch snapshot unavailable") + if fault == missingBoundaryCase { + f.repo.problems = nil + } else { + f.repo.stateRead = func(context.Context, string, uint64) (*repository.EpochCompletionState, error) { + return nil, cause + } + } + addDeliveryJoinCandidate(f, 8, 25) + // No separately verified priority evidence is supplied by this failed read. + report, err := f.d.sender.PublishNext(t.Context(), f.d) + if fault == missingBoundaryCase { + require.ErrorIs(t, err, txsender.ErrInconclusive) + } else { + require.ErrorIs(t, err, cause) + } + require.False(t, report.Attempted) + require.Equal(t, call.Key, report.Current.Action) + require.False(t, report.Current.Neutral) + require.Len(t, f.backend.published, 1) + }) + } +} + +func TestPRTPostSignRechecksTerminalPrefix(t *testing.T) { + for _, kind := range []actionKind{joinTournament, stageTournament, acceptTournament} { + for _, change := range []string{"later stop", "cutoff", failedApplicationCase, divergedApplicationCase, + corruptedApplicationCase, missingBoundaryCase, snapshotReadFailedCase} { + t.Run(actionName(kind)+"/"+change, func(t *testing.T) { + f, call, _ := preparePRTAction(t, kind) + cause := errors.New("post-sign application epoch snapshot unavailable") + factory := deliverySigningFactory{TransactOptsFactory: f.d.txOptsFactory, afterSign: func() { + setPRTTestStop(f, model.ApplicationStatus_MachineHalted, f.epoch.Index+1) + switch change { + case "cutoff": + f.repo.problems[f.app.ID].EpochIndex = f.epoch.Index + case failedApplicationCase: + f.app.Status = model.ApplicationStatus_Failed + case divergedApplicationCase: + f.app.Status = model.ApplicationStatus_Diverged + case corruptedApplicationCase: + f.app.Status = model.ApplicationStatus_Corrupted + case missingBoundaryCase: + f.repo.problems = nil + case snapshotReadFailedCase: + f.repo.stateRead = func(context.Context, string, uint64) (*repository.EpochCompletionState, error) { + return nil, cause + } + } + }} + sender, err := txsender.New[actionID](f.backend, factory, 31337, f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + report, err := sender.PublishNext(t.Context(), singleAction{f.d, call}) + if change == "later stop" { + require.NoError(t, err) + require.True(t, report.Attempted) + } else { + require.Error(t, err) + require.False(t, report.Attempted) + require.Empty(t, f.backend.published) + if change == missingBoundaryCase { + require.ErrorIs(t, err, txsender.ErrInconclusive) + } + if change == snapshotReadFailedCase { + require.ErrorIs(t, err, cause) + } + } + }) + } + } +} + +func TestPRTObserverStagesValidPrefixAfterExecutionStop(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = status + epoch := checkpointEpoch(0, "0x100") + problem := prtTestProblem(status, 1) + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index). + Return(&repository.EpochCompletionState{Application: f.app, Epoch: epoch, Problem: problem}, nil) + tournament := resultBoundaryTournament(f, epoch, *epoch.Commitment) + f.expectResultBoundaryWindow(epoch, tournament, 100) + view := resultTestSnapshot(epoch, true) + view.sealed.StagingBlockNumber = 100 + f.expectResultBoundarySnapshot(epoch, view, 100) + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)).Return(nil).Once() + + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + require.NoError(t, f.s.observeApplication(t.Context(), f.app, observerFacts(100))) + require.Equal(t, uint64(100), tournament.Snapshot.AsOfBlock) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, status, f.app.Status) + }) + } +} + +func TestPRTStopReadFailurePreservesPassiveRootObservation(t *testing.T) { + for _, fault := range []string{missingBoundaryCase, snapshotReadFailedCase, "consensus read failed"} { + t.Run(fault, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_MachineHalted + epoch := checkpointEpoch(0, "0x100") + cause := errors.New("protected source unavailable") + state := &repository.EpochCompletionState{Application: f.app, Epoch: epoch, Problem: prtTestProblem(f.app.Status, 1)} + var readErr error + if fault == missingBoundaryCase { + state.Problem = nil + } + if fault == snapshotReadFailedCase { + readErr = cause + } + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(state, readErr) + if fault == "consensus read failed" { + f.consensus.On("GetCurrentSealedEpoch", mock.MatchedBy(resultCallOptsAtBlock(100))). + Return(CurrentSealedEpoch{}, cause).Once() + } + f.epochs(epoch) + f.consensus.On("TournamentLevelCount", mock.MatchedBy(resultCallOptsAtBlock(100))).Return(uint64(1), nil).Once() + f.tournament(epoch, *epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, uint64(100)).Return(nil).Once() + + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + err := f.s.observeApplication(t.Context(), f.app, observerFacts(100)) + + if fault == missingBoundaryCase { + require.ErrorIs(t, err, txsender.ErrInconclusive) + } else { + require.ErrorIs(t, err, cause) + } + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + f.repo.AssertNumberOfCalls(t, "StoreTournamentInterval", 1) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} diff --git a/internal/prt/protection.go b/internal/prt/protection.go new file mode 100644 index 000000000..214f326e9 --- /dev/null +++ b/internal/prt/protection.go @@ -0,0 +1,175 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" +) + +type protectionTarget struct { + epoch *model.Epoch + index uint64 + root common.Address + lower, upper uint64 +} + +// observeProtection is diagnostic only. It precedes projection and completion +// gates, and must never change their readiness, cursors, or error decisions. +func (s *Service) observeProtection(ctx context.Context, app *model.Application, epochs []*model.Epoch, + consensus DaveConsensusAdapter, facts txsender.BlockRef, discoveryErr error, +) []prtprotection.Clock { + if ctx.Err() != nil { + return nil + } + ctx = withLiveBlock(ctx, facts.Number, facts.Hash) + var targets []protectionTarget + for _, epoch := range epochs { + if epoch != nil && epoch.TournamentAddress != nil && epoch.LastBlock <= facts.Number && + epoch.ClaimTransactionHash == nil && slices.Contains(model.NonTerminalEpochStatuses(), epoch.Status) { + targets = append(targets, protectionTarget{epoch, epoch.Index, *epoch.TournamentAddress, + epoch.InputIndexLowerBound, epoch.InputIndexUpperBound}) + } + } + // The sealed view is independent of stage/accept readiness and event decoding. + // A nonempty local list cannot prove that a newer root was ingested. + var sealedErr error + if consensus != nil { + sealed, err := consensus.GetCurrentSealedEpoch(pinnedCallOpts(ctx, facts.Number)) + sealedErr = err + if err == nil && sealed.Tournament != (common.Address{}) { + known := false + for _, epoch := range epochs { + if epoch != nil && epoch.Index == sealed.EpochNumber && epoch.TournamentAddress != nil && + *epoch.TournamentAddress == sealed.Tournament { + known = true // Preserve observed acceptance and foreclosure facts. + if epoch.LastBlock > facts.Number && epoch.ClaimTransactionHash == nil && + slices.Contains(model.NonTerminalEpochStatuses(), epoch.Status) { + // Current chain evidence identifies this obligation even when + // its local sealing boundary cannot authenticate the clock. + targets = append(targets, protectionTarget{epoch, epoch.Index, sealed.Tournament, + sealed.InputIndexLowerBound, sealed.InputIndexUpperBound}) + } + break + } + } + if !known { + targets = append(targets, protectionTarget{index: sealed.EpochNumber, root: sealed.Tournament, + lower: sealed.InputIndexLowerBound, upper: sealed.InputIndexUpperBound}) + } + } else if err == nil { + sealedErr = errors.New("current sealed epoch has no root tournament") + } + } + if txsender.ShutdownOnly(ctx, errors.Join(discoveryErr, sealedErr)) { + return nil + } + s.reportProtectionDiscovery(app, protectionClockDiscovery, errors.Join(discoveryErr, sealedErr)) + clocks := make([]prtprotection.Clock, 0, len(targets)) + for _, target := range targets { + clock := s.readProtectionClock(ctx, app, target, facts) + if errors.Is(ctx.Err(), context.Canceled) { + return clocks + } + clocks = append(clocks, clock) + s.reportSealBacklog(app, target, clock, facts) + } + return clocks +} + +func (s *Service) readProtectionClock(ctx context.Context, app *model.Application, + target protectionTarget, facts txsender.BlockRef, +) prtprotection.Clock { + clock := prtprotection.Clock{EpochIndex: target.index, Root: target.root, + Source: fmt.Sprintf("pinned root descriptor at block %d (%s)", facts.Number, facts.Hash)} + if target.root == (common.Address{}) { + clock.Err = errors.New("sealed root tournament identity is unavailable") + return clock + } + if target.epoch != nil && (target.epoch.ApplicationID != app.ID || target.epoch.Index != target.index) { + clock.Err = errors.New("sealed epoch identity does not match application") + return clock + } + adapter, err := s.adapterFactory.CreateTournamentAdapter(target.root) + if err != nil { + clock.Err = err + return clock + } + descriptor, err := adapter.Descriptor(pinnedCallOpts(ctx, facts.Number)) + switch { + case err != nil: + clock.Err = err + case descriptor.Level != uint64(RootLevel): + clock.Err = errors.New("protection descriptor is not a root tournament") + case target.epoch != nil && descriptor.StartInstant != target.epoch.LastBlock: + clock.Err = errors.New("root protection start does not match sealed epoch") + default: + end, addErr := prtprotection.ClosingBlock(descriptor.StartInstant, descriptor.Allowance) + clock.Err = addErr + if addErr == nil { + clock.ClosingBlock = new(end) + } + } + return clock +} + +func (s *Service) protectionStopBoundary(ctx context.Context, app *model.Application, clocks []prtprotection.Clock) (*uint64, error) { + if s.publicationStopped() { + return new(uint64(0)), nil + } + if app.Status == model.ApplicationStatus_Failed || app.Status == model.ApplicationStatus_Diverged || + app.Status == model.ApplicationStatus_Corrupted { + return new(uint64(0)), nil + } + if !app.Status.IsExecutionTerminal() && app.Status != model.ApplicationStatus_InvalidOutputsRoot || len(clocks) == 0 { + return nil, nil + } + state, err := s.repository.GetEpochCompletionState(ctx, app.IApplicationAddress.Hex(), clocks[0].EpochIndex) + if err != nil { + return nil, err + } + if state == nil || state.Application == nil || state.Application.ID != app.ID || + state.Epoch == nil || state.Epoch.Index != clocks[0].EpochIndex { + return nil, errors.New("protection stop boundary identity is unavailable") + } + if !state.Application.Enabled || !state.Application.Status.IsExecutionTerminal() && + state.Application.Status != model.ApplicationStatus_InvalidOutputsRoot { + return nil, nil + } + if _, err := state.AllowsEpochCompletion(); err != nil { + return nil, err + } + return new(state.Problem.EpochIndex), nil +} + +// The publication latch is set before this diagnostic scan. Failed root reads +// retain their own unavailable evidence and do not suppress another application. +func (s *Service) reportGlobalProtectionStop(ctx context.Context, through int64, cause error) { + filter := repository.ApplicationFilter{ConsensusType: new(model.Consensus_PRT), Enabled: new(true)} + for after := int64(0); after < through; { + apps, err := s.repository.ListApplicationsByID(ctx, filter, after, through, deliveryApplicationPageSize) + if err != nil { + s.Logger.Error("PRT global stop deadline discovery unavailable", "clock_available", false, "cause", errors.Join(cause, err)) + return + } + if len(apps) == 0 { + return + } + for _, app := range apps { + after = app.ID + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, 0, cause) + if ctx.Err() != nil { + return + } + } + } +} diff --git a/internal/prt/protection_backlog.go b/internal/prt/protection_backlog.go new file mode 100644 index 000000000..a9f16c34d --- /dev/null +++ b/internal/prt/protection_backlog.go @@ -0,0 +1,80 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/txsender" +) + +func protectionLocallyReady(epoch *model.Epoch) bool { + if epoch == nil || epoch.Commitment == nil || epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + return false + } + if epoch.Status == model.EpochStatus_ClaimStaged { + return true + } + if epoch.Status != model.EpochStatus_ClaimComputed { + return false + } + _, err := epoch.StateProof() + return err == nil +} + +func pendingExecutionInputs(lower, upper, processed uint64) (uint64, error) { + if lower > upper { + return 0, errors.New("sealed epoch input bounds are inverted") + } + cursor := min(upper, max(lower, processed)) + return upper - cursor, nil +} + +func (s *Service) reportSealBacklog(app *model.Application, target protectionTarget, + clock prtprotection.Clock, facts txsender.BlockRef, +) { + key := protectionNoticeKey{root: clock.Root, kind: protectionBacklog} + if protectionLocallyReady(target.epoch) { + delete(s.protectionNotices[app.ID], key) + return + } + status := "unavailable" + if target.epoch != nil { + status = target.epoch.Status.String() + } + identity := protectionClockIdentity(protectionNoticeIdentity{cause: status, epoch: clock.EpochIndex, + generation: s.deployments.capture(deploymentIdentity(app)).snapshot.ResetGeneration}, clock, facts) + if !s.protectionNoticeDue(app.ID, key, identity) { + return + } + args := []any{"application", app.Name, "address", app.IApplicationAddress, "epoch_index", target.index, + "root_tournament", target.root, "epoch_status", status, "configured_head", facts.Number, + "advance_max_deadline_ns", int64(app.ExecutionParameters.AdvanceMaxDeadline), + "clock_available", clock.ClosingBlock != nil, "clock_source", clock.Source, + "validation_pending", true, "execution_budget_scope", "execution only"} + if clock.ClosingBlock != nil { + args = append(args, "join_close_block", *clock.ClosingBlock, "join_window_closed", facts.Number >= *clock.ClosingBlock) + } + if clock.Err != nil { + args = append(args, "clock_error", clock.Err) + } + pending, err := pendingExecutionInputs(target.lower, target.upper, app.ProcessedInputs) + if err != nil { + args = append(args, "backlog_error", err) + } else { + args = append(args, "pending_execution_inputs", pending) + if deadline := app.ExecutionParameters.AdvanceMaxDeadline; deadline > 0 { + // This exact budget excludes loading, replay, validation, scheduling, + // RPC, signing, and inclusion. It is not an ETA or completion bound. + budget := new(big.Int).Mul(new(big.Int).SetUint64(pending), big.NewInt(int64(deadline))) + args = append(args, "execution_budget_ns", budget.String()) + } else { + args = append(args, "execution_budget_ns", "unbounded") + } + } + s.Logger.Warn("PRT sealed epoch is not locally ready for publication", args...) +} diff --git a/internal/prt/protection_discovery_test.go b/internal/prt/protection_discovery_test.go new file mode 100644 index 000000000..cc87af827 --- /dev/null +++ b/internal/prt/protection_discovery_test.go @@ -0,0 +1,111 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestProtectionConfirmedRestrictionIsConclusive(t *testing.T) { + for _, state := range []deploymentState{deploymentNone, deploymentPending, deploymentGeneration, deploymentTable} { + t.Run(string(state), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + clock := prtprotection.Clock{EpochIndex: 1, Root: common.HexToAddress("0x123"), ClosingBlock: new(uint64(110))} + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, observerFacts(100), state, 0, nil) + require.Contains(t, output.String(), "deployment assessment "+string(state)) + if state == deploymentNone || state == deploymentPending { + require.Contains(t, output.String(), txsender.ErrInconclusive.Error()) + } else { + require.NotContains(t, output.String(), txsender.ErrInconclusive.Error()) + } + require.NotContains(t, output.String(), "%!w") + }) + } +} + +func TestProtectionDiscoveryIncidentSurvivesRootPruning(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + report := func(head uint64, failure error, want int) { + t.Helper() + output.Reset() + facts := observerFacts(head) + clocks := f.s.observeProtection(t.Context(), f.app, nil, nil, facts, failure) + f.s.reportProtectionStops(t.Context(), f.app, clocks, facts, deploymentCompatible, 0, nil) + require.Equal(t, want, strings.Count(output.String(), "PRT protection deadline discovery unavailable"), output.String()) + } + report(100, errors.New("provider unavailable"), 1) + report(101, errors.New("different wrapper at a newer head"), 0) + report(102, nil, 0) + report(103, errors.New("provider unavailable again"), 1) + clock := prtprotection.Clock{EpochIndex: 1, Root: common.HexToAddress("0x123"), ClosingBlock: new(uint64(110))} + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, observerFacts(103), deploymentNone, 0, nil) + require.Contains(t, output.String(), "PRT protection stopped") + require.Contains(t, output.String(), "join_close_block=110") +} + +func TestProtectionDiscoverySuccessDoesNotClearBoundaryFailure(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_MachineHalted + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + epoch := checkpointEpoch(1, "0x123") + clock := prtprotection.Clock{EpochIndex: epoch.Index, Root: *epoch.TournamentAddress, ClosingBlock: new(uint64(110))} + state := &repository.EpochCompletionState{Application: f.app, Epoch: epoch, Problem: &model.ApplicationProblem{ + EpochIndex: epoch.Index, Kind: model.ApplicationStatus_MachineHalted, InputIndex: new(uint64(0)), + }} + for i, failed := range []bool{true, true, false, true} { + output.Reset() + var err error + if failed { + err = fmt.Errorf("boundary read unavailable at block %d", 100+i) + } + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index). + Return(state, err).Once() + facts := observerFacts(uint64(100 + i)) + f.s.observeProtection(t.Context(), f.app, nil, nil, facts, nil) + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, facts, deploymentCompatible, 0, nil) + want := 0 + if i == 0 || i == 3 { + want = 1 + } + require.Equal(t, want, strings.Count(output.String(), "PRT protection deadline discovery unavailable"), output.String()) + } +} + +func TestProtectionBoundaryIncidentSurvivesUnavailableInventory(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_MachineHalted + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + epoch := checkpointEpoch(1, "0x123") + clock := prtprotection.Clock{EpochIndex: epoch.Index, Root: *epoch.TournamentAddress, ClosingBlock: new(uint64(110))} + failure := errors.New("boundary database query unavailable") + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index). + Return(nil, failure).Twice() + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, observerFacts(100), deploymentCompatible, 0, nil) + require.Equal(t, 1, strings.Count(output.String(), "PRT protection deadline discovery unavailable")) + output.Reset() + // The clock inventory failed. No boundary query succeeded in this pass. + f.s.reportProtectionStops(t.Context(), f.app, nil, observerFacts(101), deploymentCompatible, 0, nil) + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, observerFacts(102), deploymentCompatible, 0, nil) + require.Zero(t, strings.Count(output.String(), "PRT protection deadline discovery unavailable"), + "an absent inventory is not successful recovery of a persistent boundary failure") +} diff --git a/internal/prt/protection_finality_test.go b/internal/prt/protection_finality_test.go new file mode 100644 index 000000000..f8b76fbef --- /dev/null +++ b/internal/prt/protection_finality_test.go @@ -0,0 +1,125 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "log/slog" + "math/big" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestProtectionReportsMidIntervalFinalityInSameObserverPass(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.s.defaultBlock = model.DefaultBlock_Finalized + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + second := *f.app + second.ID++ + second.Name = "second application" + second.IApplicationAddress = common.HexToAddress("0x991") + second.IConsensusAddress = common.HexToAddress("0x992") + f.repo.deploymentRows[second.ID] = model.DeploymentAdmissionState{ApplicationID: second.ID} + secondConsensus := newCompatibleDaveConsensusAdapterMock(second.IConsensusAddress) + f.factory.On("CreateDaveConsensusAdapter", second.IConsensusAddress).Return(secondConsensus, nil).Once() + + header := &types.Header{Number: big.NewInt(100)} + conflict := types.CopyHeader(header) + conflict.Extra = []byte("changed after interval reads") + client := ðClientMock{} + f.client, f.s.client = client, client + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(header, nil).Once() + checks := 0 + client.On("HeaderByNumber", mock.Anything, big.NewInt(100)).Return(func(context.Context, *big.Int) *types.Header { + checks++ + if checks < 3 { + return header + } + return conflict + }, nil) + facts := txsender.BlockRef{Number: 100, Hash: header.Hash()} + filter := repository.ApplicationFilter{ConsensusType: new(model.Consensus_PRT), Enabled: new(true)} + f.repo.On("GetApplicationIDBoundary", mock.Anything, filter).Return(second.ID, nil).Once() + f.repo.On("ListApplicationsByID", mock.Anything, filter, int64(0), second.ID, deliveryApplicationPageSize). + Return([]*model.Application{f.app, &second}, nil).Maybe() + + firstEpoch, secondEpoch := checkpointEpoch(0, "0x111"), checkpointEpoch(1, "0x222") + secondEpoch.ApplicationID = second.ID + require.True(t, protectionLocallyReady(firstEpoch)) + require.True(t, protectionLocallyReady(secondEpoch)) + firstRoot := &model.Tournament{ApplicationID: f.app.ID, EpochIndex: firstEpoch.Index, + Address: *firstEpoch.TournamentAddress, StartInstant: firstEpoch.LastBlock, Allowance: 300, + Snapshot: model.TournamentSnapshot{AsOfBlock: 100}} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), firstRoot.Address.Hex()).Return(firstRoot, nil).Maybe() + secondFixture := &observerCheckpointFixture{t: t, s: f.s, repo: f.repo, factory: f.factory, + consensus: secondConsensus, client: client, app: &second} + secondRoot := secondFixture.tournament(secondEpoch, *secondEpoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + f.repo.On("GetTournament", mock.Anything, second.IApplicationAddress.Hex(), secondRoot.Address.Hex()).Return(secondRoot, nil).Maybe() + + for i, item := range []struct { + app *model.Application + epoch *model.Epoch + root *model.Tournament + consensus *daveConsensusAdapterMock + }{{f.app, firstEpoch, firstRoot, f.consensus}, {&second, secondEpoch, secondRoot, secondConsensus}} { + f.repo.On("ListEpochs", mock.Anything, item.app.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch{item.epoch}, uint64(1), nil).Once() + f.repo.On("ListTournaments", mock.Anything, item.app.Name, repository.TournamentFilter{}, repository.Pagination{}, false). + Return([]*model.Tournament{item.root}, uint64(1), nil).Once() + f.repo.On("GetEpoch", mock.Anything, item.app.IApplicationAddress.Hex(), item.epoch.Index).Return(item.epoch, nil).Maybe() + view := resultTestSnapshot(item.epoch, false) + if i == 0 { + view.stage.IsFinished = false + view.stage.WinnerCommitment, view.stage.WinnerPostEpochMachineStateHash = common.Hash{}, common.Hash{} + } + item.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(view.sealed, nil) + item.consensus.On("CanStageTournamentResult", protectionOpts(facts)).Return(view.stage, nil).Once() + item.consensus.On("CanAcceptStagedTournamentResult", protectionOpts(facts)).Return(view.accept, nil).Once() + if i == 1 { + item.consensus.On("TournamentLevelCount", protectionOpts(facts)).Return(uint64(1), nil).Once() + } + adapter := &tournamentAdapterMock{} + call := f.factory.On("CreateTournamentAdapter", *item.epoch.TournamentAddress).Return(adapter, nil).Once() + f.factory.ExpectedCalls = append([]*mock.Call{call}, f.factory.ExpectedCalls[:len(f.factory.ExpectedCalls)-1]...) + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{ + StartInstant: item.epoch.LastBlock, Allowance: item.root.Allowance}, nil).Once() + t.Cleanup(func() { adapter.AssertExpectations(t) }) + // Keep diagnostic reads optional so the alert assertions detect an omitted scan. + f.repo.On("ListEpochs", mock.Anything, item.app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: model.NonTerminalEpochStatuses()}, repository.Pagination{}, false). + Return([]*model.Epoch{item.epoch}, uint64(1), nil).Maybe() + } + + require.True(t, f.s.Ready()) + err := f.s.observe(t.Context()) + require.ErrorIs(t, err, txsender.ErrFinality) + require.True(t, f.s.publicationStopped()) + require.False(t, f.s.Ready()) + require.GreaterOrEqual(t, checks, 3, "contradiction must occur after interval reads") + require.Empty(t, f.repo.committedTournaments) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.ApplicationStatus_OK, second.Status) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + require.Equal(t, uint64(50), second.LastTournamentCheckBlock) + require.NotContains(t, output.String(), "PRT sealed epoch is not locally ready") + require.Equal(t, 2, strings.Count(output.String(), "PRT protection stopped"), "this observer call must report both ready apps") + require.Contains(t, output.String(), "application=prt-app") + require.Contains(t, output.String(), "root_tournament="+firstRoot.Address.Hex()) + require.Contains(t, output.String(), "root_tournament="+secondRoot.Address.Hex()) + require.Contains(t, output.String(), "join_close_block=310") + require.Contains(t, output.String(), "join_close_block=10") + secondConsensus.AssertExpectations(t) +} diff --git a/internal/prt/protection_limiter.go b/internal/prt/protection_limiter.go new file mode 100644 index 000000000..ae959d94e --- /dev/null +++ b/internal/prt/protection_limiter.go @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/ethereum/go-ethereum/common" +) + +type protectionNoticeKind uint8 + +const ( + protectionRootStop protectionNoticeKind = iota + protectionBacklog + protectionClockDiscovery + protectionBoundaryDiscovery +) + +type protectionNoticeKey struct { + root common.Address + kind protectionNoticeKind +} + +type protectionNoticeIdentity struct { + cause string + generation int64 + epoch, close uint64 + available, closed, lastChance bool +} + +type protectionNotice struct { + identity protectionNoticeIdentity + warnedAt time.Time +} + +func (s *Service) protectionNoticeDue(appID int64, key protectionNoticeKey, identity protectionNoticeIdentity) bool { + if s.protectionNotices == nil { + s.protectionNotices = make(map[int64]map[protectionNoticeKey]protectionNotice) + } + if s.protectionNotices[appID] == nil { + s.protectionNotices[appID] = make(map[protectionNoticeKey]protectionNotice) + } + previous := s.protectionNotices[appID][key] + if previous.identity == identity && !warningDue(previous.warnedAt) { + return false + } + s.protectionNotices[appID][key] = protectionNotice{identity: identity, warnedAt: time.Now()} + return true +} + +// Each discovery site owns its incident. Success at another site cannot clear it. +func (s *Service) reportProtectionDiscovery(app *model.Application, kind protectionNoticeKind, cause error) { + key := protectionNoticeKey{kind: kind} + if cause == nil { + delete(s.protectionNotices[app.ID], key) + return + } + // Moving heads and provider wrapper text do not identify a new incident. + if s.protectionNoticeDue(app.ID, key, protectionNoticeIdentity{}) { + prtprotection.ReportUnavailable(s.Logger, app, cause) + } +} diff --git a/internal/prt/protection_notice.go b/internal/prt/protection_notice.go new file mode 100644 index 000000000..9c5f410a2 --- /dev/null +++ b/internal/prt/protection_notice.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/txsender" +) + +// The observer owns these notices. Each application retains only its current stopped roots. +func (s *Service) reportProtectionStops(ctx context.Context, app *model.Application, clocks []prtprotection.Clock, + facts txsender.BlockRef, state deploymentState, generation int64, deploymentErr error, +) { + if txsender.ShutdownOnly(ctx, deploymentErr) { + return + } + first, stopErr := s.protectionStopBoundary(ctx, app, clocks) + if len(clocks) > 0 && !txsender.ShutdownOnly(ctx, stopErr) { + s.reportProtectionDiscovery(app, protectionBoundaryDiscovery, stopErr) + } + current := make(map[protectionNoticeKey]struct{}) + assessmentCause := s.deployments.capture(deploymentIdentity(app)).snapshot.Cause + for _, clock := range clocks { + current[protectionNoticeKey{root: clock.Root, kind: protectionBacklog}] = struct{}{} + var cause error + if first != nil && clock.EpochIndex >= *first { + if app.Status != model.ApplicationStatus_OK { + cause = fmt.Errorf("application status %s", app.Status) + if app.Reason != nil { + cause = fmt.Errorf("application status %s: %s", app.Status, *app.Reason) + } + } + if s.publicationStopped() { + cause = errors.Join(cause, s.publicationFailure()) + } + } + identity := protectionNoticeIdentity{generation: generation, epoch: clock.EpochIndex} + if cause != nil { + identity.cause = cause.Error() + } + if state != deploymentCompatible { + identity.cause += "|deployment " + string(state) + identity.cause += ":" + string(assessmentCause) + detail := deploymentErr + if state == deploymentNone || state == deploymentPending { + detail = errors.Join(txsender.ErrInconclusive, detail) + } + cause = errors.Join(cause, fmt.Errorf("deployment assessment %s", state), detail) + } + if cause == nil { + continue + } + identity = protectionClockIdentity(identity, clock, facts) + key := protectionNoticeKey{root: clock.Root, kind: protectionRootStop} + if s.protectionNoticeDue(app.ID, key, identity) { + prtprotection.ReportClock(s.Logger, app, clock, cause) + } + current[key] = struct{}{} + } + for key := range s.protectionNotices[app.ID] { + if key.kind == protectionClockDiscovery || key.kind == protectionBoundaryDiscovery { + continue + } + if _, present := current[key]; !present { + delete(s.protectionNotices[app.ID], key) + } + } + if len(s.protectionNotices[app.ID]) == 0 { + delete(s.protectionNotices, app.ID) + } +} + +func protectionClockIdentity(identity protectionNoticeIdentity, clock prtprotection.Clock, + facts txsender.BlockRef, +) protectionNoticeIdentity { + if clock.ClosingBlock != nil { + identity.available, identity.close = true, *clock.ClosingBlock + identity.closed = facts.Number >= identity.close + identity.lastChance = identity.close > 0 && facts.Number == identity.close-1 + } + return identity +} diff --git a/internal/prt/protection_notice_test.go b/internal/prt/protection_notice_test.go new file mode 100644 index 000000000..c6d428f7f --- /dev/null +++ b/internal/prt/protection_notice_test.go @@ -0,0 +1,122 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestProtectionNoticesKeepImmediateTransitions(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + clock := prtprotection.Clock{EpochIndex: 1, Root: common.HexToAddress("0x123"), ClosingBlock: new(uint64(110))} + state, generation, readErr := deploymentNone, int64(0), errors.New("read unavailable") + facts := observerFacts(100) + report := func(want int) { + t.Helper() + output.Reset() + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, facts, state, generation, readErr) + require.Equal(t, want, strings.Count(output.String(), "PRT protection stopped"), output.String()) + } + report(1) + report(0) + facts = observerFacts(109) + report(1) + facts = observerFacts(110) + report(1) + require.Contains(t, output.String(), "join_close_block=110") + require.NotContains(t, output.String(), "join_window_closed=") + report(0) + clock.ClosingBlock, clock.Err = nil, errors.New("descriptor unavailable") + report(1) + require.Contains(t, output.String(), "clock_available=false") + clock.ClosingBlock, clock.Err = new(uint64(120)), nil + report(1) + generation++ + report(1) + readErr = errors.New("same provider failure at another pinned head") + report(0) + state, readErr = deploymentGeneration, nil + report(1) + clock.Root = common.HexToAddress("0x456") + report(1) + require.Len(t, f.s.protectionNotices[f.app.ID], 1, "retired roots must not accumulate") + notice := f.s.protectionNotices[f.app.ID][protectionNoticeKey{root: clock.Root}] + notice.warnedAt = time.Now().Add(-publicationWarningInterval) + f.s.protectionNotices[f.app.ID][protectionNoticeKey{root: clock.Root}] = notice + report(1) + state = deploymentCompatible + report(0) + require.Empty(t, f.s.protectionNotices) + state = deploymentNone + report(1) +} + +func TestProtectionNoticesCombineHealthAndDeploymentStops(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_Failed + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + clock := prtprotection.Clock{Root: common.HexToAddress("0x123"), ClosingBlock: new(uint64(110))} + for range 2 { + f.s.reportProtectionStops(t.Context(), f.app, []prtprotection.Clock{clock}, observerFacts(100), deploymentNone, 0, nil) + } + require.Equal(t, 1, strings.Count(output.String(), "PRT protection stopped")) + require.Contains(t, output.String(), "application status FAILED") + require.Contains(t, output.String(), "deployment assessment none") +} + +func TestProtectionNoticesKeepSiblingClocksOnOneHead(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + clocks := []prtprotection.Clock{ + {EpochIndex: 1, Root: common.HexToAddress("0x123"), Err: errors.New("descriptor unavailable")}, + {EpochIndex: 2, Root: common.HexToAddress("0x456"), ClosingBlock: new(uint64(110))}, + } + for range 2 { + f.s.reportProtectionStops(t.Context(), f.app, clocks, observerFacts(100), deploymentNone, 0, nil) + } + require.Equal(t, 2, strings.Count(output.String(), "PRT protection stopped")) + output.Reset() + clocks[0].ClosingBlock, clocks[0].Err = new(uint64(120)), nil + f.s.reportProtectionStops(t.Context(), f.app, clocks, observerFacts(100), deploymentNone, 0, nil) + require.Equal(t, 1, strings.Count(output.String(), "PRT protection stopped")) + require.Contains(t, output.String(), clocks[0].Root.Hex()) +} + +func TestProtectionBacklogNoticeKeepsClockTransitions(t *testing.T) { + f := newObserverCheckpointFixture(t) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + epoch := checkpointEpoch(1, "0x123") + epoch.Status = model.EpochStatus_InputsProcessed + clock := prtprotection.Clock{EpochIndex: epoch.Index, Root: *epoch.TournamentAddress} + target := protectionTarget{epoch: epoch, index: epoch.Index, root: clock.Root} + for range 2 { + f.s.reportSealBacklog(f.app, target, clock, observerFacts(100)) + } + require.Equal(t, 1, strings.Count(output.String(), "PRT sealed epoch is not locally ready")) + output.Reset() + clock.ClosingBlock = new(uint64(110)) + f.s.reportSealBacklog(f.app, target, clock, observerFacts(100)) + require.Contains(t, output.String(), "join_close_block=110") + output.Reset() + f.s.reportSealBacklog(f.app, target, clock, observerFacts(109)) + require.Contains(t, output.String(), "PRT sealed epoch is not locally ready") + output.Reset() + f.s.reportSealBacklog(f.app, target, clock, observerFacts(110)) + require.Contains(t, output.String(), "join_window_closed=true") +} diff --git a/internal/prt/protection_test.go b/internal/prt/protection_test.go new file mode 100644 index 000000000..328890c97 --- /dev/null +++ b/internal/prt/protection_test.go @@ -0,0 +1,310 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "math" + "math/big" + "slices" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func protectionOpts(facts txsender.BlockRef) any { + return mock.MatchedBy(func(opts *bind.CallOpts) bool { + return opts.BlockHash == facts.Hash && opts.BlockNumber == nil && !opts.Pending + }) +} + +// Existing observation tests explicitly declare their additional diagnostic +// reads. Separate adapters prevent diagnostic clocks from consuming projection +// expectations. No standing, event, repository, or unknown read is defaulted. +func expectProtectionReads(t *testing.T, factory *adapterFactoryMock, consensus *daveConsensusAdapterMock, + facts txsender.BlockRef, epochs ...*model.Epoch, +) { + t.Helper() + var sealed CurrentSealedEpoch + if len(epochs) > 0 { + epoch := epochs[len(epochs)-1] + sealed.EpochNumber = epoch.Index + sealed.InputIndexLowerBound, sealed.InputIndexUpperBound = epoch.InputIndexLowerBound, epoch.InputIndexUpperBound + if epoch.TournamentAddress != nil { + sealed.Tournament = *epoch.TournamentAddress + } + } + call := consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(sealed, nil).Once() + consensus.ExpectedCalls = append([]*mock.Call{call}, consensus.ExpectedCalls[:len(consensus.ExpectedCalls)-1]...) + for _, epoch := range epochs { + if epoch.TournamentAddress == nil || epoch.LastBlock > facts.Number || epoch.ClaimTransactionHash != nil || + !slices.Contains(model.NonTerminalEpochStatuses(), epoch.Status) { + continue + } + adapter := &tournamentAdapterMock{} + call := factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + factory.ExpectedCalls = append([]*mock.Call{call}, factory.ExpectedCalls[:len(factory.ExpectedCalls)-1]...) + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{ + Level: 0, StartInstant: epoch.LastBlock, Allowance: 300, + }, nil).Once() + t.Cleanup(func() { adapter.AssertExpectations(t) }) + } +} + +func expectStoredProtection(t *testing.T, repo *prtRepositoryMock, app *model.Application, epochs ...*model.Epoch) { + t.Helper() + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: model.NonTerminalEpochStatuses(), + }, repository.Pagination{}, false).Run(func(mock.Arguments) { + // This query starts after the health transition. Earlier domain reads + // retain their own expectations and cannot consume these clock reads. + for _, epoch := range epochs { + if epoch.TournamentAddress == nil || epoch.ClaimTransactionHash != nil || + !slices.Contains(model.NonTerminalEpochStatuses(), epoch.Status) { + continue + } + call := repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return( + &model.Tournament{ApplicationID: app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + StartInstant: epoch.LastBlock, Allowance: 300}, nil).Once() + repo.ExpectedCalls = append([]*mock.Call{call}, repo.ExpectedCalls[:len(repo.ExpectedCalls)-1]...) + } + }).Return(epochs, uint64(len(epochs)), nil).Once() +} + +func TestProtectionIndependentPinnedClocksKeepSiblingAfterFailure(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_Failed + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x101") + facts := observerFacts(100) + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(CurrentSealedEpoch{ + EpochNumber: second.Index, Tournament: *second.TournamentAddress, + }, nil).Once() + for _, epoch := range []*model.Epoch{first, second} { + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + if epoch == first { + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{}, errors.New("descriptor unavailable")).Once() + } else { + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{StartInstant: 10, Allowance: 250}, nil).Once() + } + t.Cleanup(func() { adapter.AssertExpectations(t) }) + } + clocks := f.s.observeProtection(t.Context(), f.app, []*model.Epoch{first, second}, f.consensus, facts, nil) + f.s.reportProtectionStops(t.Context(), f.app, clocks, facts, deploymentCompatible, 0, nil) + require.Equal(t, 2, strings.Count(output.String(), "PRT protection stopped")) + require.Contains(t, output.String(), "epoch_index=0 root_tournament="+first.TournamentAddress.Hex()) + require.Contains(t, output.String(), "clock_available=false") + require.Contains(t, output.String(), "epoch_index=1 root_tournament="+second.TournamentAddress.Hex()) + require.Contains(t, output.String(), "join_close_block=260") + require.True(t, f.s.Ready()) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + f.consensus.AssertNotCalled(t, "CanStageTournamentResult", mock.Anything) + f.consensus.AssertNotCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestProtectionCurrentSealedFallbackSurvivesDiscoveryFailure(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_Failed + f.app.ExecutionParameters.AdvanceMaxDeadline = 2 * time.Second + facts := observerFacts(100) + cause := errors.New("projection repository unavailable") + f.repo.On("ListEpochs", mock.Anything, f.app.Name, repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false). + Return([]*model.Epoch(nil), uint64(0), cause).Once() + root := common.HexToAddress("0x777") + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(CurrentSealedEpoch{ + EpochNumber: 7, Tournament: root, InputIndexLowerBound: 20, InputIndexUpperBound: 23, + }, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", root).Return(adapter, nil).Once() + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{StartInstant: 90, Allowance: 300}, nil).Once() + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + require.ErrorIs(t, f.s.observeApplication(t.Context(), f.app, facts), cause) + require.Contains(t, output.String(), "epoch_index=7 root_tournament="+root.Hex()) + require.Contains(t, output.String(), "join_close_block=390") + require.Contains(t, output.String(), "pending_execution_inputs=3") + require.Contains(t, output.String(), "execution_budget_ns=6000000000") + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + f.consensus.AssertNotCalled(t, "CanStageTournamentResult", mock.Anything) + f.consensus.AssertNotCalled(t, "CanAcceptStagedTournamentResult", mock.Anything) + f.repo.AssertNotCalled(t, "StoreTournamentInterval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + adapter.AssertExpectations(t) +} + +func TestProtectionUnavailableClockRetainsExactIdentity(t *testing.T) { + for _, test := range []struct { + name string + descriptor TournamentDescriptor + }{ + {"inner", TournamentDescriptor{Level: 1, StartInstant: 10, Allowance: 50}}, + {"wrong boundary", TournamentDescriptor{StartInstant: 11, Allowance: 50}}, + {"overflow", TournamentDescriptor{StartInstant: 10, Allowance: math.MaxUint64}}, + } { + t.Run(test.name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(9, "0x999") + facts := observerFacts(100) + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + adapter.On("Descriptor", protectionOpts(facts)).Return(test.descriptor, nil).Once() + clock := f.s.readProtectionClock(withLiveBlock(t.Context(), facts.Number, facts.Hash), f.app, + protectionTarget{epoch: epoch, index: epoch.Index, root: *epoch.TournamentAddress}, facts) + require.Equal(t, epoch.Index, clock.EpochIndex) + require.Equal(t, *epoch.TournamentAddress, clock.Root) + require.Nil(t, clock.ClosingBlock) + require.Error(t, clock.Err) + adapter.AssertExpectations(t) + }) + } +} + +func TestProtectionExecutionStopKeepsValidPrefix(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = status + first, second := checkpointEpoch(0, "0x100"), checkpointEpoch(1, "0x101") + facts := observerFacts(100) + expectProtectionReads(t, f.factory, f.consensus, facts, first, second) + f.repo.On("GetEpochCompletionState", mock.Anything, f.app.IApplicationAddress.Hex(), first.Index). + Return(&repository.EpochCompletionState{Application: f.app, Epoch: first, Problem: prtTestProblem(status, 1)}, nil).Once() + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + clocks := f.s.observeProtection(t.Context(), f.app, []*model.Epoch{first, second}, f.consensus, facts, nil) + f.s.reportProtectionStops(t.Context(), f.app, clocks, facts, deploymentCompatible, 0, nil) + require.Equal(t, 1, strings.Count(output.String(), "PRT protection stopped")) + require.Contains(t, output.String(), "epoch_index=1 root_tournament="+second.TournamentAddress.Hex()) + require.Equal(t, status, f.app.Status) + }) + } +} + +func TestProtectionBacklogExactStoredExecutionBudgets(t *testing.T) { + for _, test := range []struct { + name string + lower, upper, processed uint64 + deadline time.Duration + pending uint64 + budget string + }{ + {"stored deadline", 10, 20, 12, 7 * time.Second, 8, "56000000000"}, + {"previous epoch cursor", 10, 20, 1, time.Second, 10, "10000000000"}, + {"validation remains", 10, 20, 30, time.Second, 0, "0"}, + {"no finite deadline", 10, 20, 12, 0, 8, "unbounded"}, + {"duration overflow", 0, math.MaxUint64, 0, time.Hour, math.MaxUint64, "66408278665354385814000000000000"}, + } { + t.Run(test.name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.ProcessedInputs = test.processed + f.app.ExecutionParameters.AdvanceMaxDeadline = test.deadline + epoch := checkpointEpoch(2, "0x200") + epoch.Status = model.EpochStatus_InputsProcessed + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + clock := prtprotection.Clock{EpochIndex: 2, Root: *epoch.TournamentAddress, ClosingBlock: new(uint64(100)), Source: "test"} + target := protectionTarget{epoch, epoch.Index, *epoch.TournamentAddress, test.lower, test.upper} + f.s.reportSealBacklog(f.app, target, clock, observerFacts(100)) + require.Contains(t, output.String(), "pending_execution_inputs="+new(big.Int).SetUint64(test.pending).String()) + require.Contains(t, output.String(), "execution_budget_ns="+test.budget) + require.Contains(t, output.String(), "validation_pending=true") + require.Contains(t, output.String(), "join_window_closed=true") + require.NotContains(t, output.String(), "eta") + require.True(t, f.s.Ready()) + }) + } + _, err := pendingExecutionInputs(20, 10, 0) + require.Error(t, err) +} + +func TestProtectionGlobalStopKeepsSiblingApplications(t *testing.T) { + f := newObserverCheckpointFixture(t) + second := *f.app + second.ID++ + second.Name = "second" + second.IApplicationAddress = common.HexToAddress("0x999") + filter := repository.ApplicationFilter{ConsensusType: new(model.Consensus_PRT), Enabled: new(true)} + f.repo.On("ListApplicationsByID", mock.Anything, filter, int64(0), second.ID, deliveryApplicationPageSize). + Return([]*model.Application{f.app, &second}, nil).Once() + query := repository.EpochFilter{HasTournament: new(true), Status: model.NonTerminalEpochStatuses()} + cause := errors.New("hard publication failure") + f.s.stopPublication(cause) + f.repo.On("ListEpochs", mock.Anything, f.app.IApplicationAddress.Hex(), query, repository.Pagination{}, false). + Run(func(mock.Arguments) { require.True(t, f.s.publicationStopped()) }). + Return([]*model.Epoch(nil), uint64(0), errors.New("first application unavailable")).Once() + epoch := checkpointEpoch(5, "0x555") + epoch.ApplicationID = second.ID + f.repo.On("ListEpochs", mock.Anything, second.IApplicationAddress.Hex(), query, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + f.repo.On("GetTournament", mock.Anything, second.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(&model.Tournament{ApplicationID: second.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + StartInstant: epoch.LastBlock, Allowance: 300}, nil).Once() + var output bytes.Buffer + f.s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + f.s.reportGlobalProtectionStop(t.Context(), second.ID, cause) + require.Contains(t, output.String(), "PRT protection deadline discovery unavailable") + require.Contains(t, output.String(), "application=second") + require.Contains(t, output.String(), "join_close_block=310") + require.True(t, f.s.publicationStopped()) + require.ErrorIs(t, f.s.publicationFailure(), cause) +} + +func TestProtectionQuietShutdownDoesNotRead(t *testing.T) { + f := newObserverCheckpointFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + f.s.observeProtection(ctx, f.app, []*model.Epoch{checkpointEpoch(0, "0x100")}, f.consensus, observerFacts(100), nil) + f.consensus.AssertNotCalled(t, "GetCurrentSealedEpoch", mock.Anything) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", mock.Anything) +} + +func TestProtectionCurrentSealedIdentitySurvivesWrongLocalBoundary(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_Failed + epoch := checkpointEpoch(3, "0x333") + epoch.LastBlock = 101 + facts := observerFacts(100) + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(CurrentSealedEpoch{ + EpochNumber: epoch.Index, Tournament: *epoch.TournamentAddress, + }, nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", *epoch.TournamentAddress).Return(adapter, nil).Once() + adapter.On("Descriptor", protectionOpts(facts)).Return(TournamentDescriptor{StartInstant: 90, Allowance: 300}, nil).Once() + clocks := f.s.observeProtection(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, facts, nil) + require.Len(t, clocks, 1) + require.Equal(t, epoch.Index, clocks[0].EpochIndex) + require.Equal(t, *epoch.TournamentAddress, clocks[0].Root) + require.Nil(t, clocks[0].ClosingBlock) + require.ErrorContains(t, clocks[0].Err, "does not match sealed epoch") + adapter.AssertExpectations(t) +} + +func TestProtectionObservedAcceptanceRetiresClockWithoutReceiptRead(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.Status = model.ApplicationStatus_Failed + epoch := checkpointEpoch(3, "0x333") + epoch.ClaimTransactionHash = new(common.HexToHash("0x123")) + facts := observerFacts(100) + f.consensus.On("GetCurrentSealedEpoch", protectionOpts(facts)).Return(CurrentSealedEpoch{ + EpochNumber: epoch.Index, Tournament: *epoch.TournamentAddress, + }, nil).Once() + clocks := f.s.observeProtection(t.Context(), f.app, []*model.Epoch{epoch}, f.consensus, facts, nil) + require.Empty(t, clocks) + f.factory.AssertNotCalled(t, "CreateTournamentAdapter", mock.Anything) + f.client.AssertNotCalled(t, "TransactionReceipt", mock.Anything, mock.Anything) +} diff --git a/internal/prt/prt.go b/internal/prt/prt.go new file mode 100644 index 000000000..27880b6fe --- /dev/null +++ b/internal/prt/prt.go @@ -0,0 +1,736 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "math" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/cartesi/rollups-node/internal/appstatus" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/ethutil" +) + +type prtRepository interface { + repository.DeliveryRepository + repository.DeploymentRepository + GetApplication(ctx context.Context, address string) (*Application, error) + ListApplications(ctx context.Context, f repository.ApplicationFilter, + p repository.Pagination, descending bool) ([]*Application, uint64, error) + UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error + HasUndrainedEpochsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + HasUnreconciledClaimsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + UpdateEpochWithForeclosedClaim(ctx context.Context, applicationID int64, index uint64) error + + ListEpochs(ctx context.Context, nameOrAddress string, f repository.EpochFilter, + p repository.Pagination, descending bool) ([]*Epoch, uint64, error) + GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) + GetEpochCompletionState(ctx context.Context, nameOrAddress string, index uint64) (*repository.EpochCompletionState, error) + GetLastNonOpenEpochIndex(ctx context.Context, nameOrAddress string) (*uint64, error) + UpdateEpochReconciledStaged(ctx context.Context, applicationID int64, index uint64, stagedAtBlock uint64) error + UpdateEpochStagingBlock(ctx context.Context, applicationID int64, index, expectedOldBlock, newBlock uint64) error + UpdateEpochWithAcceptedClaim(ctx context.Context, applicationID int64, index uint64, txHash *common.Hash) error + + GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) + GetMatch(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress, idHash string) (*Match, error) + ListTournaments(ctx context.Context, nameOrAddress string, f repository.TournamentFilter, + p repository.Pagination, descending bool) ([]*Tournament, uint64, error) + GetCommitment(ctx context.Context, nameOrAddress string, epochIndex uint64, + tournamentAddress string, commitmentHex string) (*Commitment, error) + ListCommitments(ctx context.Context, nameOrAddress string, filter repository.CommitmentFilter, + pagination repository.Pagination, descending bool) ([]*Commitment, uint64, error) + ListMatches(ctx context.Context, nameOrAddress string, filter repository.MatchFilter, + pagination repository.Pagination, descending bool) ([]*Match, uint64, error) + + LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) +} + +// EthClientInterface defines the methods we need from ethclient.Client +type EthClientInterface interface { + SuggestGasPrice(ctx context.Context) (*big.Int, error) + TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) + ChainID(ctx context.Context) (*big.Int, error) + BlockNumber(ctx context.Context) (uint64, error) + HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) + TransactionByHash(ctx context.Context, hash common.Hash) (*types.Transaction, bool, error) +} + +// DefaultAdapterFactory creates adapters using a concrete *ethclient.Client. +type DefaultAdapterFactory struct { + client *ethclient.Client + filter ethutil.Filter +} + +// NewDefaultAdapterFactory creates a DefaultAdapterFactory. +func NewDefaultAdapterFactory(client *ethclient.Client, filter ethutil.Filter) *DefaultAdapterFactory { + return &DefaultAdapterFactory{client: client, filter: filter} +} + +func (f *DefaultAdapterFactory) CreateTournamentAdapter(addr common.Address) (TournamentAdapter, error) { + return NewITournamentAdapter(addr, f.client, f.filter) +} + +func (f *DefaultAdapterFactory) CreateDaveConsensusAdapter(addr common.Address) (DaveConsensusAdapter, error) { + return NewDaveConsensusAdapter(addr, f.client) +} + +func getTournamentObservationEpochs(ctx context.Context, r prtRepository, nameOrAddress string) ([]*Epoch, uint64, error) { + f := repository.EpochFilter{HasTournament: new(true)} + return r.ListEpochs(ctx, nameOrAddress, f, repository.Pagination{}, false) +} + +// getDefaultBlockNumber selects the block used for stored chain state. Live +// transaction checks use a separate latest block and cannot advance this view. +func (s *Service) getDefaultBlockNumber(ctx context.Context) (uint64, error) { + header, err := s.getDefaultBlockHeader(ctx) + if err != nil { + return 0, err + } + return header.Number.Uint64(), nil +} + +func (s *Service) getDefaultBlockHeader(ctx context.Context) (*types.Header, error) { + var tag rpc.BlockNumber + switch s.defaultBlock { + case DefaultBlock_Pending: + tag = rpc.PendingBlockNumber + case DefaultBlock_Latest: + tag = rpc.LatestBlockNumber + case DefaultBlock_Finalized: + tag = rpc.FinalizedBlockNumber + case DefaultBlock_Safe: + tag = rpc.SafeBlockNumber + default: + return nil, fmt.Errorf("default block %v not supported", s.defaultBlock) + } + header, err := s.client.HeaderByNumber(ctx, big.NewInt(tag.Int64())) + if err != nil { + return nil, fmt.Errorf("fetching %s block header: %w", tag, err) + } + if header == nil || header.Number == nil || !header.Number.IsUint64() { + return nil, fmt.Errorf("returned %s block header is invalid", tag) + } + return header, nil +} + +func getAllSubTournaments( + ctx context.Context, + r prtRepository, + nameOrAddress string, + epochIndex uint64, + tournamentAddress *common.Address, + level TournamentLevel, +) ([]*Tournament, uint64, error) { + f := repository.TournamentFilter{EpochIndex: &epochIndex, ParentTournamentAddress: tournamentAddress, Level: (*uint64)(&level)} + return r.ListTournaments(ctx, nameOrAddress, f, repository.Pagination{}, false) +} + +func (s *Service) setApplicationDiverged(ctx context.Context, app *Application, reasonFmt string, args ...any) error { + reason := fmt.Sprintf(reasonFmt, args...) + err := appstatus.SetDiverged(ctx, s.Logger, s.repository, app, reason) + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, 0, errors.New(reason)) + return err +} + +func (s *Service) setApplicationCorrupted(ctx context.Context, app *Application, reasonFmt string, args ...any) error { + reason := fmt.Sprintf(reasonFmt, args...) + err := appstatus.SetCorrupted(ctx, s.Logger, s.repository, app, reason) + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, 0, errors.New(reason)) + return err +} + +func (s *Service) readTournament( + ctx context.Context, + app *Application, + epoch *Epoch, + level TournamentLevel, + parentMatchIDHash *common.Hash, + parentTournamentAddress *common.Address, + tournamentAddress common.Address, + adapter TournamentAdapter, + levelCount uint64, + mostRecentBlock uint64, +) (*Tournament, error) { + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + descriptor, err := adapter.Descriptor(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament descriptor", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + if err := validateTournamentDescriptor(descriptor, level, levelCount); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) + } + if descriptor.StartInstant > mostRecentBlock { + return nil, fmt.Errorf("tournament %s starts at block %d after observed block %d", + tournamentAddress, descriptor.StartInstant, mostRecentBlock) + } + baseCycle, err := Uint256FromBig(descriptor.BaseCycle) + if err != nil { + return nil, fmt.Errorf("tournament %s base cycle: %w", tournamentAddress, err) + } + + t := &Tournament{ + ApplicationID: app.ID, + EpochIndex: epoch.Index, + Address: tournamentAddress, + ParentMatchIDHash: parentMatchIDHash, + ParentTournamentAddress: parentTournamentAddress, + MaxLevel: levelCount, + Level: descriptor.Level, + Log2Step: descriptor.Log2Stride, + Height: descriptor.Height, + InitialHash: descriptor.InitialHash, + BaseCycle: baseCycle, + Kind: descriptor.Kind, + StartInstant: descriptor.StartInstant, + Allowance: descriptor.Allowance, + } + bond, err := adapter.BondValue(callOpts) + if err != nil { + return nil, fmt.Errorf("reading tournament bond: %w", err) + } + t.BondValue, err = Uint256FromBig(bond) + if err != nil { + return nil, fmt.Errorf("tournament %s bond: %w", tournamentAddress, err) + } + if t.BondValue.ToBig().Sign() == 0 { + return nil, fmt.Errorf("tournament %s has zero bond", tournamentAddress) + } + if err := s.updateTournamentStanding(ctx, app, epoch, level, adapter, t, mostRecentBlock); err != nil { + return nil, err + } + if t.Snapshot.FinishedAtBlock == 0 { + s.Logger.Info("Found open tournament", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + } + return t, nil +} + +func (s *Service) logFailedRootTournament(app *Application, epochIndex uint64, tournament common.Address) { + s.Logger.Warn("Root tournament finished without a winner; this consensus cannot advance. "+ + "Ask the application guardian to consider foreclosure", + "application", app.Name, "epoch_index", epochIndex, "tournament", tournament) +} + +func validateTournamentDescriptor(descriptor TournamentDescriptor, expectedLevel TournamentLevel, levelCount uint64) error { + if levelCount == 0 { + return errors.New("tournament level count is zero") + } + if descriptor.BaseCycle == nil || descriptor.BaseCycle.Sign() < 0 { + return errors.New("tournament descriptor has invalid base cycle") + } + if descriptor.Level != uint64(expectedLevel) { + return fmt.Errorf("tournament descriptor level %d does not match expected level %d", + descriptor.Level, expectedLevel) + } + if descriptor.Level >= levelCount { + return fmt.Errorf("tournament descriptor level %d is outside level count %d", + descriptor.Level, levelCount) + } + if descriptor.Kind != TournamentKindLeaf && descriptor.Kind != TournamentKindNonLeaf { + return fmt.Errorf("tournament descriptor has unknown kind %s", descriptor.Kind) + } + expectedKind := TournamentKindNonLeaf + if descriptor.Level+1 == levelCount { + expectedKind = TournamentKindLeaf + } + if descriptor.Kind != expectedKind { + return fmt.Errorf("tournament descriptor level %d has kind %s, expected %s", + descriptor.Level, descriptor.Kind, expectedKind) + } + return nil +} + +func validateTournamentStanding(standing TournamentStanding, mostRecentBlock uint64) error { + switch standing.State { + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, + TournamentStandingRootWinner, TournamentStandingRootFailed, TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + default: + return fmt.Errorf("tournament has unknown standing %s", standing.State) + } + if isTerminalTournamentStanding(standing.State) { + if standing.FinishedAt == 0 { + return errors.New("terminal tournament has zero finished block") + } + if standing.FinishedAt > mostRecentBlock { + return fmt.Errorf("tournament finished at block %d after observed block %d", + standing.FinishedAt, mostRecentBlock) + } + } + return nil +} + +func isTerminalTournamentStanding(state TournamentStandingState) bool { + switch state { + case TournamentStandingRootWinner, TournamentStandingRootFailed, TournamentStandingInnerWinner, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + return true + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure: + return false + default: + return false + } +} + +func (s *Service) refreshTournament( + ctx context.Context, + app *Application, + epoch *Epoch, + level TournamentLevel, + adapter TournamentAdapter, + t *Tournament, + mostRecentBlock uint64, +) error { + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + + descriptor, err := adapter.Descriptor(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament descriptor", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) + return err + } + if err := validateTournamentDescriptor(descriptor, level, t.MaxLevel); err != nil { + return fmt.Errorf("tournament %s: %w", t.Address, err) + } + if t.Level != descriptor.Level || t.Log2Step != descriptor.Log2Stride || t.Height != descriptor.Height { + return fmt.Errorf("tournament %s descriptor does not match stored geometry", t.Address) + } + if t.InitialHash != descriptor.InitialHash || t.BaseCycle.ToBig().Cmp(descriptor.BaseCycle) != 0 || + t.Kind != descriptor.Kind || t.StartInstant != descriptor.StartInstant || t.Allowance != descriptor.Allowance { + return fmt.Errorf("tournament %s descriptor does not match stored configuration", t.Address) + } + return s.updateTournamentStanding(ctx, app, epoch, level, adapter, t, mostRecentBlock) +} + +// updateTournamentStanding applies the current chain view. A prior winner can +// expire, so a finished tournament must still be refreshed. +func (s *Service) updateTournamentStanding( + ctx context.Context, + app *Application, + epoch *Epoch, + level TournamentLevel, + adapter TournamentAdapter, + t *Tournament, + mostRecentBlock uint64, +) error { + callOpts := pinnedCallOpts(ctx, mostRecentBlock) + standing, err := adapter.Standing(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament standing", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) + return err + } + if err := validateTournamentStanding(standing, mostRecentBlock); err != nil { + return fmt.Errorf("tournament %s: %w", t.Address, err) + } + if standing.State == TournamentStandingMatchesActive { + s.warnUnsupportedDispute(app, epoch.Index, t.Address) + } + if level == RootLevel && standing.State == TournamentStandingRootFailed { + s.logFailedRootTournament(app, epoch.Index, t.Address) + } + snapshot := TournamentSnapshot{ + AsOfBlock: mostRecentBlock, Standing: standing.State, AcceptsJoins: standing.AcceptsJoins, + FinishedAtBlock: standing.FinishedAt, + } + if standing.HasCandidate { + snapshot.Candidate = new(standing.Candidate) + } + if standing.State == TournamentStandingRootWinner || standing.State == TournamentStandingInnerWinner { + snapshot.WinnerCommitment = new(standing.Candidate) + snapshot.FinalStateHash = new(standing.FinalState) + } + if standing.State == TournamentStandingInnerWinner { + snapshot.ParentCommitment = new(standing.ParentCommitment) + snapshot.WinnerExpiresAt = standing.WinnerExpiresAt + } + if level != RootLevel { + result, err := adapter.InnerResult(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch inner tournament result", err, + "application", app.Name, "tournament", t.Address) + return err + } + snapshot.InnerResult = &TournamentInnerResult{Disposition: result.Disposition} + switch result.Disposition { + case InnerTournamentWinner: + snapshot.InnerResult.ParentCommitment = new(result.ParentCommitment) + snapshot.InnerResult.PausedAllowance = result.PausedAllowance + case InnerTournamentUnsettled, InnerTournamentEliminable: + default: + return fmt.Errorf("tournament %s has unknown inner disposition %s", t.Address, result.Disposition) + } + } + recovery, err := adapter.BondRecovery(callOpts) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to fetch tournament bond recovery", err, + "application", app.Name, "tournament", t.Address) + return err + } + snapshot.BondRecovery.Disposition = recovery.Disposition + switch recovery.Disposition { + case BondDispositionRecoverable: + payment, err := Uint256FromBig(recovery.Payment) + if err != nil { + return fmt.Errorf("tournament %s bond payment: %w", t.Address, err) + } + snapshot.BondRecovery.Claimer = new(recovery.Claimer) + snapshot.BondRecovery.Payment = &payment + case BondDispositionTournamentRunning, BondDispositionNoWinner, BondDispositionRecovered: + default: + return fmt.Errorf("tournament %s has unknown bond disposition %s", t.Address, recovery.Disposition) + } + t.Snapshot = snapshot + return nil +} + +// A failed RPC read or database write leaves the shared cursor unchanged. +func (s *Service) reconcileAcceptedEpochs( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, mostRecentBlock uint64, +) (bool, error) { + for _, epoch := range epochs { + if epoch.Status != EpochStatus_ClaimComputed && epoch.Status != EpochStatus_ClaimStaged { + continue + } + var err error + epoch, err = s.observerCompletionEpoch(ctx, app, epoch) + if err != nil { + return true, err + } + if epoch == nil { + continue + } + state, _, gateErr := s.deploymentVerdictState(ctx, app) + if gateErr != nil { + return true, gateErr + } + if !deploymentAllowsVerdict(state) { + return true, nil + } + if epoch.TournamentAddress == nil || epoch.Commitment == nil || + epoch.MachineHash == nil || epoch.TxBufferDataBlock == nil { + return true, s.setApplicationCorrupted(ctx, app, + "epoch %d has missing required fields for pending claim processing", epoch.Index) + } + + // Compare only after the complete root and descendant window is durable. + // A losing local claim must not hide the on-chain winner from clients. + tournament, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()) + if err != nil { + return true, fmt.Errorf("loading epoch %d observed tournament: %w", epoch.Index, err) + } + if app.IsForeclosed() && epoch.LastBlock < app.ForecloseBlock && !rootObservationCovers(tournament, mostRecentBlock) { + return true, nil + } + if tournament == nil || tournament.Snapshot.FinishedAtBlock == 0 || + tournament.Snapshot.FinishedAtBlock > tournament.Snapshot.AsOfBlock || + tournament.Snapshot.AsOfBlock > mostRecentBlock { + return false, nil + } + if tournament.Snapshot.FinalStateHash != nil && *tournament.Snapshot.FinalStateHash != *epoch.MachineHash { + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash, tournament.Snapshot.FinalStateHash) + } + if epoch.ClaimTransactionHash == nil { // no accepting EpochSealed event observed yet + break + } + + observedBlock := min(mostRecentBlock, tournament.Snapshot.AsOfBlock) + if tournamentObservationComplete(tournament, tournament.Snapshot.AsOfBlock, mostRecentBlock) { + observedBlock = mostRecentBlock + } + event, err := s.readEpochAcceptance(ctx, app, epoch, consensus, observedBlock) + if err != nil || event == nil { + return true, err + } + + state, _, gateErr = s.deploymentVerdictState(ctx, app) + if gateErr != nil { + return true, gateErr + } + if !deploymentAllowsVerdict(state) { + return true, nil + } + if *epoch.MachineHash != event.InitialMachineStateHash { + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent machine hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.MachineHash.String(), hexutil.Encode(event.InitialMachineStateHash[:])) + } + if *epoch.TxBufferDataBlock != event.OutputsMerkleRoot { + return true, s.setApplicationDiverged(ctx, app, + "Epoch %d has inconsistent claim hash between off-chain (%s) and on-chain (%s)", + epoch.Index, epoch.TxBufferDataBlock.String(), hexutil.Encode(event.OutputsMerkleRoot[:])) + } + + s.Logger.Info("Observed accepted epoch at selected block policy; outputs root matched", + "application", app.Name, + "epoch", epoch.Index, + "event_block_number", event.Raw.BlockNumber, + "outputs_merkle_root", fmt.Sprintf("%x", event.OutputsMerkleRoot), + "tx", epoch.ClaimTransactionHash, + ) + + err = s.repository.UpdateEpochWithAcceptedClaim(ctx, app.ID, epoch.Index, epoch.ClaimTransactionHash) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to update epoch status to claim accepted", err, + "application", app.Name, "epoch", epoch.Index) + return true, err + } + } + return false, nil +} + +// gatherTournamentData reads one tournament and only newly discovered +// descendants. Already stored children receive their own independent turn. +func (s *Service) gatherTournamentData( + ctx context.Context, app *Application, epoch *Epoch, level TournamentLevel, + parentMatchIDHash *common.Hash, parentTournamentAddress *common.Address, + tournamentAddress common.Address, levelCount, mostRecentBlock uint64, creation *TournamentCreation, parent *Match, +) ([]*repository.TournamentEventBatch, error) { + t, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), tournamentAddress.Hex()) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to load tournament from database", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + created := t == nil + var previousAsOf uint64 + previousBondDisposition := BondDispositionTournamentRunning + if !created { + previousAsOf = t.Snapshot.AsOfBlock + previousBondDisposition = t.Snapshot.BondRecovery.Disposition + } + if !created { + if t.MaxLevel != levelCount || t.Level != uint64(level) { + return nil, fmt.Errorf("tournament %s database geometry does not match observed hierarchy", tournamentAddress) + } + if t.Snapshot.AsOfBlock >= mostRecentBlock || tournamentObservationComplete(t, t.Snapshot.AsOfBlock, mostRecentBlock) { + return nil, nil + } + } + adapter, err := s.adapterFactory.CreateTournamentAdapter(tournamentAddress) + if err != nil { + s.Logger.Error("failed to create tournament adapter", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), "error", err) + return nil, err + } + if created { + t, err = s.readTournament(ctx, app, epoch, level, + parentMatchIDHash, parentTournamentAddress, tournamentAddress, adapter, levelCount, mostRecentBlock) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to create new tournament", err, + "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + } + if created { + if level == RootLevel && t.StartInstant != epoch.LastBlock { + return nil, fmt.Errorf("root creation start does not match observed epoch seal block") + } + if level != RootLevel { + if err := applyTournamentCreationEvent(t, creation); err != nil { + return nil, err + } + t.ContestedFinalStateOne, t.ContestedFinalStateTwo = creation.ContestedFinalStateOne, creation.ContestedFinalStateTwo + } + } + if !created { + // Do not mutate a repository-owned projection before the window commits. + projection := *t + t = &projection + previousFinish := t.Snapshot.FinishedAtBlock + err = s.refreshTournament(ctx, app, epoch, level, adapter, t, mostRecentBlock) + if err != nil { + s.logErrorUnlessShutdown(ctx, "failed to check if tournament was finished", err, "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String()) + return nil, err + } + if previousFinish == 0 && t.Snapshot.FinishedAtBlock != 0 { + s.Logger.Info("Found finished tournament", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", t.Address.String()) + } + } + + nextSearchBlock := t.StartInstant + if !created { + if previousAsOf == math.MaxUint64 { + return nil, errors.New("tournament cursor overflow") + } + nextSearchBlock = max(t.StartInstant, previousAsOf+1) + } + + if created { + // First observation must include the clone's history even if its + // creation is below this application's existing checkpoint. + nextSearchBlock = t.StartInstant + } + endBlock := mostRecentBlock + events := &TournamentEvents{} + if nextSearchBlock <= endBlock { + s.Logger.Debug("Searching for tournament events", "level", level, "application", app.Name, + "epoch", epoch.Index, "tournament_address", tournamentAddress.String(), + "next_search_block", nextSearchBlock, "end_block", endBlock) + events, err = adapter.RetrieveAllEvents(&bind.FilterOpts{Context: ctx, Start: nextSearchBlock, End: &endBlock}) + if err != nil { + return nil, fmt.Errorf("retrieving tournament %s events: %w", tournamentAddress, err) + } + if events == nil { + return nil, fmt.Errorf("tournament %s returned nil events", tournamentAddress) + } + } + + s.Logger.Debug("Retrieved events for tournament", "level", level, "address", t.Address.String(), + "epoch", epoch.Index, + "commitmentJoined", len(events.CommitmentJoined), + "matchCreated", len(events.MatchCreated), + "matchAdvanced", len(events.MatchAdvanced), + "matchDeleted", len(events.MatchDeleted), + "newInnerTournament", len(events.NewInnerTournament)) + + previousCounts := zeroStructuralEventCounts() + if !created && nextSearchBlock > t.StartInstant { + // Committed event identities preserve the cumulative baseline even when + // the provider has pruned state at the previous observation block. + counts, countErr := s.repository.GetTournamentEventCounts(ctx, app.ID, epoch.Index, tournamentAddress, previousAsOf) + if countErr != nil { + return nil, fmt.Errorf("reading committed tournament event counts: %w", countErr) + } + if err := validateStoredStandingCount(counts); err != nil { + return nil, err + } + previousCounts = structuralEventCounts(counts) + } + currentCounts, err := adapter.StructuralEventCounts(pinnedCallOpts(ctx, endBlock)) + if err != nil { + return nil, fmt.Errorf("reading current tournament event counts: %w", err) + } + if err := validateTournamentEventCounts(previousCounts, currentCounts, events); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) + } + if err := validateTournamentFinancialEvents(previousBondDisposition, t.Snapshot.BondRecovery.Disposition, events); err != nil { + return nil, fmt.Errorf("tournament %s: %w", tournamentAddress, err) + } + batch, err := s.tournamentEventBatch(ctx, app, epoch, t, adapter, events, endBlock, parent) + if err != nil { + return nil, fmt.Errorf("projecting tournament %s events: %w", tournamentAddress, err) + } + children, err := s.gatherInnerTournamentData(ctx, app, epoch, batch, events, mostRecentBlock) + if err != nil { + return nil, err + } + return append([]*repository.TournamentEventBatch{batch}, children...), nil +} + +// rootObservationCovers separates immutable tournament facts from later +// DaveConsensus stage/accept transitions. Retirement never advances a cursor. +func rootObservationCovers(root *Tournament, head uint64) bool { + if root == nil || root.Snapshot.AsOfBlock > head { + return false + } + return root.Snapshot.AsOfBlock == head || tournamentObservationComplete(root, root.Snapshot.AsOfBlock, head) +} + +// Finished clones reject joins and all match mutations. Once their winner can +// no longer expire and their bond cannot be recovered, their current views and +// events are immutable. Require a committed complete observation first. +func tournamentObservationComplete(t *Tournament, cursor, head uint64) bool { + snapshot := t.Snapshot + if snapshot.FinishedAtBlock == 0 || snapshot.FinishedAtBlock > snapshot.AsOfBlock || + snapshot.AsOfBlock > cursor || snapshot.AsOfBlock > head || + (t.Level != 0 && t.CreationEvent == nil) { + return false + } + switch snapshot.Standing { + case TournamentStandingRootWinner, TournamentStandingRootFailed, + TournamentStandingInnerEliminableNoWinner, TournamentStandingInnerEliminableWinnerExpired: + return snapshot.BondRecovery.Disposition == BondDispositionNoWinner || + snapshot.BondRecovery.Disposition == BondDispositionRecovered + case TournamentStandingMatchesActive, TournamentStandingAwaitingClosure, TournamentStandingInnerWinner: + return false + default: + return false + } +} + +func (s *Service) gatherInnerTournamentData( + ctx context.Context, app *Application, epoch *Epoch, batch *repository.TournamentEventBatch, events *TournamentEvents, head uint64, +) ([]*repository.TournamentEventBatch, error) { + t := batch.Tournament + if len(events.NewInnerTournament) > 0 && (t.Kind != TournamentKindNonLeaf || t.Level+1 >= t.MaxLevel) { + return nil, fmt.Errorf("child creation requires a non-leaf tournament") + } + seen := make(map[common.Address]bool) + var batches []*repository.TournamentEventBatch + for _, event := range events.NewInnerTournament { + if seen[event.ChildTournament] { + return nil, fmt.Errorf("duplicate child creation for %s", event.ChildTournament) + } + seen[event.ChildTournament] = true + var parent *Match + for _, match := range batch.Matches { + if match.IDHash == event.MatchIdHash { + parent = match + break + } + } + if parent == nil { + return nil, fmt.Errorf("child creation has no parent match") + } + creation, err := childTournamentCreation(t, parent, event) + if err != nil { + return nil, err + } + stored, err := s.repository.GetTournament(ctx, app.IApplicationAddress.Hex(), event.ChildTournament.Hex()) + if err != nil { + return nil, err + } + if stored != nil { + projection := *stored + if err = applyTournamentCreationEvent(&projection, creation); err != nil { + return nil, err + } + if projection.ContestedFinalStateOne == nil || projection.ContestedFinalStateTwo == nil || + *projection.ContestedFinalStateOne != *creation.ContestedFinalStateOne || + *projection.ContestedFinalStateTwo != *creation.ContestedFinalStateTwo { + return nil, fmt.Errorf("child has conflicting contested states") + } + if err = validateChildContestedStates(&projection, parent); err != nil { + return nil, err + } + continue + } + hash := common.Hash(event.MatchIdHash) + children, err := s.gatherTournamentData(ctx, app, epoch, TournamentLevel(t.Level+1), &hash, &t.Address, + event.ChildTournament, t.MaxLevel, head, creation, parent) + if err != nil { + return nil, err + } + if len(children) == 0 { + return nil, fmt.Errorf("new child tournament %s has no initial observation", event.ChildTournament) + } + child := children[0].Tournament + if err = validateChildContestedStates(child, parent); err != nil { + return nil, err + } + batches = append(batches, children...) + } + return batches, nil +} diff --git a/internal/prt/publication_closure_test.go b/internal/prt/publication_closure_test.go new file mode 100644 index 000000000..b8b068699 --- /dev/null +++ b/internal/prt/publication_closure_test.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type publicationRefusal string + +func (e publicationRefusal) Error() string { return string(e) } +func (publicationRefusal) ErrorCode() int { return -32000 } + +func TestPRTFreshJoinSelectionUsesEarliestDeadline(t *testing.T) { + f, _, _ := preparePRTAction(t, joinTournament) // deadline 100+50 + urgent := addDeliveryJoinCandidate(f, 8, 25) // deadline 100+25 + require.NoError(t, f.d.tick(t.Context())) + require.Len(t, f.backend.published, 1) + require.Equal(t, common.BigToAddress(big.NewInt(urgent.ID*10+3)), *f.backend.published[0].To(), + "the earlier-deadline join must be chosen first") +} + +func TestPRTRepeatedPreSendRefusalsRecoverWithoutApplicationPause(t *testing.T) { + f, call, _ := preparePRTAction(t, joinTournament) + require.Positive(t, call.Value.Sign()) + f.backend.balance = big.NewInt(1) + for range 7 { + clear(f.d.candidateCooldown) + require.Error(t, f.d.tick(t.Context())) + require.Empty(t, f.backend.published) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.False(t, f.d.publicationStopped()) + } + f.backend.balance = nil + clear(f.d.candidateCooldown) + require.NoError(t, f.d.tick(t.Context())) + require.Len(t, f.backend.published, 1, "funding restores publication without operator action") +} + +func TestPRTUsefulUntimedActionKeepsNonceAgainstUntimedCandidate(t *testing.T) { + f, call, _ := preparePRTAction(t, acceptTournament) + submitPRTAction(t, f, call) + addSecondStageApplication(f) + report, err := f.d.sender.PublishNext(t.Context(), f.d) + require.NoError(t, err) + require.Equal(t, call.Key, report.Current.Action, "the held acceptance keeps its nonce") + require.Equal(t, f.backend.raw[0], f.backend.raw[len(f.backend.raw)-1]) +} + +func TestPRTRefusedPostEndsCandidateSelection(t *testing.T) { + f, _, _ := preparePRTAction(t, stageTournament) + addSecondStageApplication(f) + f.backend.publishError = publicationRefusal("insufficient funds for gas * price + value") + err := f.d.tick(t.Context()) + require.Error(t, err) + require.Len(t, f.backend.published, 1, "one POST per pass") +} + +func TestPRTProgressWarningsCoverStalledNonceJoinMarginAndFrozenHead(t *testing.T) { + f := newDeliveryFixture(t) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + join := actionID{ApplicationID: f.app.ID, Application: f.app.IApplicationAddress, Kind: joinTournament, + Epoch: 3, Deadline: 100 + joinWarningMargin} + report := func(block, nonce uint64) txsender.Report[actionID] { + return txsender.Report[actionID]{Block: txsender.BlockRef{Number: block, Hash: common.BigToHash(new(big.Int).SetUint64(block))}, + Nonce: nonce, Current: &txsender.Current[actionID]{Action: join}} + } + f.d.recordLiveProgress(report(100, 7), nil, nil) + require.Contains(t, logs.String(), "level=WARN msg=\"PRT join remains pending near its deadline\"") + first := logs.String() + for range 3 { + f.d.recordLiveProgress(report(100, 7), nil, nil) + } + require.Equal(t, first, logs.String(), "near-deadline warnings are rate limited") + f.d.recordLiveProgress(report(100+stalledNonceBlocks, 7), nil, nil) + require.Contains(t, logs.String(), "level=WARN msg=\"PRT signer nonce is stalled\"") + f.d.diagnostics.headSince = time.Now().Add(-2 * frozenLiveHeadInterval) + f.d.recordLiveProgress(report(100+stalledNonceBlocks, 7), nil, nil) + require.Contains(t, logs.String(), "level=WARN msg=\"PRT latest block has not advanced; publication may miss a deadline\"") + before := logs.String() + for range 3 { + f.d.recordLiveProgress(report(100+stalledNonceBlocks, 7), nil, nil) + } + require.Equal(t, before, logs.String(), "progress warnings are rate limited") + require.Equal(t, 1, strings.Count(logs.String(), "PRT signer nonce is stalled")) +} + +func TestPRTCreateWiresSigningTimeout(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "private_key") + viper.Set(config.PRT_AUTH_PRIVATE_KEY, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_PRIVATE_KEY, nil) + }) + repo := &prtBlockPolicyCreateRepository{} + repo.On("RegisterSubmitter", mock.Anything, PrtConfigKey, mock.Anything). + Return(PersistentConfig{ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, ClaimSubmissionEnabled: true}, nil).Once() + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + cfg := prtStartupConfig(true) + cfg.BlockchainHttpRequestTimeout = 7 * time.Second + svc, err := Create(t.Context(), &CreateInfo{Config: cfg, EthClient: client, Repository: repo, + AdapterFactory: &adapterFactoryMock{}, DeliveryBackend: &deliveryBackendFake{}}) + require.NoError(t, err) + require.Equal(t, 7*time.Second, svc.(*Service).delivery.deliveryPolicy.SigningTimeout) +} diff --git a/internal/prt/publication_diagnostics_test.go b/internal/prt/publication_diagnostics_test.go new file mode 100644 index 000000000..4e691f8b1 --- /dev/null +++ b/internal/prt/publication_diagnostics_test.go @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/require" +) + +func TestPRTHeldPublicationErrorsWarnWithBackoff(t *testing.T) { + f, _, _ := preparePRTAction(t, stageTournament) + require.NoError(t, f.d.tick(t.Context())) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + failure := errors.New("provider execution read unavailable") + f.backend.preflightError = failure + for range 7 { + clear(f.d.candidateCooldown) + err := f.d.tick(t.Context()) + require.ErrorIs(t, err, failure) + f.d.reportPublicationError(t.Context(), err) + } + require.Equal(t, 1, strings.Count(logs.String(), "PRT publication deferred")) + require.Equal(t, 1, strings.Count(logs.String(), "PRT transaction could not publish")) + require.NotContains(t, logs.String(), "level=ERROR") + require.Contains(t, logs.String(), f.app.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), "stage tournament result") +} + +func TestPRTPublicationShutdownPreservesRealFailures(t *testing.T) { + for _, test := range []struct { + name string + err error + warn bool + }{ + {"cancelled waits", errors.Join(context.Canceled, txsender.ErrInconclusive, txsender.ErrLimit), false}, + {"cancelled database failure", errors.Join(context.Canceled, errors.New("database unavailable")), true}, + {"cancelled operation deadline", errors.Join(context.Canceled, context.DeadlineExceeded), true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newDeliveryFixture(t) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + f.d.reportPublicationError(ctx, test.err) + require.Equal(t, test.warn, strings.Contains(logs.String(), "level=WARN"), logs.String()) + }) + } +} + +func TestPRTRefusalNamesTheSelectedActionWhileCancellationIsPending(t *testing.T) { + for _, canceling := range []bool{false, true} { + f, call, _ := preparePRTAction(t, joinTournament) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + report := txsender.Report[actionID]{Nonce: 7, Block: f.block, Canceling: canceling, + Current: &txsender.Current[actionID]{Neutral: true}} + f.d.warnPublicationRefusal(call.Key, report, errors.New("replacement transaction underpriced")) + require.Contains(t, logs.String(), f.app.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), "join tournament") + require.Equal(t, canceling, strings.Contains(logs.String(), "cancel obsolete"), logs.String()) + } +} + +func TestPRTConsensusRefusalWarnsBeforeIneligibleFiltering(t *testing.T) { + f := newDeliveryFixture(t) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + key := actionID{ApplicationID: f.app.ID, Kind: stageTournament, Epoch: f.epoch.Index} + refusal := &consensusRefusal{key: key, reason: errors.Join(txsender.ErrIneligible, errors.New("consensus migrated"))} + for range 3 { + f.d.warnPlanningPause(f.app, stageTournament, f.block, refusal) + } + require.Equal(t, 1, strings.Count(logs.String(), "PRT cannot publish consensus action with the registered contracts")) + require.Contains(t, logs.String(), f.app.Name) + require.Contains(t, logs.String(), "consensus migrated") + logs.Reset() + f.d.warnPlanningPause(f.app, stageTournament, f.block, txsender.ErrIneligible) + require.Empty(t, logs.String(), "ordinary ineligibility does not need an operator warning") +} + +func TestPRTIncompleteReportDoesNotResetProgress(t *testing.T) { + f, call, _ := preparePRTAction(t, stageTournament) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + current := &txsender.Current[actionID]{Action: call.Key} + f.d.recordLiveProgress(txsender.Report[actionID]{Block: f.block, Nonce: 7, Current: current}, nil, nil) + f.d.diagnostics.headSince = time.Now().Add(-2 * frozenLiveHeadInterval) + for range 3 { + f.d.recordLiveProgress(txsender.Report[actionID]{Current: current}, nil, nil) + } + require.Equal(t, f.block, f.d.diagnostics.head) + require.EqualValues(t, 7, f.d.diagnostics.nonce) + require.Empty(t, logs.String(), "a failed account read is not evidence that block zero or nonce zero was observed") + f.d.recordLiveProgress(txsender.Report[actionID]{Block: f.block, Nonce: 7, Current: current}, nil, nil) + require.Contains(t, logs.String(), "PRT latest block has not advanced") + require.NotContains(t, logs.String(), "block=0") +} diff --git a/internal/prt/publication_nonce_race_test.go b/internal/prt/publication_nonce_race_test.go new file mode 100644 index 000000000..bc9c60d12 --- /dev/null +++ b/internal/prt/publication_nonce_race_test.go @@ -0,0 +1,97 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "log/slog" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestPRTLostNonceRacesWarnWithoutRetainedBytesAndRecover(t *testing.T) { + for _, beforePost := range []bool{true, false} { + t.Run(map[bool]string{true: "stale before POST", false: "nonce-low at POST"}[beforePost], func(t *testing.T) { + f, _, view := preparePRTAction(t, stageTournament) + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + interfere := true + consumeNonce := func() { + if interfere { + f.backend.nonce++ + updatePRTView(f, f.block.Number+1, view) + } + } + factory := f.d.txOptsFactory + if beforePost { + factory = deliverySigningFactory{TransactOptsFactory: factory, afterSign: consumeNonce} + } else { + f.backend.publishError = publicationRefusal("nonce too low") + f.backend.onPublish = func(*types.Transaction) { consumeNonce() } + } + var err error + f.d.sender, err = txsender.New[actionID](f.backend, factory, 31337, + f.d.deliveryPolicy, f.d.guardPublication, f.d.Logger) + require.NoError(t, err) + conflict := func() { + t.Helper() + err := f.d.tick(t.Context()) + require.Error(t, err) + require.True(t, txsender.WaitOnly(err), "%v", err) + if beforePost { + require.ErrorIs(t, err, txsender.ErrStale) + } else { + var reply *txsender.PublicationError + require.ErrorAs(t, err, &reply) + require.True(t, reply.NonceLowOnly()) + } + } + conflict() + require.NotContains(t, logs.String(), "level=WARN", "one race is not a repeated nonce-progress symptom") + for range 5 { + conflict() + } + const warning = "PRT publication lost a race while signer nonce advanced; inspect other signer users" + require.Equal(t, 1, strings.Count(logs.String(), warning), logs.String()) + require.Contains(t, logs.String(), f.app.IApplicationAddress.Hex()) + require.Contains(t, logs.String(), "stage tournament result") + require.NotContains(t, logs.String(), "level=ERROR") + f.d.diagnostics.warnedNonce = time.Now().Add(-publicationWarningInterval) + conflict() + require.Equal(t, 2, strings.Count(logs.String(), warning)) + if beforePost { + require.Empty(t, f.backend.published, "a stale preparation must never POST") + } else { + require.Len(t, f.backend.published, 7, "one definite refusal ends each pass") + } + + interfere, f.backend.publishError = false, nil + logs.Reset() + before := len(f.backend.published) + require.NoError(t, f.d.tick(t.Context()), "exclusive use restores publication") + require.Len(t, f.backend.published, before+1) + require.Equal(t, f.backend.nonce, f.backend.published[before].Nonce()) + require.NotContains(t, logs.String(), "level=WARN", "normal publication after recovery is quiet") + }) + } +} + +func TestPRTIdleNonceProgressDoesNotWarnAboutSignerContention(t *testing.T) { + f := newDeliveryFixture(t) + f.repo.apps = nil + var logs bytes.Buffer + f.d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + for range 4 { + f.backend.nonce++ + f.backend.advance(f.backend.latest + 1) + require.NoError(t, f.d.tick(t.Context())) + } + require.Empty(t, f.backend.published) + require.Empty(t, logs.String()) +} diff --git a/internal/prt/readonly_backend.go b/internal/prt/readonly_backend.go new file mode 100644 index 000000000..657e1d40f --- /dev/null +++ b/internal/prt/readonly_backend.go @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Generated bindings are readers and event decoders. Publication belongs to +// txsender; refusing SendTransaction prevents accidental binding publication. +type readOnlyBackend struct{ *ethclient.Client } + +func (b readOnlyBackend) CallContractAtHash(ctx context.Context, msg ethereum.CallMsg, hash common.Hash) ([]byte, error) { + return (txsender.ProtectedClient{Client: b.Client}).CallContractAtHash(ctx, msg, hash) +} +func (b readOnlyBackend) CodeAtHash(ctx context.Context, addr common.Address, hash common.Hash) ([]byte, error) { + return (txsender.ProtectedClient{Client: b.Client}).CodeAtHash(ctx, addr, hash) +} +func (b readOnlyBackend) SendTransaction(context.Context, *types.Transaction) error { + return errors.New("PRT bindings cannot publish transactions") +} diff --git a/internal/prt/readonly_backend_test.go b/internal/prt/readonly_backend_test.go new file mode 100644 index 000000000..c53bff8b8 --- /dev/null +++ b/internal/prt/readonly_backend_test.go @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type hashBindingRPC struct{ blocks []rpc.BlockNumberOrHash } + +func (s *hashBindingRPC) GetCode(_ context.Context, _ common.Address, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + s.blocks = append(s.blocks, block) + return []byte{1}, nil +} +func (s *hashBindingRPC) Call(_ context.Context, _ map[string]any, block rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + s.blocks = append(s.blocks, block) + return []byte{2}, nil +} + +func TestReadOnlyBindingBackendRequiresCanonicalHashAndCannotPublish(t *testing.T) { + server := rpc.NewServer() + t.Cleanup(server.Stop) + api := &hashBindingRPC{} + require.NoError(t, server.RegisterName("eth", api)) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + backend := readOnlyBackend{Client: client} + hash := common.HexToHash("0x123") + address := common.HexToAddress("0xabc") + code, err := backend.CodeAtHash(t.Context(), address, hash) + require.NoError(t, err) + require.Equal(t, []byte{1}, code) + result, err := backend.CallContractAtHash(t.Context(), ethereum.CallMsg{To: &address}, hash) + require.NoError(t, err) + require.Equal(t, []byte{2}, result) + require.Len(t, api.blocks, 2) + for _, block := range api.blocks { + require.True(t, block.RequireCanonical) + require.Equal(t, &hash, block.BlockHash) + } + require.ErrorContains(t, backend.SendTransaction(t.Context(), types.NewTx(&types.LegacyTx{})), "cannot publish") +} + +func TestFinalizedDeliveryReferenceNeverSubstitutesLatest(t *testing.T) { + f := newDeliveryFixture(t) + f.d.defaultBlock = "FINALIZED" + client := ðClientMock{} + f.d.client = client + header := &types.Header{Number: big.NewInt(100)} + client.On("HeaderByNumber", t.Context(), big.NewInt(rpc.FinalizedBlockNumber.Int64())).Return(header, nil).Once() + client.On("HeaderByNumber", t.Context(), big.NewInt(100)).Return(header, nil).Once() + reference, err := f.d.observationReference(t.Context()) + require.NoError(t, err) + require.Equal(t, txsender.BlockRef{Number: 100, Hash: header.Hash()}, reference) + client.AssertExpectations(t) +} diff --git a/internal/prt/recovery_selection_test.go b/internal/prt/recovery_selection_test.go new file mode 100644 index 000000000..7f5da9d19 --- /dev/null +++ b/internal/prt/recovery_selection_test.go @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "testing" +) + +func TestRecoveryDoesNotDependOnCurrentClaimReadiness(t *testing.T) { + for _, status := range []model.EpochStatus{model.EpochStatus_Open, model.EpochStatus_Closed, model.EpochStatus_InputsProcessed} { + t.Run(status.String(), func(t *testing.T) { + f := newRecoveryPortFixture(t) + f.epoch.Status = status + call, err := f.plan(t.Context()) + require.NoError(t, err) + require.NotNil(t, call, "refunds remain useful while the current epoch is computing") + f.repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestFailedRootAllowsRecoveryWithoutPreparedClaim(t *testing.T) { + for _, status := range []model.EpochStatus{model.EpochStatus_Open, model.EpochStatus_Closed, model.EpochStatus_InputsProcessed} { + f := newRecoveryPortFixture(t) + f.epoch.Status = status + call, err := f.plan(t.Context()) + require.NoError(t, err) + require.NotNil(t, call) + f.repo.AssertNotCalled(t, "GetEpoch", mock.Anything, mock.Anything, mock.Anything) + f.factory.AssertNotCalled(t, "CreateDaveConsensusAdapter", mock.Anything) + } +} + +func TestRecoveryRunsDuringKnownSafeTournamentWait(t *testing.T) { + for _, staged := range []bool{false, true} { + f := newRecoveryPortFixture(t) + if staged { + f.epoch.Status = model.EpochStatus_ClaimStaged + } + view := resultTestSnapshot(f.epoch, staged) + view.stage.IsFinished = false + view.accept.IsClaimStagingPeriodOver = false + view.accept.DoAllSentriesAgreeWithStagedTournamentResult = false + f.snapshot(view) + for _, kind := range []actionKind{stageTournament, acceptTournament} { + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, txsender.BlockRef{Number: 100}) + require.NoError(t, err) + require.Nil(t, call) + } + call, err := f.plan(t.Context()) + require.NoError(t, err) + require.NotNil(t, call) + } +} + +func TestRecoveryRunsWhenJoinIsAwaitingEventSync(t *testing.T) { + f := newRecoveryPortFixture(t) + f.epoch.ClaimTransactionHash = nil + call, err := f.plan(t.Context()) + require.NoError(t, err) + require.NotNil(t, call, "published root refund selection requires no local join record") + f.repo.AssertNotCalled(t, "GetCommitment", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/prt/result_observation_boundary_test.go b/internal/prt/result_observation_boundary_test.go new file mode 100644 index 000000000..8c69cccbe --- /dev/null +++ b/internal/prt/result_observation_boundary_test.go @@ -0,0 +1,255 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +func TestResultWritesWaitForTournamentObservation(t *testing.T) { + for _, staged := range []bool{false, true} { + name := "false final state" + if staged { + name = "matching result is staged" + } + t.Run(name, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastEpochCheckBlock = 99 + epoch := checkpointEpoch(0, "0x100") + winner := *epoch.Commitment + if !staged { + winner = common.HexToHash("0xbad") + } + tournament := resultBoundaryTournament(f, epoch, winner) + snapshot := resultTestSnapshot(epoch, staged) + snapshot.stage.WinnerCommitment = winner + if !staged { + snapshot.stage.WinnerPostEpochMachineStateHash = common.HexToHash("0xdead") + } + observed := resultTestSnapshot(epoch, false) + observed.stage.IsFinished = false + observed.stage.WinnerCommitment = common.Hash{} + observed.stage.WinnerPostEpochMachineStateHash = common.Hash{} + if staged { + snapshot.sealed.StagingBlockNumber = 100 + } + var writeCoverage []uint64 + if staged { + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, f.app.LastTournamentCheckBlock) }).Return(nil).Once() + } else { + f.repo.On("UpdateApplicationStatus", mock.Anything, f.app.ID, model.ApplicationStatus_Diverged, mock.Anything). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, f.app.LastTournamentCheckBlock) }).Return(nil).Once() + } + f.expectResultBoundaryWindow(epoch, tournament, 99) + f.expectResultBoundarySnapshot(epoch, observed, 99) + + expectProtectionReads(t, f.factory, f.consensus, observerFacts(99), epoch) + require.NoError(t, f.s.delivery.observeApplication(t.Context(), f.app, observerFacts(99))) + require.Equal(t, uint64(99), f.app.LastTournamentCheckBlock, "the older window must still publish successfully") + require.Equal(t, uint64(99), tournament.Snapshot.AsOfBlock) + require.Equal(t, model.TournamentStandingAwaitingClosure, tournament.Snapshot.Standing) + require.Empty(t, writeCoverage, "a newer configured result must not change persistent local state") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Nil(t, epoch.StagedAtBlock) + + // No-work at the same observation boundary is not evidence that the + // configured result has become durable. + f.epochs(epoch) + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), tournament.Address.Hex()). + Return(tournament, nil).Once() + f.expectResultBoundarySnapshot(epoch, observed, 99) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(99), epoch) + require.NoError(t, f.s.delivery.observeApplication(t.Context(), f.app, observerFacts(99))) + require.Empty(t, writeCoverage) + + f.app.LastEpochCheckBlock = 100 + f.expectResultBoundaryWindowForState(epoch, tournament, 100, snapshot.stage.WinnerPostEpochMachineStateHash) + if staged { + f.expectResultBoundarySnapshot(epoch, snapshot, 100) + } else { + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + } + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + if !staged { + expectStoredProtection(t, f.repo, f.app, epoch) + } + err := f.s.delivery.observeApplication(t.Context(), f.app, observerFacts(100)) + if staged { + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(100)), epoch.StagedAtBlock) + } else { + require.ErrorContains(t, err, "Epoch 0 has inconsistent machine hash") + require.Equal(t, model.ApplicationStatus_Diverged, f.app.Status) + } + require.Equal(t, []uint64{100}, writeCoverage, "the complete result window must precede the local write") + require.Equal(t, uint64(100), tournament.Snapshot.AsOfBlock) + require.Equal(t, &winner, tournament.Snapshot.WinnerCommitment) + require.Nil(t, f.s.delivery.sender, "observation never needs a publication owner") + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + }) + } +} + +func resultBoundaryTournament(f *observerCheckpointFixture, epoch *model.Epoch, winner common.Hash) *model.Tournament { + return &model.Tournament{ + ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: *epoch.TournamentAddress, + MaxLevel: 1, Height: 1, Kind: model.TournamentKindLeaf, StartInstant: epoch.LastBlock, Allowance: 90, + Snapshot: model.TournamentSnapshot{AsOfBlock: f.app.LastTournamentCheckBlock, + Standing: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, Candidate: &winner, + BondRecovery: model.TournamentBondRecovery{Disposition: model.BondDispositionTournamentRunning}}, + } +} + +func (f *observerCheckpointFixture) expectResultBoundarySnapshot(epoch *model.Epoch, snapshot daveConsensusSnapshot, head uint64) { + f.t.Helper() + opts := mock.MatchedBy(resultCallOptsAtBlock(head)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(snapshot.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(snapshot.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(snapshot.accept, nil).Once() + f.repo.On("GetEpoch", mock.Anything, f.app.IApplicationAddress.Hex(), epoch.Index).Return(epoch, nil).Once() +} + +func (f *observerCheckpointFixture) expectResultBoundaryWindow(epoch *model.Epoch, tournament *model.Tournament, head uint64) { + f.t.Helper() + f.expectResultBoundaryWindowForState(epoch, tournament, head, *epoch.MachineHash) +} + +func (f *observerCheckpointFixture) expectResultBoundaryWindowForState( + epoch *model.Epoch, tournament *model.Tournament, head uint64, finalState common.Hash, +) { + f.t.Helper() + f.epochs(epoch) + opts := mock.MatchedBy(resultCallOptsAtBlock(head)) + f.consensus.On("TournamentLevelCount", opts).Return(uint64(1), nil).Once() + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), tournament.Address.Hex()). + Return(tournament, nil).Twice() // observation load, then local reconciliation + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", tournament.Address).Return(adapter, nil).Once() + adapter.On("Descriptor", opts).Return(TournamentDescriptor{BaseCycle: new(big.Int), Height: tournament.Height, + Kind: tournament.Kind, StartInstant: tournament.StartInstant, Allowance: tournament.Allowance}, nil).Once() + standing := TournamentStanding{State: model.TournamentStandingAwaitingClosure, AcceptsJoins: true, + HasCandidate: true, Candidate: *tournament.Snapshot.Candidate} + if head >= 100 { + standing.State, standing.AcceptsJoins = model.TournamentStandingRootWinner, false + standing.FinalState, standing.FinishedAt = finalState, 100 + } + adapter.On("Standing", opts).Return(standing, nil).Once() + expectTournamentAuxiliaryReads(adapter, opts, RootLevel, standing.State) + previous := f.app.LastTournamentCheckBlock + adapter.On("RetrieveAllEvents", mock.MatchedBy(func(opts *bind.FilterOpts) bool { + return opts.Start == previous+1 && opts.End != nil && *opts.End == head + })).Return(&TournamentEvents{}, nil).Once() + counts := zeroStructuralEventCounts() + counts.CommitmentJoined.SetUint64(1) + f.repo.eventCounts = map[common.Address]repository.TournamentEventCounts{ + tournament.Address: {StandingChanged: 1, CommitmentJoined: 1}, + } + adapter.On("StructuralEventCounts", opts).Return(counts, nil).Once() + address := tournament.Address.Hex() + commitment := &model.Commitment{ApplicationID: f.app.ID, EpochIndex: epoch.Index, TournamentAddress: tournament.Address, + Commitment: standing.Candidate, FinalStateHash: finalState, BlockNumber: 20} + f.repo.On("ListCommitments", mock.Anything, f.app.Name, + repository.CommitmentFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return([]*model.Commitment{commitment}, uint64(1), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, + repository.MatchFilter{EpochIndex: &epoch.Index, TournamentAddress: &address}, repository.Pagination{}, false). + Return([]*model.Match{}, uint64(0), nil).Once() + adapter.On("CommitmentStanding", opts, [32]byte(commitment.Commitment)). + Return(CommitmentStanding{Joined: true, FinalState: commitment.FinalStateHash, ClockAllowance: 80}, nil).Once() + statusBeforePublication := epoch.Status + applicationStatusBeforePublication := f.app.Status + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, head). + Run(func(args mock.Arguments) { + batches := args.Get(2).([]*repository.TournamentEventBatch) + require.Len(f.t, batches, 1) + require.Equal(f.t, applicationStatusBeforePublication, f.app.Status) + require.Equal(f.t, statusBeforePublication, epoch.Status) + *tournament = *batches[0].Tournament + }).Return(nil).Once() + f.t.Cleanup(func() { adapter.AssertExpectations(f.t) }) +} + +func TestAcceptanceWaitsForTournamentObservation(t *testing.T) { + s, repo := newPRTServiceMock() + app := prtRevertTestApp() + app.LastTournamentCheckBlock = 99 + epoch := checkpointEpoch(0, "0x100") + epoch.ClaimTransactionHash = new(common.HexToHash("0x900")) + tournament := &model.Tournament{Snapshot: model.TournamentSnapshot{ + AsOfBlock: 99, FinishedAtBlock: 90, WinnerCommitment: epoch.Commitment, + }} + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()). + Return(tournament, nil).Twice() + header := &types.Header{Number: big.NewInt(100)} + raw := types.Log{Address: app.IConsensusAddress, TxHash: *epoch.ClaimTransactionHash, BlockNumber: 100, BlockHash: header.Hash()} + receipt := &types.Receipt{TxHash: raw.TxHash, BlockNumber: big.NewInt(100), + BlockHash: raw.BlockHash, Status: types.ReceiptStatusSuccessful, Logs: []*types.Log{&raw}} + client := ðClientMock{} + client.On("TransactionReceipt", mock.Anything, raw.TxHash).Return(receipt, nil).Twice() + client.On("HeaderByNumber", mock.Anything, receipt.BlockNumber).Return(header, nil).Once() + s.client = client + consensus := newCompatibleDaveConsensusAdapterMock() + consensus.On("ParseEpochSealed", raw).Return(&idaveconsensus.IDaveConsensusEpochSealed{ + EpochNumber: big.NewInt(1), InitialMachineStateHash: *epoch.MachineHash, + OutputsMerkleRoot: *epoch.TxBufferDataBlock, Raw: raw, + }, nil).Once() + var writeCoverage []uint64 + repo.On("UpdateEpochWithAcceptedClaim", mock.Anything, app.ID, epoch.Index, epoch.ClaimTransactionHash). + Run(func(mock.Arguments) { writeCoverage = append(writeCoverage, app.LastTournamentCheckBlock) }).Return(nil).Once() + + deferActions, err := s.reconcileAcceptedEpochs(t.Context(), app, []*model.Epoch{epoch}, consensus, 100) + require.NoError(t, err) + require.True(t, deferActions, "the acceptance receipt is newer than the published tournament window") + require.Empty(t, writeCoverage) + consensus.AssertNotCalled(t, "ParseEpochSealed", mock.Anything) + + app.LastTournamentCheckBlock = 100 + tournament.Snapshot.AsOfBlock = 100 + deferActions, err = s.reconcileAcceptedEpochs(t.Context(), app, []*model.Epoch{epoch}, consensus, 100) + require.NoError(t, err) + require.False(t, deferActions) + require.Equal(t, []uint64{100}, writeCoverage) + repo.AssertExpectations(t) + client.AssertExpectations(t) + consensus.AssertExpectations(t) +} + +func TestResultWindowPublicationFailureCannotWriteProtocolResult(t *testing.T) { + f := newDeliveryIntervalFixture(t) + failure := errors.New("database connection lost during window publication") + f.repo.windowError = failure + view := resultTestSnapshot(f.epoch, false) + opts := mock.MatchedBy(resultCallOptsAtBlock(100)) + f.consensus.On("GetCurrentSealedEpoch", opts).Return(view.sealed, nil).Once() + f.consensus.On("CanStageTournamentResult", opts).Return(view.stage, nil).Once() + f.consensus.On("CanAcceptStagedTournamentResult", opts).Return(view.accept, nil).Once() + f.checkpoint.epochs(f.epoch) + f.levels(1) + f.checkpoint.tournament(f.epoch, *f.epoch.TournamentAddress, 0, 1, 90, 100, &TournamentEvents{}, nil) + expectProtectionReads(t, f.factory, f.consensus, f.facts, f.epoch) + require.ErrorIs(t, f.d.observeApplication(t.Context(), f.app, f.facts), failure) + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + require.Equal(t, model.EpochStatus_ClaimComputed, f.epoch.Status) + require.Nil(t, f.epoch.StagedAtBlock) + f.repo.AssertNotCalled(t, "UpdateEpochReconciledStaged", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + f.consensus.AssertNumberOfCalls(t, "GetCurrentSealedEpoch", 2) +} diff --git a/internal/prt/result_observation_lag_test.go b/internal/prt/result_observation_lag_test.go new file mode 100644 index 000000000..04cf6420c --- /dev/null +++ b/internal/prt/result_observation_lag_test.go @@ -0,0 +1,61 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestResultPublicationMakesProgressWithContinuousObserverLag(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0x100") + tournament := resultBoundaryTournament(f, epoch, *epoch.Commitment) + var stageWriteCoverage []uint64 + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)). + Run(func(mock.Arguments) { + require.Equal(t, uint64(100), tournament.Snapshot.AsOfBlock, "publish the result window before its staged marker") + stageWriteCoverage = append(stageWriteCoverage, f.app.LastTournamentCheckBlock) + }).Return(nil).Once() + + for _, published := range []uint64{99, 100, 101} { + configured := published + 1 + f.app.LastEpochCheckBlock = published + f.expectResultBoundaryWindow(epoch, tournament, published) + staged := published >= 100 + snapshot := resultTestSnapshot(epoch, staged) + if staged { + snapshot.sealed.StagingBlockNumber = 100 + } else { + snapshot.stage.IsFinished = false + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + } + // Bind every result read to the block that was actually published. A + // read at the moving configured head cannot establish this checkpoint. + f.expectResultBoundarySnapshot(epoch, snapshot, published) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(published), epoch) + require.NoError(t, f.s.delivery.observeApplication(t.Context(), f.app, observerFacts(published))) + require.Equal(t, published, f.app.LastTournamentCheckBlock) + require.Equal(t, published, tournament.Snapshot.AsOfBlock) + require.Less(t, f.app.LastTournamentCheckBlock, configured, "the observer never catches the configured head") + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + if staged { + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, new(uint64(100)), epoch.StagedAtBlock) + require.Equal(t, []uint64{100}, stageWriteCoverage, "record once at the next tick, then retain the marker") + } else { + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Nil(t, epoch.StagedAtBlock) + require.Empty(t, stageWriteCoverage, "block 99 must not publish the stage from block 100") + } + } + require.Nil(t, f.s.delivery.sender, "observation never needs a publication owner") + f.client.AssertNotCalled(t, "BlockNumber", mock.Anything) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/prt/result_projection_test.go b/internal/prt/result_projection_test.go new file mode 100644 index 000000000..9da8518b0 --- /dev/null +++ b/internal/prt/result_projection_test.go @@ -0,0 +1,54 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "testing" +) + +func TestResultSnapshotAllowsZeroFinalState(t *testing.T) { + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + epoch.MachineHash = new(common.Hash{}) + snapshot := resultTestSnapshot(epoch, false) + require.NoError(t, validateDaveConsensusSnapshot(snapshot, 20)) + require.NoError(t, matchConsensusSnapshotToEpoch(epoch, snapshot)) +} + +func TestFailedRootRepeatedObservationAndForeclosure(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(model.EpochStatus_ClaimComputed) + snapshot := resultTestSnapshot(epoch, false) + snapshot.stage.IsTournamentFailed = true + snapshot.stage.WinnerCommitment = common.Hash{} + snapshot.stage.WinnerPostEpochMachineStateHash = common.Hash{} + service, repo := newPRTServiceMock() + for range 2 { + require.NoError(t, service.recordTournamentResult(t.Context(), app, epoch, snapshot)) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } + + app.ForecloseBlock = 100 + app.LastEpochCheckBlock = 100 + app.LastInputCheckBlock = 100 + app.LastTournamentCheckBlock = 100 + repo.On("HasUndrainedEpochsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(false, nil).Once() + repo.On("HasUnreconciledClaimsBeforeBlock", mock.Anything, app.ID, app.ForecloseBlock).Return(true, nil).Once() + repo.On("ListEpochs", mock.Anything, app.Name, repository.EpochFilter{Status: model.NonTerminalEpochStatuses()}, + repository.Pagination{}, false).Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), epoch.TournamentAddress.Hex()).Return( + &model.Tournament{Address: *epoch.TournamentAddress, Snapshot: model.TournamentSnapshot{ + AsOfBlock: 100, FinishedAtBlock: 90, Standing: model.TournamentStandingRootFailed, + }}, nil).Once() + repo.On("UpdateEpochWithForeclosedClaim", mock.Anything, app.ID, epoch.Index).Return(nil).Once() + + require.NoError(t, service.drainForeclosedClaims(t.Context(), app, []*model.Epoch{epoch}, nil, 100)) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} diff --git a/internal/prt/retired_root_result_test.go b/internal/prt/retired_root_result_test.go new file mode 100644 index 000000000..e3c6f7087 --- /dev/null +++ b/internal/prt/retired_root_result_test.go @@ -0,0 +1,55 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRetiredRootDoesNotHideLaterStaging(t *testing.T) { + f := newObserverCheckpointFixture(t) + f.app.LastTournamentCheckBlock = 90 + epoch := checkpointEpoch(0, "0x100") + f.epochs(epoch) + root := resultBoundaryTournament(f, epoch, *epoch.Commitment) + root.Snapshot.AsOfBlock = 90 + root.Snapshot.FinishedAtBlock = 80 + root.Snapshot.Standing = model.TournamentStandingRootWinner + root.Snapshot.AcceptsJoins = false + root.Snapshot.WinnerCommitment = epoch.Commitment + root.CreationEvent = &model.TournamentCreationEvent{BlockNumber: 10} + root.Snapshot.BondRecovery.Disposition = model.BondDispositionRecovered + require.True(t, tournamentObservationComplete(root, 90, 100)) + f.repo.storedTournaments = []*model.Tournament{root} + f.repo.On("GetTournament", mock.Anything, f.app.IApplicationAddress.Hex(), root.Address.Hex()).Return(root, nil) + snapshot := resultTestSnapshot(epoch, true) + snapshot.sealed.StagingBlockNumber = 100 + f.expectResultBoundarySnapshot(epoch, snapshot, 100) + f.repo.On("UpdateEpochReconciledStaged", mock.Anything, f.app.ID, epoch.Index, uint64(100)).Return(nil).Once() + + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + require.NoError(t, f.s.observeApplication(t.Context(), f.app, observerFacts(100))) + require.Equal(t, model.EpochStatus_ClaimStaged, epoch.Status) + require.Equal(t, uint64(90), root.Snapshot.AsOfBlock, "an immutable root needs no new tournament scan") +} + +func TestRetiredRootDoesNotHideLaterAcceptance(t *testing.T) { + f := newAcceptanceReceiptFixture(t) + root := f.repo.ExpectedCalls[0].ReturnArguments.Get(0).(*model.Tournament) + root.Snapshot.AsOfBlock = 90 + root.Snapshot.Standing = model.TournamentStandingRootWinner + root.CreationEvent = &model.TournamentCreationEvent{BlockNumber: 10} + root.Snapshot.BondRecovery.Disposition = model.BondDispositionRecovered + require.True(t, tournamentObservationComplete(root, 90, 100)) + f.expectReceipt() + f.expectAcceptance() + + deferred, err := f.service.reconcileAcceptedEpochs(t.Context(), f.app, []*model.Epoch{f.epoch}, f.consensus, 100) + require.NoError(t, err) + require.False(t, deferred, "a canonical consensus acceptance at finalized100 can follow retirement at90") +} diff --git a/internal/prt/reverts_test.go b/internal/prt/reverts_test.go new file mode 100644 index 000000000..a87c21cc6 --- /dev/null +++ b/internal/prt/reverts_test.go @@ -0,0 +1,168 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "strings" + "testing" +) + +const ( + executionRevertedError = "execution reverted" + applicationForeclosed = "ApplicationForeclosed" +) + +// daveConsensusRevertError creates a typed IDaveConsensus revert carrying only +// the 4-byte selector — sufficient for the classifiers to match by name. +func daveConsensusRevertError(name string) error { + parsed, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + if err != nil { + panic(err) + } + abiErr, ok := parsed.Errors[name] + if !ok { + panic(fmt.Sprintf("unknown IDaveConsensus error: %s", name)) + } + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", abiErr.ID[:4])} +} + +// daveRevertWithArgs creates a typed IDaveConsensus revert carrying the given +// arguments, ABI-encoded as the contract would emit it. +func daveRevertWithArgs(name string, args ...any) error { + parsed, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + if err != nil { + panic(err) + } + abiErr, ok := parsed.Errors[name] + if !ok { + panic(fmt.Sprintf("unknown IDaveConsensus error: %s", name)) + } + packed, err := abiErr.Inputs.Pack(args...) + if err != nil { + panic(err) + } + payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", payload)} +} + +// tournamentRevertError creates a typed ITournament revert carrying only the +// 4-byte selector. +func tournamentRevertError(name string) error { + parsed, err := itournament.ITournamentMetaData.GetAbi() + if err != nil { + panic(err) + } + abiErr, ok := parsed.Errors[name] + if !ok { + panic(fmt.Sprintf("unknown ITournament error: %s", name)) + } + return &rpcDataError{code: 3, msg: executionRevertedError, data: fmt.Sprintf("0x%x", abiErr.ID[:4])} +} + +func prtRevertTestApp() *model.Application { + return &model.Application{ + ID: 7, + Name: "prt-app", + IApplicationAddress: common.BigToAddress(common.Big1), + IConsensusAddress: common.HexToAddress("0x100"), + ConsensusType: model.Consensus_PRT, + Status: model.ApplicationStatus_OK, + Enabled: true, + } +} + +func prtRevertTestEpoch() *model.Epoch { + tournament := common.BigToAddress(common.Big2) + commitment := common.HexToHash("0xabcd") + machineHash := common.HexToHash("0x1234") + return &model.Epoch{ + Index: 3, + TournamentAddress: &tournament, + Commitment: &commitment, + MachineHash: &machineHash, + } +} + +// reasonContains builds a mock.MatchedBy predicate over a status reason. +func reasonContains(substrings ...string) func(*string) bool { + return func(reason *string) bool { + if reason == nil { + return false + } + for _, want := range substrings { + if !strings.Contains(*reason, want) { + return false + } + } + return true + } +} + +// Contract simulation is a volatile verdict. A failed simulation cannot write +// application health, and an unpublished plan releases only its own reservation. +func TestHandleStageTournamentResultRevert(t *testing.T) { + for _, name := range []string{"TournamentResultAlreadyStaged", "TournamentNotFinishedYet", applicationForeclosed, + "InvalidSiblingsArrayLength", "InvalidMachineMerkleProof", "InvalidPostEpochMachineIflagsYRegister", + "InvalidPostEpochMachineHtifTohostRegister", "ApplicationNotDeployed"} { + t.Run(name, func(t *testing.T) { assertSimulationFailure(t, stageTournament, daveConsensusRevertError(name)) }) + } + for _, cause := range []error{ + tournamentRevertError("TournamentFailedNoWinner"), + daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(3), big.NewInt(5)), + daveRevertWithArgs("IncorrectEpochNumber", big.NewInt(7), big.NewInt(5)), + daveRevertWithArgs("ApplicationReverted", common.BigToAddress(common.Big1), []byte{0xde, 0xad}), + daveRevertWithArgs("IllformedApplicationReturnData", common.BigToAddress(common.Big1), []byte{0xde, 0xad}), + errors.New("nonce too low"), errors.New("unknown RPC failure"), + } { + assertSimulationFailure(t, stageTournament, cause) + } +} + +func TestHandleAcceptTournamentResultRevert(t *testing.T) { + names := []string{"TournamentResultNotStaged", "ClaimStagingPeriodNotOverYet", applicationForeclosed, "ApplicationNotDeployed"} + for _, name := range names { + t.Run(name, func(t *testing.T) { assertSimulationFailure(t, acceptTournament, daveConsensusRevertError(name)) }) + } + assertSimulationFailure(t, acceptTournament, errors.New("factory failed")) +} + +func TestHandleJoinTournamentRevert(t *testing.T) { + for _, name := range []string{"ClockAlreadyInitialized", "TournamentIsClosed", "TournamentIsFinished", + "CommitmentStateMismatch", "CommitmentProofWrongSize"} { + t.Run(name, func(t *testing.T) { assertSimulationFailure(t, joinTournament, tournamentRevertError(name)) }) + } + assertSimulationFailure(t, joinTournament, errors.New("nonce too low")) + assertSimulationFailure(t, joinTournament, errors.New("unknown RPC failure")) +} + +func assertSimulationFailure(t *testing.T, kind actionKind, cause error) { + t.Helper() + f := newDeliveryFixture(t) + view := resultTestSnapshot(f.epoch, kind == acceptTournament) + view.stage.IsFinished = kind != joinTournament + view.accept.IsClaimStagingPeriodOver = true + f.snapshot(view) + if kind == joinTournament { + deliveryJoinAdapter(f, f.epoch, 30) + } + call, err := f.d.planAction(t.Context(), f.app, kind, f.block, observerFacts(100)) + require.NoError(t, err) + require.NotNil(t, call) + f.backend.preflightError = cause + report, err := f.d.sender.PublishNext(t.Context(), singleAction{f.d, *call}) + require.ErrorIs(t, err, cause) + require.False(t, report.Attempted) + require.Empty(t, f.backend.published) + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + f.repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/prt/root_creation_test.go b/internal/prt/root_creation_test.go new file mode 100644 index 000000000..94daa0ffd --- /dev/null +++ b/internal/prt/root_creation_test.go @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestChildCreationComparesEveryDescriptorFieldAndBond(t *testing.T) { + const level = uint64(1) + for _, test := range []struct { + name string + edit func(*TournamentCreation) + }{ + {"address", func(c *TournamentCreation) { c.Address[0]++ }}, + {"initial hash", func(c *TournamentCreation) { c.Descriptor.InitialHash[0]++ }}, + {"base cycle high bit", func(c *TournamentCreation) { + c.Descriptor.BaseCycle = new(big.Int).Add(c.Descriptor.BaseCycle, new(big.Int).Lsh(big.NewInt(1), 64)) + }}, + {"descriptor stride", func(c *TournamentCreation) { c.Descriptor.Log2Stride++ }}, + {"descriptor height", func(c *TournamentCreation) { c.Descriptor.Height++ }}, + {"descriptor level", func(c *TournamentCreation) { c.Descriptor.Level++ }}, + {"kind", func(c *TournamentCreation) { c.Descriptor.Kind = model.TournamentKindNonLeaf }}, + {"start", func(c *TournamentCreation) { c.Descriptor.StartInstant++ }}, + {"allowance", func(c *TournamentCreation) { c.Descriptor.Allowance++ }}, + {"bond high bit", func(c *TournamentCreation) { + c.BondValue, _ = model.Uint256FromBig(new(big.Int).Add(c.BondValue.ToBig(), new(big.Int).Lsh(big.NewInt(1), 64))) + }}, + {"creation block", func(c *TournamentCreation) { c.Event.BlockNumber++ }}, + } { + t.Run(new(big.Int).SetUint64(level).String()+"/"+test.name, func(t *testing.T) { + d := TournamentDescriptor{InitialHash: common.HexToHash("0x11"), BaseCycle: big.NewInt(7), Log2Stride: 0, Height: 68, + Level: level, Kind: model.TournamentKindLeaf, StartInstant: 10, Allowance: 20} + c := creationFixture(t, common.HexToAddress("0xa0"), d) + row := &model.Tournament{Address: c.Address, InitialHash: common.HexToHash("0x11"), BaseCycle: model.Uint256{7}, + Log2Step: 0, + Height: 68, Level: level, Kind: model.TournamentKindLeaf, StartInstant: 10, Allowance: 20, BondValue: c.BondValue} + require.NoError(t, applyTournamentCreationEvent(row, c)) + row.CreationEvent = nil // Exercise first publication without a stored identity guard. + test.edit(c) + require.Error(t, applyTournamentCreationEvent(row, c)) + require.Nil(t, row.CreationEvent) + }) + } +} + +func TestNewTournamentRejectsZeroBondBeforeStanding(t *testing.T) { + s, _ := newPRTServiceMock() + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{BaseCycle: big.NewInt(0), + Kind: model.TournamentKindLeaf}, nil).Once() + adapter.On("BondValue", mock.Anything).Return(big.NewInt(0), nil).Once() + row, err := s.readTournament(t.Context(), prtRevertTestApp(), checkpointEpoch(0, "0xa0"), RootLevel, nil, nil, + common.HexToAddress("0xa0"), adapter, 1, 100) + require.ErrorContains(t, err, "zero bond") + require.Nil(t, row) + adapter.AssertExpectations(t) + adapter.AssertNotCalled(t, "Standing", mock.Anything) +} diff --git a/internal/prt/service.go b/internal/prt/service.go new file mode 100644 index 000000000..67d73a117 --- /dev/null +++ b/internal/prt/service.go @@ -0,0 +1,411 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "sync/atomic" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" +) + +type CreateInfo struct { + TransactOptsFactory ethutil.TransactOptsFactory + Config config.PrtConfig + Logger *slog.Logger + Repository repository.Repository + EthClient EthClientInterface + AdapterFactory AdapterFactory + DeliveryBackend txsender.Backend +} + +type Service struct { + protectionNotices map[int64]map[protectionNoticeKey]protectionNotice + deployments deploymentCache + observationHardWarnedAt time.Time + service.TickServiceTemplate + delivery *delivery + pollInterval time.Duration + observerFacts txsender.BlockRef + hardFailureReason atomic.Pointer[prtFailure] + repository prtRepository + client EthClientInterface + adapterFactory AdapterFactory + submissionEnabled bool + publicationReady atomic.Bool + defaultBlock DefaultBlock + filter ethutil.Filter + txOptsFactory ethutil.TransactOptsFactory // Set by Create whenever submission is enabled. + disputeWarnings map[common.Address]struct{} + zeroStagingWarnings map[int64]struct{} + observationFailures map[int64]tournamentObservationFailure // Owned only by the observer; Ready does not read it. +} + +type prtFailure struct{ err error } + +func (s *Service) stopPublication(err error) { + s.hardFailureReason.CompareAndSwap(nil, &prtFailure{err: err}) +} +func (s *Service) publicationStopped() bool { return s.hardFailureReason.Load() != nil } + +func (s *Service) publicationFailure() error { + if reason := s.hardFailureReason.Load(); reason != nil { + return reason.err + } + return fmt.Errorf("%w: PRT protected publication prerequisite failed", txsender.ErrContent) +} + +const PrtConfigKey = "prt" + +type PersistentConfig = config.PersistentSubmitterConfig + +func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { + var err error + if c == nil { + return nil, errors.New("invalid CreateInfo is nil") + } + if err = ctx.Err(); err != nil { + return nil, err // This returns context.Canceled or context.DeadlineExceeded. + } + if c.Repository == nil { + return nil, fmt.Errorf("repository on prt service Create is nil") + } + + s := &Service{pollInterval: c.Config.PrtPollingInterval} + if s.pollInterval <= 0 { + s.pollInterval = time.Minute + } + tickCfg := &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: config.ServicePrt, + Logger: c.Logger, + LogLevel: c.Config.LogLevel, + LogColor: c.Config.LogColor, + }, + PollInterval: c.Config.PrtPollingInterval, + } + err = service.InitTickServiceTemplate(&s.TickServiceTemplate, tickCfg, s) + if err != nil { + return nil, err + } + + ethClient := c.EthClient + if ethClient == nil { + authOpt, err := config.HTTPAuthorizationOption() + if err != nil { + return nil, err + } + //nolint:forbidigo // Protected Ethereum connection. + ethClient, err = ethutil.NewEthClient(ctx, c.Config.BlockchainHttpEndpoint.Raw(), s.Logger, + ethutil.RetryConfig{ + MaxRetries: c.Config.BlockchainHttpMaxRetries, + RetryMinWait: c.Config.BlockchainHttpRetryMinWait, + RetryMaxWait: c.Config.BlockchainHttpRetryMaxWait, + RequestTimeout: c.Config.BlockchainHttpRequestTimeout, + }, authOpt) + if err != nil { + return nil, err + } + } + + chainID, err := ethClient.ChainID(ctx) + if err != nil { + return nil, err + } + if err := config.CheckNetworkChainID(chainID, c.Config.BlockchainId); err != nil { + return nil, err + } + + s.repository = c.Repository + + var signer common.Address + if c.Config.FeatureClaimSubmissionEnabled { + if c.Config.BlockchainHttpRequestTimeout <= 0 { + return nil, fmt.Errorf("BlockchainHttpRequestTimeout must be positive") + } + s.txOptsFactory = c.TransactOptsFactory + if s.txOptsFactory == nil { + s.txOptsFactory, err = auth.GetPrtTransactOptsFactory(ctx, chainID) + } + if err != nil { + return nil, fmt.Errorf("PRT submission is enabled and requires signer configuration "+ + "(CARTESI_PRT_AUTH_*); the standalone node always starts PRT: %w", err) + } + signer = s.txOptsFactory.From() + s.Logger.Info("PRT submitter identity", "address", signer) + } + nodeConfig, err := s.setupPersistentConfig(ctx, &c.Config, c.Repository, signer) + if err != nil { + return nil, err + } + + s.client = ethClient + s.submissionEnabled = nodeConfig.ClaimSubmissionEnabled + s.defaultBlock = nodeConfig.DefaultBlock + if s.defaultBlock != DefaultBlock_Finalized { + s.Logger.Warn("PRT stores provisional observations; reorg rollback is unsupported", "block_policy", s.defaultBlock) + } + s.filter = ethutil.Filter{ + MaxChunkSize: new(big.Int).SetUint64(c.Config.BlockchainMaxBlockRange), + Logger: s.Logger, + } + + s.disputeWarnings = map[common.Address]struct{}{} + s.zeroStagingWarnings = map[int64]struct{}{} + s.observationFailures = map[int64]tournamentObservationFailure{} + + s.delivery, err = newDelivery(ctx, c, s, nodeConfig, chainID) + if err != nil { + return nil, err + } + + s.Logger.Info("Created", "config", c.Config) + + return s, nil +} + +// logErrorUnlessShutdown suppresses shutdown cancellations and ordinary waits. +// Any real failure or expired operation deadline remains visible. +func (s *Service) logErrorUnlessShutdown(ctx context.Context, message string, err error, args ...any) { + if txsender.ShutdownOnly(ctx, err, repository.ErrTournamentIntervalStale, txsender.ErrLimit) { + return + } + args = append(args, "error", err) + s.Logger.Error(message, args...) +} + +// Serve runs the PRT observer and publisher independently of Claimer. +// Temporary publication failures retain the current transaction for the next pass. +func (s *Service) Serve(ctx context.Context) error { + if !s.submissionEnabled { + return s.TickServiceTemplate.Serve(ctx) + } + if s.delivery == nil { + return errors.New("PRT delivery is not initialized") + } + if err := s.waitForObserver(ctx); err != nil { + return err + } + s.publicationReady.Store(true) + defer s.publicationReady.Store(false) + return s.serveLoops(ctx) +} + +// Tick is the observer loop. The publisher runs independently in Serve. +func (s *Service) Tick(ctx context.Context) (bool, error) { + if err := ctx.Err(); err != nil { + if errors.Is(err, context.Canceled) { + return false, nil + } + return false, err + } + if s.delivery == nil { + return false, errors.New("PRT delivery is not initialized") + } + err := s.observe(ctx) + if txsender.ShutdownOnly(ctx, err, repository.ErrTournamentIntervalStale, txsender.ErrLimit) { + return false, nil + } + if errors.Is(err, txsender.ErrFinality) || errors.Is(err, txsender.ErrContent) { + if !warningDue(s.observationHardWarnedAt) { + return false, nil + } + s.observationHardWarnedAt = time.Now() + } + if deliveryWaitOnly(err) { + s.logDeliveryWait(err) + return false, nil + } + return false, err +} + +// serveLoops waits for both workers before returning from service shutdown. +func (s *Service) serveLoops(ctx context.Context) error { + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + observerDone := make(chan error, 1) + go func() { observerDone <- s.TickServiceTemplate.Serve(runCtx) }() + ticker := time.NewTicker(s.pollInterval) + defer ticker.Stop() + for { + err := s.delivery.tick(runCtx) + if deliveryWaitOnly(err) { + s.logDeliveryWait(err) + } else if err != nil && !s.publicationStopped() { + s.delivery.reportPublicationError(runCtx, err) + } + select { + case <-ctx.Done(): + cancel() + return errors.Join(ctx.Err(), <-observerDone) + case err := <-observerDone: + cancel() + return err + case <-ticker.C: + } + } +} + +func (s *Service) drainForeclosedClaims( + ctx context.Context, app *Application, epochs []*Epoch, consensus DaveConsensusAdapter, mostRecentBlock uint64, +) error { + state, generation, gateErr := s.deploymentVerdictState(ctx, app) + if gateErr != nil { + return gateErr + } + if state == deploymentNone || state == deploymentPending { + return nil + } + // Another observer can have committed a newer foreclosure. Absence at our + // protected head cannot prove that a claim is no longer acceptable there. + if app.ForecloseBlock > mostRecentBlock { + return txsender.ErrInconclusive + } + // Bootstrap-readiness guard. The drain gate below answers "given the + // rows currently in the local input table, is there any pre-foreclosure + // input still status=NONE?". For a freshly registered PRT app against + // an already-foreclosed contract, evmreader's checkForForeclosure writes + // foreclose_block before checkForEpochsAndInputs has had a chance to + // ingest the historical sealed epochs (and their inputs) — so the gate + // would see an empty table and return false. Dave writes both scanner + // cursors. ForeclosureScanCaughtUp requires last_epoch_check_block AND + // last_input_check_block to reach foreclose_block: sealed-epoch coverage + // alone does not prove that open-epoch inputs have been ingested. + if !app.ForeclosureScanCaughtUp() { + s.Logger.Info( + "Foreclosed PRT application still ingesting pre-foreclosure sealed epochs and inputs", + "application", app.Name, + "address", app.IApplicationAddress, + "last_epoch_check_block", app.LastEpochCheckBlock, + "last_input_check_block", app.LastInputCheckBlock, + "foreclose_block", app.ForecloseBlock, + ) + return nil + } + undrained, err := s.repository.HasUndrainedEpochsBeforeBlock(ctx, app.ID, app.ForecloseBlock) + if err != nil { + return fmt.Errorf("foreclosed app drain check (%s): %w", + app.IApplicationAddress, err) + } + if undrained { + s.Logger.Info( + "Foreclosed PRT application still draining pre-foreclosure inputs", + "application", app.Name, + "address", app.IApplicationAddress, + "foreclose_block", app.ForecloseBlock, + ) + return nil + } + // Epoch-level completion gate. Once every pre-foreclosure epoch is terminal + // (CLAIM_ACCEPTED or CLAIM_FORECLOSED), the drain is done and evmreader + // continues the post-foreclosure observation (drive-prove, withdrawals). + unreconciled, err := s.repository.HasUnreconciledClaimsBeforeBlock(ctx, app.ID, app.ForecloseBlock) + if err != nil { + return fmt.Errorf("foreclosed app claim-reconciliation check (%s): %w", + app.IApplicationAddress, err) + } + if !unreconciled { + return nil + } + if state == deploymentGeneration { + return s.forecloseGenerationClaims(ctx, app, generation) + } + + // Read-only reconciliation: accept epochs whose root result has + // our commitment, and surface any divergence. This sends no transactions. + deferActions, err := s.reconcileAcceptedEpochs(ctx, app, epochs, consensus, mostRecentBlock) + if err != nil { + // A divergence detected here marks the app DIVERGED and returns the + // reason; propagate it like the normal validation path does. + return err + } + if deferActions { + return nil + } + + // Pending claim epochs without an acceptance transaction can never be + // accepted now that the app is foreclosed. Make them CLAIM_FORECLOSED. + return s.foreclosePendingClaimEpochs(ctx, app) +} + +func (s *Service) foreclosePendingClaimEpochs(ctx context.Context, app *Application) error { + filter := repository.EpochFilter{Status: NonTerminalEpochStatuses()} + epochs, _, err := s.repository.ListEpochs(ctx, app.Name, filter, repository.Pagination{}, false) + if err != nil { + return fmt.Errorf("listing pending claim epochs for foreclosed app %s: %w", + app.IApplicationAddress, err) + } + for _, epoch := range epochs { + if epoch.FirstBlock > app.ForecloseBlock { + continue + } + // A complete valid prefix epoch still needs the validator before its + // accepted result can be reconciled. Foreclosure must not erase it. + if epoch.Status == EpochStatus_InputsProcessed && epoch.LastBlock < app.ForecloseBlock { + pending, err := s.observerCompletionEpoch(ctx, app, epoch) + if err != nil { + return err + } + if pending != nil { + continue + } + } + if epoch.ClaimTransactionHash != nil { + s.Logger.Debug("Skipping foreclose terminalization for epoch with on-chain claim transaction", + "application", app.Name, + "address", app.IApplicationAddress, + "epoch_index", epoch.Index, + "tx", epoch.ClaimTransactionHash, + ) + continue + } + if err := s.repository.UpdateEpochWithForeclosedClaim(ctx, app.ID, epoch.Index); err != nil { + // The guarded transition can lose eligibility after selection. Defer + // the no-write result; dependency errors stay visible. + if errors.Is(err, repository.ErrNoUpdate) { + continue + } + return fmt.Errorf("foreclosing epoch %d of app %s: %w", + epoch.Index, app.IApplicationAddress, err) + } + s.Logger.Info("Terminalized unaccepted epoch of foreclosed application", + "application", app.Name, + "address", app.IApplicationAddress, + "epoch_index", epoch.Index, + "foreclose_block", app.ForecloseBlock, + ) + } + return nil +} + +func (s *Service) setupPersistentConfig( + ctx context.Context, + c *config.PrtConfig, + repo repository.DeliveryRepository, + signer common.Address, +) (*PersistentConfig, error) { + requested := PersistentConfig{ + DefaultBlock: c.BlockchainDefaultBlock, ChainID: c.BlockchainId, + ClaimSubmissionEnabled: c.FeatureClaimSubmissionEnabled, + ResolvedSignerAddress: signer, + } + saved, err := repo.RegisterSubmitter(ctx, PrtConfigKey, requested) + if err != nil { + return nil, err + } + s.Logger.Info("PRT persistent config matches requested config", "config", saved) + return &saved, nil +} diff --git a/internal/prt/service_config_test.go b/internal/prt/service_config_test.go new file mode 100644 index 000000000..96c57e981 --- /dev/null +++ b/internal/prt/service_config_test.go @@ -0,0 +1,127 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSetupPersistentConfigUsesSerializedRegistration(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, concurrent := range []bool{false, true} { + t.Run(fmt.Sprintf("enabled=%t/concurrent=%t", enabled, concurrent), func(t *testing.T) { + s, _ := newPRTServiceMock() + repo := &prtBlockPolicyCreateRepository{} + var signer common.Address + if enabled { + signer = common.HexToAddress("0x600") + } + requested := PersistentConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 42, ClaimSubmissionEnabled: enabled, + ResolvedSignerAddress: signer, + } + stored := requested + if concurrent { + stored.ClaimSubmissionEnabled = !enabled + } + registrationErr := stored.CheckRequested(requested) + repo.On("RegisterSubmitter", mock.Anything, PrtConfigKey, requested). + Return(stored, registrationErr).Once() + got, err := s.setupPersistentConfig(t.Context(), &config.PrtConfig{ + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, + }, repo, signer) + if concurrent { + require.Nil(t, got) + require.ErrorContains(t, err, "claim submission mode mismatch") + } else { + require.NoError(t, err) + require.Equal(t, requested, *got) + } + repo.AssertNotCalled(t, "LoadNodeConfigRaw", mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "InitializeNodeConfigRaw", mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "SaveNodeConfigRaw", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } + } +} + +func TestCreateRejectsInvalidRegisteredConfig(t *testing.T) { + for _, test := range []struct { + name string + raw string + requestedEnabled bool + want string + }{ + {"chain mismatch", `{"ChainID":2,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true, + "ResolvedSignerAddress":"0x0000000000000000000000000000000000000600"}`, true, + "chain ID mismatch: database=2, configured=42"}, + {"policy mismatch", `{"ChainID":42,"DefaultBlock":"LATEST","ClaimSubmissionEnabled":true, + "ResolvedSignerAddress":"0x0000000000000000000000000000000000000600"}`, true, + "observation policy mismatch: database=LATEST, configured=FINALIZED"}, + {"enable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":false}`, true, + "claim submission mode mismatch: database=false, configured=true"}, + {"disable submission", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":true, + "ResolvedSignerAddress":"0x0000000000000000000000000000000000000600"}`, false, + "claim submission mode mismatch: database=true, configured=false"}, + {"missing mode", `{"ChainID":42,"DefaultBlock":"FINALIZED"}`, true, "non-null ClaimSubmissionEnabled"}, + {"null mode", `{"ChainID":42,"DefaultBlock":"FINALIZED","ClaimSubmissionEnabled":null}`, true, + "non-null ClaimSubmissionEnabled"}, + {"null config", `null`, true, "non-null DefaultBlock and ChainID"}, + {"invalid policy", `{"ChainID":42,"DefaultBlock":"invalid","ClaimSubmissionEnabled":true, + "ResolvedSignerAddress":"0x0000000000000000000000000000000000000600"}`, true, "invalid DefaultBlock"}, + } { + t.Run(test.name, func(t *testing.T) { + signer := common.HexToAddress("0x600") + registered := false + repo := &prtBlockPolicyCreateRepository{register: func( + _ context.Context, key string, requested PersistentConfig, + ) (PersistentConfig, error) { + registered = true + require.Equal(t, PrtConfigKey, key) + if test.requestedEnabled { + require.Equal(t, signer, requested.ResolvedSignerAddress) + } else { + require.Zero(t, requested.ResolvedSignerAddress) + } + var stored PersistentConfig + if err := json.Unmarshal([]byte(test.raw), &stored); err != nil { + return stored, err + } + return stored, stored.CheckRequested(requested) + }} + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + info := &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, + BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: test.requestedEnabled, BlockchainHttpRequestTimeout: time.Second, + }, + EthClient: client, Repository: repo, + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: signer}), + } + svc, err := Create(t.Context(), info) + require.Nil(t, svc) + require.ErrorContains(t, err, test.want) + require.True(t, registered) + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/prt/standing_projection.go b/internal/prt/standing_projection.go new file mode 100644 index 000000000..20457e09d --- /dev/null +++ b/internal/prt/standing_projection.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + "math" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +func optionalStandingHash(hash common.Hash) *common.Hash { + if hash == (common.Hash{}) { + return nil + } + return new(hash) +} + +func sameStandingHash(one, two *common.Hash) bool { + return (one == nil && two == nil) || (one != nil && two != nil && *one == *two) +} + +func appendTournamentStandingEvents(batch *repository.TournamentEventBatch, app *model.Application, epoch *model.Epoch, + events *TournamentEvents, matches map[common.Hash]*model.Match, parent *model.Match, +) error { + t := batch.Tournament + var latest *model.StandingChanged + for _, event := range events.StandingChanged { + count, err := model.Uint256FromBig(event.MatchCount) + if err != nil { + return fmt.Errorf("standing match count: %w", err) + } + standing := &model.StandingChanged{ApplicationID: app.ID, EpochIndex: epoch.Index, TournamentAddress: t.Address, + MatchCount: count, Dangling: optionalStandingHash(event.Dangling), + ParentCommitment: optionalStandingHash(event.ParentCommitment), + ResultAt: event.ResultAt, WinnerExpiresAt: event.WinnerExpiresAt, BlockNumber: event.Raw.BlockNumber, + TxHash: event.Raw.TxHash, + LogIndex: uint64(event.Raw.Index)} + if standing.ParentCommitment != nil && (t.Level == uint64(RootLevel) || parent == nil || + (*standing.ParentCommitment != parent.CommitmentOne && *standing.ParentCommitment != parent.CommitmentTwo)) { + return fmt.Errorf("standing parent commitment is outside the parent match pair") + } + if count.ToBig().Sign() == 0 && (t.Allowance > math.MaxUint64-t.StartInstant || standing.ResultAt < t.StartInstant+t.Allowance) { + return fmt.Errorf("standing result precedes tournament closure") + } + batch.StandingChanges = append(batch.StandingChanges, standing) + if latest == nil || standing.BlockNumber > latest.BlockNumber || + (standing.BlockNumber == latest.BlockNumber && standing.LogIndex > latest.LogIndex) { + latest = standing + } + } + if latest == nil { + return nil + } + live := uint64(0) + for _, match := range matches { + if match.DeletionReason == model.MatchDeletionReason_NOT_DELETED { + live++ + } + } + count := latest.MatchCount.ToBig() + if count.Cmp(new(big.Int).SetUint64(live)) != 0 { + return fmt.Errorf("standing match count does not match folded live matches") + } + snapshot := t.Snapshot + if (count.Sign() > 0) != (snapshot.Standing == model.TournamentStandingMatchesActive) { + return fmt.Errorf("standing active matches do not match current view") + } + if !sameStandingHash(latest.Dangling, snapshot.Candidate) { + return fmt.Errorf("standing dangling does not match current candidate") + } + if isTerminalTournamentStanding(snapshot.Standing) && latest.ResultAt != snapshot.FinishedAtBlock { + return fmt.Errorf("standing result does not match terminal current view") + } + if snapshot.Standing == model.TournamentStandingInnerWinner && + (!sameStandingHash(latest.ParentCommitment, snapshot.ParentCommitment) || latest.WinnerExpiresAt != snapshot.WinnerExpiresAt) { + return fmt.Errorf("standing winner does not match current inner result") + } + return nil +} diff --git a/internal/prt/startup.go b/internal/prt/startup.go new file mode 100644 index 000000000..18df932be --- /dev/null +++ b/internal/prt/startup.go @@ -0,0 +1,44 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "time" +) + +const provenanceCheckTimeout = 5 * time.Second + +// The contributing observer can register after this independent service starts. +// Check once before publication; do not couple each POST to registration reads. +func (s *Service) waitForObserver(ctx context.Context) error { + interval := s.pollInterval + if interval <= 0 || interval > time.Second { + interval = time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + var warnedAt time.Time + for ctx.Err() == nil { + check, cancel := context.WithTimeout(ctx, provenanceCheckTimeout) + err := s.repository.CheckSubmitterProvenance(check, PrtConfigKey) + cancel() + if ctx.Err() != nil { + return ctx.Err() + } + if err == nil { + return nil + } + if warningDue(warnedAt) && ctx.Err() == nil { + s.Logger.Warn("PRT publication waits for compatible observer registration", "error", err) + warnedAt = time.Now() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } + return ctx.Err() +} diff --git a/internal/prt/startup_signer_test.go b/internal/prt/startup_signer_test.go new file mode 100644 index 000000000..33de2cc6a --- /dev/null +++ b/internal/prt/startup_signer_test.go @@ -0,0 +1,143 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCreateExplainsRequiredPRTSigner(t *testing.T) { + for _, enabled := range []bool{true, false} { + name := "submission disabled" + if enabled { + name = "submission enabled" + } + t.Run(name, func(t *testing.T) { + viper.Set(config.PRT_AUTH_KIND, "mnemonic_file") + viper.Set(config.PRT_AUTH_MNEMONIC, "") + viper.Set(config.PRT_AUTH_MNEMONIC_FILE, filepath.Join(t.TempDir(), "missing-mnemonic")) + t.Cleanup(func() { + viper.Set(config.PRT_AUTH_KIND, nil) + viper.Set(config.PRT_AUTH_MNEMONIC, nil) + viper.Set(config.PRT_AUTH_MNEMONIC_FILE, nil) + }) + repo := &prtBlockPolicyCreateRepository{} + if !enabled { + repo.On("RegisterSubmitter", mock.Anything, PrtConfigKey, mock.Anything). + Return(PersistentConfig{ + ChainID: 42, DefaultBlock: model.DefaultBlock_Finalized, + }, nil).Once() + } + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + svc, err := Create(t.Context(), &CreateInfo{ + Config: config.PrtConfig{ + PrtPollingInterval: time.Hour, BlockchainId: 42, BlockchainDefaultBlock: model.DefaultBlock_Finalized, + FeatureClaimSubmissionEnabled: enabled, BlockchainHttpRequestTimeout: time.Second, + }, + Repository: repo, EthClient: client, AdapterFactory: &adapterFactoryMock{}, + }) + if enabled { + require.Nil(t, svc) + require.ErrorIs(t, err, os.ErrNotExist, "preserve the underlying credential error") + require.ErrorContains(t, err, "PRT submission is enabled and requires signer configuration (CARTESI_PRT_AUTH_*)") + require.ErrorContains(t, err, "the standalone node always starts PRT") + repo.AssertNotCalled(t, "RegisterSubmitter", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } else { + require.NoError(t, err, "reader mode must not require signer credentials") + require.Nil(t, svc.(*Service).txOptsFactory) + } + repo.AssertExpectations(t) + client.AssertExpectations(t) + }) + } +} + +func TestCreateRegistersResolvedPRTIdentityBeforeDelivery(t *testing.T) { + signer := common.HexToAddress("0x600") + repo := &prtBlockPolicyCreateRepository{register: func( + _ context.Context, key string, requested PersistentConfig, + ) (PersistentConfig, error) { + require.Equal(t, PrtConfigKey, key) + require.Equal(t, signer, requested.ResolvedSignerAddress) + require.True(t, requested.ClaimSubmissionEnabled) + return requested, nil + }} + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + svc, err := Create(t.Context(), &CreateInfo{ + Config: prtStartupConfig(true), Repository: repo, EthClient: client, AdapterFactory: &adapterFactoryMock{}, + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: signer}), + DeliveryBackend: &deliveryBackendFake{}, + }) + require.NoError(t, err) + s := svc.(*Service) + require.Equal(t, signer, s.txOptsFactory.From()) + require.NotNil(t, s.delivery.sender) + require.NoError(t, s.delivery.guardPublication(t.Context())) + require.False(t, s.Ready(), "Serve verifies the contributing observer before publication") + client.AssertExpectations(t) +} + +func TestCreateAcceptsOptionalFeeCeilingAndRejectsInvalidValue(t *testing.T) { + for _, value := range []string{"", "100", "invalid"} { + t.Run(value, func(t *testing.T) { + cfg := prtStartupConfig(true) + cfg.BlockchainTxMaxFeePerGas = value + repo := &prtBlockPolicyCreateRepository{register: func(_ context.Context, _ string, + requested PersistentConfig) (PersistentConfig, error) { + return requested, nil + }} + client := ðClientMock{} + client.On("ChainID", mock.Anything).Return(big.NewInt(42), nil).Once() + svc, err := Create(t.Context(), &CreateInfo{Config: cfg, Repository: repo, EthClient: client, + AdapterFactory: &adapterFactoryMock{}, + TransactOptsFactory: ethutil.NewStaticTransactOptsFactory(&bind.TransactOpts{From: common.HexToAddress("0x600")}), + DeliveryBackend: &deliveryBackendFake{}}) + if value == "invalid" { + require.Nil(t, svc) + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, svc) + } + }) + } +} + +func TestPRTReadinessRequiresStartupAndRejectsOnlyHardFailures(t *testing.T) { + f := newDeliveryFixture(t) + f.d.delivery, f.d.submissionEnabled = f.d, true + require.False(t, f.d.Ready(), "publication has not started") + f.d.publicationReady.Store(true) + f.repo.onProvenance = func(context.Context) error { + t.Fatal("publication guard must not reread startup registration") + return nil + } + require.NoError(t, f.d.guardPublication(t.Context())) + require.True(t, f.d.Ready()) + f.d.observationFailures[f.app.ID] = tournamentObservationFailure{failedHeads: tournamentObservationFailureThreshold} + require.True(t, f.d.Ready(), "one application's failed observation is not a service prerequisite") + f.d.stopPublication(txsender.ErrFinality) + require.False(t, f.d.Ready()) + require.ErrorIs(t, f.d.guardPublication(t.Context()), txsender.ErrFinality) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, newDeliveryFixture(t).d.guardPublication(ctx), context.Canceled) +} diff --git a/internal/prt/state_proof_test.go b/internal/prt/state_proof_test.go new file mode 100644 index 000000000..d0455cbb0 --- /dev/null +++ b/internal/prt/state_proof_test.go @@ -0,0 +1,35 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestStageTournamentResultEncodesModelProof(t *testing.T) { + proof := repotest.KeccakStateProof(common.HexToHash("0xab")) + contractABI, err := idaveconsensus.IDaveConsensusMetaData.GetAbi() + require.NoError(t, err) + want, err := contractABI.Pack("stageTournamentResult", big.NewInt(3), idaveconsensus.MachineValidityProof{ + IflagsYProof: idaveconsensus.LeafProof{DataBlock: proof.IflagsYDataBlock, Siblings: proof.IflagsYProof}, + HtifTohostProof: idaveconsensus.LeafProof{DataBlock: proof.HtifTohostDataBlock, Siblings: proof.HtifTohostProof}, + TxBufferProof: idaveconsensus.LeafProof{DataBlock: proof.TxBufferDataBlock, Siblings: proof.TxBufferProof}, + }) + require.NoError(t, err) + packed, err := contractABI.Pack("stageTournamentResult", big.NewInt(3), daveMachineValidityProof(proof)) + require.NoError(t, err) + require.Equal(t, want, packed) + // Encoding does not alias mutable database proof slices. + adapted := daveMachineValidityProof(proof) + require.NotEmpty(t, adapted.IflagsYProof.Siblings) + original := adapted.IflagsYProof.Siblings[0] + proof.IflagsYProof[0] = [32]byte{} + require.Equal(t, original, adapted.IflagsYProof.Siblings[0]) +} diff --git a/internal/prt/supervisor_readiness_test.go b/internal/prt/supervisor_readiness_test.go new file mode 100644 index 000000000..12a17f3dd --- /dev/null +++ b/internal/prt/supervisor_readiness_test.go @@ -0,0 +1,61 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSupervisorKeepsPRTReadyThroughPerApplicationObservationFailures(t *testing.T) { + s, repo := newPRTServiceMock() + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: config.ServicePrt, Logger: s.Logger}, PollInterval: time.Hour, + }, s)) + started := make(chan struct{}) + // Stop the first tick before it changes observer eligibility. This test + // isolates the supervisor's use of the PRT-specific readiness method. + repo.On("GetApplicationIDBoundary", mock.Anything, mock.Anything). + Run(func(mock.Arguments) { close(started) }). + Return(int64(0), errors.New("application list unavailable")).Once() + supervisor, err := service.NewSupervisor(t.Context(), &service.SupervisorConfigs{ + BaseConfigs: service.BaseConfigs{Name: "node", Logger: s.Logger}, + Factories: []service.FactoryFunction{ + func(context.Context, service.Supervisor) (service.SupervisedService, error) { return s, nil }, + }, + }) + require.NoError(t, err) + finished := make(chan error, 1) + go func() { finished <- supervisor.Serve() }() + t.Cleanup(func() { + supervisor.Stop() + select { + case err := <-finished: + require.NoError(t, err) + case <-time.After(time.Second): + t.Error("supervisor did not stop") + } + }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("PRT service did not start") + } + require.Empty(t, supervisor.NotReady()) + app := prtRevertTestApp() + for head := uint64(100); head < 100+tournamentObservationFailureThreshold; head++ { + s.recordTournamentObservationFailure(t.Context(), app, head, head, errors.New("observation unavailable")) + } + require.Empty(t, supervisor.NotReady(), "per-app failures do not change process prerequisites") + s.clearTournamentObservationFailure(app.ID) + require.Empty(t, supervisor.NotReady()) + repo.AssertExpectations(t) +} diff --git a/internal/prt/tick_policy_test.go b/internal/prt/tick_policy_test.go new file mode 100644 index 000000000..e1a731b7f --- /dev/null +++ b/internal/prt/tick_policy_test.go @@ -0,0 +1,197 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "math/big" + "testing" +) + +const ( + activeApplicationName = "active application" + foreclosedApplicationName = "foreclosed application" + secondApplicationName = "second" + boundaryReadName = "application boundary" + applicationPageName = "application page" + configuredHeadName = "configured head" +) + +func tickFixture(t *testing.T) (*deliveryFixture, *ethClientMock) { + t.Helper() + f := newDeliveryFixture(t) + f.d.delivery = f.d + f.d.defaultBlock = model.DefaultBlock_Finalized + client := ðClientMock{} + f.d.client = client + expectNumericWindowHeaders(client) + return f, client +} + +func tickHead(client *ethClientMock, head uint64) { + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return(&types.Header{Number: new(big.Int).SetUint64(head)}, nil).Once() +} + +func TestPRTTickOnlyObservesAndDoesNotCreateEmptyCoverage(t *testing.T) { + f, client := tickFixture(t) + f.d.submissionEnabled = true + f.repo.publishedRoots = nil + f.repo.storedTournaments = nil + f.app.Status = model.ApplicationStatus_Failed + second := *f.app + second.ID++ + second.Name = secondApplicationName + second.IApplicationAddress = common.HexToAddress("0x901") + second.IConsensusAddress = common.HexToAddress("0x902") + f.repo.apps = append(f.repo.apps, &second) + secondConsensus := newCompatibleDaveConsensusAdapterMock(second.IConsensusAddress) + f.factory.On("CreateDaveConsensusAdapter", second.IConsensusAddress).Return(secondConsensus, nil) + f.repo.On("ListEpochs", mock.Anything, mock.Anything, repository.EpochFilter{HasTournament: new(true)}, + repository.Pagination{}, false).Return([]*model.Epoch{}, uint64(0), nil) + f.repo.On("GetLastNonOpenEpochIndex", mock.Anything, mock.Anything).Return((*uint64)(nil), nil) + for tick := range 2 { + head := uint64(101 + tick) + tickHead(client, head) + f.block.Number = uint64(120 + tick) + expectProtectionReads(t, f.factory, f.consensus, observerFacts(head)) + expectProtectionReads(t, f.factory, secondConsensus, observerFacts(head)) + reschedule, err := f.d.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + require.Equal(t, uint64(100), second.LastTournamentCheckBlock) + } + require.Empty(t, f.backend.headerReads, "the observer never calls the sender") + require.Empty(t, f.repo.windows, "no tournament means no fabricated interval") + require.Empty(t, f.backend.published) + client.AssertNotCalled(t, "BlockNumber", mock.Anything) + client.AssertExpectations(t) +} + +func TestPRTTickWithoutApplicationsDoesNotReadHead(t *testing.T) { + f, client := tickFixture(t) + f.repo.apps = nil + reschedule, err := f.d.Tick(t.Context()) + require.False(t, reschedule) + require.NoError(t, err) + require.Empty(t, client.Calls) +} + +func TestPRTTickSuppressesHeadReadCancellationDuringShutdown(t *testing.T) { + f, client := tickFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Run(func(mock.Arguments) { cancel() }).Return((*types.Header)(nil), context.Canceled).Once() + _, err := f.d.Tick(ctx) + require.NoError(t, err) + require.Empty(t, f.backend.published) +} + +func TestPRTTickPreservesDependencyCancellationAndDeadlines(t *testing.T) { + dbError := errors.New("database unavailable") + branches := []string{boundaryReadName, applicationPageName, configuredHeadName, activeApplicationName, foreclosedApplicationName} + for _, branch := range branches { + for _, test := range []struct { + name string + shutdown bool + cause error + quiet bool + }{ + {"shutdown cancellation", true, context.Canceled, true}, + {"wrapped shutdown", true, fmt.Errorf("read: %w", context.Canceled), true}, + {"joined shutdown", true, errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), true}, + {"dependency cancellation", false, context.Canceled, false}, + {"database during shutdown", true, dbError, false}, + {"mixed database during shutdown", true, errors.Join(context.Canceled, dbError), false}, + {"deadline during shutdown", true, errors.Join(context.Canceled, context.DeadlineExceeded), false}, + } { + t.Run(branch+"/"+test.name, func(t *testing.T) { + f, client := tickFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if branch == foreclosedApplicationName { + f.app.ForecloseBlock = 100 + } + switch branch { + case boundaryReadName: + f.repo.boundaryError = test.cause + case applicationPageName: + f.repo.pageError = test.cause + tickHead(client, 100) + case configuredHeadName: + client.On("HeaderByNumber", mock.Anything, big.NewInt(rpc.FinalizedBlockNumber.Int64())). + Return((*types.Header)(nil), test.cause).Once() + default: + tickHead(client, 100) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch(nil), uint64(0), test.cause).Run(func(mock.Arguments) { + if test.shutdown { + cancel() + } + }).Once() + } + if test.shutdown && branch != activeApplicationName && branch != foreclosedApplicationName { + // Cancellation happens at the failing dependency, after Tick's entry check. + f.repo.onBoundary = func() { + if branch == boundaryReadName { + cancel() + } + } + f.repo.onPage = func() { + if branch == applicationPageName { + cancel() + } + } + if branch == configuredHeadName { + client.ExpectedCalls[len(client.ExpectedCalls)-1].Run(func(mock.Arguments) { cancel() }) + } + } + if !test.shutdown && (branch == activeApplicationName || branch == foreclosedApplicationName) { + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100)) + } + _, err := f.d.Tick(ctx) + if test.quiet { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.cause) + } + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + require.Empty(t, f.backend.published) + }) + } + } +} + +func TestPRTTickRetainsEarlierApplicationErrorDuringShutdown(t *testing.T) { + f, client := tickFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + firstError := errors.New("first observation failed") + second := *f.app + second.ID++ + second.Name = "second" + second.ForecloseBlock = 100 + f.repo.apps = append(f.repo.apps, &second) + tickHead(client, 100) + f.repo.On("ListEpochs", mock.Anything, f.app.Name, mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch(nil), uint64(0), firstError).Once() + f.repo.On("ListEpochs", mock.Anything, second.Name, mock.Anything, repository.Pagination{}, false). + Run(func(mock.Arguments) { cancel() }).Return([]*model.Epoch(nil), uint64(0), context.Canceled).Once() + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100)) + _, err := f.d.Tick(ctx) + require.ErrorIs(t, err, firstError) + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, f.backend.published) +} diff --git a/internal/prt/tournament_creation.go b/internal/prt/tournament_creation.go new file mode 100644 index 000000000..086cf46b8 --- /dev/null +++ b/internal/prt/tournament_creation.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" +) + +func applyTournamentCreationEvent(t *model.Tournament, creation *TournamentCreation) error { + if creation == nil { + return fmt.Errorf("tournament %s has no creation event", t.Address) + } + d := creation.Descriptor + if t.Address != creation.Address || t.InitialHash != d.InitialHash || d.BaseCycle == nil || t.BaseCycle.ToBig().Cmp(d.BaseCycle) != 0 || + t.Log2Step != d.Log2Stride || t.Height != d.Height || t.Level != d.Level || t.Kind != d.Kind || + t.StartInstant != d.StartInstant || t.Allowance != d.Allowance || t.BondValue != creation.BondValue || + creation.BondValue.ToBig().Sign() == 0 || creation.Event.BlockNumber != t.StartInstant || creation.Event.TxHash == (common.Hash{}) { + return fmt.Errorf("tournament %s descriptor or bond does not match its creation event", t.Address) + } + if t.CreationEvent != nil && *t.CreationEvent != creation.Event { + return fmt.Errorf("tournament %s has conflicting creation events", t.Address) + } + t.CreationEvent = new(creation.Event) + return nil +} + +func childTournamentCreation(t *model.Tournament, parent *model.Match, + event *itournament.ITournamentNewInnerTournament) (*TournamentCreation, error) { + if parent.CommitmentOne != event.One || parent.CommitmentTwo != event.Two || + event.Raw.Address != t.Address { + return nil, fmt.Errorf("child creation does not match parent tournament and creation pair") + } + d, err := tournamentDescriptorFromBinding(event.Descriptor) + if err != nil { + return nil, err + } + bond, err := model.Uint256FromBig(event.BondValue) + if err != nil { + return nil, err + } + return &TournamentCreation{Address: event.ChildTournament, Descriptor: d, BondValue: bond, + ContestedFinalStateOne: new(common.Hash(event.ContestedFinalStateOne)), + ContestedFinalStateTwo: new(common.Hash(event.ContestedFinalStateTwo)), + Event: model.TournamentCreationEvent{BlockNumber: event.Raw.BlockNumber, TxHash: event.Raw.TxHash, + LogIndex: uint64(event.Raw.Index)}}, nil +} + +func validateChildContestedStates(child *model.Tournament, parent *model.Match) error { + if child.ContestedFinalStateOne == nil || child.ContestedFinalStateTwo == nil { + return fmt.Errorf("child contested states are absent") + } + if parent.Snapshot.Phase == model.MatchPhaseSealed && (parent.Snapshot.Sealed == nil || + *child.ContestedFinalStateOne != parent.Snapshot.Sealed.FinalStateOne || + *child.ContestedFinalStateTwo != parent.Snapshot.Sealed.FinalStateTwo) { + return fmt.Errorf("child contested states do not match current sealed parent") + } + return nil +} diff --git a/internal/prt/tournament_views_test.go b/internal/prt/tournament_views_test.go new file mode 100644 index 000000000..5955be5e2 --- /dev/null +++ b/internal/prt/tournament_views_test.go @@ -0,0 +1,387 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type tournamentViewRPC struct{ mock.Mock } + +func (m *tournamentViewRPC) Call( + _ context.Context, call map[string]json.RawMessage, block rpc.BlockNumber, +) (hexutil.Bytes, error) { + var address common.Address + var input hexutil.Bytes + if err := json.Unmarshal(call["to"], &address); err != nil { + return nil, err + } + if err := json.Unmarshal(call["input"], &input); err != nil { + return nil, err + } + args := m.Called(address, []byte(input), block) + return args.Get(0).([]byte), args.Error(1) +} + +type tournamentViewFixture struct { + t *testing.T + adapter TournamentAdapter + backend *tournamentViewRPC + abi *abi.ABI + address common.Address + opts *bind.CallOpts +} + +func newTournamentViewFixture(t *testing.T) *tournamentViewFixture { + t.Helper() + contractABI, err := itournament.ITournamentMetaData.GetAbi() + require.NoError(t, err) + backend := &tournamentViewRPC{} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + address := common.HexToAddress("0x1234") + adapter, err := NewITournamentAdapter(address, client, ethutil.Filter{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + require.NoError(t, err) + t.Cleanup(func() { backend.AssertExpectations(t) }) + return &tournamentViewFixture{t: t, adapter: adapter, backend: backend, abi: contractABI, + address: address, opts: &bind.CallOpts{Context: t.Context(), BlockNumber: big.NewInt(200)}} +} + +func (f *tournamentViewFixture) expect(name string, inputs []any, outputs ...any) { + f.t.Helper() + encoded, err := f.abi.Methods[name].Outputs.Pack(outputs...) + require.NoError(f.t, err) + f.expectBytes(name, inputs, encoded, nil) +} + +func (f *tournamentViewFixture) expectBytes(name string, inputs []any, output []byte, callErr error) { + f.t.Helper() + input, err := f.abi.Pack(name, inputs...) + require.NoError(f.t, err) + f.backend.On("Call", f.address, input, rpc.BlockNumber(f.opts.BlockNumber.Int64())).Return(output, callErr).Once() +} + +func TestTournamentAdapterDecodesCompleteViews(t *testing.T) { + f := newTournamentViewFixture(t) + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + large := new(big.Int).Lsh(big.NewInt(1), 200) + f.expect("tournamentDescriptor", nil, itournament.ITournamentTournamentDescriptor{ + InitialHash: one, BaseCycle: large, Log2Stride: 3, Height: 4, Level: 5, Kind: 1, StartInstant: 6, Allowance: 7, + }) + descriptor, err := f.adapter.Descriptor(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentDescriptor{InitialHash: one, BaseCycle: large, Log2Stride: 3, Height: 4, Level: 5, + Kind: model.TournamentKindNonLeaf, StartInstant: 6, Allowance: 7}, descriptor) + + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{ + Standing: 4, HasCandidate: true, Candidate: one, FinalState: two, ParentCommitment: three, FinishedAt: 8, WinnerExpiresAt: 9, + }) + standing, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentStanding{State: model.TournamentStandingInnerWinner, HasCandidate: true, + Candidate: one, FinalState: two, ParentCommitment: three, FinishedAt: 8, WinnerExpiresAt: 9}, standing) + + f.expect("commitmentStanding", []any{one}, itournament.ITournamentCommitmentStandingView{ + Joined: true, FinalState: two, Claimer: f.address, ClockRunning: true, ClockDeadline: 11, ClockAllowance: 12, + }) + commitment, err := f.adapter.CommitmentStanding(f.opts, one) + require.NoError(t, err) + require.Equal(t, CommitmentStanding{Joined: true, FinalState: two, Claimer: f.address, + ClockRunning: true, ClockDeadline: 11, ClockAllowance: 12}, commitment) + + f.expect("innerResult", nil, itournament.ITournamentInnerResultView{Disposition: 1, ParentCommitment: three, PausedAllowance: 13}) + inner, err := f.adapter.InnerResult(f.opts) + require.NoError(t, err) + require.Equal(t, InnerResult{Disposition: model.InnerTournamentWinner, ParentCommitment: three, PausedAllowance: 13}, inner) + f.expect("bondRecovery", nil, uint8(2), f.address, large) + bond, err := f.adapter.BondRecovery(f.opts) + require.NoError(t, err) + require.Equal(t, BondRecovery{Disposition: model.BondDispositionRecoverable, Claimer: f.address, Payment: large}, bond) + f.expect("bondValue", nil, large) + value, err := f.adapter.BondValue(f.opts) + require.NoError(t, err) + require.Equal(t, large, value) +} + +func TestTournamentAdapterReadsSixDistinctCounters(t *testing.T) { + f := newTournamentViewFixture(t) + names := []string{"getCommitmentJoinedCount", "getMatchCreatedCount", "getMatchAdvancedCount", + "getLeafMatchSealedCount", "getMatchDeletedCount", "getNewInnerTournamentCount"} + values := make([]*big.Int, len(names)) + for index, name := range names { + values[index] = new(big.Int).Lsh(big.NewInt(1), 200+uint(index)) + f.expect(name, nil, values[index]) + } + counts, err := f.adapter.StructuralEventCounts(f.opts) + require.NoError(t, err) + require.Equal(t, StructuralEventCounts{CommitmentJoined: values[0], MatchCreated: values[1], MatchAdvanced: values[2], + LeafMatchSealed: values[3], MatchDeleted: values[4], NewInnerTournament: values[5]}, counts) +} + +func TestTournamentAdapterDecodesStandingOrdinalsAndTimeOnlyChanges(t *testing.T) { + states := []struct { + ordinal uint8 + state model.TournamentStandingState + }{ + {0, model.TournamentStandingMatchesActive}, {1, model.TournamentStandingAwaitingClosure}, + {2, model.TournamentStandingRootWinner}, {3, model.TournamentStandingRootFailed}, {4, model.TournamentStandingInnerWinner}, + {5, model.TournamentStandingInnerEliminableNoWinner}, {6, model.TournamentStandingInnerEliminableWinnerExpired}, + } + for _, test := range states { + t.Run(string(test.state), func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{Standing: test.ordinal}) + standing, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, test.state, standing.State) + }) + } + f := newTournamentViewFixture(t) + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{AcceptsJoins: true}) + open, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.True(t, open.AcceptsJoins) + require.False(t, open.HasCandidate) + one := common.HexToHash("0x11") + f.expect("tournamentStanding", nil, itournament.ITournamentTournamentStandingView{ + Standing: 6, HasCandidate: true, Candidate: one, FinishedAt: 100, + }) + expired, err := f.adapter.Standing(f.opts) + require.NoError(t, err) + require.Equal(t, TournamentStanding{State: model.TournamentStandingInnerEliminableWinnerExpired, + HasCandidate: true, Candidate: one, FinishedAt: 100}, expired) +} + +func TestTournamentAdapterDecodesInactiveViews(t *testing.T) { + f := newTournamentViewFixture(t) + one := common.HexToHash("0x11") + for _, binding := range []itournament.ITournamentCommitmentStandingView{ + {}, {Joined: true, FinalState: one, ClockAllowance: 12}, + } { + f.expect("commitmentStanding", []any{one}, binding) + standing, err := f.adapter.CommitmentStanding(f.opts, one) + require.NoError(t, err) + require.Equal(t, binding.Joined, standing.Joined) + require.False(t, standing.ClockRunning) + require.Zero(t, standing.ClockDeadline) + require.Equal(t, binding.ClockAllowance, standing.ClockAllowance) + require.Zero(t, standing.Claimer, "bond recovery can clear the claimer without clearing the join record") + } + for _, test := range []struct { + raw uint8 + want model.InnerTournamentDisposition + }{{0, model.InnerTournamentUnsettled}, {2, model.InnerTournamentEliminable}} { + f.expect("innerResult", nil, itournament.ITournamentInnerResultView{Disposition: test.raw}) + inner, err := f.adapter.InnerResult(f.opts) + require.NoError(t, err) + require.Equal(t, InnerResult{Disposition: test.want}, inner) + } + for _, test := range []struct { + raw uint8 + want model.BondDisposition + }{{0, model.BondDispositionTournamentRunning}, {1, model.BondDispositionNoWinner}, {3, model.BondDispositionRecovered}} { + f.expect("bondRecovery", nil, test.raw, common.Address{}, big.NewInt(0)) + bond, err := f.adapter.BondRecovery(f.opts) + require.NoError(t, err) + require.Equal(t, test.want, bond.Disposition) + require.Zero(t, bond.Claimer) + require.Zero(t, bond.Payment.Sign()) + } +} + +func TestTournamentAdapterDecodesEveryMatchPhase(t *testing.T) { + one, two, three := common.HexToHash("0x11"), common.HexToHash("0x22"), common.HexToHash("0x33") + position := new(big.Int).Lsh(big.NewInt(1), 100) + cycle := new(big.Int).Lsh(big.NewInt(1), 200) + for _, test := range []struct { + name string + phase uint8 + outcome uint8 + charge uint64 + method string + payload any + want ObservedMatchSnapshot + }{ + {name: "absent", want: ObservedMatchSnapshot{Phase: model.MatchPhaseUninitialized, TimeoutOutcome: model.MatchTimeoutNone}}, + {name: "bisecting", phase: 1, outcome: 2, charge: 7, method: "bisectingMatch", + payload: itournament.ITournamentBisectingMatchView{RevealingParent: one, WaitingLeft: two, WaitingRight: three, + SegmentStartPosition: position, SegmentStartCycle: cycle, CurrentHeight: 4, Responder: 0}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseBisecting, TimeoutOutcome: model.MatchTimeoutTwoWins, DeferredCharge: 7, + Bisecting: &BisectingMatch{RevealingParent: one, WaitingLeft: two, WaitingRight: three, + SegmentStartPosition: position, SegmentStartCycle: cycle, CurrentHeight: 4, Responder: model.CommitmentSideOne}}}, + {name: "ready to seal", phase: 2, outcome: 1, charge: 8, method: "readyToSealMatch", + payload: itournament.ITournamentReadyToSealMatchView{RevealingParent: three, WaitingLeft: two, WaitingRight: one, + SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: 1}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseReadyToSeal, TimeoutOutcome: model.MatchTimeoutOneWins, DeferredCharge: 8, + ReadyToSeal: &ReadyToSealMatch{RevealingParent: three, WaitingLeft: two, WaitingRight: one, + SegmentStartPosition: position, SegmentStartCycle: cycle, Responder: model.CommitmentSideTwo}}}, + {name: "sealed", phase: 3, outcome: 3, method: "sealedMatch", + payload: itournament.ITournamentSealedMatchView{AgreeState: one, DivergencePosition: position, + DivergenceCycle: cycle, FinalStateOne: two, FinalStateTwo: three}, + want: ObservedMatchSnapshot{Phase: model.MatchPhaseSealed, TimeoutOutcome: model.MatchTimeoutEliminateBoth, + Sealed: &SealedMatch{AgreeState: one, DivergencePosition: position, DivergenceCycle: cycle, + FinalStateOne: two, FinalStateTwo: three}}}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, test.outcome, test.charge) + if test.method != "" { + f.expect(test.method, []any{crypto.Keccak256Hash(one[:], two[:])}, test.phase, test.payload) + } + snapshot, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.NoError(t, err) + require.Equal(t, test.want, snapshot) + }) + } +} + +func TestTournamentAdapterRejectsUnknownViewEnums(t *testing.T) { + for _, test := range []struct { + name string + method string + output []any + read func(TournamentAdapter, *bind.CallOpts) error + }{ + {"kind", "tournamentDescriptor", []any{itournament.ITournamentTournamentDescriptor{Kind: 2, BaseCycle: big.NewInt(0)}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.Descriptor(o); return err }}, + {"standing", "tournamentStanding", []any{itournament.ITournamentTournamentStandingView{Standing: 7}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.Standing(o); return err }}, + {"inner result", "innerResult", []any{itournament.ITournamentInnerResultView{Disposition: 3}}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.InnerResult(o); return err }}, + {"bond", "bondRecovery", []any{uint8(4), common.Address{}, big.NewInt(0)}, + func(a TournamentAdapter, o *bind.CallOpts) error { _, err := a.BondRecovery(o); return err }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect(test.method, nil, test.output...) + require.ErrorContains(t, test.read(f.adapter, f.opts), "unknown") + }) + } +} + +func TestTournamentAdapterRejectsInvalidMatchViews(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + name string + phase uint8 + outcome uint8 + charge uint64 + projectionPhase uint8 + responder uint8 + readProjection bool + }{ + {name: "unknown phase", phase: 4}, + {name: "unknown timeout", phase: 1, outcome: 4}, + {name: "absent timeout", outcome: 1}, + {name: "inactive charge", phase: 1, charge: 1}, + {name: "different pinned phase", phase: 1, projectionPhase: 2, readProjection: true}, + {name: "unknown responder", phase: 1, projectionPhase: 1, responder: 2, readProjection: true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, test.outcome, test.charge) + if test.readProjection { + f.expect("bisectingMatch", []any{crypto.Keccak256Hash(one[:], two[:])}, test.projectionPhase, + itournament.ITournamentBisectingMatchView{SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0), + CurrentHeight: 2, Responder: test.responder}) + } + snapshot, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.Error(t, err) + require.Equal(t, ObservedMatchSnapshot{}, snapshot) + }) + } +} + +func TestTournamentReadsRejectUnpinnedSnapshots(t *testing.T) { + for _, opts := range []*bind.CallOpts{nil, {}, {BlockNumber: big.NewInt(-1)}, {BlockNumber: big.NewInt(10), Pending: true}} { + f := newTournamentViewFixture(t) + _, err := f.adapter.StructuralEventCounts(opts) + require.ErrorContains(t, err, "pinned block") + _, err = f.adapter.MatchSnapshot(opts, [32]byte{}, [32]byte{}) + require.ErrorContains(t, err, "pinned block") + } +} + +func TestTournamentAdapterChecksEachPinnedProjectionPhase(t *testing.T) { + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + for _, test := range []struct { + phase uint8 + method string + payload any + }{ + {1, "bisectingMatch", itournament.ITournamentBisectingMatchView{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, + {2, "readyToSealMatch", itournament.ITournamentReadyToSealMatchView{ + SegmentStartPosition: big.NewInt(0), SegmentStartCycle: big.NewInt(0)}}, + {3, "sealedMatch", itournament.ITournamentSealedMatchView{ + DivergencePosition: big.NewInt(0), DivergenceCycle: big.NewInt(0)}}, + } { + t.Run(test.method, func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("classifyMatchTimeout", []any{itournament.MatchId{CommitmentOne: one, CommitmentTwo: two}}, + test.phase, uint8(0), uint64(0)) + f.expect(test.method, []any{crypto.Keccak256Hash(one[:], two[:])}, uint8(0), test.payload) + _, err := f.adapter.MatchSnapshot(f.opts, one, two) + require.ErrorContains(t, err, "phase changed") + }) + } +} + +func TestTournamentReadsPropagateRPCAndScalarErrors(t *testing.T) { + t.Run("counter read error", func(t *testing.T) { + f := newTournamentViewFixture(t) + f.expect("getCommitmentJoinedCount", nil, big.NewInt(1)) + f.expectBytes("getMatchCreatedCount", nil, nil, errors.New("provider unavailable")) + counts, err := f.adapter.StructuralEventCounts(f.opts) + require.ErrorContains(t, err, "provider unavailable") + require.Equal(t, StructuralEventCounts{}, counts) + }) + t.Run("uint64 overflow", func(t *testing.T) { + f := newTournamentViewFixture(t) + encoded, err := f.abi.Methods["innerResult"].Outputs.Pack(itournament.ITournamentInnerResultView{Disposition: 1}) + require.NoError(t, err) + const allowanceWordStart = 64 + encoded[allowanceWordStart] = 1 + f.expectBytes("innerResult", nil, encoded, nil) + _, err = f.adapter.InnerResult(f.opts) + require.Error(t, err) + }) +} + +func TestTournamentUint256BoundsAndOwnership(t *testing.T) { + maximum := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + for _, value := range []*big.Int{nil, big.NewInt(-1), new(big.Int).Add(maximum, big.NewInt(1))} { + _, err := tournamentUint256("test", value) + require.Error(t, err) + } + copiedAmount, err := tournamentUint256("test", maximum) + require.NoError(t, err) + require.Equal(t, maximum, copiedAmount) + copiedAmount.SetInt64(0) + require.NotZero(t, maximum.Sign(), "the DTO owns its integer") +} diff --git a/internal/prt/types.go b/internal/prt/types.go new file mode 100644 index 000000000..ae4f0bebf --- /dev/null +++ b/internal/prt/types.go @@ -0,0 +1,220 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/txsender" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" +) + +// TournamentDescriptor is the immutable tournament configuration exposed by +// the Dave tournament contract. +type TournamentDescriptor struct { + InitialHash common.Hash + BaseCycle *big.Int + Log2Stride uint64 + Height uint64 + Level uint64 + Kind model.TournamentKind + StartInstant uint64 + Allowance uint64 +} + +// TournamentCreation is immutable parent event provenance for a child tournament. +type TournamentCreation struct { + Address common.Address + Descriptor TournamentDescriptor + BondValue model.Uint256 + ContestedFinalStateOne *common.Hash + ContestedFinalStateTwo *common.Hash + Event model.TournamentCreationEvent +} + +// TournamentStanding is the current tournament result and timing projection. +type TournamentStanding struct { + State model.TournamentStandingState + AcceptsJoins bool + HasCandidate bool + Candidate common.Hash + FinalState common.Hash + ParentCommitment common.Hash + FinishedAt uint64 + WinnerExpiresAt uint64 +} + +// CommitmentStanding is the current on-chain record for one commitment. +type CommitmentStanding struct { + Joined bool + FinalState common.Hash + Claimer common.Address + ClockRunning bool + ClockDeadline uint64 + ClockAllowance uint64 +} + +// BisectingMatch is the current divergence frontier before the final opening. +type BisectingMatch struct { + RevealingParent common.Hash + WaitingLeft common.Hash + WaitingRight common.Hash + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + CurrentHeight uint64 + Responder model.CommitmentSide +} + +// ReadyToSealMatch is the final divergence frontier, before sealing. +type ReadyToSealMatch struct { + RevealingParent common.Hash + WaitingLeft common.Hash + WaitingRight common.Hash + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + Responder model.CommitmentSide +} + +// SealedMatch stores states in commitment-side order, not reveal order. +type SealedMatch struct { + AgreeState common.Hash + DivergencePosition *big.Int + DivergenceCycle *big.Int + FinalStateOne common.Hash + FinalStateTwo common.Hash +} + +// ObservedMatchSnapshot contains only the payload selected by Phase. Deleted matches +// have phase UNINITIALIZED and no payload; their events remain historical data. +type ObservedMatchSnapshot struct { + Phase model.MatchPhase + TimeoutOutcome model.MatchTimeoutOutcome + DeferredCharge uint64 + Bisecting *BisectingMatch + ReadyToSeal *ReadyToSealMatch + Sealed *SealedMatch +} + +// InnerResult includes the carryover allowance at the observation block. +type InnerResult struct { + Disposition model.InnerTournamentDisposition + ParentCommitment common.Hash + PausedAllowance uint64 +} + +// StructuralEventCounts contains six independent on-chain counters. Financial +// events have no dedicated counter in this contract version. +type StructuralEventCounts struct { + CommitmentJoined *big.Int + MatchCreated *big.Int + MatchAdvanced *big.Int + LeafMatchSealed *big.Int + MatchDeleted *big.Int + NewInnerTournament *big.Int +} + +// BondRecovery is the current recovery state of a tournament bond. +type BondRecovery struct { + Disposition model.BondDisposition + Claimer common.Address + Payment *big.Int +} + +// TournamentAdapter provides contract views and event decoding. +type TournamentAdapter interface { + RetrieveAllEvents(opts *bind.FilterOpts) (*TournamentEvents, error) + Descriptor(opts *bind.CallOpts) (TournamentDescriptor, error) + Standing(opts *bind.CallOpts) (TournamentStanding, error) + CommitmentStanding(opts *bind.CallOpts, commitmentRoot [32]byte) (CommitmentStanding, error) + MatchSnapshot(opts *bind.CallOpts, one, two [32]byte) (ObservedMatchSnapshot, error) + InnerResult(opts *bind.CallOpts) (InnerResult, error) + StructuralEventCounts(opts *bind.CallOpts) (StructuralEventCounts, error) + BondValue(opts *bind.CallOpts) (*big.Int, error) + BondRecovery(opts *bind.CallOpts) (BondRecovery, error) +} + +// DaveConsensusAdapter wraps access to the IDaveConsensus contract. +type DaveConsensusAdapter interface { + ReadDeployment(ctx context.Context, head txsender.BlockRef) (DeploymentEvidence, error) + ParseEpochSealed(log types.Log) (*idaveconsensus.IDaveConsensusEpochSealed, error) + EpochSealedAt(ctx context.Context, blockHash common.Hash, epoch uint64) ([]*idaveconsensus.IDaveConsensusEpochSealed, error) + TournamentLevelCount(opts *bind.CallOpts) (uint64, error) + GetCurrentSealedEpoch(opts *bind.CallOpts) (CurrentSealedEpoch, error) + CanStageTournamentResult(opts *bind.CallOpts) (CanStageTournamentResult, error) + CanAcceptStagedTournamentResult(opts *bind.CallOpts) (CanAcceptStagedTournamentResult, error) +} + +// CurrentSealedEpoch is the complete state of the current sealed epoch. +type CurrentSealedEpoch struct { + EpochNumber uint64 + InputIndexLowerBound uint64 + InputIndexUpperBound uint64 + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber uint64 + StagedPostEpochMachineStateHash common.Hash + StagedPostEpochOutputsMerkleRoot common.Hash +} + +// CanStageTournamentResult is the stage readiness view for the current sealed epoch. +type CanStageTournamentResult struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber uint64 + WinnerCommitment common.Hash + WinnerPostEpochMachineStateHash common.Hash +} + +// CanAcceptStagedTournamentResult is the accept readiness view for the current sealed epoch. +type CanAcceptStagedTournamentResult struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber uint64 + StagedPostEpochMachineStateHash common.Hash + StagedPostEpochOutputsMerkleRoot common.Hash +} + +// AdapterFactory creates contract adapters from on-chain addresses. +type AdapterFactory interface { + CreateTournamentAdapter(addr common.Address) (TournamentAdapter, error) + CreateDaveConsensusAdapter(addr common.Address) (DaveConsensusAdapter, error) +} + +// Struct to hold all events retrieved at once +type TournamentEvents struct { + CommitmentJoined []*itournament.ITournamentCommitmentJoined + MatchAdvanced []*itournament.ITournamentMatchAdvanced + MatchCreated []*itournament.ITournamentMatchCreated + MatchDeleted []*itournament.ITournamentMatchDeleted + NewInnerTournament []*itournament.ITournamentNewInnerTournament + LeafMatchSealed []*itournament.ITournamentLeafMatchSealed + PartialBondRefund []*itournament.ITournamentPartialBondRefund + BondRecovered []*itournament.ITournamentBondRecovered + StandingChanged []*itournament.ITournamentStandingChanged +} + +type TournamentLevel uint64 + +const ( + RootLevel TournamentLevel = iota +) + +func (l TournamentLevel) String() string { + switch l { + case RootLevel: + return "root" + default: + return "inner" + } +} diff --git a/internal/prt/util.go b/internal/prt/util.go new file mode 100644 index 000000000..8914fe5dd --- /dev/null +++ b/internal/prt/util.go @@ -0,0 +1,16 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "unsafe" + + "github.com/ethereum/go-ethereum/common" +) + +// hashSliceToByteSlice converts []common.Hash to [][32]byte without copying. +// This is safe because common.Hash is defined as [32]byte, so the memory layout is identical. +func hashSliceToByteSlice(b []common.Hash) [][32]byte { + return *(*[][32]byte)(unsafe.Pointer(&b)) +} diff --git a/internal/prt/util_test.go b/internal/prt/util_test.go new file mode 100644 index 000000000..14bb62531 --- /dev/null +++ b/internal/prt/util_test.go @@ -0,0 +1,16 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +// rpcDataError simulates an RPC error with revert data, as returned by +// eth_estimateGas when a contract reverts. +type rpcDataError struct { + code int + msg string + data any +} + +func (e *rpcDataError) Error() string { return e.msg } +func (e *rpcDataError) ErrorCode() int { return e.code } +func (e *rpcDataError) ErrorData() any { return e.data } diff --git a/internal/prt/validation_test.go b/internal/prt/validation_test.go new file mode 100644 index 000000000..190c7a915 --- /dev/null +++ b/internal/prt/validation_test.go @@ -0,0 +1,62 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "context" + "errors" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" + "log/slog" + "strings" + "testing" +) + +func TestLogErrorUnlessShutdown(t *testing.T) { + tests := []struct { + name string + stopping bool + err error + wantError bool + }{ + {name: "ShutdownCancellation", stopping: true, err: context.Canceled, wantError: false}, + {name: "ShutdownWaitAndCancellation", stopping: true, + err: errors.Join(txsender.ErrInconclusive, context.Canceled), wantError: false}, + {name: "ShutdownWaitAndDatabaseFailure", stopping: true, + err: errors.Join(txsender.ErrInconclusive, context.Canceled, errors.New("database unavailable")), wantError: true}, + {name: "ShutdownDeadline", stopping: true, err: context.DeadlineExceeded, wantError: true}, + { + name: "ShutdownCancellationWithDeadline", + stopping: true, + err: errors.Join(context.Canceled, context.DeadlineExceeded), + wantError: true, + }, + {name: "RuntimeCancellation", stopping: false, err: context.Canceled, wantError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var output bytes.Buffer + s := &Service{ + TickServiceTemplate: service.TickServiceTemplate{ + BaseTemplate: service.BaseTemplate{ + Logger: slog.New(slog.NewTextHandler(&output, nil)), + }, + }, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if test.stopping { + cancel() + } + + s.logErrorUnlessShutdown(ctx, "operation failed", test.err, "operation", "test") + + hasError := strings.Contains(output.String(), "level=ERROR") + require.Equal(t, test.wantError, hasError, output.String()) + }) + } +} diff --git a/internal/prt/warnings.go b/internal/prt/warnings.go new file mode 100644 index 000000000..0afb70414 --- /dev/null +++ b/internal/prt/warnings.go @@ -0,0 +1,31 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +func (s *Service) warnUnsupportedDispute(app *Application, epochIndex uint64, tournament common.Address) { + if _, warned := s.disputeWarnings[tournament]; warned { + return + } + s.disputeWarnings[tournament] = struct{}{} + s.Logger.Warn("This node release sends no dispute moves; the commitment can lose by timeout", + "application", app.Name, "epoch_index", epochIndex, "tournament", tournament) +} + +func (s *Service) warnZeroStagingPeriod(app *Application) { + if app.ClaimStagingPeriod != 0 { + return + } + if _, warned := s.zeroStagingWarnings[app.ID]; warned { + return + } + s.zeroStagingWarnings[app.ID] = struct{}{} + s.Logger.Warn("Application has no claim staging delay; without sentries, a staged result can be accepted immediately", + "application", app.Name, "address", app.IApplicationAddress, + "claim_staging_period", app.ClaimStagingPeriod) +} diff --git a/internal/prt/warnings_test.go b/internal/prt/warnings_test.go new file mode 100644 index 000000000..434251d23 --- /dev/null +++ b/internal/prt/warnings_test.go @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "bytes" + "log/slog" + "math/big" + "strings" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTournamentObservationWarnsOnlyForActiveDisputes(t *testing.T) { + for _, test := range []struct { + name string + state TournamentStandingState + warn bool + }{ + {name: "active matches", state: TournamentStandingMatchesActive, warn: true}, + {name: "uncontested wait", state: TournamentStandingAwaitingClosure}, + } { + for _, update := range []bool{false, true} { + operation := projectionCreateCase + if update { + operation = projectionUpdateCase + } + t.Run(test.name+"/"+operation, func(t *testing.T) { + s, repo := newPRTServiceMock() + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + app := prtRevertTestApp() + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + tournament := &Tournament{Address: *epoch.TournamentAddress, MaxLevel: 2, Height: 48, Kind: TournamentKindNonLeaf} + adapter := &tournamentAdapterMock{} + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Height: 48, Kind: TournamentKindNonLeaf, + }, nil).Twice() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: test.state}, nil).Twice() + for range 2 { + expectTournamentAuxiliaryReads(adapter, mock.Anything, RootLevel, test.state) + if update { + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, RootLevel, adapter, tournament, 20)) + } else { + adapter.On("BondValue", mock.Anything).Return(big.NewInt(1), nil).Once() + _, err := s.readTournament(t.Context(), app, epoch, RootLevel, nil, nil, tournament.Address, adapter, 2, 20) + require.NoError(t, err) + } + } + wantWarnings := 0 + if test.warn { + wantWarnings = 1 + require.Contains(t, output.String(), "sends no dispute moves") + require.Contains(t, output.String(), "commitment can lose by timeout") + require.Contains(t, output.String(), "application="+app.Name) + require.Contains(t, output.String(), "epoch_index=3") + require.Contains(t, output.String(), tournament.Address.Hex()) + } + require.Equal(t, wantWarnings, strings.Count(output.String(), "level=WARN")) + require.Equal(t, ApplicationStatus_OK, app.Status) + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + }) + } + } +} + +func TestStoredInnerTournamentWarningsFollowCurrentStanding(t *testing.T) { + for _, test := range []struct { + name string + state TournamentStandingState + finish uint64 + wantWarnings int + }{ + {name: "active after restart", state: TournamentStandingMatchesActive, wantWarnings: 2}, + {name: "finished history", state: TournamentStandingInnerEliminableWinnerExpired, finish: 10}, + } { + t.Run(test.name, func(t *testing.T) { + app := prtRevertTestApp() + epoch := resultTestEpoch(EpochStatus_ClaimComputed) + inner := &Tournament{Address: *epoch.TournamentAddress, Level: 1, MaxLevel: 2, Kind: TournamentKindLeaf} + var output bytes.Buffer + for range 2 { + s, repo := newPRTServiceMock() + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + adapter := &tournamentAdapterMock{} + for range 2 { + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{ + BaseCycle: big.NewInt(0), Level: 1, Kind: TournamentKindLeaf, + }, nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{ + State: test.state, HasCandidate: true, Candidate: *epoch.Commitment, FinishedAt: test.finish, + }, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, 1, test.state) + require.NoError(t, s.refreshTournament(t.Context(), app, epoch, 1, adapter, inner, 20)) + } + require.Empty(t, repo.Calls) + adapter.AssertExpectations(t) + } + require.Equal(t, test.wantWarnings, strings.Count(output.String(), "sends no dispute moves")) + require.Equal(t, ApplicationStatus_OK, app.Status) + }) + } +} diff --git a/internal/prt/wide_observation_test.go b/internal/prt/wide_observation_test.go new file mode 100644 index 000000000..39a08399d --- /dev/null +++ b/internal/prt/wide_observation_test.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prt + +import ( + "context" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/txsender" + "github.com/cartesi/rollups-node/pkg/contracts/itournament" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestWideAlternateTableObservationUsesShapeAcrossVerdicts(t *testing.T) { + rows := []DeploymentParameters{{Levels: 2, Log2Step: 68, Height: 24, ResponseBudget: 11, CommitmentBudget: 22, MaxAllowance: 33}, + {Levels: 2, Log2Step: 0, Height: 68, ResponseBudget: 11, CommitmentBudget: 22, MaxAllowance: 33}} + require.Equal(t, deploymentTable, classifyDeploymentGeometry(rows, 2)) + for _, state := range []deploymentState{deploymentNone, deploymentPending, deploymentTable, deploymentGeneration} { + for _, valid := range []bool{true, false} { + suffix := "inside height" + if !valid { + suffix = "outside height" + } + t.Run(string(state)+"/"+suffix, func(t *testing.T) { + f := newObserverCheckpointFixture(t) + epoch := checkpointEpoch(0, "0xa0") + epoch.Status = model.EpochStatus_Closed + f.epochs(epoch) + key := deploymentIdentity(f.app) + capture := f.s.deployments.capture(key) + snapshot := capture.snapshot + snapshot.Assessment.State = state + snapshot.Alpha6Shape = true + snapshot.ShapeFactory = common.HexToAddress("0x160") + f.s.deployments.publish(key, snapshot) + f.consensus.deploymentRead = func(_ context.Context, head txsender.BlockRef) (DeploymentEvidence, error) { + e := compatibleDeploymentEvidence(head) + e.Candidate = deploymentNone + e.Rows = rows + return e, nil + } + root := &model.Tournament{Address: *epoch.TournamentAddress, MaxLevel: 2, Log2Step: 68, Height: 24, + Kind: model.TournamentKindNonLeaf, + Snapshot: model.TournamentSnapshot{AsOfBlock: 50, FinishedAtBlock: 30, + Standing: model.TournamentStandingRootFailed, + BondRecovery: model.TournamentBondRecovery{Disposition: model.BondDispositionNoWinner}}} + child := &model.Tournament{ApplicationID: f.app.ID, EpochIndex: epoch.Index, Address: common.HexToAddress("0xa1"), Level: 1, + MaxLevel: 2, Height: 68, + Kind: model.TournamentKindLeaf, StartInstant: 10, CreationEvent: &model.TournamentCreationEvent{BlockNumber: 10}, + ParentTournamentAddress: &root.Address, ParentMatchIDHash: new(common.HexToHash("0x33")), + ContestedFinalStateOne: new(common.Hash{}), ContestedFinalStateTwo: new(common.HexToHash("0x22")), + Snapshot: model.TournamentSnapshot{AsOfBlock: 50}} + f.repo.storedTournaments = []*model.Tournament{root, child} + f.consensus.On("TournamentLevelCount", mock.Anything).Return(uint64(2), nil).Once() + adapter := &tournamentAdapterMock{} + f.factory.On("CreateTournamentAdapter", child.Address).Return(adapter, nil).Once() + adapter.On("Descriptor", mock.Anything).Return(TournamentDescriptor{BaseCycle: big.NewInt(0), Level: 1, Height: 68, + Kind: model.TournamentKindLeaf, StartInstant: 10}, nil).Once() + adapter.On("Standing", mock.Anything).Return(TournamentStanding{State: model.TournamentStandingMatchesActive}, nil).Once() + expectTournamentAuxiliaryReads(adapter, mock.Anything, 1, model.TournamentStandingMatchesActive) + one, two := common.HexToHash("0x11"), common.HexToHash("0x22") + position := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 65), big.NewInt(7)) + if !valid { + position = new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 68), big.NewInt(7)) + } + events := &TournamentEvents{ + LeafMatchSealed: []*itournament.ITournamentLeafMatchSealed{{MatchIdHash: *child.ParentMatchIDHash, + One: one, Two: two, + DivergencePosition: position, FinalStateTwo: two, DeadlineOne: 71, DeadlineTwo: 83, EliminableAt: 83, + Raw: types.Log{Address: child.Address, BlockNumber: 60, TxHash: common.HexToHash("0x44"), Index: 1}}}, + PartialBondRefund: []*itournament.ITournamentPartialBondRefund{{Value: big.NewInt(0)}}} + adapter.On("RetrieveAllEvents", mock.Anything).Return(events, nil).Once() + counts := zeroStructuralEventCounts() + counts.CommitmentJoined.SetUint64(2) + counts.MatchCreated.SetUint64(1) + counts.LeafMatchSealed.SetUint64(1) + adapter.On("StructuralEventCounts", mock.Anything).Return(counts, nil).Once() + f.repo.eventCounts = map[common.Address]repository.TournamentEventCounts{child.Address: {CommitmentJoined: 2, + MatchCreated: 1, + StandingChanged: 2}} + address := child.Address.Hex() + f.repo.On("ListCommitments", mock.Anything, f.app.Name, repository.CommitmentFilter{EpochIndex: &epoch.Index, + TournamentAddress: &address}, repository.Pagination{}, false).Return([]*model.Commitment{}, uint64(0), nil).Once() + f.repo.On("ListMatches", mock.Anything, f.app.Name, repository.MatchFilter{EpochIndex: &epoch.Index, + TournamentAddress: &address}, repository.Pagination{}, false).Return([]*model.Match{{IDHash: *child.ParentMatchIDHash, + CommitmentOne: one, CommitmentTwo: two}}, uint64(1), nil).Once() + if valid { + adapter.On("MatchSnapshot", mock.Anything, [32]byte(one), + [32]byte(two)).Return(ObservedMatchSnapshot{Phase: model.MatchPhaseSealed, + Sealed: &SealedMatch{DivergencePosition: position, + DivergenceCycle: big.NewInt(0)}}, nil).Once() + f.repo.On("StoreTournamentInterval", mock.Anything, f.app.ID, mock.Anything, + uint64(100)).Run(func(args mock.Arguments) { + batch := args.Get(2).([]*repository.TournamentEventBatch)[0] + require.Zero(t, position.Cmp(batch.Matches[0].LeafSeal.DivergencePosition.ToBig())) + require.Equal(t, uint64(71), batch.Matches[0].LeafSeal.DeadlineOne) + }).Return(nil).Once() + } + expectProtectionReads(t, f.factory, f.consensus, observerFacts(100), epoch) + err := f.s.observeApplication(t.Context(), f.app, observerFacts(100)) + if valid { + require.NoError(t, err) + require.Equal(t, uint64(100), f.app.LastTournamentCheckBlock) + } else { + require.ErrorContains(t, err, "divergence position") + require.Equal(t, uint64(50), f.app.LastTournamentCheckBlock) + f.repo.AssertNotCalled(t, "StoreTournamentInterval", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + } + require.Equal(t, model.ApplicationStatus_OK, f.app.Status) + adapter.AssertExpectations(t) + }) + } + } +} diff --git a/internal/prtprotection/protection.go b/internal/prtprotection/protection.go new file mode 100644 index 000000000..572f15591 --- /dev/null +++ b/internal/prtprotection/protection.go @@ -0,0 +1,119 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package prtprotection reports the remaining opportunity to protect sealed epochs. +package prtprotection + +import ( + "context" + "errors" + "log/slog" + "math" + "slices" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +// Repository supplies sealed roots and their stored clock metadata. +type Repository interface { + ListEpochs(context.Context, string, repository.EpochFilter, repository.Pagination, bool) ([]*model.Epoch, uint64, error) + GetTournament(context.Context, string, string) (*model.Tournament, error) +} + +// Clock retains the identity and provenance of one protection deadline. +// A nil ClosingBlock means that numerical clock evidence is unavailable. +type Clock struct { + EpochIndex uint64 + Root common.Address + ClosingBlock *uint64 + Source string + Err error +} + +// ClosingBlock derives the exclusive end of the root's join window. +func ClosingBlock(start, allowance uint64) (uint64, error) { + if allowance > math.MaxUint64-start { + return 0, errors.New("root protection deadline overflows uint64") + } + return start + allowance, nil +} + +// StoredClocks reads each open obligation without discarding siblings on error. +// Stored metadata does not establish current chain canonicality or freshness. +func StoredClocks(ctx context.Context, repo Repository, app *model.Application, firstEpoch uint64) ([]Clock, error) { + statuses := model.NonTerminalEpochStatuses() + epochs, _, err := repo.ListEpochs(ctx, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: statuses, + }, repository.Pagination{}, false) + if err != nil { + return nil, err + } + clocks := make([]Clock, 0, len(epochs)) + for _, epoch := range epochs { + if epoch.Index < firstEpoch || epoch.TournamentAddress == nil || epoch.ClaimTransactionHash != nil || + !slices.Contains(statuses, epoch.Status) { + continue + } + clock := Clock{EpochIndex: epoch.Index, Root: *epoch.TournamentAddress, Source: "stored root descriptor"} + root, readErr := repo.GetTournament(ctx, app.IApplicationAddress.Hex(), clock.Root.Hex()) + switch { + case readErr != nil: + clock.Err = readErr + case root == nil: + clock.Err = errors.New("root descriptor is not stored") + case epoch.ApplicationID != app.ID || root.ApplicationID != app.ID || root.EpochIndex != epoch.Index || + root.Address != clock.Root || root.Level != 0 || root.StartInstant != epoch.LastBlock: + clock.Err = errors.New("stored root descriptor does not match the sealed epoch") + default: + closeBlock, addErr := ClosingBlock(root.StartInstant, root.Allowance) + clock.Err = addErr + if addErr == nil { + clock.ClosingBlock = new(closeBlock) + } + } + clocks = append(clocks, clock) + } + return clocks, nil +} + +// ReportClock reports an automatic stop for one identified sealed root. +func ReportClock(logger *slog.Logger, app *model.Application, clock Clock, cause error) { + attrs := []any{"application", app.Name, "address", app.IApplicationAddress, "epoch_index", clock.EpochIndex, + "root_tournament", clock.Root, "cause", cause, "clock_source", clock.Source, "clock_available", clock.ClosingBlock != nil} + if clock.ClosingBlock != nil { + attrs = append(attrs, "join_close_block", *clock.ClosingBlock) + } + if clock.Err != nil { + attrs = append(attrs, "clock_error", clock.Err) + } + logger.Error("PRT protection stopped", attrs...) +} + +// ReportUnavailable reports unavailable discovery without inventing root identity. +func ReportUnavailable(logger *slog.Logger, app *model.Application, cause error) { + logger.Error("PRT protection deadline discovery unavailable", "application", app.Name, "address", app.IApplicationAddress, + "cause", cause, "clock_available", false, "clock_source", "unavailable") +} + +// ReportStop reports the affected open obligations without changing service state. +func ReportStop(ctx context.Context, logger *slog.Logger, repo Repository, app *model.Application, firstEpoch uint64, cause error) { + if !app.IsDaveConsensus() { + return + } + clocks, err := StoredClocks(ctx, repo, app, firstEpoch) + if err != nil { + ReportUnavailable(logger, app, errors.Join(cause, err)) + } + for _, clock := range clocks { + ReportClock(logger, app, clock, cause) + } +} + +// ReportGlobalUnavailable reports a service stop without delaying it for discovery. +func ReportGlobalUnavailable(logger *slog.Logger, cause error) { + logger.Error("PRT global protection deadline inventory unavailable", + "affected_applications", "all enabled PRT applications", "cause", cause, + "clock_available", false, "clock_source", "unavailable") +} diff --git a/internal/prtprotection/protection_test.go b/internal/prtprotection/protection_test.go new file mode 100644 index 000000000..6c685f68a --- /dev/null +++ b/internal/prtprotection/protection_test.go @@ -0,0 +1,183 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package prtprotection + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "math" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type clockRepository struct { + epochs []*model.Epoch + roots map[common.Address]*model.Tournament + readErrors map[common.Address]error + discoveryError error + reads []common.Address + filter repository.EpochFilter + calls int +} + +func (r *clockRepository) ListEpochs( + _ context.Context, _ string, f repository.EpochFilter, _ repository.Pagination, _ bool, +) ([]*model.Epoch, uint64, error) { + r.calls++ + r.filter = f + return r.epochs, uint64(len(r.epochs)), r.discoveryError +} +func (r *clockRepository) GetTournament(_ context.Context, _ string, address string) (*model.Tournament, error) { + root := common.HexToAddress(address) + r.reads = append(r.reads, root) + return r.roots[root], r.readErrors[root] +} +func clockFixture() (*clockRepository, *model.Application) { + app := &model.Application{ID: 4, Name: "clock-app", + IApplicationAddress: common.HexToAddress("0x123"), ConsensusType: model.Consensus_PRT} + repo := &clockRepository{roots: make(map[common.Address]*model.Tournament), readErrors: make(map[common.Address]error)} + for index := uint64(0); index < 3; index++ { + address := common.BigToAddress(new(big.Int).SetUint64(index + 1)) + epoch := &model.Epoch{ApplicationID: app.ID, Index: index, + LastBlock: 100 + index, TournamentAddress: new(address), Status: model.EpochStatus_Closed} + repo.epochs = append(repo.epochs, epoch) + repo.roots[address] = &model.Tournament{ApplicationID: app.ID, EpochIndex: index, Address: address, + StartInstant: epoch.LastBlock, Allowance: 20 + index} + } + return repo, app +} + +func TestClosingBlock(t *testing.T) { + for _, tc := range []struct { + start, allowance, want uint64 + overflow bool + }{ + {100, 20, 120, false}, {0, 0, 0, false}, {math.MaxUint64, 0, math.MaxUint64, false}, {1, math.MaxUint64, 0, true}, + } { + got, err := ClosingBlock(tc.start, tc.allowance) + if tc.overflow { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, got) + } + } +} + +func TestStoredClocksRetainEveryRootAndError(t *testing.T) { + repo, app := clockFixture() + failure := errors.New("root read unavailable") + repo.readErrors[*repo.epochs[0].TournamentAddress] = failure + clocks, err := StoredClocks(t.Context(), repo, app, 0) + require.NoError(t, err) + require.Len(t, clocks, 3) + require.Len(t, repo.reads, 3) + require.ErrorIs(t, clocks[0].Err, failure) + require.Nil(t, clocks[0].ClosingBlock) + require.Equal(t, uint64(122), *clocks[1].ClosingBlock) + require.Equal(t, uint64(124), *clocks[2].ClosingBlock) + require.Equal(t, model.NonTerminalEpochStatuses(), repo.filter.Status) + require.NotNil(t, repo.filter.HasTournament) + require.True(t, *repo.filter.HasTournament) +} + +func TestStoredClocksExcludeValidPrefixAndRetiredRoots(t *testing.T) { + repo, app := clockFixture() + repo.epochs[1].ClaimTransactionHash = new(common.HexToHash("0xabc")) + clocks, err := StoredClocks(t.Context(), repo, app, 1) + require.NoError(t, err) + require.Len(t, clocks, 1) + require.Equal(t, uint64(2), clocks[0].EpochIndex) + require.Equal(t, []common.Address{*repo.epochs[2].TournamentAddress}, repo.reads) + repo.epochs[2].Status = model.EpochStatus_ClaimAccepted + clocks, err = StoredClocks(t.Context(), repo, app, 1) + require.NoError(t, err) + require.Empty(t, clocks) +} + +func TestStoredClockUnavailablePreservesIdentity(t *testing.T) { + for _, mutate := range []struct { + name string + change func(*model.Tournament) + }{ + {"missing", nil}, + {"application", func(root *model.Tournament) { root.ApplicationID++ }}, + {"epoch", func(root *model.Tournament) { root.EpochIndex++ }}, + {"address", func(root *model.Tournament) { root.Address = common.Address{} }}, + {"child", func(root *model.Tournament) { root.Level = 1 }}, + {"start", func(root *model.Tournament) { root.StartInstant++ }}, + {"overflow", func(root *model.Tournament) { root.Allowance = math.MaxUint64 }}, + } { + t.Run(mutate.name, func(t *testing.T) { + repo, app := clockFixture() + address := *repo.epochs[0].TournamentAddress + if mutate.change == nil { + delete(repo.roots, address) + } else { + mutate.change(repo.roots[address]) + } + clocks, err := StoredClocks(t.Context(), repo, app, 0) + require.NoError(t, err) + require.Equal(t, address, clocks[0].Root) + require.Equal(t, uint64(0), clocks[0].EpochIndex) + require.Nil(t, clocks[0].ClosingBlock) + require.Error(t, clocks[0].Err) + require.NotNil(t, clocks[1].ClosingBlock) + }) + } +} + +func TestProtectionStopLogsClockOrExplicitUnavailable(t *testing.T) { + repo, app := clockFixture() + repo.readErrors[*repo.epochs[0].TournamentAddress] = errors.New("RPC unavailable") + var output bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&output, nil)) + ReportStop(t.Context(), logger, repo, app, 1, errors.New("committed execution stop")) + decoder := json.NewDecoder(&output) + for _, wantEpoch := range []uint64{1, 2} { + var record map[string]any + require.NoError(t, decoder.Decode(&record)) + require.Equal(t, "ERROR", record["level"]) + require.Equal(t, "PRT protection stopped", record["msg"]) + require.Equal(t, float64(wantEpoch), record["epoch_index"]) + require.Equal(t, repo.epochs[wantEpoch].TournamentAddress.Hex(), record["root_tournament"]) + require.Equal(t, true, record["clock_available"]) + require.Equal(t, "committed execution stop", record["cause"]) + require.Equal(t, float64(120+wantEpoch*2), record["join_close_block"]) + } + output.Reset() + ReportStop(t.Context(), logger, repo, app, 0, errors.New("local failure")) + var unavailable map[string]any + require.NoError(t, json.NewDecoder(&output).Decode(&unavailable)) + require.Equal(t, false, unavailable["clock_available"]) + require.Equal(t, float64(0), unavailable["epoch_index"]) + require.NotContains(t, unavailable, "join_close_block") + require.Equal(t, "RPC unavailable", unavailable["clock_error"]) + output.Reset() + repo.discoveryError = errors.New("database unavailable") + ReportStop(t.Context(), logger, repo, app, 0, errors.New("local failure")) + var discovery map[string]any + require.NoError(t, json.Unmarshal(bytes.TrimSpace(output.Bytes()), &discovery)) + require.NotContains(t, discovery, "epoch_index") + require.NotContains(t, discovery, "root_tournament") + require.Equal(t, false, discovery["clock_available"]) +} + +func TestOtherConsensusDoesNotReadProtectionMetadata(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + repo, app := clockFixture() + app.ConsensusType = consensus + ReportStop(t.Context(), slog.Default(), repo, app, 0, errors.New("failure")) + require.Zero(t, repo.calls) + require.Empty(t, repo.reads) + } +} diff --git a/internal/replay/compare.go b/internal/replay/compare.go new file mode 100644 index 000000000..6a8f42baf --- /dev/null +++ b/internal/replay/compare.go @@ -0,0 +1,237 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package replay + +import ( + "bytes" + "crypto/sha256" + "fmt" + "math" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +type contradiction func(field string, expected, actual any) error + +func newContradiction( + application string, + epochIndex *uint64, + inputIndex uint64, + field string, + expected, actual any, +) *ContradictionError { + if application == "" { + application = "" + } + return &ContradictionError{ + Application: application, + EpochIndex: epochIndex, + InputIndex: inputIndex, + Field: field, + Expected: fmt.Sprint(expected), + Actual: fmt.Sprint(actual), + } +} + +func knownEpochIndex(epochIndex uint64) *uint64 { return &epochIndex } + +func compareRecord( + application string, + applicationID int64, + isPRT bool, + verification repository.ReplayVerificationLevel, + record *model.ReplayRecord, + actual *model.AdvanceResult, +) error { + if record == nil { + return newContradiction(application, nil, 0, "record", "persisted replay record", "nil") + } + contradiction := func(field string, expected, got any) error { + return newContradiction( + application, + knownEpochIndex(record.Input.EpochIndex), + record.Input.InputIndex, + field, + expected, + got, + ) + } + if actual == nil { + return contradiction("result", "completed result", "nil") + } + if record.Input.ApplicationID != applicationID { + return contradiction("application_id", applicationID, record.Input.ApplicationID) + } + if actual.EpochIndex != record.Input.EpochIndex { + return contradiction("epoch_index", record.Input.EpochIndex, actual.EpochIndex) + } + if actual.InputIndex != record.Input.InputIndex { + return contradiction("input_index", record.Input.InputIndex, actual.InputIndex) + } + if actual.Status != record.Input.Status { + return contradiction("status", record.Input.Status, actual.Status) + } + if !record.Input.Status.IsCompleted() { + return contradiction("status", "completed status", record.Input.Status) + } + if err := compareExceptionData(record.Input, actual, contradiction); err != nil { + return err + } + if record.Input.MachineHash == nil { + return contradiction("machine_hash", "persisted hash", "missing") + } + if actual.MachineHash != *record.Input.MachineHash { + return contradiction("machine_hash", record.Input.MachineHash.Hex(), actual.MachineHash.Hex()) + } + if record.Input.TxBufferDataBlock == nil { + return contradiction("tx_buffer_data_block", "persisted hash", "missing") + } + if actual.TxBufferDataBlock != *record.Input.TxBufferDataBlock { + return contradiction("tx_buffer_data_block", record.Input.TxBufferDataBlock.Hex(), actual.TxBufferDataBlock.Hex()) + } + if verification == repository.ReplayVerificationCanonical { + return nil + } + + if record.Input.Status == model.InputCompletionStatus_Accepted { + if err := compareBytes("outputs", record.Outputs, actual.Outputs, contradiction); err != nil { + return err + } + } else { + // Outputs from nonaccepted executions must not be persisted. + if len(record.Outputs) != 0 { + return contradiction("outputs.count", 0, len(record.Outputs)) + } + } + if err := compareBytes("reports", record.Reports, actual.Reports, contradiction); err != nil { + return err + } + + if !isPRT { + if len(record.StateHashes) != 0 { + return contradiction("state_hashes.count", 0, len(record.StateHashes)) + } + if len(actual.PeriodicStateHashes) != 0 || actual.PaddingRepetitions != 0 { + return contradiction( + "replay_hash_collection", + "none", + fmt.Sprintf("hashes=%d padding=%d", len(actual.PeriodicStateHashes), actual.PaddingRepetitions), + ) + } + return nil + } + return compareHashCollection(record, actual, contradiction) +} + +func compareExceptionData( + input model.ReplayInput, + actual *model.AdvanceResult, + contradiction contradiction, +) error { + if input.Status == model.InputCompletionStatus_Exception { + if input.ExceptionData == nil { + return contradiction("exception_data", "persisted payload", "missing") + } + if actual.ExceptionData == nil { + return contradiction("exception_data", compactBytes(input.ExceptionData), "missing") + } + if !bytes.Equal(input.ExceptionData, actual.ExceptionData) { + return contradiction( + "exception_data", + compactBytes(input.ExceptionData), + compactBytes(actual.ExceptionData), + ) + } + return nil + } + if input.ExceptionData != nil { + return contradiction("exception_data", "none", compactBytes(input.ExceptionData)) + } + if actual.ExceptionData != nil { + return contradiction("exception_data", "none", compactBytes(actual.ExceptionData)) + } + return nil +} + +func compareBytes(field string, expected, actual [][]byte, contradiction contradiction) error { + if len(expected) != len(actual) { + return contradiction(field+".count", len(expected), len(actual)) + } + for i := range expected { + if !bytes.Equal(expected[i], actual[i]) { + return contradiction( + fmt.Sprintf("%s[%d]", field, i), + compactBytes(expected[i]), + compactBytes(actual[i]), + ) + } + } + return nil +} + +func compactBytes(data []byte) string { + digest := sha256.Sum256(data) + return fmt.Sprintf("len=%d sha256=%x", len(data), digest[:8]) +} + +func compareHashCollection( + record *model.ReplayRecord, + actual *model.AdvanceResult, + contradiction contradiction, +) error { + if err := model.ValidateInputHashCollectionSpan(uint64(len(actual.PeriodicStateHashes)), actual.PaddingRepetitions); err != nil { + return contradiction( + "state_hashes.span", + model.InputHashCollectionCapacity, + fmt.Sprintf("hashes=%d padding=%d", len(actual.PeriodicStateHashes), actual.PaddingRepetitions), + ) + } + actualCount := len(actual.PeriodicStateHashes) + if actual.PaddingRepetitions > 0 { + actualCount++ + } + if len(record.StateHashes) != actualCount { + return contradiction("state_hashes.count", len(record.StateHashes), actualCount) + } + for i := 1; i < len(record.StateHashes); i++ { + previousIndex := record.StateHashes[i-1].Index + if previousIndex == math.MaxUint64 { + return contradiction("state_hashes.index", "index after uint64 maximum", record.StateHashes[i].Index) + } + if record.StateHashes[i].Index != previousIndex+1 { + return contradiction("state_hashes.index", previousIndex+1, record.StateHashes[i].Index) + } + } + for i, hash := range actual.PeriodicStateHashes { + row := record.StateHashes[i] + if row.Repetitions != 1 { + return contradiction(fmt.Sprintf("state_hashes[%d].repetitions", i), 1, row.Repetitions) + } + if row.MachineHash != common.Hash(hash) { + return contradiction( + fmt.Sprintf("state_hashes[%d].machine_hash", i), + row.MachineHash.Hex(), + common.Hash(hash).Hex(), + ) + } + } + if actual.PaddingRepetitions == 0 { + return nil + } + final := record.StateHashes[len(record.StateHashes)-1] + persistedFinal := model.FinalStateEntry(*record.Input.MachineHash) + if final.MachineHash != persistedFinal { + return contradiction("state_hashes.final.machine_hash", persistedFinal.Hex(), final.MachineHash.Hex()) + } + replayedFinal := model.FinalStateEntry(actual.MachineHash) + if final.MachineHash != replayedFinal { + return contradiction("state_hashes.final.replay_hash", final.MachineHash.Hex(), replayedFinal.Hex()) + } + if final.Repetitions != actual.PaddingRepetitions { + return contradiction("state_hashes.final.repetitions", final.Repetitions, actual.PaddingRepetitions) + } + return nil +} diff --git a/internal/replay/compare_test.go b/internal/replay/compare_test.go new file mode 100644 index 000000000..763174882 --- /dev/null +++ b/internal/replay/compare_test.go @@ -0,0 +1,390 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package replay + +import ( + "errors" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func replayFixture(status model.InputCompletionStatus, consensus model.Consensus) ( + *model.Application, + *model.ReplayRecord, + *model.AdvanceResult, +) { + machineHash := common.HexToHash("0x11") + txBufferDataBlock := common.HexToHash("0x22") + app := &model.Application{ID: 7, Name: "replay-app", ConsensusType: consensus} + record := &model.ReplayRecord{ + Input: model.ReplayInput{ + ApplicationID: app.ID, + EpochIndex: 3, + InputIndex: 9, + RawData: []byte("input"), + Status: status, + MachineHash: &machineHash, + TxBufferDataBlock: &txBufferDataBlock, + }, + } + actual := &model.AdvanceResult{ + EpochIndex: 3, + InputIndex: 9, + Status: status, + StateProof: model.StateProof{ + MachineHash: machineHash, + TxBufferDataBlock: txBufferDataBlock, + }, + } + if status == model.InputCompletionStatus_Exception { + record.Input.ExceptionData = []byte("guest exception") + actual.ExceptionData = []byte("guest exception") + } + if status == model.InputCompletionStatus_Accepted { + record.Outputs = [][]byte{[]byte("output-a"), []byte("output-b")} + record.Reports = [][]byte{[]byte("report-a"), []byte("report-b")} + actual.Outputs = [][]byte{[]byte("output-a"), []byte("output-b")} + actual.Reports = [][]byte{[]byte("report-a"), []byte("report-b")} + } + return app, record, actual +} + +func TestCompareRecordCanonicalIgnoresFullEvidence(t *testing.T) { + app, record, actual := replayFixture( + model.InputCompletionStatus_Accepted, + model.Consensus_PRT, + ) + record.Outputs = [][]byte{[]byte("persisted-output")} + record.Reports = [][]byte{[]byte("persisted-report")} + record.StateHashes = []model.ReplayStateHash{{MachineHash: *record.Input.MachineHash, Repetitions: 1}} + actual.Outputs = [][]byte{[]byte("different-output")} + actual.Reports = [][]byte{[]byte("different-report")} + actual.PeriodicStateHashes = [][32]byte{{1}} + actual.PaddingRepetitions = model.InputHashCollectionCapacity - 1 + + require.NoError(t, compareRecord( + app.Name, + app.ID, + app.IsDaveConsensus(), + repository.ReplayVerificationCanonical, + record, + actual, + )) + + actual.MachineHash[0]++ + require.ErrorIs(t, compareRecord( + app.Name, + app.ID, + app.IsDaveConsensus(), + repository.ReplayVerificationCanonical, + record, + actual, + ), ErrContradiction) +} + +func TestCompareReplayRecordExceptionData(t *testing.T) { + t.Parallel() + + t.Run("payload mismatch", func(t *testing.T) { + app, record, actual := replayFixture( + model.InputCompletionStatus_Exception, + model.Consensus_Authority, + ) + actual.ExceptionData = []byte("different guest exception") + err := compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "exception_data", detail.Field) + require.NotContains(t, err.Error(), "guest exception") + }) + + for _, test := range []struct { + name string + status model.InputCompletionStatus + mutate func(*model.ReplayRecord, *model.AdvanceResult) + }{ + {"missing persisted exception payload", model.InputCompletionStatus_Exception, func(r *model.ReplayRecord, _ *model.AdvanceResult) { + r.Input.ExceptionData = nil + }}, + {"missing replayed exception payload", model.InputCompletionStatus_Exception, func(_ *model.ReplayRecord, a *model.AdvanceResult) { + a.ExceptionData = nil + }}, + {"unexpected persisted payload", model.InputCompletionStatus_Rejected, func(r *model.ReplayRecord, _ *model.AdvanceResult) { + r.Input.ExceptionData = []byte("unexpected") + }}, + {"unexpected replayed payload", model.InputCompletionStatus_Rejected, func(_ *model.ReplayRecord, a *model.AdvanceResult) { + a.ExceptionData = []byte("unexpected") + }}, + } { + t.Run(test.name, func(t *testing.T) { + app, record, actual := replayFixture(test.status, model.Consensus_Authority) + test.mutate(record, actual) + require.ErrorIs(t, + compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), + ErrContradiction, + ) + }) + } +} + +func TestCompareReplayRecordCompletionMatrix(t *testing.T) { + t.Parallel() + + statuses := []model.InputCompletionStatus{ + model.InputCompletionStatus_Accepted, + model.InputCompletionStatus_Rejected, + model.InputCompletionStatus_Exception, + model.InputCompletionStatus_MachineHalted, + model.InputCompletionStatus_Overflow, + model.InputCompletionStatus_UnexpectedYield, + model.InputCompletionStatus_InvalidOutputsRoot, + } + consensuses := []model.Consensus{ + model.Consensus_Authority, + model.Consensus_Quorum, + model.Consensus_PRT, + } + for _, consensus := range consensuses { + for _, status := range statuses { + t.Run(consensus.String()+"/"+status.String(), func(t *testing.T) { + app, record, actual := replayFixture(status, consensus) + if status != model.InputCompletionStatus_Accepted { + actual.Outputs = [][]byte{[]byte("noncanonical diagnostic output")} + record.Reports = [][]byte{[]byte("diagnostic report")} + actual.Reports = [][]byte{[]byte("diagnostic report")} + } + if consensus == model.Consensus_PRT { + checkpoint := common.HexToHash("0x33") + actual.IsDaveConsensus = true + actual.PeriodicStateHashes = [][32]byte{checkpoint} + actual.PaddingRepetitions = model.InputHashCollectionCapacity - 1 + record.StateHashes = []model.ReplayStateHash{ + {Index: 20, MachineHash: checkpoint, Repetitions: 1}, + {Index: 21, MachineHash: model.FinalStateEntry(actual.MachineHash), + Repetitions: model.InputHashCollectionCapacity - 1}, + } + } else { + require.False(t, actual.IsDaveConsensus) + require.Empty(t, actual.PeriodicStateHashes) + require.Zero(t, actual.PaddingRepetitions) + require.Empty(t, record.StateHashes) + } + + require.NoError(t, + compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), + ) + mismatchStatus := model.InputCompletionStatus_Accepted + if status == model.InputCompletionStatus_Accepted { + mismatchStatus = model.InputCompletionStatus_Rejected + } + actual.Status = mismatchStatus + require.ErrorIs(t, + compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), + ErrContradiction, + ) + }) + } + } +} + +func TestCompareReplayRecordAcceptedMutationTable(t *testing.T) { + t.Parallel() + + mutations := []struct { + name string + mutate func(*model.Application, *model.ReplayRecord, *model.AdvanceResult) + }{ + {"application", func(_ *model.Application, r *model.ReplayRecord, _ *model.AdvanceResult) { + r.Input.ApplicationID++ + }}, + {"status", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.Status = model.InputCompletionStatus_Rejected + }}, + {"machine-root", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { a.MachineHash[0]++ }}, + {"tx-buffer-data-block", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { a.TxBufferDataBlock[0]++ }}, + {"outputs-count", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.Outputs = a.Outputs[:1] + }}, + {"outputs-content", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.Outputs[0] = []byte("changed") + }}, + {"outputs-order", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.Outputs[0], a.Outputs[1] = a.Outputs[1], a.Outputs[0] + }}, + {"reports-count", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.Reports = a.Reports[:1] + }}, + {"reports-content", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.Reports[0] = []byte("changed") + }}, + {"reports-order", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.Reports[0], a.Reports[1] = a.Reports[1], a.Reports[0] + }}, + {"unexpected-hash-collection", func(_ *model.Application, _ *model.ReplayRecord, a *model.AdvanceResult) { + a.PeriodicStateHashes = append(a.PeriodicStateHashes, common.HexToHash("0x33")) + }}, + } + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Accepted, model.Consensus_Authority) + mutation.mutate(app, record, actual) + err := compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual) + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.NotEmpty(t, detail.Field) + require.NotNil(t, detail.EpochIndex) + require.Equal(t, uint64(3), *detail.EpochIndex) + require.Contains(t, err.Error(), "epoch=3") + require.NotContains(t, err.Error(), "output-a") + }) + } + +} + +func TestCompareReplayRecordPersistedRecordValidation(t *testing.T) { + t.Parallel() + + t.Run("missing machine root", func(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) + record.Input.MachineHash = nil + require.ErrorIs(t, + compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), + ErrContradiction, + ) + }) + t.Run("missing TX buffer data block", func(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) + record.Input.TxBufferDataBlock = nil + require.ErrorIs(t, + compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), + ErrContradiction, + ) + }) + t.Run("unknown persisted status", func(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) + record.Input.Status = model.InputCompletionStatus("UNKNOWN") + require.ErrorIs(t, + compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), + ErrContradiction, + ) + }) + t.Run("nonaccepted persisted output", func(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) + record.Outputs = [][]byte{[]byte("illegal")} + actual.Outputs = [][]byte{[]byte("diagnostic")} + record.Reports = [][]byte{[]byte("diagnostic")} + actual.Reports = [][]byte{[]byte("diagnostic")} + err := compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual) + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "outputs.count", detail.Field) + }) + t.Run("nonaccepted replay outputs ignored", func(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Exception, model.Consensus_Authority) + actual.Outputs = [][]byte{[]byte("diagnostic")} + record.Reports = [][]byte{[]byte("diagnostic")} + actual.Reports = [][]byte{[]byte("diagnostic")} + require.NoError(t, compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual)) + }) +} + +func TestCompareReplayRecordPRTMutationTable(t *testing.T) { + t.Parallel() + + fixture := func() (*model.Application, *model.ReplayRecord, *model.AdvanceResult) { + app, record, actual := replayFixture(model.InputCompletionStatus_Accepted, model.Consensus_PRT) + hash0 := common.HexToHash("0x31") + hash1 := common.HexToHash("0x32") + actual.IsDaveConsensus = true + actual.PeriodicStateHashes = [][32]byte{hash0, hash1} + actual.PaddingRepetitions = model.InputHashCollectionCapacity - 2 + record.StateHashes = []model.ReplayStateHash{ + {Index: 40, MachineHash: hash0, Repetitions: 1}, + {Index: 41, MachineHash: hash1, Repetitions: 1}, + {Index: 42, MachineHash: model.FinalStateEntry(actual.MachineHash), Repetitions: model.InputHashCollectionCapacity - 2}, + } + return app, record, actual + } + + app, record, actual := fixture() + require.NoError(t, compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual)) + + mutations := []struct { + name string + mutate func(*model.ReplayRecord, *model.AdvanceResult) + }{ + {"count", func(r *model.ReplayRecord, _ *model.AdvanceResult) { r.StateHashes = r.StateHashes[:2] }}, + {"hash", func(r *model.ReplayRecord, _ *model.AdvanceResult) { r.StateHashes[0].MachineHash[0]++ }}, + {"order", func(r *model.ReplayRecord, _ *model.AdvanceResult) { + r.StateHashes[0], r.StateHashes[1] = r.StateHashes[1], r.StateHashes[0] + }}, + {"index-order", func(r *model.ReplayRecord, _ *model.AdvanceResult) { r.StateHashes[1].Index++ }}, + {"repetition", func(r *model.ReplayRecord, _ *model.AdvanceResult) { r.StateHashes[0].Repetitions++ }}, + {"final-row", func(r *model.ReplayRecord, _ *model.AdvanceResult) { r.StateHashes[2].MachineHash[0]++ }}, + {"padding", func(r *model.ReplayRecord, _ *model.AdvanceResult) { r.StateHashes[2].Repetitions++ }}, + {"result-padding", func(_ *model.ReplayRecord, a *model.AdvanceResult) { a.PaddingRepetitions++ }}, + } + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + app, record, actual := fixture() + mutation.mutate(record, actual) + require.ErrorIs(t, + compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), + ErrContradiction, + ) + }) + } + + t.Run("diagnostics keep persisted expected and replay actual", func(t *testing.T) { + app, record, actual := fixture() + actual.PaddingRepetitions = model.InputHashCollectionCapacity - 1 + err := compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "state_hashes.span", detail.Field) + require.Equal(t, fmt.Sprint(model.InputHashCollectionCapacity), detail.Expected) + require.Equal(t, fmt.Sprintf("hashes=2 padding=%d", model.InputHashCollectionCapacity-1), detail.Actual) + }) +} + +func TestContradictionErrorIdentity(t *testing.T) { + t.Parallel() + t.Run("unknown epoch", func(t *testing.T) { + err := &ContradictionError{Application: "app", Field: "status"} + require.True(t, errors.Is(err, ErrContradiction)) + require.Contains(t, err.Error(), "epoch=") + }) + t.Run("known nonzero epoch", func(t *testing.T) { + epochIndex := uint64(17) + err := &ContradictionError{ + Application: "app", + EpochIndex: &epochIndex, + Field: "status", + } + require.Contains(t, err.Error(), "epoch=17") + require.NotContains(t, err.Error(), "epoch=") + }) +} + +func TestCompareReplayCollectionRejectsConsistentWrongSpan(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Accepted, model.Consensus_PRT) + sample := common.HexToHash("0x55") + actual.PeriodicStateHashes = [][32]byte{sample} + actual.PaddingRepetitions = model.InputHashCollectionCapacity + record.StateHashes = []model.ReplayStateHash{ + {Index: 1, MachineHash: sample, Repetitions: 1}, + {Index: 2, MachineHash: model.FinalStateEntry(actual.MachineHash), Repetitions: actual.PaddingRepetitions}, + } + err := compareRecord(app.Name, app.ID, true, repository.ReplayVerificationFull, record, actual) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "state_hashes.span", detail.Field) +} diff --git a/internal/replay/report_test.go b/internal/replay/report_test.go new file mode 100644 index 000000000..d8949b692 --- /dev/null +++ b/internal/replay/report_test.go @@ -0,0 +1,96 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package replay + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func TestCompareRecordNonacceptedReports(t *testing.T) { + t.Parallel() + for _, status := range model.InputCompletionStatusAllValues { + if status == model.InputCompletionStatus_Accepted || !status.IsCompleted() { + continue + } + t.Run(status.String(), func(t *testing.T) { + for _, test := range []struct { + name string + reports [][]byte + field string + }{ + {"matching", [][]byte{[]byte("first"), []byte("second")}, ""}, + {"payload", [][]byte{[]byte("changed"), []byte("second")}, "reports[0]"}, + {"count", [][]byte{[]byte("first")}, "reports.count"}, + {"order", [][]byte{[]byte("second"), []byte("first")}, "reports[0]"}, + } { + t.Run(test.name, func(t *testing.T) { + app, record, actual := replayFixture(status, model.Consensus_Authority) + record.Reports = [][]byte{[]byte("first"), []byte("second")} + actual.Reports = test.reports + err := compareRecord(app.Name, app.ID, false, repository.ReplayVerificationFull, record, actual) + if test.field == "" { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, test.field, detail.Field) + }) + } + }) + } +} + +func TestCompareRecordLegacyReports(t *testing.T) { + t.Parallel() + app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) + actual.Reports = [][]byte{[]byte("historically discarded")} + require.NoError(t, compareRecord(app.Name, app.ID, false, repository.ReplayVerificationCanonical, record, actual)) + var detail *ContradictionError + err := compareRecord(app.Name, app.ID, false, repository.ReplayVerificationFull, record, actual) + require.ErrorAs(t, err, &detail) + require.Equal(t, "reports.count", detail.Field) + require.Equal(t, "0", detail.Expected) + require.Equal(t, "1", detail.Actual) +} + +func TestRunLegacyReports(t *testing.T) { + t.Parallel() + for _, level := range []repository.ReplayVerificationLevel{ + repository.ReplayVerificationCanonical, repository.ReplayVerificationFull, + } { + t.Run(level.String(), func(t *testing.T) { + record := replayRecords(1)[0] + record.Input.Status = model.InputCompletionStatus_Rejected + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 1, Consensus: model.Consensus_Authority}, + records: []*model.ReplayRecord{record}, + } + executor := &fakeExecutor{ + statuses: map[uint64]model.InputCompletionStatus{0: model.InputCompletionStatus_Rejected}, + reports: map[uint64][][]byte{0: {[]byte("historically discarded")}}, + } + opts := replayOptions(model.Consensus_Authority, 0, 1) + opts.Verification = level + result, err := Run(context.Background(), source, executor, opts) + if level == repository.ReplayVerificationCanonical { + require.NoError(t, err) + require.Equal(t, uint64(1), result.ReplayedInputs) + } else { + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "reports.count", detail.Field) + } + require.Nil(t, record.Reports, "replay must leave the source record unchanged") + }) + } +} diff --git a/internal/replay/run.go b/internal/replay/run.go new file mode 100644 index 000000000..a14031783 --- /dev/null +++ b/internal/replay/run.go @@ -0,0 +1,263 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package replay + +import ( + "context" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +// Executor is the machine behavior required by Run. Machine lifecycle and +// persistence remain the caller's responsibility. +type Executor interface { + ProcessedInputs() uint64 + Advance( + ctx context.Context, + input []byte, + epochIndex uint64, + inputIndex uint64, + computeHashes bool, + ) (*model.AdvanceResult, error) +} + +// Options selects an absolute, application-local, half-open replay range. +type Options struct { + Application *model.Application + FromInput uint64 + ToInputExclusive uint64 + BatchSize uint64 + Verification repository.ReplayVerificationLevel +} + +// Result summarizes a successfully verified replay. +type Result struct { + ReplayedInputs uint64 +} + +// Run reconstructs and verifies a machine over the requested input range. It +// never closes or stores executor; callers discard it after any error. +func Run( + ctx context.Context, + source repository.ReplayRepository, + executor Executor, + opts Options, +) (Result, error) { + if err := validateOptions(source, executor, opts); err != nil { + return Result{}, err + } + applicationLabel := opts.Application.Name + if applicationLabel == "" { + applicationLabel = opts.Application.IApplicationAddress.String() + } + summary, err := source.ReplaySummary( + ctx, + opts.Application.IApplicationAddress, + opts.Verification, + ) + if err != nil { + return Result{}, classifySourceError(applicationLabel, err) + } + if err := verifySummary(applicationLabel, opts.Application, summary, opts.FromInput); err != nil { + return Result{}, err + } + + isPRT := opts.Application.IsDaveConsensus() + computeHashes := opts.Verification == repository.ReplayVerificationFull && isPRT + contradiction := func(inputIndex uint64, field string, expected, actual any) error { + return newContradiction(applicationLabel, nil, inputIndex, field, expected, actual) + } + var replayed uint64 + for opts.FromInput+replayed < opts.ToInputExclusive { + from := opts.FromInput + replayed + remaining := opts.ToInputExclusive - from + limit := min(opts.BatchSize, remaining) + records, err := source.ReplayPage(ctx, repository.ReplayPageRequest{ + ApplicationID: summary.ApplicationID, + FromInput: from, + ToInputExclusive: opts.ToInputExclusive, + Limit: limit, + Verification: opts.Verification, + }) + if err != nil { + return Result{}, classifySourceError(applicationLabel, err) + } + if err := validatePage(applicationLabel, records, from, limit, remaining); err != nil { + return Result{}, err + } + for _, record := range records { + input := &record.Input + actual, err := executor.Advance( + ctx, + input.RawData, + input.EpochIndex, + input.InputIndex, + computeHashes, + ) + if err != nil { + return Result{}, fmt.Errorf( + "%w: input %d: %w", + ErrExecution, + input.InputIndex, + err, + ) + } + if err := compareRecord( + applicationLabel, + opts.Application.ID, + isPRT, + opts.Verification, + record, + actual, + ); err != nil { + return Result{}, err + } + if input.Status.IsTerminal() && input.InputIndex != summary.ProcessedInputs-1 { + return Result{}, newContradiction( + applicationLabel, + knownEpochIndex(input.EpochIndex), + input.InputIndex, + "terminal_status.position", + summary.ProcessedInputs-1, + input.InputIndex, + ) + } + replayed++ + if expected := input.InputIndex + 1; executor.ProcessedInputs() != expected { + return Result{}, contradiction( + input.InputIndex, + "executor.processed_inputs", + expected, + executor.ProcessedInputs(), + ) + } + } + } + return Result{ReplayedInputs: replayed}, nil +} + +func verifySummary( + application string, + expected *model.Application, + actual model.ReplaySummary, + firstInput uint64, +) error { + contradiction := func(field string, expected, actual any) error { + return newContradiction(application, nil, firstInput, field, expected, actual) + } + if actual.ApplicationID != expected.ID { + return contradiction("application_id", expected.ID, actual.ApplicationID) + } + if actual.Consensus != expected.ConsensusType { + return contradiction("consensus", expected.ConsensusType, actual.Consensus) + } + if actual.ProcessedInputs != expected.ProcessedInputs { + return contradiction( + "processed_inputs.count", + expected.ProcessedInputs, + actual.ProcessedInputs, + ) + } + return nil +} + +func validateOptions(source repository.ReplayRepository, executor Executor, opts Options) error { + switch { + case source == nil: + return fmt.Errorf("%w: replay source is nil", ErrInvalidOptions) + case executor == nil: + return fmt.Errorf("%w: replay executor is nil", ErrInvalidOptions) + case opts.Application == nil: + return fmt.Errorf("%w: application is nil", ErrInvalidOptions) + case !opts.Verification.IsValid(): + return fmt.Errorf("%w: unsupported verification level %d", ErrInvalidOptions, opts.Verification) + case opts.BatchSize == 0: + return fmt.Errorf("%w: replay batch size must be greater than zero", ErrInvalidOptions) + case opts.FromInput > opts.ToInputExclusive: + return fmt.Errorf( + "%w: lower bound %d exceeds upper bound %d", + ErrInvalidOptions, + opts.FromInput, + opts.ToInputExclusive, + ) + case opts.ToInputExclusive > opts.Application.ProcessedInputs: + return fmt.Errorf( + "%w: upper bound %d exceeds application processed input count %d", + ErrInvalidOptions, + opts.ToInputExclusive, + opts.Application.ProcessedInputs, + ) + case executor.ProcessedInputs() != opts.FromInput: + return fmt.Errorf( + "%w: executor has %d processed inputs; replay starts at %d", + ErrInvalidOptions, + executor.ProcessedInputs(), + opts.FromInput, + ) + default: + return nil + } +} + +func validatePage( + application string, + records []*model.ReplayRecord, + firstInput uint64, + limit uint64, + remaining uint64, +) error { + contradiction := func(inputIndex uint64, field string, expected, actual any) error { + return newContradiction(application, nil, inputIndex, field, expected, actual) + } + if uint64(len(records)) > limit { + return contradiction(firstInput, "replay_page.count", limit, len(records)) + } + if len(records) == 0 { + return contradiction(firstInput, "replay_sequence.remaining_records", remaining, 0) + } + for offset, record := range records { + expectedInput := firstInput + uint64(offset) + if record == nil { + return contradiction(expectedInput, "replay_record", "record", "nil") + } + if record.Input.InputIndex != expectedInput { + return contradiction( + expectedInput, + "input_index.sequence", + expectedInput, + record.Input.InputIndex, + ) + } + } + return nil +} + +func classifySourceError(application string, err error) error { + var structure *repository.ReplayStructureViolationError + if errors.As(err, &structure) { + return newContradiction( + application, + structure.EpochIndex, + structure.InputIndex, + "source."+structure.Kind.String(), + "valid persisted replay structure", + structure.Error(), + ) + } + var inconsistency *repository.ReplayInconsistentEvidenceError + if errors.As(err, &inconsistency) { + return newContradiction( + application, + nil, + inconsistency.InputIndex, + "source."+inconsistency.Kind.String()+".completed_input", + "completed replay input in page", + "not present", + ) + } + return fmt.Errorf("%w: source: %w", ErrExecution, err) +} diff --git a/internal/replay/run_test.go b/internal/replay/run_test.go new file mode 100644 index 000000000..c70ac6efc --- /dev/null +++ b/internal/replay/run_test.go @@ -0,0 +1,398 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package replay + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type fakeSource struct { + summary model.ReplaySummary + summaryErr error + records []*model.ReplayRecord + pageErr error + pageRequests []repository.ReplayPageRequest + summaryLevels []repository.ReplayVerificationLevel + summaryApps []common.Address + pageOverride func(repository.ReplayPageRequest) ([]*model.ReplayRecord, error) +} + +func (source *fakeSource) ReplaySummary( + _ context.Context, + applicationAddress common.Address, + verification repository.ReplayVerificationLevel, +) (model.ReplaySummary, error) { + source.summaryApps = append(source.summaryApps, applicationAddress) + source.summaryLevels = append(source.summaryLevels, verification) + return source.summary, source.summaryErr +} + +func (source *fakeSource) ReplayPage( + _ context.Context, + request repository.ReplayPageRequest, +) ([]*model.ReplayRecord, error) { + source.pageRequests = append(source.pageRequests, request) + if source.pageOverride != nil { + return source.pageOverride(request) + } + if source.pageErr != nil { + return nil, source.pageErr + } + page := make([]*model.ReplayRecord, 0, request.Limit) + for _, record := range source.records { + if record == nil || record.Input.InputIndex >= request.FromInput && + record.Input.InputIndex < request.ToInputExclusive { + page = append(page, record) + if uint64(len(page)) == request.Limit { + break + } + } + } + return page, nil +} + +type fakeExecutor struct { + processed uint64 + advanceErr error + advanceCalls []model.ReplayInput + computeHashes []bool + wrongResultPos bool + fullPRTResult bool + statuses map[uint64]model.InputCompletionStatus + reports map[uint64][][]byte +} + +func (executor *fakeExecutor) ProcessedInputs() uint64 { return executor.processed } + +func (executor *fakeExecutor) Advance( + _ context.Context, + data []byte, + epochIndex uint64, + inputIndex uint64, + computeHashes bool, +) (*model.AdvanceResult, error) { + if executor.advanceErr != nil { + return nil, executor.advanceErr + } + executor.advanceCalls = append(executor.advanceCalls, model.ReplayInput{ + EpochIndex: epochIndex, + InputIndex: inputIndex, + RawData: data, + }) + executor.computeHashes = append(executor.computeHashes, computeHashes) + executor.processed++ + resultIndex := inputIndex + if executor.wrongResultPos { + resultIndex++ + } + result := &model.AdvanceResult{ + EpochIndex: epochIndex, + InputIndex: resultIndex, + Status: model.InputCompletionStatus_Accepted, + Reports: executor.reports[inputIndex], + StateProof: model.StateProof{ + MachineHash: common.BigToHash(newBig(inputIndex + 1)), + TxBufferDataBlock: common.BigToHash(newBig(inputIndex + 100)), + }, + } + if status, ok := executor.statuses[inputIndex]; ok { + result.Status = status + } + if executor.fullPRTResult { + result.PaddingRepetitions = 1 << 24 + } + return result, nil +} + +func newBig(value uint64) *big.Int { return new(big.Int).SetUint64(value) } + +func replayRecords(count uint64) []*model.ReplayRecord { + records := make([]*model.ReplayRecord, count) + for index := range count { + machineHash := common.BigToHash(newBig(index + 1)) + txBufferDataBlock := common.BigToHash(newBig(index + 100)) + records[index] = &model.ReplayRecord{Input: model.ReplayInput{ + ApplicationID: 7, + EpochIndex: index / 2, + InputIndex: index, + RawData: []byte{byte(index)}, + Status: model.InputCompletionStatus_Accepted, + MachineHash: &machineHash, + TxBufferDataBlock: &txBufferDataBlock, + }} + } + return records +} + +func replayOptions(consensus model.Consensus, from, to uint64) Options { + return Options{ + Application: &model.Application{ + ID: 7, + Name: "app", + IApplicationAddress: common.HexToAddress("0x1234"), + ConsensusType: consensus, + ProcessedInputs: to, + }, + FromInput: from, + ToInputExclusive: to, + BatchSize: 2, + Verification: repository.ReplayVerificationCanonical, + } +} + +func TestRunCanonicalRangeAndPagination(t *testing.T) { + records := replayRecords(5) + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 5, Consensus: model.Consensus_PRT}, + records: records, + } + executor := &fakeExecutor{processed: 2} + opts := replayOptions(model.Consensus_PRT, 2, 5) + + result, err := Run(context.Background(), source, executor, opts) + require.NoError(t, err) + require.Equal(t, uint64(3), result.ReplayedInputs) + require.Equal(t, uint64(5), executor.ProcessedInputs()) + require.Equal(t, []bool{false, false, false}, executor.computeHashes) + require.Equal(t, []common.Address{opts.Application.IApplicationAddress}, source.summaryApps) + require.Equal(t, []repository.ReplayPageRequest{ + {ApplicationID: 7, FromInput: 2, ToInputExclusive: 5, Limit: 2, Verification: repository.ReplayVerificationCanonical}, + {ApplicationID: 7, FromInput: 4, ToInputExclusive: 5, Limit: 1, Verification: repository.ReplayVerificationCanonical}, + }, source.pageRequests) +} + +func TestRunSelectsReplaySummaryByTypedAddress(t *testing.T) { + source := &fakeSource{ + summary: model.ReplaySummary{ + ApplicationID: 7, ProcessedInputs: 0, Consensus: model.Consensus_Authority, + }, + } + opts := replayOptions(model.Consensus_Authority, 0, 0) + // A valid application name may itself look like another application's + // address. Replay identity must never be inferred from that string. + opts.Application.Name = common.HexToAddress("0x9999").String() + + _, err := Run(context.Background(), source, &fakeExecutor{}, opts) + require.NoError(t, err) + require.Equal(t, []common.Address{opts.Application.IApplicationAddress}, source.summaryApps) +} + +func TestRunFullPRTComputesHashes(t *testing.T) { + record := replayRecords(1)[0] + record.StateHashes = []model.ReplayStateHash{{ + MachineHash: model.FinalStateEntry(*record.Input.MachineHash), Repetitions: model.InputHashCollectionCapacity, + }} + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 1, Consensus: model.Consensus_PRT}, + records: []*model.ReplayRecord{record}, + } + executor := &fakeExecutor{fullPRTResult: true} + opts := replayOptions(model.Consensus_PRT, 0, 1) + opts.Verification = repository.ReplayVerificationFull + + _, err := Run(context.Background(), source, executor, opts) + require.NoError(t, err) + require.Equal(t, []bool{true}, executor.computeHashes) +} + +func TestRunCaughtUpStillValidatesSummary(t *testing.T) { + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 2, Consensus: model.Consensus_Authority}, + } + executor := &fakeExecutor{processed: 2} + opts := replayOptions(model.Consensus_Authority, 2, 2) + + result, err := Run(context.Background(), source, executor, opts) + require.NoError(t, err) + require.Zero(t, result.ReplayedInputs) + require.Equal(t, []repository.ReplayVerificationLevel{repository.ReplayVerificationCanonical}, source.summaryLevels) + require.Empty(t, source.pageRequests) +} + +func TestRunRejectsCompletedInputAfterTerminalStatus(t *testing.T) { + records := replayRecords(2) + records[0].Input.Status = model.InputCompletionStatus_MachineHalted + source := &fakeSource{ + summary: model.ReplaySummary{ + ApplicationID: 7, ProcessedInputs: 2, Consensus: model.Consensus_Authority, + }, + records: records, + } + executor := &fakeExecutor{statuses: map[uint64]model.InputCompletionStatus{ + 0: model.InputCompletionStatus_MachineHalted, + }} + + _, err := Run( + context.Background(), source, executor, + replayOptions(model.Consensus_Authority, 0, 2), + ) + require.ErrorIs(t, err, ErrContradiction) + var contradiction *ContradictionError + require.ErrorAs(t, err, &contradiction) + require.Equal(t, "terminal_status.position", contradiction.Field) + require.Equal(t, "1", contradiction.Expected) + require.Equal(t, "0", contradiction.Actual) + require.Len(t, executor.advanceCalls, 1) +} + +func TestRunAcceptsTerminalStatusAtEndOfReplay(t *testing.T) { + records := replayRecords(2) + records[1].Input.Status = model.InputCompletionStatus_UnexpectedYield + source := &fakeSource{ + summary: model.ReplaySummary{ + ApplicationID: 7, ProcessedInputs: 2, Consensus: model.Consensus_Authority, + }, + records: records, + } + executor := &fakeExecutor{statuses: map[uint64]model.InputCompletionStatus{ + 1: model.InputCompletionStatus_UnexpectedYield, + }} + + result, err := Run( + context.Background(), source, executor, + replayOptions(model.Consensus_Authority, 0, 2), + ) + + require.NoError(t, err) + require.Equal(t, uint64(2), result.ReplayedInputs) + require.Equal(t, uint64(2), executor.ProcessedInputs()) +} + +func TestRunInvalidOutputsRoot(t *testing.T) { + for _, storedStatus := range []model.InputCompletionStatus{ + model.InputCompletionStatus_InvalidOutputsRoot, + model.InputCompletionStatus_Accepted, + } { + t.Run(storedStatus.String(), func(t *testing.T) { + record := replayRecords(1)[0] + record.Input.Status = storedStatus + record.StateHashes = []model.ReplayStateHash{{ + MachineHash: model.FinalStateEntry(*record.Input.MachineHash), + Repetitions: model.InputHashCollectionCapacity, + }} + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 1, Consensus: model.Consensus_PRT}, + records: []*model.ReplayRecord{record}, + } + executor := &fakeExecutor{ + fullPRTResult: true, + statuses: map[uint64]model.InputCompletionStatus{ + 0: model.InputCompletionStatus_InvalidOutputsRoot, + }, + } + opts := replayOptions(model.Consensus_PRT, 0, 1) + opts.Verification = repository.ReplayVerificationFull + result, err := Run(context.Background(), source, executor, opts) + if storedStatus == model.InputCompletionStatus_InvalidOutputsRoot { + require.NoError(t, err) + require.Equal(t, uint64(1), result.ReplayedInputs) + } else { + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "status", detail.Field) + } + require.Equal(t, storedStatus, record.Input.Status, "replay must not rewrite stored completion") + require.Equal(t, []bool{true}, executor.computeHashes) + }) + } +} + +func TestRunRejectsMalformedPagesBeforeExecution(t *testing.T) { + tests := []struct { + name string + records []*model.ReplayRecord + }{ + {name: "empty", records: nil}, + {name: "nil record", records: []*model.ReplayRecord{nil}}, + {name: "gap", records: replayRecords(2)[1:]}, + {name: "oversized", records: replayRecords(3)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 2, Consensus: model.Consensus_Authority}, + records: test.records, + } + if test.name == "oversized" { + source.pageOverride = func(repository.ReplayPageRequest) ([]*model.ReplayRecord, error) { + return test.records, nil + } + } + executor := &fakeExecutor{} + opts := replayOptions(model.Consensus_Authority, 0, 2) + + _, err := Run(context.Background(), source, executor, opts) + require.ErrorIs(t, err, ErrContradiction) + require.Empty(t, executor.advanceCalls) + }) + } +} + +func TestRunErrorClassification(t *testing.T) { + opts := replayOptions(model.Consensus_Authority, 0, 1) + t.Run("source transport", func(t *testing.T) { + sourceErr := errors.New("database unavailable") + source := &fakeSource{summaryErr: sourceErr} + _, err := Run(context.Background(), source, &fakeExecutor{}, opts) + require.ErrorIs(t, err, ErrExecution) + require.ErrorIs(t, err, sourceErr) + require.NotErrorIs(t, err, ErrContradiction) + }) + t.Run("source structure", func(t *testing.T) { + source := &fakeSource{summaryErr: &repository.ReplayStructureViolationError{ + Kind: repository.ReplayStructureCompletedInputSequence, + InputIndex: 0, + }} + _, err := Run(context.Background(), source, &fakeExecutor{}, opts) + require.ErrorIs(t, err, ErrContradiction) + }) + t.Run("executor", func(t *testing.T) { + executionErr := errors.New("emulator unavailable") + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 1, Consensus: model.Consensus_Authority}, + records: replayRecords(1), + } + _, err := Run(context.Background(), source, &fakeExecutor{advanceErr: executionErr}, opts) + require.ErrorIs(t, err, ErrExecution) + require.ErrorIs(t, err, executionErr) + }) +} + +func TestRunRejectsInvalidOptions(t *testing.T) { + source := &fakeSource{} + executor := &fakeExecutor{} + opts := replayOptions(model.Consensus_Authority, 0, 1) + tests := []struct { + name string + source repository.ReplayRepository + exec Executor + mutate func(*Options) + }{ + {"nil source", nil, executor, func(*Options) {}}, + {"nil executor", source, nil, func(*Options) {}}, + {"nil app", source, executor, func(o *Options) { o.Application = nil }}, + {"invalid level", source, executor, func(o *Options) { o.Verification = 255 }}, + {"zero batch", source, executor, func(o *Options) { o.BatchSize = 0 }}, + {"inverted range", source, executor, func(o *Options) { o.FromInput = 2 }}, + {"upper beyond app", source, executor, func(o *Options) { o.ToInputExclusive = 2 }}, + {"executor position", source, &fakeExecutor{processed: 1}, func(*Options) {}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + current := opts + test.mutate(¤t) + _, err := Run(context.Background(), test.source, test.exec, current) + require.ErrorIs(t, err, ErrInvalidOptions) + }) + } +} diff --git a/internal/replay/types.go b/internal/replay/types.go new file mode 100644 index 000000000..716672da2 --- /dev/null +++ b/internal/replay/types.go @@ -0,0 +1,55 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package replay reconstructs and verifies Cartesi machines from persisted +// canonical input outcomes. Repository evidence access is defined by +// repository.ReplayRepository; machine lifecycle remains caller responsibility. +package replay + +import ( + "errors" + "fmt" +) + +var ( + // ErrContradiction identifies a deterministic difference between a + // persisted result and its replay. + ErrContradiction = errors.New("replay contradicts persisted canonical result") + + // ErrExecution identifies a failure to reconstruct the requested range. + ErrExecution = errors.New("failed to replay machine execution") + + // ErrInvalidOptions identifies an invalid replay request. + ErrInvalidOptions = errors.New("invalid replay options") +) + +// ContradictionError identifies the first deterministic difference between a +// persisted result and its replay. Byte values are represented by lengths and +// digests, so the diagnostic never exposes input, output, or report payloads. +type ContradictionError struct { + Application string + EpochIndex *uint64 + InputIndex uint64 + Field string + Expected string + Actual string +} + +func (e *ContradictionError) Error() string { + epochIndex := "" + if e.EpochIndex != nil { + epochIndex = fmt.Sprint(*e.EpochIndex) + } + return fmt.Sprintf( + "%v: app=%q epoch=%s input=%d field=%s expected=%s actual=%s", + ErrContradiction, + e.Application, + epochIndex, + e.InputIndex, + e.Field, + e.Expected, + e.Actual, + ) +} + +func (e *ContradictionError) Unwrap() error { return ErrContradiction } diff --git a/internal/repository/claim_page.go b/internal/repository/claim_page.go new file mode 100644 index 000000000..a3074a4f4 --- /dev/null +++ b/internal/repository/claim_page.go @@ -0,0 +1,45 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import "github.com/cartesi/rollups-node/internal/model" + +type ClaimWork struct { + Application *model.Application + Epoch *model.Epoch +} + +// ClaimPage is one application's oldest row and its resumed later-row window. +type ClaimPage struct { + Work []ClaimWork + ApplicationID int64 + NextEpoch *uint64 +} + +// ClaimCursor rotates application IDs and resumes epochs independently within +// each application. Epoch numbers from different applications are never compared. +// Observer and publisher keep separate cursors for each stored claim status. +type ClaimCursor struct { + AfterApplicationID int64 + Epochs map[int64]uint64 + // PublishableOnly limits publisher pagination to eligible epoch prefixes. + // Observers leave it false to reconcile every enabled status. + PublishableOnly bool +} + +func (c *ClaimCursor) Advance(work ClaimWork, page ClaimPage) { + c.AfterApplicationID = work.Application.ID + if c.Epochs == nil { + c.Epochs = make(map[int64]uint64) + } + if len(page.Work) == 0 { + return + } + if work.Epoch.Index > page.Work[0].Epoch.Index { + c.Epochs[work.Application.ID] = work.Epoch.Index + } + if page.NextEpoch == nil && work.Epoch.Index == page.Work[len(page.Work)-1].Epoch.Index { + delete(c.Epochs, work.Application.ID) + } +} diff --git a/internal/repository/claim_page_test.go b/internal/repository/claim_page_test.go new file mode 100644 index 000000000..927662f1a --- /dev/null +++ b/internal/repository/claim_page_test.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" + "testing" +) + +func TestClaimCursorKeepsEachApplicationsEpochPosition(t *testing.T) { + first := &model.Application{ID: 1} + second := &model.Application{ID: 2} + page := ClaimPage{ApplicationID: 1, NextEpoch: new(uint64(900)), Work: []ClaimWork{ + {Application: first, Epoch: &model.Epoch{Index: 300}}, {Application: first, Epoch: &model.Epoch{Index: 900}}, + }} + var cursor ClaimCursor + cursor.Advance(page.Work[0], page) + require.NotContains(t, cursor.Epochs, int64(1), "inspecting only the oldest does not skip the uninspected tail") + cursor.Advance(page.Work[1], page) + require.Equal(t, uint64(900), cursor.Epochs[1]) + other := ClaimPage{ApplicationID: 2, NextEpoch: new(uint64(4)), Work: []ClaimWork{ + {Application: second, Epoch: &model.Epoch{Index: 1}}, {Application: second, Epoch: &model.Epoch{Index: 4}}, + }} + for _, work := range other.Work { + cursor.Advance(work, other) + } + require.Equal(t, uint64(900), cursor.Epochs[1]) + require.Equal(t, uint64(4), cursor.Epochs[2]) + require.Equal(t, int64(2), cursor.AfterApplicationID) + other.NextEpoch = nil + cursor.Advance(other.Work[1], other) + require.NotContains(t, cursor.Epochs, int64(2), "the next app visit restarts its finite tail") + require.Equal(t, uint64(900), cursor.Epochs[1]) +} diff --git a/internal/repository/claim_proof.go b/internal/repository/claim_proof.go new file mode 100644 index 000000000..3a4918d7f --- /dev/null +++ b/internal/repository/claim_proof.go @@ -0,0 +1,30 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" +) + +// OutputProof identifies one output and its cumulative-tree proof. +// The writer obtains the application ID from the claim's epoch. +type OutputProof struct { + Index uint64 + Hash common.Hash + Siblings []common.Hash +} + +// ClaimProofWriter uses the claim transaction's connection. +// It is valid only during write and is not safe for concurrent use. +// A scan callback must not call the writer while its rows are open. +type ClaimProofWriter interface { + ScanOutputs(ctx context.Context, first, last uint64, fn OutputScanFunc) error + StoreOutputProofs(ctx context.Context, proofs []OutputProof) error +} + +// ClaimProofWriteFunc writes proofs before the claim transaction commits. +// An error rolls back the claim and all proof batches. +type ClaimProofWriteFunc func(context.Context, ClaimProofWriter) error diff --git a/internal/repository/delivery.go b/internal/repository/delivery.go new file mode 100644 index 000000000..2e3f1392e --- /dev/null +++ b/internal/repository/delivery.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +var ( + ErrObserverNotRegistered = errors.New("contributing observer is not registered") + ErrSubmitterProvenance = errors.New("submission provenance is not usable") + ErrTournamentIntervalStale = errors.New("tournament interval cursor changed") +) + +// TournamentEventCounts counts committed structural event facts through a block. +// Mutable snapshots do not change these cumulative counts. +type TournamentEventCounts struct { + CommitmentJoined uint64 + MatchCreated uint64 + MatchAdvanced uint64 + LeafMatchSealed uint64 + MatchDeleted uint64 + NewInnerTournament uint64 + StandingChanged uint64 +} + +type DeliveryRepository interface { + RegisterSubmitter(ctx context.Context, key string, requested config.PersistentSubmitterConfig) ( + config.PersistentSubmitterConfig, error, + ) + RegisterObserver(ctx context.Context, key string, requested config.PersistentChainConfig) ( + config.PersistentChainConfig, error, + ) + CheckSubmitterProvenance(ctx context.Context, key string) error + // StoreTournamentInterval publishes one primary tournament interval and only + // its new descendants, in parent order, at the primary snapshot's endpoint. + // A nil expectedAsOf requires a new root; otherwise the primary cursor must + // equal the expectation. Existing children advance independently. + StoreTournamentInterval(ctx context.Context, applicationID int64, expectedAsOf *uint64, batches []*TournamentEventBatch) error + GetTournamentEventCounts( + ctx context.Context, applicationID int64, epochIndex uint64, tournament common.Address, throughBlock uint64, + ) (TournamentEventCounts, error) + ListApplicationsByID( + ctx context.Context, filter ApplicationFilter, afterID, throughID int64, limit uint64, + ) ([]*model.Application, error) + GetApplicationIDBoundary(ctx context.Context, filter ApplicationFilter) (int64, error) + ListRecoverableRootBonds( + ctx context.Context, applicationID int64, claimer common.Address, observedBlock uint64, + afterEpoch *uint64, throughEpoch, limit uint64, + ) ([]RootBondRecoveryCandidate, error) +} diff --git a/internal/repository/deployment.go b/internal/repository/deployment.go new file mode 100644 index 000000000..393baf708 --- /dev/null +++ b/internal/repository/deployment.go @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +// DeploymentIdentity binds an assessment to the application configuration it read. +type DeploymentIdentity struct { + ApplicationID int64 + ApplicationAddress common.Address + ConsensusAddress common.Address +} + +type DeploymentAssessment struct { + Identity DeploymentIdentity + ResetGeneration int64 + ConfirmedTier *model.DeploymentTier // Nil clears a saved restriction after fresh compatible recovery. + Reason string +} + +// DeploymentCompletionState contains one fresh, closed, read-only RR snapshot. +// Missing control state never establishes compatible deployment admission. +type DeploymentCompletionState struct { + Completion *EpochCompletionState + Deployment *model.DeploymentAdmissionState +} + +// DeploymentDrainRequest identifies an epoch selected by a fresh generation assessment. +// The writer independently checks the durable generation and restriction tier. +type DeploymentDrainRequest struct { + Identity DeploymentIdentity + EpochIndex uint64 + ResetGeneration int64 +} + +type DeploymentRepository interface { + GetDeploymentAdmissionState(context.Context, DeploymentIdentity) (*model.DeploymentAdmissionState, error) + // BeginDeploymentStartup requires an existing control row and returns only its committed increment. + BeginDeploymentStartup(context.Context, DeploymentIdentity, int64) (*model.DeploymentAdmissionState, error) + // AcknowledgeDeploymentAssessment stores the nullable tier and returns the previous row after commit. + AcknowledgeDeploymentAssessment(context.Context, DeploymentAssessment) (*model.DeploymentAdmissionState, error) + GetDeploymentCompletionState(context.Context, DeploymentIdentity, uint64) (*DeploymentCompletionState, error) + ForecloseDeploymentEpoch(context.Context, DeploymentDrainRequest) error +} diff --git a/internal/repository/epoch_completion_state.go b/internal/repository/epoch_completion_state.go new file mode 100644 index 000000000..cbfd946b4 --- /dev/null +++ b/internal/repository/epoch_completion_state.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "fmt" + "github.com/cartesi/rollups-node/internal/model" +) + +// EpochCompletionState is a coherent database view used to decide whether an +// epoch can complete. A present application with a missing epoch is inconclusive, +// rather than evidence that its retained transaction is obsolete. +type EpochCompletionState struct { + Application *model.Application + Epoch *model.Epoch + Problem *model.ApplicationProblem +} + +// AllowsEpochCompletion checks status and the typed boundary. Callers retain +// their existing enable, foreclosure, validation and protocol checks. +func (s *EpochCompletionState) AllowsEpochCompletion() (bool, error) { + if s == nil || s.Application == nil || s.Epoch == nil { + return false, fmt.Errorf("application epoch source is unavailable") + } + if s.Epoch.ApplicationID != s.Application.ID { + return false, fmt.Errorf("epoch %d does not belong to application %d", s.Epoch.Index, s.Application.ID) + } + status := s.Application.Status + stopped := status.IsExecutionTerminal() || status == model.ApplicationStatus_InvalidOutputsRoot + if status != model.ApplicationStatus_OK && !stopped { + return false, nil + } + if s.Problem == nil { + if stopped { + return false, fmt.Errorf("application %d has status %s without an epoch stop boundary", s.Application.ID, status) + } + return true, nil + } + p := s.Problem + if !p.Kind.IsExecutionTerminal() && p.Kind != model.ApplicationStatus_InvalidOutputsRoot || + p.Kind.IsExecutionTerminal() && p.InputIndex == nil { + return false, fmt.Errorf("application %d has an inconsistent epoch stop boundary", s.Application.ID) + } + return s.Epoch.Index < p.EpochIndex, nil +} diff --git a/internal/repository/epoch_completion_state_test.go b/internal/repository/epoch_completion_state_test.go new file mode 100644 index 000000000..a5414ab2e --- /dev/null +++ b/internal/repository/epoch_completion_state_test.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" + "testing" +) + +// Expected decisions come from the terminal-prefix requirements. The focused +// TLC model checks schedules; these cases check its database-domain boundary. +func TestEpochCompletionStateBoundary(t *testing.T) { + cut := &model.ApplicationProblem{EpochIndex: 2, Kind: model.ApplicationStatus_MachineHalted, InputIndex: new(uint64(7))} + for _, tc := range []struct { + name string + status model.ApplicationStatus + epoch uint64 + problem *model.ApplicationProblem + allowed, inconclusive bool + }{ + {name: "healthy", status: model.ApplicationStatus_OK, allowed: true}, + {name: "earlier under ET", status: model.ApplicationStatus_MachineHalted, epoch: 1, problem: cut, allowed: true}, + {name: "earlier under IOR", status: model.ApplicationStatus_InvalidOutputsRoot, epoch: 1, problem: cut, allowed: true}, + {name: "at cutoff", status: model.ApplicationStatus_MachineHalted, epoch: 2, problem: cut}, + {name: "after cutoff", status: model.ApplicationStatus_MachineHalted, epoch: 3, problem: cut}, + {name: "healthy still respects persisted cutoff", status: model.ApplicationStatus_OK, epoch: 2, problem: cut}, + {name: "FAILED remains global", status: model.ApplicationStatus_Failed, problem: cut}, + {name: "DIVERGED remains global", status: model.ApplicationStatus_Diverged, problem: cut}, + {name: "CORRUPTED remains global", status: model.ApplicationStatus_Corrupted, problem: cut}, + {name: "missing cutoff", status: model.ApplicationStatus_MachineHalted, inconclusive: true}, + {name: "invalid kind", status: model.ApplicationStatus_MachineHalted, + problem: &model.ApplicationProblem{EpochIndex: 2, Kind: model.ApplicationStatus_OK}, inconclusive: true}, + {name: "ET without input", status: model.ApplicationStatus_MachineHalted, + problem: &model.ApplicationProblem{EpochIndex: 2, Kind: model.ApplicationStatus_MachineHalted}, inconclusive: true}, + {name: "validator IOR has no input", status: model.ApplicationStatus_InvalidOutputsRoot, + problem: &model.ApplicationProblem{EpochIndex: 2, Kind: model.ApplicationStatus_InvalidOutputsRoot}, allowed: true}, + {name: "cutoff can be zero", status: model.ApplicationStatus_InvalidOutputsRoot, + problem: &model.ApplicationProblem{EpochIndex: 0, Kind: model.ApplicationStatus_InvalidOutputsRoot}}, + } { + t.Run(tc.name, func(t *testing.T) { + state := &EpochCompletionState{Application: &model.Application{ID: 1, Status: tc.status}, + Epoch: &model.Epoch{ApplicationID: 1, Index: tc.epoch}, Problem: tc.problem} + allowed, err := state.AllowsEpochCompletion() + require.Equal(t, tc.allowed, allowed) + if tc.inconclusive { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } + state := &EpochCompletionState{Application: &model.Application{ID: 1, Status: model.ApplicationStatus_OK}} + _, err := state.AllowsEpochCompletion() + require.Error(t, err, "missing epoch is inconclusive") +} diff --git a/internal/repository/factory/factory.go b/internal/repository/factory/factory.go new file mode 100644 index 000000000..0506a4501 --- /dev/null +++ b/internal/repository/factory/factory.go @@ -0,0 +1,61 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package factory + +import ( + "context" + "fmt" + "strings" + "time" + + . "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres" + // "github.com/cartesi/rollups-node/internal/repository/sqlite" +) + +// NewRepositoryFromConnectionString chooses the backend based on the connection string. +// For instance: +// - "postgres://user:pass@localhost/dbname" => Postgres +// - "sqlite://some/path.db" => SQLite +// +// Then it initializes the repo, runs migrations, and returns it. +func NewRepositoryFromConnectionString(ctx context.Context, conn string) (Repository, error) { + lowerConn := strings.ToLower(conn) + switch { + case strings.HasPrefix(lowerConn, "postgres://"), strings.HasPrefix(lowerConn, "postgresql://"): + return newPostgresRepository(ctx, conn) + // case strings.HasPrefix(lowerConn, "sqlite://"): + // return newSQLiteRepository(ctx, conn) + default: + return nil, fmt.Errorf( + "unrecognized connection string scheme (expected postgres:// or postgresql://)", + ) + } +} + +func newPostgresRepository(ctx context.Context, conn string) (Repository, error) { + pgRepo, err := postgres.NewPostgresRepository(ctx, conn, 5, 3*time.Second) // FIXME: get from config + if err != nil { + return nil, err + } + + return pgRepo, nil +} + +// func newSQLiteRepository(ctx context.Context, conn string) (Repository, error) { +// // Typically parse out the file from the "sqlite://somefile.db" connection string, +// // open database, etc. +// sqliteRepo, err := sqlite.NewSQLiteRepository(ctx, conn) +// if err != nil { +// return nil, err +// } +// +// // run migrations for SQLite, if applicable +// if err := migration.EnsureMigrationsSQLite(ctx, conn); err != nil { +// sqliteRepo.Close() +// return nil, err +// } +// +// return sqliteRepo, nil +// } diff --git a/internal/repository/node_config_test.go b/internal/repository/node_config_test.go new file mode 100644 index 000000000..6a1f2318a --- /dev/null +++ b/internal/repository/node_config_test.go @@ -0,0 +1,59 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type nodeConfigReadStub struct { + NodeConfigReader + load func(context.Context, string) ([]byte, time.Time, time.Time, error) +} + +func (r nodeConfigReadStub) LoadNodeConfigRaw(ctx context.Context, key string) ([]byte, time.Time, time.Time, error) { + return r.load(ctx, key) +} + +func TestLoadNodeConfigResult(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + raw []byte + want string + }{ + {name: "nil payload", want: "has no JSON value"}, + {name: "empty payload", raw: []byte{}, want: "unmarshal node_config value failed"}, + {name: "invalid JSON", raw: []byte(`{`), want: "unmarshal node_config value failed"}, + {name: "valid payload", raw: []byte(`{"ChainID":42}`)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + createdAt := time.Unix(1, 0) + updatedAt := createdAt.Add(time.Second) + repo := nodeConfigReadStub{load: func(ctx context.Context, key string) ([]byte, time.Time, time.Time, error) { + require.Equal(t, t.Context(), ctx) + require.Equal(t, "test-config", key) + return test.raw, createdAt, updatedAt, nil + }} + type testConfig struct{ ChainID uint64 } + got, err := LoadNodeConfig[testConfig](t.Context(), repo, "test-config") + if test.want != "" { + require.ErrorContains(t, err, test.want) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got, "a successful load must return a record") + require.Equal(t, "test-config", got.Key) + require.Equal(t, testConfig{ChainID: 42}, got.Value) + require.Equal(t, createdAt, got.CreatedAt) + require.Equal(t, updatedAt, got.UpdatedAt) + }) + } +} diff --git a/internal/repository/postgres/application.go b/internal/repository/postgres/application.go new file mode 100644 index 000000000..271b90550 --- /dev/null +++ b/internal/repository/postgres/application.go @@ -0,0 +1,1188 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +// ------------------------ ApplicationRepository Methods ------------------------ // + +func (r *PostgresRepository) CreateApplication( + ctx context.Context, + app *model.Application, + withExecutionParameters bool, +) (int64, error) { + + insertStmt := table.Application. + INSERT( + table.Application.Name, + table.Application.IapplicationAddress, + table.Application.IconsensusAddress, + table.Application.IinputboxAddress, + table.Application.TemplateHash, + table.Application.TemplateURI, + table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, + table.Application.ConsensusType, + table.Application.Enabled, + table.Application.Status, + table.Application.IinputboxBlock, + table.Application.LastEpochCheckBlock, + table.Application.LastInputCheckBlock, + table.Application.LastOutputCheckBlock, + table.Application.LastTournamentCheckBlock, + table.Application.LastForecloseCheckBlock, + table.Application.LastAccountsDriveProvedCheckBlock, + table.Application.LastWithdrawalCheckBlock, + table.Application.ProcessedInputs, + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, + ). + VALUES( + app.Name, + app.IApplicationAddress, + app.IConsensusAddress, + app.IInputBoxAddress, + app.TemplateHash, + app.TemplateURI, + app.EpochLength, + app.ClaimStagingPeriod, + app.WithdrawalConfig.Guardian, + app.WithdrawalConfig.Log2LeavesPerAccount, + app.WithdrawalConfig.Log2MaxNumOfAccounts, + app.WithdrawalConfig.AccountsDriveStartIndex, + app.WithdrawalConfig.WithdrawalOutputBuilder, + app.ConsensusType, + app.Enabled, + app.Status, + app.IInputBoxBlock, + app.LastEpochCheckBlock, + app.LastInputCheckBlock, + app.LastOutputCheckBlock, + app.LastTournamentCheckBlock, + app.LastForecloseCheckBlock, + app.LastAccountsDriveProvedCheckBlock, + app.LastWithdrawalCheckBlock, + app.ProcessedInputs, + app.ForecloseBlock, + app.ForecloseTransaction, + app.AccountsDriveProvedBlock, + app.AccountsDriveProvedTransaction, + app.AccountsDriveMerkleRoot, + ). + RETURNING(table.Application.ID) + + tx, err := r.db.Begin(ctx) + if err != nil { + return 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + sqlStr, args := insertStmt.Sql() + var newID int64 + err = tx.QueryRow(ctx, sqlStr, args...).Scan(&newID) + if err != nil { + return 0, fmt.Errorf("unable to create database application: %w", err) + } + + switch { + case !withExecutionParameters && app.ConsensusType == model.Consensus_PRT: + sqlStr, args = table.ExecutionParameters. + INSERT( + table.ExecutionParameters.ApplicationID, + table.ExecutionParameters.AdvanceIncDeadline, + table.ExecutionParameters.AdvanceMaxDeadline, + ). + VALUES( + newID, + model.PrtAdvanceIncDeadline, + model.PrtAdvanceMaxDeadline, + ).Sql() + case !withExecutionParameters: + sqlStr, args = table.ExecutionParameters. + INSERT( + table.ExecutionParameters.ApplicationID, + ). + VALUES( + newID, + ).Sql() + default: + sqlStr, args = table.ExecutionParameters. + INSERT( + table.ExecutionParameters.ApplicationID, + table.ExecutionParameters.SnapshotPolicy, + table.ExecutionParameters.AdvanceIncCycles, + table.ExecutionParameters.AdvanceMaxCycles, + table.ExecutionParameters.InspectIncCycles, + table.ExecutionParameters.InspectMaxCycles, + table.ExecutionParameters.AdvanceIncDeadline, + table.ExecutionParameters.AdvanceMaxDeadline, + table.ExecutionParameters.InspectIncDeadline, + table.ExecutionParameters.InspectMaxDeadline, + table.ExecutionParameters.LoadDeadline, + table.ExecutionParameters.StoreDeadline, + table.ExecutionParameters.FastDeadline, + table.ExecutionParameters.MaxConcurrentInspects, + ). + VALUES( + newID, + app.ExecutionParameters.SnapshotPolicy, + app.ExecutionParameters.AdvanceIncCycles, + app.ExecutionParameters.AdvanceMaxCycles, + app.ExecutionParameters.InspectIncCycles, + app.ExecutionParameters.InspectMaxCycles, + app.ExecutionParameters.AdvanceIncDeadline, + app.ExecutionParameters.AdvanceMaxDeadline, + app.ExecutionParameters.InspectIncDeadline, + app.ExecutionParameters.InspectMaxDeadline, + app.ExecutionParameters.LoadDeadline, + app.ExecutionParameters.StoreDeadline, + app.ExecutionParameters.FastDeadline, + app.ExecutionParameters.MaxConcurrentInspects, + ).Sql() + } + + _, err = tx.Exec(ctx, sqlStr, args...) + if err != nil { + return 0, err + } + + sqlStr, args = table.DeploymentAdmissionState.INSERT(table.DeploymentAdmissionState.ApplicationID).VALUES(newID).Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return 0, err + } + + err = tx.Commit(ctx) + if err != nil { + return 0, err + } + return newID, nil +} + +// GetApplication retrieves one application by ID, optionally loading status & execution parameters. +func (r *PostgresRepository) GetApplication(ctx context.Context, nameOrAddress string) (*model.Application, error) { + app, _, err := getApplication(ctx, r.db, nameOrAddress) + return app, err +} + +func getApplication( + ctx context.Context, + queryer rowQueryer, + nameOrAddress string, +) (*model.Application, *model.ApplicationProblem, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + stmt := table.Application. + SELECT( + table.Application.ID, + table.Application.Name, + table.Application.IapplicationAddress, + table.Application.IconsensusAddress, + table.Application.IinputboxAddress, + table.Application.TemplateHash, + table.Application.TemplateURI, + table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, + table.Application.ConsensusType, + table.Application.Enabled, + table.Application.Status, + table.Application.Reason, + table.Application.ProblemEpochIndex, table.Application.ProblemKind, table.Application.ProblemInputIndex, + table.Application.IinputboxBlock, + table.Application.LastEpochCheckBlock, + table.Application.LastInputCheckBlock, + table.Application.LastOutputCheckBlock, + table.Application.LastTournamentCheckBlock, + table.Application.LastForecloseCheckBlock, + table.Application.LastAccountsDriveProvedCheckBlock, + table.Application.LastWithdrawalCheckBlock, + table.Application.ProcessedInputs, + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, + table.Application.CreatedAt, + table.Application.UpdatedAt, + table.ExecutionParameters.ApplicationID, + table.ExecutionParameters.SnapshotPolicy, + table.ExecutionParameters.AdvanceIncCycles, + table.ExecutionParameters.AdvanceMaxCycles, + table.ExecutionParameters.InspectIncCycles, + table.ExecutionParameters.InspectMaxCycles, + table.ExecutionParameters.AdvanceIncDeadline, + table.ExecutionParameters.AdvanceMaxDeadline, + table.ExecutionParameters.InspectIncDeadline, + table.ExecutionParameters.InspectMaxDeadline, + table.ExecutionParameters.LoadDeadline, + table.ExecutionParameters.StoreDeadline, + table.ExecutionParameters.FastDeadline, + table.ExecutionParameters.MaxConcurrentInspects, + table.ExecutionParameters.CreatedAt, + table.ExecutionParameters.UpdatedAt, + ). + FROM( + table.Application.INNER_JOIN( + table.ExecutionParameters, + table.ExecutionParameters.ApplicationID.EQ(table.Application.ID), + ), + ). + WHERE(whereClause) + + sqlStr, args := stmt.Sql() + row := queryer.QueryRow(ctx, sqlStr, args...) + + var app model.Application + var problemEpoch, problemInput *uint64 + var problemKind *model.ApplicationStatus + err := row.Scan( + &app.ID, + &app.Name, + &app.IApplicationAddress, + &app.IConsensusAddress, + &app.IInputBoxAddress, + &app.TemplateHash, + &app.TemplateURI, + &app.EpochLength, + &app.ClaimStagingPeriod, + &app.WithdrawalConfig.Guardian, + &app.WithdrawalConfig.Log2LeavesPerAccount, + &app.WithdrawalConfig.Log2MaxNumOfAccounts, + &app.WithdrawalConfig.AccountsDriveStartIndex, + &app.WithdrawalConfig.WithdrawalOutputBuilder, + &app.ConsensusType, + &app.Enabled, + &app.Status, + &app.Reason, + &problemEpoch, &problemKind, &problemInput, + &app.IInputBoxBlock, + &app.LastEpochCheckBlock, + &app.LastInputCheckBlock, + &app.LastOutputCheckBlock, + &app.LastTournamentCheckBlock, + &app.LastForecloseCheckBlock, + &app.LastAccountsDriveProvedCheckBlock, + &app.LastWithdrawalCheckBlock, + &app.ProcessedInputs, + &app.ForecloseBlock, + &app.ForecloseTransaction, + &app.AccountsDriveProvedBlock, + &app.AccountsDriveProvedTransaction, + &app.AccountsDriveMerkleRoot, + &app.CreatedAt, + &app.UpdatedAt, + &app.ExecutionParameters.ApplicationID, + &app.ExecutionParameters.SnapshotPolicy, + &app.ExecutionParameters.AdvanceIncCycles, + &app.ExecutionParameters.AdvanceMaxCycles, + &app.ExecutionParameters.InspectIncCycles, + &app.ExecutionParameters.InspectMaxCycles, + &app.ExecutionParameters.AdvanceIncDeadline, + &app.ExecutionParameters.AdvanceMaxDeadline, + &app.ExecutionParameters.InspectIncDeadline, + &app.ExecutionParameters.InspectMaxDeadline, + &app.ExecutionParameters.LoadDeadline, + &app.ExecutionParameters.StoreDeadline, + &app.ExecutionParameters.FastDeadline, + &app.ExecutionParameters.MaxConcurrentInspects, + &app.ExecutionParameters.CreatedAt, + &app.ExecutionParameters.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, nil // not found + } + if err != nil { + return nil, nil, err + } + + problem, err := decodeApplicationProblem(problemEpoch, problemKind, problemInput) + return &app, problem, err +} + +// GetProcessedInputCount retrieves the ProcessedInputs field from an application by Name or address. +func (r *PostgresRepository) GetProcessedInputCount( + ctx context.Context, + nameOrAddress string, +) (uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + stmt := table.Application. + SELECT(table.Application.ProcessedInputs). + WHERE(whereClause) + + sqlStr, args := stmt.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var processedInputs uint64 + err := row.Scan(&processedInputs) + if errors.Is(err, sql.ErrNoRows) { + return 0, repository.ErrNotFound + } + return processedInputs, err +} + +// UpdateApplication updates application configuration fields. +// +// Status, operator intent, scanner cursors, processed input counters, and +// foreclosure columns are deliberately excluded. Dedicated methods own those +// fields so a stale in-memory application cannot rewind service progress or +// move the app back into normal work while changing unrelated configuration. +func (r *PostgresRepository) UpdateApplication( + ctx context.Context, + app *model.Application, +) error { + + updateStmt := table.Application. + UPDATE( + table.Application.Name, + table.Application.IapplicationAddress, + table.Application.IconsensusAddress, + table.Application.IinputboxAddress, + table.Application.TemplateHash, + table.Application.TemplateURI, + table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, + table.Application.ConsensusType, + table.Application.IinputboxBlock, + ). + SET( + app.Name, + app.IApplicationAddress, + app.IConsensusAddress, + app.IInputBoxAddress, + app.TemplateHash, + app.TemplateURI, + app.EpochLength, + app.ClaimStagingPeriod, + app.WithdrawalConfig.Guardian, + app.WithdrawalConfig.Log2LeavesPerAccount, + app.WithdrawalConfig.Log2MaxNumOfAccounts, + app.WithdrawalConfig.AccountsDriveStartIndex, + app.WithdrawalConfig.WithdrawalOutputBuilder, + app.ConsensusType, + app.IInputBoxBlock, + ). + WHERE(table.Application.ID.EQ(postgres.Int(app.ID))) + + sqlStr, args := updateStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +// UpdateApplicationEnabled changes only the operator intent bit. It must not +// touch service-owned scanner cursors or status fields. +func (r *PostgresRepository) UpdateApplicationEnabled( + ctx context.Context, + appID int64, + enabled bool, +) error { + updateStmt := table.Application. + UPDATE(table.Application.Enabled). + SET(enabled). + WHERE(table.Application.ID.EQ(postgres.Int(appID))) + + sqlStr, args := updateStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +// EnableApplicationAndResetDeployment enables and resets deployment admission. +// It clears current FAILED health only with observed recovery authorization. +// Terminal health stays unchanged. Foreclosed apps remain ineligible for execution. +func (r *PostgresRepository) EnableApplicationAndResetDeployment( + ctx context.Context, + appID int64, + clearFailed bool, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return err + } + defer rollbackDeliveryTx(tx) + failedRecovery := postgres.Bool(clearFailed).AND( + table.Application.Status.EQ(postgres.NewEnumValue(model.ApplicationStatus_Failed.String())), + ) + updateStmt := table.Application. + UPDATE( + table.Application.Enabled, + table.Application.Status, + ). + SET( + true, + postgres.CASE(). + WHEN(failedRecovery). + THEN(postgres.NewEnumValue(model.ApplicationStatus_OK.String())). + ELSE(table.Application.Status), + ). + WHERE(table.Application.ID.EQ(postgres.Int(appID))) + + sqlStr, args := updateStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + // Lock application before control. Every explicit enable starts a fresh episode. + control := table.DeploymentAdmissionState + sqlStr, args = control.INSERT(control.ApplicationID, control.ResetGeneration). + VALUES(appID, int64(1)).ON_CONFLICT(control.ApplicationID).DO_UPDATE( + postgres.SET(control.ResetGeneration.SET(control.ResetGeneration.ADD(postgres.Int64(1))), + control.ConfirmedTier.SET(postgres.StringExp(postgres.NULL)), control.DeploymentReason.SET(postgres.StringExp(postgres.NULL))), + ).Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return err + } + return tx.Commit(ctx) +} + +// UpdateApplicationLastForecloseCheckBlock advances the per-app record +// of how far the Foreclosure-event search has scanned. The clause +// `WHERE last_foreclose_check_block < blockNumber` makes the write +// strictly monotonic: out-of-order or duplicate observations from a slow +// tick cannot rewind the value and re-cause a long-window rescan. A no-op +// (0 rows affected) is not an error — it just means the caller's view is +// stale. +func (r *PostgresRepository) UpdateApplicationLastForecloseCheckBlock( + ctx context.Context, + appID int64, + blockNumber uint64, +) error { + updateStmt := table.Application. + UPDATE(table.Application.LastForecloseCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastForecloseCheckBlock.LT(uint64Expr(blockNumber))), + ) + + sqlStr, args := updateStmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +// UpdateApplicationForeclosure records the one-shot Foreclosure() event and +// advances last_foreclose_check_block in the same +// transaction. If the marker was already recorded, this is an idempotent no-op. +func (r *PostgresRepository) UpdateApplicationForeclosure( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + blockNumber uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + cmd, err := tx.Exec(ctx, ` +UPDATE "application" +SET + "foreclose_block" = $1, + "foreclose_transaction" = $2 +WHERE "id" = $3 AND "foreclose_block" = 0 +`, block, txHash, appID) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + probeStmt := table.Application. + SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int(appID))) + psql, pargs := probeStmt.Sql() + var dummy int64 + err = tx.QueryRow(ctx, psql, pargs...).Scan(&dummy) + if errors.Is(err, sql.ErrNoRows) { + return repository.ErrNotFound + } + if err != nil { + return fmt.Errorf("probing application existence (id=%d): %w", appID, err) + } + return tx.Commit(ctx) + } + + cursorStmt := table.Application. + UPDATE(table.Application.LastForecloseCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastForecloseCheckBlock.LT(uint64Expr(blockNumber))), + ) + sqlStr, args := cursorStmt.Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return err + } + return tx.Commit(ctx) +} + +// UpdateAccountsDriveProved records the one-shot drive-prove transition and +// advances the scanner cursor in the same +// transaction. If the marker was already recorded, this is an idempotent no-op. +func (r *PostgresRepository) UpdateAccountsDriveProved( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + root common.Hash, + blockNumber uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + updateStmt := table.Application. + UPDATE( + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, + ). + SET( + block, + &txHash, + &root, + ). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.AccountsDriveProvedBlock.EQ(uint64Expr(0))), + ) + + sqlStr, args := updateStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + probeStmt := table.Application. + SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int(appID))) + psql, pargs := probeStmt.Sql() + var dummy int64 + err = tx.QueryRow(ctx, psql, pargs...).Scan(&dummy) + if errors.Is(err, sql.ErrNoRows) { + return repository.ErrNotFound + } + if err != nil { + return fmt.Errorf("probing application existence (id=%d): %w", appID, err) + } + return tx.Commit(ctx) + } + + cursorStmt := table.Application. + UPDATE(table.Application.LastAccountsDriveProvedCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastAccountsDriveProvedCheckBlock.LT(uint64Expr(blockNumber))), + ) + sqlStr, args = cursorStmt.Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return err + } + return tx.Commit(ctx) +} + +// UpdateApplicationLastAccountsDriveProvedCheckBlock advances the per-app +// scanner cursor for the getAccountsDriveMerkleRoot().wasProved observer. +// Strictly monotonic — mirrors UpdateApplicationLastForecloseCheckBlock. +func (r *PostgresRepository) UpdateApplicationLastAccountsDriveProvedCheckBlock( + ctx context.Context, + appID int64, + blockNumber uint64, +) error { + updateStmt := table.Application. + UPDATE(table.Application.LastAccountsDriveProvedCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastAccountsDriveProvedCheckBlock.LT(uint64Expr(blockNumber))), + ) + + sqlStr, args := updateStmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +func (r *PostgresRepository) UpdateApplicationStatus( + ctx context.Context, + appID int64, + status model.ApplicationStatus, + reason *string, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + updateStmt := table.Application. + UPDATE( + table.Application.Status, + table.Application.Reason, + ). + SET( + status, + reason, + ). + WHERE(table.Application.ID.EQ(postgres.Int(appID))) + + sqlStr, args := updateStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +func getColumnForEvent(event model.MonitoredEvent) (postgres.ColumnFloat, error) { + switch event { + case model.MonitoredEvent_EpochSealed: + return table.Application.LastEpochCheckBlock, nil + case model.MonitoredEvent_InputAdded: + return table.Application.LastInputCheckBlock, nil + case model.MonitoredEvent_OutputExecuted: + return table.Application.LastOutputCheckBlock, nil + case model.MonitoredEvent_CommitmentJoined, model.MonitoredEvent_MatchAdvanced, + model.MonitoredEvent_MatchCreated, model.MonitoredEvent_MatchDeleted, model.MonitoredEvent_NewInnerTournament, + model.MonitoredEvent_LeafMatchSealed, model.MonitoredEvent_PartialBondRefund, model.MonitoredEvent_BondRecovered, + model.MonitoredEvent_StandingChanged: + return table.Application.LastTournamentCheckBlock, nil + case model.MonitoredEvent_ClaimAccepted: + // This safe cursor covers both staged and accepted claims. + return table.Application.LastClaimCheckBlock, nil + case model.MonitoredEvent_ClaimSubmitted, + model.MonitoredEvent_Foreclosure, model.MonitoredEvent_Withdrawal, model.MonitoredEvent_AccountsDriveMerkleRootProved: + // These events use their own claim reconciliation or atomic marker writes. + } + return nil, fmt.Errorf("invalid monitored event type: %v", event) +} + +func (r *PostgresRepository) GetEventLastCheckBlock( + ctx context.Context, + appID int64, + event model.MonitoredEvent, +) (uint64, error) { + column, err := getColumnForEvent(event) + if err != nil { + return 0, err + } + + stmt := table.Application.SELECT(column).WHERE( + table.Application.ID.EQ(postgres.Int(appID)), + ) + + sqlStr, args := stmt.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var eventBlock uint64 + err = row.Scan(&eventBlock) + if errors.Is(err, sql.ErrNoRows) { + return 0, repository.ErrNotFound + } + return eventBlock, err +} + +func (r *PostgresRepository) UpdateEventLastCheckBlock( + ctx context.Context, + appIDs []int64, + event model.MonitoredEvent, + blockNumber uint64, +) error { + column, err := getColumnForEvent(event) + if err != nil { + return err + } + + if len(appIDs) == 0 { + return nil + } + + ids := make([]postgres.Expression, len(appIDs)) + for i, id := range appIDs { + ids[i] = postgres.Int(id) + } + + updateStmt := table.Application. + UPDATE( + column, + ). + SET( + uint64Expr(blockNumber), + ). + WHERE( + table.Application.ID.IN(ids...). + AND(column.LT(uint64Expr(blockNumber))), + ) + + sqlStr, args := updateStmt.Sql() + _, err = r.db.Exec(ctx, sqlStr, args...) + return err +} + +// GetLastSnapshot retrieves the most recent input with a snapshot for the given application +func (r *PostgresRepository) GetLastSnapshot(ctx context.Context, nameOrAddress string) (*model.Input, error) { + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Input. + SELECT( + table.Input.EpochApplicationID, + table.Input.EpochIndex, + table.Input.Index, + table.Input.BlockNumber, + table.Input.RawData, + table.Input.Status, + table.Input.ExceptionData, + table.Input.MachineHash, + table.Input.TxBufferDataBlock, + table.Input.TransactionHash, + table.Input.LogIndex, + table.Input.SnapshotURI, + table.Input.CreatedAt, + table.Input.UpdatedAt, + ). + FROM( + table.Input. + INNER_JOIN(table.Application, + table.Input.EpochApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String()))). + AND(table.Input.SnapshotURI.IS_NOT_NULL()), + ). + ORDER_BY(table.Input.Index.DESC()). + LIMIT(1) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var inp model.Input + err := row.Scan( + &inp.EpochApplicationID, + &inp.EpochIndex, + &inp.Index, + &inp.BlockNumber, + &inp.RawData, + &inp.Status, + &inp.ExceptionData, + &inp.MachineHash, + &inp.TxBufferDataBlock, + &inp.TransactionHash, + &inp.LogIndex, + &inp.SnapshotURI, + &inp.CreatedAt, + &inp.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &inp, nil +} + +// ListApplications queries multiple apps with optional filters & pagination. +func (r *PostgresRepository) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, +) ([]*model.Application, uint64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + return r.listApplications(ctx, f, p, descending, false, false) +} + +func (r *PostgresRepository) listApplications( + ctx context.Context, + f repository.ApplicationFilter, + p repository.Pagination, + descending bool, + skipCount bool, + orderByID bool, +) ([]*model.Application, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + + fromClause := table.Application.INNER_JOIN( + table.ExecutionParameters, + table.ExecutionParameters.ApplicationID.EQ(table.Application.ID), + ) + + conditions := applicationConditions(f) + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + var total uint64 + if !skipCount { + countStmt := table.Application.SELECT(postgres.COUNT(postgres.STAR)).FROM(fromClause) + if len(conditions) > 0 { + countStmt = countStmt.WHERE(postgres.AND(conditions...)) + } + total, err = countFromTx(ctx, tx, countStmt) + if err != nil { + return nil, 0, err + } + if total == 0 { + return nil, 0, nil + } + + } + + sel := table.Application. + SELECT( + table.Application.ID, + table.Application.Name, + table.Application.IapplicationAddress, + table.Application.IconsensusAddress, + table.Application.IinputboxAddress, + table.Application.TemplateHash, + table.Application.TemplateURI, + table.Application.EpochLength, + table.Application.ClaimStagingPeriod, + table.Application.WithdrawalGuardian, + table.Application.WithdrawalLog2LeavesPerAccount, + table.Application.WithdrawalLog2MaxNumOfAccounts, + table.Application.WithdrawalAccountsDriveStartIndex, + table.Application.WithdrawalOutputBuilder, + table.Application.ConsensusType, + table.Application.Enabled, + table.Application.Status, + table.Application.Reason, + table.Application.IinputboxBlock, + table.Application.LastEpochCheckBlock, + table.Application.LastInputCheckBlock, + table.Application.LastOutputCheckBlock, + table.Application.LastTournamentCheckBlock, + table.Application.LastForecloseCheckBlock, + table.Application.LastAccountsDriveProvedCheckBlock, + table.Application.LastWithdrawalCheckBlock, + table.Application.ProcessedInputs, + table.Application.ForecloseBlock, + table.Application.ForecloseTransaction, + table.Application.AccountsDriveProvedBlock, + table.Application.AccountsDriveProvedTransaction, + table.Application.AccountsDriveMerkleRoot, + table.Application.CreatedAt, + table.Application.UpdatedAt, + table.ExecutionParameters.ApplicationID, + table.ExecutionParameters.SnapshotPolicy, + table.ExecutionParameters.AdvanceIncCycles, + table.ExecutionParameters.AdvanceMaxCycles, + table.ExecutionParameters.InspectIncCycles, + table.ExecutionParameters.InspectMaxCycles, + table.ExecutionParameters.AdvanceIncDeadline, + table.ExecutionParameters.AdvanceMaxDeadline, + table.ExecutionParameters.InspectIncDeadline, + table.ExecutionParameters.InspectMaxDeadline, + table.ExecutionParameters.LoadDeadline, + table.ExecutionParameters.StoreDeadline, + table.ExecutionParameters.FastDeadline, + table.ExecutionParameters.MaxConcurrentInspects, + table.ExecutionParameters.CreatedAt, + table.ExecutionParameters.UpdatedAt, + ). + FROM(fromClause) + + if len(conditions) > 0 { + sel = sel.WHERE(postgres.AND(conditions...)) + } + + switch { + case orderByID: + sel = sel.ORDER_BY(table.Application.ID.ASC()) + case descending: + sel = sel.ORDER_BY(table.Application.Name.DESC()) + default: + sel = sel.ORDER_BY(table.Application.Name.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var apps []*model.Application + for rows.Next() { + var app model.Application + err := rows.Scan( + &app.ID, + &app.Name, + &app.IApplicationAddress, + &app.IConsensusAddress, + &app.IInputBoxAddress, + &app.TemplateHash, + &app.TemplateURI, + &app.EpochLength, + &app.ClaimStagingPeriod, + &app.WithdrawalConfig.Guardian, + &app.WithdrawalConfig.Log2LeavesPerAccount, + &app.WithdrawalConfig.Log2MaxNumOfAccounts, + &app.WithdrawalConfig.AccountsDriveStartIndex, + &app.WithdrawalConfig.WithdrawalOutputBuilder, + &app.ConsensusType, + &app.Enabled, + &app.Status, + &app.Reason, + &app.IInputBoxBlock, + &app.LastEpochCheckBlock, + &app.LastInputCheckBlock, + &app.LastOutputCheckBlock, + &app.LastTournamentCheckBlock, + &app.LastForecloseCheckBlock, + &app.LastAccountsDriveProvedCheckBlock, + &app.LastWithdrawalCheckBlock, + &app.ProcessedInputs, + &app.ForecloseBlock, + &app.ForecloseTransaction, + &app.AccountsDriveProvedBlock, + &app.AccountsDriveProvedTransaction, + &app.AccountsDriveMerkleRoot, + &app.CreatedAt, + &app.UpdatedAt, + &app.ExecutionParameters.ApplicationID, + &app.ExecutionParameters.SnapshotPolicy, + &app.ExecutionParameters.AdvanceIncCycles, + &app.ExecutionParameters.AdvanceMaxCycles, + &app.ExecutionParameters.InspectIncCycles, + &app.ExecutionParameters.InspectMaxCycles, + &app.ExecutionParameters.AdvanceIncDeadline, + &app.ExecutionParameters.AdvanceMaxDeadline, + &app.ExecutionParameters.InspectIncDeadline, + &app.ExecutionParameters.InspectMaxDeadline, + &app.ExecutionParameters.LoadDeadline, + &app.ExecutionParameters.StoreDeadline, + &app.ExecutionParameters.FastDeadline, + &app.ExecutionParameters.MaxConcurrentInspects, + &app.ExecutionParameters.CreatedAt, + &app.ExecutionParameters.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + apps = append(apps, &app) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + return apps, total, nil +} + +func (r *PostgresRepository) GetExecutionParameters( + ctx context.Context, + applicationID int64, +) (*model.ExecutionParameters, error) { + + stmt := table.ExecutionParameters. + SELECT( + table.ExecutionParameters.ApplicationID, + table.ExecutionParameters.SnapshotPolicy, + table.ExecutionParameters.AdvanceIncCycles, + table.ExecutionParameters.AdvanceMaxCycles, + table.ExecutionParameters.InspectIncCycles, + table.ExecutionParameters.InspectMaxCycles, + table.ExecutionParameters.AdvanceIncDeadline, + table.ExecutionParameters.AdvanceMaxDeadline, + table.ExecutionParameters.InspectIncDeadline, + table.ExecutionParameters.InspectMaxDeadline, + table.ExecutionParameters.LoadDeadline, + table.ExecutionParameters.StoreDeadline, + table.ExecutionParameters.FastDeadline, + table.ExecutionParameters.MaxConcurrentInspects, + table.ExecutionParameters.CreatedAt, + table.ExecutionParameters.UpdatedAt, + ). + WHERE(table.ExecutionParameters.ApplicationID.EQ(postgres.Int(applicationID))) + + sqlStr, args := stmt.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var ep model.ExecutionParameters + err := row.Scan( + &ep.ApplicationID, + &ep.SnapshotPolicy, + &ep.AdvanceIncCycles, + &ep.AdvanceMaxCycles, + &ep.InspectIncCycles, + &ep.InspectMaxCycles, + &ep.AdvanceIncDeadline, + &ep.AdvanceMaxDeadline, + &ep.InspectIncDeadline, + &ep.InspectMaxDeadline, + &ep.LoadDeadline, + &ep.StoreDeadline, + &ep.FastDeadline, + &ep.MaxConcurrentInspects, + &ep.CreatedAt, + &ep.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil // not found + } + if err != nil { + return nil, err + } + return &ep, nil +} + +func (r *PostgresRepository) UpdateExecutionParameters( + ctx context.Context, + ep *model.ExecutionParameters, +) error { + + upd := table.ExecutionParameters. + UPDATE( + table.ExecutionParameters.SnapshotPolicy, + table.ExecutionParameters.AdvanceIncCycles, + table.ExecutionParameters.AdvanceMaxCycles, + table.ExecutionParameters.InspectIncCycles, + table.ExecutionParameters.InspectMaxCycles, + table.ExecutionParameters.AdvanceIncDeadline, + table.ExecutionParameters.AdvanceMaxDeadline, + table.ExecutionParameters.InspectIncDeadline, + table.ExecutionParameters.InspectMaxDeadline, + table.ExecutionParameters.LoadDeadline, + table.ExecutionParameters.StoreDeadline, + table.ExecutionParameters.FastDeadline, + table.ExecutionParameters.MaxConcurrentInspects, + ). + SET( + ep.SnapshotPolicy, + ep.AdvanceIncCycles, + ep.AdvanceMaxCycles, + ep.InspectIncCycles, + ep.InspectMaxCycles, + ep.AdvanceIncDeadline, + ep.AdvanceMaxDeadline, + ep.InspectIncDeadline, + ep.InspectMaxDeadline, + ep.LoadDeadline, + ep.StoreDeadline, + ep.FastDeadline, + ep.MaxConcurrentInspects, + ). + WHERE(table.ExecutionParameters.ApplicationID.EQ(postgres.Int(ep.ApplicationID))) + + sqlStr, args := upd.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +func applicationConditions(f repository.ApplicationFilter) []postgres.BoolExpression { + conditions := []postgres.BoolExpression{} + if f.Enabled != nil { + conditions = append(conditions, table.Application.Enabled.EQ(postgres.Bool(*f.Enabled))) + } + if f.Status != nil { + conditions = append(conditions, table.Application.Status.EQ(postgres.NewEnumValue(f.Status.String()))) + } + if len(f.Statuses) > 0 { + statuses := make([]postgres.Expression, len(f.Statuses)) + for i, status := range f.Statuses { + statuses[i] = postgres.NewEnumValue(status.String()) + } + conditions = append(conditions, table.Application.Status.IN(statuses...)) + } + if f.ConsensusType != nil { + conditions = append(conditions, table.Application.ConsensusType.EQ(postgres.NewEnumValue(f.ConsensusType.String()))) + } + if len(f.ConsensusTypes) > 0 { + consensusTypes := make([]postgres.Expression, len(f.ConsensusTypes)) + for i, consensusType := range f.ConsensusTypes { + consensusTypes[i] = postgres.NewEnumValue(consensusType.String()) + } + conditions = append(conditions, table.Application.ConsensusType.IN(consensusTypes...)) + } + if f.ForeclosureRecorded != nil { + if *f.ForeclosureRecorded { + conditions = append(conditions, table.Application.ForecloseBlock.GT(uint64Expr(0))) + } else { + conditions = append(conditions, table.Application.ForecloseBlock.EQ(uint64Expr(0))) + } + } + + if f.AfterID != nil { + conditions = append(conditions, table.Application.ID.GT(postgres.Int64(*f.AfterID))) + } + if f.ThroughID != nil { + conditions = append(conditions, table.Application.ID.LT_EQ(postgres.Int64(*f.ThroughID))) + } + return conditions +} diff --git a/internal/repository/postgres/application_control_test.go b/internal/repository/postgres/application_control_test.go new file mode 100644 index 000000000..bb9c8f8e0 --- /dev/null +++ b/internal/repository/postgres/application_control_test.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestApplicationCreationIncludesDeploymentControl(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum, model.Consensus_PRT} { + app := repotest.NewApplicationBuilder().WithConsensus(consensus).Create(t.Context(), t, r) + state, err := r.GetDeploymentAdmissionState(t.Context(), deploymentKey(app)) + require.NoError(t, err) + require.Equal(t, &model.DeploymentAdmissionState{ApplicationID: app.ID}, state) + } +} + +func TestApplicationCreationRollsBackWhenControlFails(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + _, err := r.db.Exec(t.Context(), `ALTER TABLE deployment_admission_state + ADD CONSTRAINT control_creation_rejected CHECK (application_id < 0)`) + require.NoError(t, err) + t.Cleanup(func() { + _, err := r.db.Exec(context.Background(), `ALTER TABLE deployment_admission_state DROP CONSTRAINT control_creation_rejected`) + require.NoError(t, err) + }) + app := repotest.NewApplicationBuilder().Build() + _, err = r.CreateApplication(t.Context(), app, false) + var failure *pgconn.PgError + require.ErrorAs(t, err, &failure) + require.Equal(t, "control_creation_rejected", failure.ConstraintName) + const applicationTableName = "application" + for _, table := range []string{applicationTableName, "execution_parameters", "deployment_admission_state"} { + var count uint64 + require.NoError(t, r.db.QueryRow(t.Context(), "SELECT count(*) FROM "+table).Scan(&count)) + require.Zero(t, count, "control failure must roll back %s", table) + } +} diff --git a/internal/repository/postgres/application_deletion_test.go b/internal/repository/postgres/application_deletion_test.go new file mode 100644 index 000000000..76d8efb97 --- /dev/null +++ b/internal/repository/postgres/application_deletion_test.go @@ -0,0 +1,82 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func TestApplicationDeletionRequiresDisabledAndNoChainRegistration(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + require.ErrorIs(t, r.DeleteApplication(t.Context(), app.ID), repository.ErrApplicationEnabled) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + require.NoError(t, r.DeleteApplication(t.Context(), app.ID)) + require.ErrorIs(t, r.DeleteApplication(t.Context(), app.ID), repository.ErrNotFound) +} + +func TestApplicationDeletionRechecksConcurrentEnable(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithEnabled(false).Create(t.Context(), t, r) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + tx, err := r.db.Begin(ctx) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + stmt := table.Application.UPDATE(table.Application.Enabled).SET(postgres.Bool(true)). + WHERE(table.Application.ID.EQ(postgres.Int64(app.ID))) + query, args := stmt.Sql() + _, err = tx.Exec(ctx, query, args...) + require.NoError(t, err) + var pid int32 + require.NoError(t, tx.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&pid)) + result := make(chan error, 1) + go func() { result <- r.DeleteApplication(ctx, app.ID) }() + require.Eventually(t, func() bool { + var blocked bool + err := r.db.QueryRow(ctx, + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid)))", pid).Scan(&blocked) + return err == nil && blocked + }, time.Second, 10*time.Millisecond) + require.NoError(t, tx.Commit(ctx)) + require.ErrorIs(t, <-result, repository.ErrApplicationEnabled) + stored, err := r.GetApplication(ctx, app.Name) + require.NoError(t, err) + require.True(t, stored.Enabled, "the committed enable must prevent deletion after its lock wait") +} + +func TestApplicationDeletionRollsBackCancelledCascade(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithEnabled(false).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*model.Epoch][]*model.Input{epoch: {}}, 10)) + blocker, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(blocker) + stmt := table.Epoch.SELECT(table.Epoch.Index).WHERE(table.Epoch.ApplicationID.EQ(postgres.Int64(app.ID))).FOR(postgres.UPDATE()) + query, args := stmt.Sql() + _, err = blocker.Exec(t.Context(), query, args...) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + require.Error(t, r.DeleteApplication(ctx, app.ID)) + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + require.NoError(t, blocker.Rollback(t.Context())) + stored, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.NotNil(t, stored, "failed cascade must roll back the application deletion") + storedEpoch, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NotNil(t, storedEpoch, "failed cascade must retain child records") + require.NoError(t, r.DeleteApplication(t.Context(), app.ID), "deletion must recover after the lock is released") +} diff --git a/internal/repository/postgres/application_problem.go b/internal/repository/postgres/application_problem.go new file mode 100644 index 000000000..6cefef639 --- /dev/null +++ b/internal/repository/postgres/application_problem.go @@ -0,0 +1,165 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/enum" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +// Execution stops preserve a valid earlier prefix; FAILED/DC do not grant +// permission to discard work, even when a previous stop boundary is retained. +var executionStopStatusExpressions = []postgres.Expression{ + enum.ApplicationStatus.GuestException, enum.ApplicationStatus.MachineHalted, + enum.ApplicationStatus.McycleOverflow, enum.ApplicationStatus.UnexpectedYield, + enum.ApplicationStatus.InvalidOutputsRoot, +} + +type rowQueryer interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +func decodeApplicationProblem(epoch *uint64, kind *model.ApplicationStatus, input *uint64) (*model.ApplicationProblem, error) { + if epoch == nil { + if kind != nil || input != nil { + return nil, fmt.Errorf("epoch stop boundary is missing its epoch") + } + return nil, nil + } + if kind == nil || !kind.IsExecutionTerminal() && *kind != model.ApplicationStatus_InvalidOutputsRoot || + kind.IsExecutionTerminal() && input == nil { + return nil, fmt.Errorf("epoch %d has inconsistent stop metadata", *epoch) + } + return &model.ApplicationProblem{EpochIndex: *epoch, Kind: *kind, InputIndex: input}, nil +} + +func (r *PostgresRepository) GetEpochCompletionState( + ctx context.Context, nameOrAddress string, index uint64, +) (*repository.EpochCompletionState, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, err + } + defer rollbackDeliveryTx(tx) + return readEpochCompletion(ctx, tx, nameOrAddress, index) +} + +func readEpochCompletion(ctx context.Context, tx pgx.Tx, nameOrAddress string, index uint64) (*repository.EpochCompletionState, error) { + app, problem, err := getApplication(ctx, tx, nameOrAddress) + if err != nil || app == nil { + return nil, err + } + epoch, err := getEpoch(ctx, tx, nameOrAddress, index) + if err != nil { + return nil, err + } + return &repository.EpochCompletionState{Application: app, Problem: problem, Epoch: epoch}, nil +} + +// A boundary writer locks the application before the epoch. Its status and +// cutoff checks must remain current until the new finding commits. +func lockEpochCompletion(ctx context.Context, tx pgx.Tx, appID int64, index uint64) (*repository.EpochCompletionState, error) { + stmt := table.Application.SELECT(table.Application.Status, + table.Application.ProblemEpochIndex, table.Application.ProblemKind, table.Application.ProblemInputIndex). + WHERE(table.Application.ID.EQ(postgres.Int64(appID))).FOR(postgres.NO_KEY_UPDATE()) + query, args := stmt.Sql() + app := &model.Application{ID: appID} + var problemEpoch, problemInput *uint64 + var problemKind *model.ApplicationStatus + if err := tx.QueryRow(ctx, query, args...).Scan(&app.Status, &problemEpoch, &problemKind, &problemInput); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, repository.ErrNoUpdate + } + return nil, err + } + problem, err := decodeApplicationProblem(problemEpoch, problemKind, problemInput) + if err != nil { + return nil, err + } + epoch := &model.Epoch{ApplicationID: appID, Index: index} + stmt = table.Epoch.SELECT(table.Epoch.Status). + WHERE(table.Epoch.ApplicationID.EQ(postgres.Int64(appID)).AND(table.Epoch.Index.EQ(uint64Expr(index)))). + FOR(postgres.UPDATE()) + query, args = stmt.Sql() + if err := tx.QueryRow(ctx, query, args...).Scan(&epoch.Status); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, repository.ErrNoUpdate + } + return nil, err + } + if epoch.Status == model.EpochStatus_ClaimForeclosed { + return nil, repository.ErrEpochForeclosed + } + if epoch.Status != model.EpochStatus_InputsProcessed { + return nil, repository.ErrNoUpdate + } + return &repository.EpochCompletionState{Application: app, Problem: problem, Epoch: epoch}, nil +} + +// recordApplicationProblem runs only while its caller holds the application +// row lock. Replace the entire typed source only for a strictly earlier epoch. +func recordApplicationProblem(ctx context.Context, tx pgx.Tx, appID int64, p model.ApplicationProblem) error { + var input any + if p.InputIndex != nil { + input = uint64Expr(*p.InputIndex) + } + stmt := table.Application.UPDATE(table.Application.ProblemEpochIndex, table.Application.ProblemKind, + table.Application.ProblemInputIndex).SET(uint64Expr(p.EpochIndex), p.Kind, input). + WHERE(table.Application.ID.EQ(postgres.Int64(appID)).AND( + table.Application.ProblemEpochIndex.IS_NULL().OR(table.Application.ProblemEpochIndex.GT(uint64Expr(p.EpochIndex))))) + query, args := stmt.Sql() + _, err := tx.Exec(ctx, query, args...) + return err +} + +func (r *PostgresRepository) StoreInvalidOutputsRoot(ctx context.Context, appID int64, index uint64, reason string) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer rollbackDeliveryTx(tx) + state, err := lockEpochCompletion(ctx, tx, appID, index) + if err != nil { + return err + } + status := state.Application.Status + if status != model.ApplicationStatus_OK && !status.IsExecutionTerminal() && status != model.ApplicationStatus_InvalidOutputsRoot { + return fmt.Errorf("%w: application %d has status %s", repository.ErrApplicationNotRunnable, appID, status) + } + // Repeated evidence at the cutoff is idempotent. A later stale validation + // cannot replace an earlier finding or clear a concurrent FAILED/DC stop. + if state.Problem == nil && status != model.ApplicationStatus_OK { + return fmt.Errorf("application %d is missing its epoch stop boundary", appID) + } + if state.Problem != nil && index > state.Problem.EpochIndex { + return fmt.Errorf("%w: epoch %d is after the stop boundary", repository.ErrApplicationNotRunnable, index) + } + if err := recordApplicationProblem(ctx, tx, appID, model.ApplicationProblem{ + EpochIndex: index, Kind: model.ApplicationStatus_InvalidOutputsRoot, + }); err != nil { + return err + } + if status == model.ApplicationStatus_OK { + stmt := table.Application.UPDATE(table.Application.Status, table.Application.Reason). + SET(model.ApplicationStatus_InvalidOutputsRoot, appstatus.NormalizeReason(reason)). + WHERE(table.Application.ID.EQ(postgres.Int64(appID))) + query, args := stmt.Sql() + if _, err := tx.Exec(ctx, query, args...); err != nil { + return err + } + } + return tx.Commit(ctx) +} diff --git a/internal/repository/postgres/application_problem_test.go b/internal/repository/postgres/application_problem_test.go new file mode 100644 index 000000000..fe9affcd5 --- /dev/null +++ b/internal/repository/postgres/application_problem_test.go @@ -0,0 +1,278 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +// Keep the advancer ahead of validation: epochs 0/1 are complete but unclaimed, +// while input 2 in epoch 2 is still pending. Proof cryptography is not this +// repository fixture's oracle; it exercises transactional and eligibility rules. +func seedProblemEpochs(t *testing.T, r repository.Repository) (*model.Application, []*model.Epoch) { + t.Helper() + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + epochs := make([]*model.Epoch, 3) + rows := make(map[*model.Epoch][]*model.Input) + for i := range uint64(3) { + epochs[i] = repotest.NewEpochBuilder(app.ID).WithIndex(i).WithStatus(model.EpochStatus_Closed). + WithBlocks(i*10, i*10+9).WithInputBounds(i, i+1).Build() + input := repotest.NewInputBuilder().WithIndex(i).WithBlockNumber(i*10 + 5).Build() + rows[epochs[i]] = []*model.Input{input} + } + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, rows, 30)) + for i := range uint64(2) { + proof := repotest.DummyStateProof() + require.NoError(t, r.StoreAdvanceResult(t.Context(), app.ID, &model.AdvanceResult{ + EpochIndex: i, InputIndex: i, Status: model.InputCompletionStatus_Accepted, StateProof: *proof, + })) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, i, proof)) + stored, err := r.GetEpoch(t.Context(), app.Name, i) + require.NoError(t, err) + epochs[i] = stored + } + return app, epochs +} + +func recordTestRuntimeStop(t *testing.T, r repository.Repository, app *model.Application, status model.InputCompletionStatus) error { + t.Helper() + result := &model.AdvanceResult{EpochIndex: 2, InputIndex: 2, Status: status, StateProof: *repotest.DummyStateProof()} + if status == model.InputCompletionStatus_Exception { + result.ExceptionData = []byte("guest exception") + } + return r.StoreAdvanceResult(t.Context(), app.ID, result) +} + +func TestApplicationProblemRuntimeSourcesSurviveReconnect(t *testing.T) { + for _, status := range []model.InputCompletionStatus{model.InputCompletionStatus_Exception, + model.InputCompletionStatus_MachineHalted, model.InputCompletionStatus_Overflow, + model.InputCompletionStatus_UnexpectedYield, model.InputCompletionStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t) + app, _ := seedProblemEpochs(t, r) + require.NoError(t, recordTestRuntimeStop(t, r, app, status)) + endpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + reconnected, err := NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + defer reconnected.Close() + state, err := reconnected.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.NotNil(t, state.Problem) + kind, _ := status.TerminalApplicationStatus() + require.Equal(t, kind, state.Application.Status) + require.Equal(t, &model.ApplicationProblem{EpochIndex: 2, Kind: kind, InputIndex: new(uint64(2))}, state.Problem) + require.Equal(t, uint64(3), state.Application.ProcessedInputs) + allowed, err := state.AllowsEpochCompletion() + require.NoError(t, err) + require.True(t, allowed) + }) + } +} + +func TestApplicationProblemEarlierIORAndFirstIntegrityStop(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epochs := seedProblemEpochs(t, r) + require.NoError(t, recordTestRuntimeStop(t, r, app, model.InputCompletionStatus_MachineHalted)) + require.NoError(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, 1, "epoch 1 root mismatch")) + require.NoError(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, 1, "same evidence retried")) + state, err := r.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_MachineHalted, state.Application.Status) + require.Equal(t, &model.ApplicationProblem{EpochIndex: 1, Kind: model.ApplicationStatus_InvalidOutputsRoot}, state.Problem) + require.Equal(t, uint64(3), state.Application.ProcessedInputs, "publication cutoff does not rewind execution") + // Defensive stale-caller case: the ordered, single validator does not + // normally compute epoch 1 concurrently with its own earlier IOR finding. + // A cut already visible when the write starts still rejects that epoch. + require.ErrorIs(t, r.StoreClaimAndProofs(t.Context(), epochs[1], nil), repository.ErrApplicationNotRunnable) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epochs[0], nil)) + for _, kind := range []model.ApplicationStatus{model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted} { + reason := "integrity finding" + err := r.UpdateApplicationStatus(t.Context(), app.ID, kind, &reason) + if kind == model.ApplicationStatus_Diverged { + require.NoError(t, err) + } else { + require.Error(t, err) + } + } + state, err = r.GetEpochCompletionState(t.Context(), app.Name, 1) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Diverged, state.Application.Status) + require.Equal(t, uint64(1), state.Problem.EpochIndex) + allowed, err := state.AllowsEpochCompletion() + require.NoError(t, err) + require.False(t, allowed) +} + +func TestApplicationProblemCorruptedPreservesStopBoundary(t *testing.T) { + for _, stop := range []model.ApplicationStatus{ + model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot, + } { + t.Run(stop.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t) + app, _ := seedProblemEpochs(t, r) + wantProblem := &model.ApplicationProblem{EpochIndex: 1, Kind: model.ApplicationStatus_InvalidOutputsRoot} + if stop == model.ApplicationStatus_MachineHalted { + require.NoError(t, recordTestRuntimeStop(t, r, app, model.InputCompletionStatus_MachineHalted)) + wantProblem = &model.ApplicationProblem{EpochIndex: 2, Kind: stop, InputIndex: new(uint64(2))} + } else { + require.NoError(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, 1, "epoch 1 root mismatch")) + } + before, err := r.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, stop, before.Application.Status) + require.Equal(t, wantProblem, before.Problem) + + reason := "persisted staged claim contradicts trusted chain observation" + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Corrupted, &reason)) + after, err := r.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Corrupted, after.Application.Status) + require.Equal(t, &reason, after.Application.Reason) + require.Equal(t, wantProblem, after.Problem, "integrity escalation must preserve epoch, kind and input") + require.Equal(t, before.Application.ProcessedInputs, after.Application.ProcessedInputs) + require.Equal(t, before.Epoch, after.Epoch) + allowed, err := after.AllowsEpochCompletion() + require.NoError(t, err) + require.False(t, allowed, "retained boundary is diagnostic context, not publication authorization") + }) + } +} + +func TestApplicationProblemRuntimeWriteRollsBackTogether(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, _ := seedProblemEpochs(t, r) + _, err := r.db.Exec(t.Context(), `ALTER TABLE application ADD CONSTRAINT node3_runtime_rollback + CHECK (status <> 'MACHINE_HALTED') NOT VALID`) + require.NoError(t, err) + err = recordTestRuntimeStop(t, r, app, model.InputCompletionStatus_MachineHalted) + require.Error(t, err) + state, err := r.GetEpochCompletionState(t.Context(), app.Name, 2) + require.NoError(t, err) + require.Nil(t, state.Problem) + require.Equal(t, model.ApplicationStatus_OK, state.Application.Status) + require.Equal(t, uint64(2), state.Application.ProcessedInputs) + input, err := r.GetInput(t.Context(), app.Name, 2) + require.NoError(t, err) + require.Equal(t, model.InputCompletionStatus_None, input.Status) + require.Nil(t, input.MachineHash) + require.Nil(t, state.Epoch.MachineHash) +} + +func TestApplicationProblemValidatorWriteRollsBackTogether(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, _ := seedProblemEpochs(t, r) + _, err := r.db.Exec(t.Context(), `ALTER TABLE application ADD CONSTRAINT node3_validator_rollback + CHECK (status <> 'INVALID_OUTPUTS_ROOT') NOT VALID`) + require.NoError(t, err) + require.Error(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, 0, "root mismatch")) + state, err := r.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Nil(t, state.Problem) + require.Equal(t, model.ApplicationStatus_OK, state.Application.Status) + require.Equal(t, model.EpochStatus_InputsProcessed, state.Epoch.Status) +} + +func TestApplicationProblemValidationRejectsStopVisibleBeforeWrite(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_Failed, + model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted} { + t.Run(status.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epochs := seedProblemEpochs(t, r) + reason := "independent service or integrity failure" + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, status, &reason)) + // Both paths begin their writes after the durable stop is visible. + // A stop arriving during the claim batch need not roll back valid work. + err := r.StoreInvalidOutputsRoot(t.Context(), app.ID, 0, "later root finding") + require.ErrorIs(t, err, repository.ErrApplicationNotRunnable) + require.ErrorIs(t, r.StoreClaimAndProofs(t.Context(), epochs[0], nil), repository.ErrApplicationNotRunnable) + state, err := r.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, status, state.Application.Status) + require.Equal(t, &reason, state.Application.Reason) + require.Nil(t, state.Problem) + require.Equal(t, model.EpochStatus_InputsProcessed, state.Epoch.Status) + }) + } +} + +func TestApplicationProblemConcurrentIOROnlyLowersBoundary(t *testing.T) { + r := newEpochPublicationRepository(t) + app, _ := seedProblemEpochs(t, r) + require.NoError(t, recordTestRuntimeStop(t, r, app, model.InputCompletionStatus_MachineHalted)) + start := make(chan struct{}) + results := make(chan error, 2) + for _, index := range []uint64{0, 1} { + go func() { <-start; results <- r.StoreInvalidOutputsRoot(t.Context(), app.ID, index, "root mismatch") }() + } + close(start) + for range 2 { + err := <-results + require.True(t, err == nil || errors.Is(err, repository.ErrApplicationNotRunnable), "unexpected writer result: %v", err) + } + state, err := r.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, &model.ApplicationProblem{EpochIndex: 0, Kind: model.ApplicationStatus_InvalidOutputsRoot}, state.Problem) + require.Equal(t, model.ApplicationStatus_MachineHalted, state.Application.Status) +} + +func TestApplicationProblemReaderDistinguishesAbsenceAndSnapshot(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, _ := seedProblemEpochs(t, r) + missing, err := r.GetEpochCompletionState(t.Context(), "missing-application", 0) + require.NoError(t, err) + require.Nil(t, missing) + state, err := r.GetEpochCompletionState(t.Context(), app.Name, 99) + require.NoError(t, err) + require.NotNil(t, state.Application) + require.Nil(t, state.Epoch) + // Exercise the exact private reads used by the public composed reader, + // with a real concurrent commit between them in its REPEATABLE READ view. + tx, err := beginReadTx(t.Context(), r.db) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + before, problem, err := getApplication(t.Context(), tx, app.Name) + require.NoError(t, err) + require.Nil(t, problem) + require.NoError(t, recordTestRuntimeStop(t, r, app, model.InputCompletionStatus_MachineHalted)) + epoch, err := getEpoch(t.Context(), tx, app.Name, 2) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_OK, before.Status) + require.Nil(t, epoch.MachineHash, "both reads precede the committed stop in this snapshot") + state, err = r.GetEpochCompletionState(t.Context(), app.Name, 2) + require.NoError(t, err) + require.NotNil(t, state.Problem) + require.NotNil(t, state.Epoch.MachineHash) +} + +func TestApplicationProblemSchemaRejectsPartialSources(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, _ := seedProblemEpochs(t, r) + for _, query := range []string{ + `UPDATE application SET problem_epoch_index=0 WHERE id=$1`, + `UPDATE application SET problem_kind='INVALID_OUTPUTS_ROOT' WHERE id=$1`, + `UPDATE application SET problem_epoch_index=0,problem_kind='OK' WHERE id=$1`, + `UPDATE application SET problem_epoch_index=0,problem_kind='MACHINE_HALTED' WHERE id=$1`, + } { + _, err := r.db.Exec(t.Context(), query, app.ID) + var pgerr *pgconn.PgError + require.ErrorAs(t, err, &pgerr) + require.Equal(t, "application_problem_shape", pgerr.ConstraintName) + } + // No physical problem relation or second lifecycle survives app removal. + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + require.NoError(t, r.DeleteApplication(t.Context(), app.ID)) + state, err := r.GetEpochCompletionState(context.Background(), app.Name, 0) + require.NoError(t, err) + require.Nil(t, state) +} diff --git a/internal/repository/postgres/bond_event.go b/internal/repository/postgres/bond_event.go new file mode 100644 index 000000000..44cbf4956 --- /dev/null +++ b/internal/repository/postgres/bond_event.go @@ -0,0 +1,91 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func (r *PostgresRepository) GetBondEvent( + ctx context.Context, nameOrAddress string, txHash common.Hash, logIndex uint64, +) (*BondEvent, error) { + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt). + FROM(table.BondEvents.INNER_JOIN(table.Application, table.BondEvents.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.BondEvents.TxHash.EQ(postgres.Bytea(txHash.Bytes())).AND(table.BondEvents.LogIndex.EQ(uint64Expr(logIndex))), + )) + sqlStr, args := sel.Sql() + value, err := scanBondEvent(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListBondEvents( + ctx context.Context, nameOrAddress string, f repository.BondEventFilter, p repository.Pagination, descending bool, +) ([]*BondEvent, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.BondEvents.INNER_JOIN(table.Application, table.BondEvents.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} + if f.EpochIndex != nil { + conditions = append(conditions, table.BondEvents.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + if f.TournamentAddress != nil { + conditions = append(conditions, table.BondEvents.TournamentAddress.EQ(postgres.Bytea(f.TournamentAddress.Bytes()))) + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.BondEvents.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY(table.BondEvents.BlockNumber.DESC(), table.BondEvents.LogIndex.DESC(), table.BondEvents.TxHash.DESC()) + } else { + sel = sel.ORDER_BY(table.BondEvents.BlockNumber.ASC(), table.BondEvents.LogIndex.ASC(), table.BondEvents.TxHash.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*BondEvent + for rows.Next() { + value, err := scanBondEvent(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} diff --git a/internal/repository/postgres/bulk.go b/internal/repository/postgres/bulk.go new file mode 100644 index 000000000..6890385c3 --- /dev/null +++ b/internal/repository/postgres/bulk.go @@ -0,0 +1,788 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + "net" + "os" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/enum" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +func encodeSiblings[T ~[32]byte](siblings []T) [][]byte { + arr := make([][]byte, len(siblings)) + for i, h := range siblings { + arr[i] = make([]byte, len(h)) + copy(arr[i], h[:]) + } + return arr +} + +func getOutputNextIndex( + ctx context.Context, + tx pgx.Tx, + appID int64, +) (uint64, error) { + + query := table.Output.SELECT( + postgres.COALESCE( + postgres.Float(1).ADD(postgres.MAXf(table.Output.Index)), + postgres.Float(0), + ), + ).FROM( + table.Output.INNER_JOIN(table.Input, table.Input.EpochApplicationID.EQ(table.Output.InputEpochApplicationID). + AND(table.Input.Index.EQ(table.Output.InputIndex))), + ).WHERE( + table.Output.InputEpochApplicationID.EQ(postgres.Int64(appID)). + AND(table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String()))), + ) + + queryStr, args := query.Sql() + var currentIndex uint64 + err := tx.QueryRow(ctx, queryStr, args...).Scan(¤tIndex) + if err != nil { + return 0, fmt.Errorf("failed to get the next output index: %w", err) + } + return currentIndex, nil +} + +func getReportNextIndex( + ctx context.Context, + tx pgx.Tx, + appID int64, +) (uint64, error) { + + query := table.Report.SELECT( + postgres.COALESCE( + postgres.Float(1).ADD(postgres.MAXf(table.Report.Index)), + postgres.Float(0), + ), + ).FROM(table.Report).WHERE( + table.Report.InputEpochApplicationID.EQ(postgres.Int64(appID)), + ) + + queryStr, args := query.Sql() + var currentIndex uint64 + err := tx.QueryRow(ctx, queryStr, args...).Scan(¤tIndex) + if err != nil { + return 0, fmt.Errorf("failed to get the next report index: %w", err) + } + return currentIndex, nil +} + +func getStateHashNextIndex( + ctx context.Context, + tx pgx.Tx, + appID int64, + epochIndex uint64, +) (uint64, error) { + + query := table.StateHashes.SELECT(table.StateHashes.Index). + WHERE( + table.StateHashes.InputEpochApplicationID.EQ(postgres.Int64(appID)). + AND(table.StateHashes.EpochIndex.EQ(uint64Expr(epochIndex))), + ). + ORDER_BY(table.StateHashes.Index.DESC()). + LIMIT(1) + queryStr, args := query.Sql() + var maxIndex uint64 + if err := tx.QueryRow(ctx, queryStr, args...).Scan(&maxIndex); errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } else if err != nil { + return 0, fmt.Errorf("failed to get the last state hash index: %w", err) + } + if maxIndex == math.MaxUint64 { + return 0, errors.New("state hash index space is exhausted") + } + return maxIndex + 1, nil +} + +func insertOutputs( + ctx context.Context, + tx pgx.Tx, + appID int64, + inputIndex uint64, + dataArray [][]byte, +) error { + if len(dataArray) < 1 { + return nil + } + + nextIndex, err := getOutputNextIndex(ctx, tx, appID) + if err != nil { + return err + } + + return copyAdvanceData(ctx, tx, table.Output, postgres.ColumnList{ + table.Output.InputEpochApplicationID, table.Output.InputIndex, table.Output.Index, table.Output.RawData, + }, appID, inputIndex, nextIndex, dataArray) +} + +func insertReports( + ctx context.Context, + tx pgx.Tx, + appID int64, + inputIndex uint64, + dataArray [][]byte, +) error { + if len(dataArray) < 1 { + return nil + } + + nextIndex, err := getReportNextIndex(ctx, tx, appID) + if err != nil { + return err + } + + return copyAdvanceData(ctx, tx, table.Report, postgres.ColumnList{ + table.Report.InputEpochApplicationID, table.Report.InputIndex, table.Report.Index, table.Report.RawData, + }, appID, inputIndex, nextIndex, dataArray) +} + +// copyAdvanceData streams rows within the result transaction, avoiding the +// extended protocol's parameter-count and aggregate Bind-message size limits. +func copyAdvanceData( + ctx context.Context, + tx pgx.Tx, + destination postgres.Table, + columns postgres.ColumnList, + appID int64, + inputIndex uint64, + nextIndex uint64, + dataArray [][]byte, +) error { + columnNames := make([]string, len(columns)) + for i, column := range columns { + columnNames[i] = column.Name() + } + tableName := destination.TableName() + copied, err := tx.CopyFrom( + ctx, + pgx.Identifier{destination.SchemaName(), tableName}, + columnNames, + pgx.CopyFromSlice(len(dataArray), func(i int) ([]any, error) { + index := nextIndex + uint64(i) //nolint:gosec // CopyFromSlice supplies nonnegative row positions. + return []any{appID, inputIndex, index, dataArray[i]}, nil + }), + ) + if err != nil { + return fmt.Errorf("failed to copy %s rows for application %d input %d: %w", + tableName, appID, inputIndex, normalizeCopyError(ctx, err)) + } + if copied != int64(len(dataArray)) { + return fmt.Errorf("copied %d %s rows for application %d input %d, expected %d", + copied, tableName, appID, inputIndex, len(dataArray)) + } + return nil +} + +// COPY's concurrent reader and writer can report a locally closed socket after +// cancellation sets its deadline. Normalize only local socket closure or deadline on a +// canceled context; server errors, peer disconnections, and mixed causes remain failures. +func normalizeCopyError(ctx context.Context, err error) error { + if ctx.Err() != context.Canceled || err == nil { + return err + } + cause := err + for { + if _, mixed := cause.(interface{ Unwrap() []error }); mixed { + return err + } + wrapper, ok := cause.(interface{ Unwrap() error }) + if !ok || wrapper.Unwrap() == nil { + break + } + cause = wrapper.Unwrap() + } + if errors.Is(cause, net.ErrClosed) || errors.Is(cause, os.ErrDeadlineExceeded) { + // Preserve the transport diagnostic while exposing cancellation as the cause. + return fmt.Errorf("COPY interrupted (%v): %w", err, context.Canceled) + } + return err +} + +func insertStateHashes( + ctx context.Context, + tx pgx.Tx, + appID int64, + epochIndex uint64, + inputIndex uint64, + hashes [][32]byte, + machineHash common.Hash, + paddingRepetitions uint64, +) error { + rowCount, err := stateHashInsertShape(uint64(len(hashes)), paddingRepetitions) + if err != nil { + return err + } + + nextIndex, err := getStateHashNextIndex(ctx, tx, appID, epochIndex) + if err != nil { + return err + } + if rowCount-1 > math.MaxUint64-nextIndex { + return fmt.Errorf( + "state hash index range overflows uint64: start=%d rows=%d", + nextIndex, + rowCount, + ) + } + + source := &stateHashCopySource{ + appID: appID, + epochIndex: epochIndex, + inputIndex: inputIndex, + nextIndex: nextIndex, + hashes: hashes, + finalEntry: model.FinalStateEntry(machineHash), + paddingRepetitions: paddingRepetitions, + rowCount: rowCount, + } + copied, err := tx.CopyFrom( + ctx, + pgx.Identifier{"public", "state_hashes"}, + []string{ + "input_epoch_application_id", + "epoch_index", + "input_index", + "index", + "machine_hash", + "repetitions", + }, + source, + ) + if err != nil { + return normalizeCopyError(ctx, err) + } + expectedCopied := int64(rowCount) //nolint:gosec // Span validation bounds rowCount far below MaxInt64. + if copied != expectedCopied { + return fmt.Errorf("copied %d state hashes, expected %d", copied, expectedCopied) + } + return nil +} + +// stateHashCopySource streams one input hash collection directly into PostgreSQL. It +// holds only the caller's hash slice and one row of CopyFrom values, so even a +// full input-span collection does not create a second multi-million-row object. +type stateHashCopySource struct { + appID int64 + epochIndex uint64 + inputIndex uint64 + nextIndex uint64 + hashes [][32]byte + finalEntry common.Hash + paddingRepetitions uint64 + rowCount uint64 + nextRow uint64 + currentRow uint64 + values [6]any +} + +func (source *stateHashCopySource) Next() bool { + if source.nextRow >= source.rowCount { + return false + } + source.currentRow = source.nextRow + source.nextRow++ + return true +} + +func (source *stateHashCopySource) Values() ([]any, error) { + source.values[0] = source.appID + source.values[1] = source.epochIndex + source.values[2] = source.inputIndex + source.values[3] = source.nextIndex + source.currentRow + if source.currentRow < uint64(len(source.hashes)) { + source.values[4] = source.hashes[source.currentRow][:] + source.values[5] = int64(1) + return source.values[:], nil + } + source.values[4] = source.finalEntry[:] + source.values[5] = source.paddingRepetitions + return source.values[:], nil +} + +func (*stateHashCopySource) Err() error { return nil } + +func stateHashInsertShape( + hashCount uint64, + paddingRepetitions uint64, +) (rowCount uint64, err error) { + if err := model.ValidateInputHashCollectionSpan(hashCount, paddingRepetitions); err != nil { + return 0, fmt.Errorf("invalid input hash collection shape: %w", err) + } + if paddingRepetitions == 0 { + return hashCount, nil + } + // Span validation bounds hashCount by the collection capacity, so appending the + // padding row cannot overflow uint64. + return hashCount + 1, nil +} + +func updateInput( + ctx context.Context, + tx pgx.Tx, + appID int64, + epochIndex uint64, + inputIndex uint64, + status model.InputCompletionStatus, + exceptionData []byte, + txBufferDataBlock common.Hash, + machineHash common.Hash, +) error { + + updStmt := table.Input. + UPDATE( + table.Input.Status, + table.Input.ExceptionData, + table.Input.MachineHash, + table.Input.TxBufferDataBlock, + ). + SET( + status, + exceptionData, + machineHash[:], + txBufferDataBlock[:], + ). + WHERE( + table.Input.EpochApplicationID.EQ(postgres.Int64(appID)). + AND(table.Input.EpochIndex.EQ(uint64Expr(epochIndex))). + AND(table.Input.Index.EQ(uint64Expr(inputIndex))). + AND(table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_None.String()))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +func updateEpochState( + ctx context.Context, + tx pgx.Tx, + appID int64, + epochIndex uint64, + proof *model.StateProof, +) error { + + updStmt := table.Epoch. + UPDATE( + table.Epoch.TxBufferDataBlock, + table.Epoch.TxBufferProof, + table.Epoch.MachineHash, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, + ). + SET( + proof.TxBufferDataBlock[:], + encodeSiblings(proof.TxBufferProof), + proof.MachineHash[:], + proof.IflagsYDataBlock[:], + encodeSiblings(proof.IflagsYProof), + proof.HtifTohostDataBlock[:], + encodeSiblings(proof.HtifTohostProof), + ). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.Epoch.Index.EQ(uint64Expr(epochIndex))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +// lockAdvanceRows acquires and validates StoreAdvanceResult's lock set in the +// shared Application -> child-row order. The application row is the aggregate +// lock that serializes multi-row work for one application; the input row lock +// then keeps the exact input's epoch and completion status stable until the +// result transaction finishes. +// +// These are PostgreSQL transaction-scoped row locks, not Go mutexes or session +// advisory locks. Commit or rollback releases them. If the node connection is +// lost, PostgreSQL aborts its transaction when it detects the disconnect; if +// PostgreSQL itself restarts, crash recovery discards the uncommitted +// transaction. A network failure can delay release until the server detects +// the dead connection (or a configured timeout terminates it), but the lock has +// no lifetime independent of that transaction and server backend. +func lockAdvanceRows( + ctx context.Context, + tx pgx.Tx, + appID int64, + epochIndex uint64, + inputIndex uint64, +) error { + // SELECT FOR NO KEY UPDATE reads the cursor and status while taking a row + // lock that conflicts with concurrent updates of this application. It does + // not modify the row and does not block ordinary SELECTs. Because each + // participating multi-row writer takes this row before an input or epoch, + // two writers cannot form an Application <-> child-row deadlock cycle. + appStmt := table.Application. + SELECT(table.Application.ProcessedInputs, table.Application.Status). + WHERE(table.Application.ID.EQ(postgres.Int64(appID))). + FOR(postgres.NO_KEY_UPDATE()) + appSQL, appArgs := appStmt.Sql() + var processedInputs uint64 + var applicationStatus model.ApplicationStatus + if err := tx.QueryRow(ctx, appSQL, appArgs...).Scan( + &processedInputs, &applicationStatus, + ); errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } else if err != nil { + return err + } + if applicationStatus != model.ApplicationStatus_OK { + classification := repository.ErrApplicationNotRunnable + if applicationStatus.IsTerminal() { + classification = errors.Join(classification, repository.ErrAdvanceAfterTerminal) + } + return fmt.Errorf( + "%w: application %d has status %q", + classification, + appID, + applicationStatus, + ) + } + if processedInputs != inputIndex { + return fmt.Errorf( + "%w: application %d expects input %d, got %d", + repository.ErrAdvanceCursorMismatch, + appID, + processedInputs, + inputIndex, + ) + } + + // SELECT FOR UPDATE is stronger because this exact input will be updated. + // Taking it after the aggregate lock makes the preflight check durable: no + // concurrent writer can change its epoch or completion status between this + // validation and the result's output/proof/application writes. + inputStmt := table.Input. + SELECT(table.Input.EpochIndex, table.Input.Status). + WHERE( + table.Input.EpochApplicationID.EQ(postgres.Int64(appID)). + AND(table.Input.Index.EQ(uint64Expr(inputIndex))), + ). + FOR(postgres.UPDATE()) + inputSQL, inputArgs := inputStmt.Sql() + var storedEpochIndex uint64 + var storedStatus model.InputCompletionStatus + if err := tx.QueryRow(ctx, inputSQL, inputArgs...).Scan(&storedEpochIndex, &storedStatus); errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } else if err != nil { + return err + } + if storedEpochIndex != epochIndex || storedStatus != model.InputCompletionStatus_None { + return fmt.Errorf( + "%w: input %d has epoch %d and status %q, result has epoch %d", + repository.ErrAdvanceCursorMismatch, + inputIndex, + storedEpochIndex, + storedStatus, + epochIndex, + ) + } + + return nil +} + +func updateApp( + ctx context.Context, + tx pgx.Tx, + appID int64, + inputIndex uint64, + completionStatus model.InputCompletionStatus, +) error { + where := table.Application.ID.EQ(postgres.Int64(appID)). + AND(table.Application.ProcessedInputs.EQ(uint64Expr(inputIndex))). + AND(table.Application.Status.EQ( + postgres.NewEnumValue(model.ApplicationStatus_OK.String()), + )) + + if applicationStatus, terminal := completionStatus.TerminalApplicationStatus(); terminal { + reason := fmt.Sprintf("input %d completed with %s", inputIndex, completionStatus) + if completionStatus == model.InputCompletionStatus_InvalidOutputsRoot { + reason += ": an accepted yield must declare exactly 32 bytes" + } + updStmt := table.Application. + UPDATE( + table.Application.ProcessedInputs, + table.Application.Status, + table.Application.Reason, + ). + SET( + uint64Expr(inputIndex+1), + postgres.NewEnumValue(applicationStatus.String()), + reason, + ). + WHERE(where) + return executeApplicationAdvanceUpdate(ctx, tx, updStmt) + } + + updStmt := table.Application. + UPDATE( + table.Application.ProcessedInputs, + ). + SET( + uint64Expr(inputIndex + 1), + ). + WHERE(where) + return executeApplicationAdvanceUpdate(ctx, tx, updStmt) +} + +func executeApplicationAdvanceUpdate( + ctx context.Context, + tx pgx.Tx, + updStmt postgres.UpdateStatement, +) error { + sqlStr, args := updStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrApplicationNotRunnable + } + return nil +} + +func (r *PostgresRepository) StoreAdvanceResult( + ctx context.Context, + appID int64, + res *model.AdvanceResult, +) error { + if res == nil { + return errors.New("advance result must not be nil") + } + if !res.Status.IsCompleted() { + return fmt.Errorf("cannot store advance result with noncompleted status %q", res.Status) + } + if err := validateAdvanceExceptionData(res.Status, res.ExceptionData); err != nil { + return err + } + if res.InputIndex == math.MaxUint64 { + return errors.New("cannot store an advance result at the maximum input index") + } + if res.Status != model.InputCompletionStatus_Accepted && len(res.Outputs) != 0 { + return fmt.Errorf("advance result with status %q must not contain outputs", res.Status) + } + if !res.IsComplete() { + return repository.ErrInvalidStateProof + } + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + // Acquire and validate the transaction-scoped Application -> Input lock set + // before inserting any effects. Holding both rows through Commit keeps the + // application cursor/status and the pending input used by this result + // unchanged while outputs, reports, proofs, and terminal status are written. + // Cancellation makes the waiting query return; the deferred rollback then + // releases any lock that this transaction had already acquired. + if err := lockAdvanceRows(ctx, tx, appID, res.EpochIndex, res.InputIndex); err != nil { + return err + } + + if res.Status == model.InputCompletionStatus_Accepted { + err = insertOutputs(ctx, tx, appID, res.InputIndex, res.Outputs) + if err != nil { + return err + } + } + // Reports are diagnostics for every completed input, independent of outputs. + if err := insertReports(ctx, tx, appID, res.InputIndex, res.Reports); err != nil { + return err + } + + if res.IsDaveConsensus { + err = insertStateHashes( + ctx, tx, appID, res.EpochIndex, res.InputIndex, + res.PeriodicStateHashes, res.MachineHash, res.PaddingRepetitions, + ) + if err != nil { + return err + } + } + + err = updateInput( + ctx, tx, appID, res.EpochIndex, res.InputIndex, res.Status, res.ExceptionData, + res.TxBufferDataBlock, res.MachineHash, + ) + if err != nil { + return err + } + + switch res.Status { + case model.InputCompletionStatus_Accepted: + err = updateEpochState(ctx, tx, appID, res.EpochIndex, &res.StateProof) + case model.InputCompletionStatus_Rejected: + // A rejection consumes the input but leaves the pre-input epoch state + // and proof tuple unchanged. + case model.InputCompletionStatus_Exception, + model.InputCompletionStatus_MachineHalted, + model.InputCompletionStatus_Overflow, + model.InputCompletionStatus_UnexpectedYield, + model.InputCompletionStatus_InvalidOutputsRoot: + err = updateEpochState(ctx, tx, appID, res.EpochIndex, &res.StateProof) + case model.InputCompletionStatus_None: + return fmt.Errorf("unsupported noncompleted advance status %q", res.Status) + default: + return fmt.Errorf("unsupported completed advance status %q", res.Status) + } + if err != nil { + return err + } + + if kind, terminal := res.Status.TerminalApplicationStatus(); terminal { + if err := recordApplicationProblem(ctx, tx, appID, model.ApplicationProblem{ + EpochIndex: res.EpochIndex, Kind: kind, InputIndex: new(res.InputIndex), + }); err != nil { + return err + } + } + + err = updateApp(ctx, tx, appID, res.InputIndex, res.Status) + if err != nil { + return err + } + + return tx.Commit(ctx) +} + +func validateAdvanceExceptionData(status model.InputCompletionStatus, data []byte) error { + switch { + case status == model.InputCompletionStatus_Exception && data == nil: + return errors.New("exception advance result must include exception data") + case status != model.InputCompletionStatus_Exception && data != nil: + return fmt.Errorf("advance result with status %q must not include exception data", status) + default: + return nil + } +} + +func updateEpochClaim( + ctx context.Context, + tx pgx.Tx, + e *model.Epoch, +) error { + + whereCriteria := postgres.AND( + table.Epoch.ApplicationID.EQ(postgres.Int64(e.ApplicationID)), + table.Epoch.Index.EQ(uint64Expr(e.Index)), + ) + + updStmt := table.Epoch. + UPDATE( + table.Epoch.Commitment, + table.Epoch.CommitmentProof, + table.Epoch.Status, + ). + SET( + hashToBytes(e.Commitment), + encodeSiblings(e.CommitmentProof), + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()), + ). + FROM(table.Application). + WHERE( + whereCriteria.AND(table.Epoch.Status.EQ(enum.EpochStatus.InputsProcessed)). + AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). + AND(table.Application.Status.IN( + append([]postgres.Expression{enum.ApplicationStatus.Ok}, executionStopStatusExpressions...)...)). + AND(table.Application.ProblemEpochIndex.IS_NULL().AND(table.Application.Status.EQ(enum.ApplicationStatus.Ok)). + OR(table.Epoch.Index.LT(table.Application.ProblemEpochIndex))), + ) + + // The application predicates use this statement's snapshot, without holding + // its row across the proof batch. A concurrent stop can commit before this + // transaction; publication decisions recheck the current status and cut. + sqlStr, args := updStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("SetEpochClaimAndInsertProofsTransaction failed: %w", err) + } + if cmd.RowsAffected() != 1 { + statusQuery := table.Epoch.SELECT(table.Epoch.Status).WHERE(whereCriteria) + query, statusArgs := statusQuery.Sql() + var status model.EpochStatus + if err := tx.QueryRow(ctx, query, statusArgs...).Scan(&status); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNoUpdate + } + return fmt.Errorf("reading epoch status after rejected claim: %w", err) + } + if status == model.EpochStatus_ClaimForeclosed { + return repository.ErrEpochForeclosed + } + if status == model.EpochStatus_InputsProcessed { + return repository.ErrApplicationNotRunnable + } + return repository.ErrNoUpdate + } + return nil +} + +func (r *PostgresRepository) StoreClaimAndProofs( + ctx context.Context, epoch *model.Epoch, write repository.ClaimProofWriteFunc, +) error { + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return fmt.Errorf("SetEpochClaimAndInsertProofsTransaction failed: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + if err := updateEpochClaim(ctx, tx, epoch); err != nil { + return err + } + if write != nil { + writer := &claimProofWriter{tx: tx, appID: epoch.ApplicationID} + if err := write(ctx, writer); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func updateLastProcessedBlock(ctx context.Context, tx pgx.Tx, appID int64, lastProcessedBlock uint64) error { + lastBlock := uint64Expr(lastProcessedBlock) + appUpdateStmt := table.Application. + UPDATE( + table.Application.LastTournamentCheckBlock, + ). + SET( + lastBlock, + ). + WHERE(postgres.AND( + table.Application.ID.EQ(postgres.Int64(appID)), + table.Application.LastTournamentCheckBlock.LT(lastBlock), + )) + + sqlStr, args := appUpdateStmt.Sql() + _, err := tx.Exec(ctx, sqlStr, args...) + return err +} diff --git a/internal/repository/postgres/bulk_test.go b/internal/repository/postgres/bulk_test.go new file mode 100644 index 000000000..056ed8277 --- /dev/null +++ b/internal/repository/postgres/bulk_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +func TestInsertAdvanceDataQualifiesPublicSchema(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx := t.Context() + repo, err := NewPostgresRepository(ctx, endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + seed := repotest.Seed(ctx, t, repo) + tx, err := repo.(*PostgresRepository).db.Begin(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = tx.Rollback(context.Background()) }) + _, err = tx.Exec(ctx, ` + CREATE TEMP TABLE output (LIKE public.output INCLUDING ALL) ON COMMIT DROP; + CREATE TEMP TABLE report (LIKE public.report INCLUDING ALL) ON COMMIT DROP; + SET LOCAL search_path TO pg_temp, public`) + require.NoError(t, err) + payloads := [][]byte{[]byte("public evidence")} + require.NoError(t, insertOutputs(ctx, tx, seed.App.ID, 0, payloads)) + require.NoError(t, insertReports(ctx, tx, seed.App.ID, 0, payloads)) + for _, tableName := range []string{table.Output.TableName(), table.Report.TableName()} { + t.Run(tableName, func(t *testing.T) { + var publicCount, shadowCount uint64 + require.NoError(t, tx.QueryRow(ctx, "SELECT count(*) FROM public."+tableName).Scan(&publicCount)) + require.NoError(t, tx.QueryRow(ctx, "SELECT count(*) FROM pg_temp."+tableName).Scan(&shadowCount)) + require.Equal(t, uint64(1), publicCount) + require.Zero(t, shadowCount) + }) + } + require.NoError(t, tx.Commit(ctx)) +} diff --git a/internal/repository/postgres/claim_observation_refresh_test.go b/internal/repository/postgres/claim_observation_refresh_test.go new file mode 100644 index 000000000..59d85aaeb --- /dev/null +++ b/internal/repository/postgres/claim_observation_refresh_test.go @@ -0,0 +1,187 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestClaimObservationRefreshPreservesContentAndRejectsStaleCAS(t *testing.T) { + for _, status := range []model.EpochStatus{model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged} { + t.Run(string(status), func(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_Quorum, status) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + var block *uint64 + if status == model.EpochStatus_ClaimStaged { + block = new(uint64(12)) + } + hash := repotest.UniqueHash() + wrongContent := *before + wrongContent.MachineHash = new(repotest.UniqueHash()) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), &wrongContent, new(hash), block), repository.ErrNoUpdate) + wrongContent = *before + wrongContent.TxBufferDataBlock = new(repotest.UniqueHash()) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), &wrongContent, new(hash), block), repository.ErrNoUpdate) + wrongContent = *before + wrongContent.LastBlock++ + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), &wrongContent, new(hash), block), repository.ErrNoUpdate) + require.NoError(t, r.RefreshClaimObservation(t.Context(), before, new(hash), block)) + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + want := *before + want.ClaimTransactionHash, want.UpdatedAt = new(hash), stored.UpdatedAt + if block != nil { + want.StagedAtBlock = block + } + require.Equal(t, want, *stored, "a refresh changes audit metadata only") + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), before, new(repotest.UniqueHash()), block), repository.ErrNoUpdate) + require.NoError(t, r.UpdateEpochWithAcceptedClaim(t.Context(), app.ID, epoch.Index, new(hash))) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), stored, new(repotest.UniqueHash()), block), repository.ErrNoUpdate) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + require.NoError(t, r.DeleteApplication(t.Context(), app.ID)) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), stored, new(repotest.UniqueHash()), block), repository.ErrNoUpdate) + removed, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Nil(t, removed) + }) + } +} + +func TestClaimObservationRefreshRejectsPRTAndHasOneConcurrentWinner(t *testing.T) { + r := newEpochPublicationRepository(t) + prtApp, prtEpoch := seedForeclosureStatus(t, r, model.Consensus_PRT, model.EpochStatus_ClaimStaged) + prtStored, err := r.GetEpoch(t.Context(), prtApp.Name, prtEpoch.Index) + require.NoError(t, err) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), prtStored, + new(repotest.UniqueHash()), new(uint64(12))), repository.ErrNoUpdate) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_Authority, model.EpochStatus_ClaimStaged) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func(hash common.Hash) { + <-start + results <- r.RefreshClaimObservation(t.Context(), before, new(hash), new(uint64(12))) + }(repotest.UniqueHash()) + } + close(start) + one, two := <-results, <-results + require.True(t, (one == nil) != (two == nil), "exactly one prior observation wins: %v / %v", one, two) + if one == nil { + require.ErrorIs(t, two, repository.ErrNoUpdate) + } else { + require.ErrorIs(t, one, repository.ErrNoUpdate) + } +} + +func TestPRTStagingClockRefreshRejectsAuthorityAndQuorum(t *testing.T) { + r := newEpochPublicationRepository(t) + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + app, epoch := seedForeclosureStatus(t, r, consensus, model.EpochStatus_ClaimStaged) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.ErrorIs(t, r.UpdateEpochStagingBlock(t.Context(), app.ID, epoch.Index, 4, 12), repository.ErrNoUpdate) + after, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before, after) + } +} + +func TestClaimObservationRefreshComparesHashAndClockSeparately(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_Quorum, model.EpochStatus_ClaimStaged) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NoError(t, r.RefreshClaimObservation(t.Context(), before, new(repotest.UniqueHash()), before.StagedAtBlock)) + before, err = r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NotNil(t, before.ClaimTransactionHash) + require.NotNil(t, before.StagedAtBlock) + wrongHash := *before + wrongHash.ClaimTransactionHash = new(repotest.UniqueHash()) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), &wrongHash, before.ClaimTransactionHash, + new(uint64(12))), repository.ErrNoUpdate, "a non-null old hash participates in CAS") + wrongClock := *before + wrongClock.StagedAtBlock = new(*before.StagedAtBlock + 1) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), &wrongClock, before.ClaimTransactionHash, + new(uint64(12))), repository.ErrNoUpdate, "the clock participates even when the hash is unchanged") + require.NoError(t, r.RefreshClaimObservation(t.Context(), before, before.ClaimTransactionHash, new(uint64(12)))) + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before.ClaimTransactionHash, stored.ClaimTransactionHash) + require.Equal(t, new(uint64(12)), stored.StagedAtBlock) +} + +func TestClaimObservationRefreshCanRecordMissingOwnVoteWithoutLosingStage(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_Quorum, model.EpochStatus_ClaimStaged) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NoError(t, r.RefreshClaimObservation(t.Context(), before, new(repotest.UniqueHash()), before.StagedAtBlock)) + before, err = r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NotNil(t, before.ClaimTransactionHash) + require.NoError(t, r.RefreshClaimObservation(t.Context(), before, nil, new(uint64(12)))) + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimStaged, stored.Status) + require.Nil(t, stored.ClaimTransactionHash, "a peer's deciding transaction is not evidence of our vote") + require.Equal(t, new(uint64(12)), stored.StagedAtBlock) + replacement := repotest.UniqueHash() + require.NoError(t, r.RefreshClaimObservation(t.Context(), stored, new(replacement), new(uint64(12)))) + stored, err = r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, new(replacement), stored.ClaimTransactionHash) +} + +func TestClaimObservationRefreshDoesNotBorrowAnotherApplicationsConsensus(t *testing.T) { + r := newEpochPublicationRepository(t) + // A matching Authority row must exist before trying the PRT refresh. Without + // the application-to-epoch join guard it would authorize the unrelated row. + seedForeclosureStatus(t, r, model.Consensus_Authority, model.EpochStatus_ClaimStaged) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_PRT, model.EpochStatus_ClaimStaged) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.ErrorIs(t, r.RefreshClaimObservation(t.Context(), before, new(repotest.UniqueHash()), + new(uint64(12))), repository.ErrNoUpdate) + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before, stored) +} + +func TestClaimObservationRefreshPreservesConcurrentAcceptance(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_Quorum, model.EpochStatus_ClaimStaged) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + acceptedHash := repotest.UniqueHash() + start := make(chan struct{}) + refreshed, accepted := make(chan error, 1), make(chan error, 1) + go func() { + <-start + refreshed <- r.RefreshClaimObservation(t.Context(), before, nil, new(uint64(12))) + }() + go func() { + <-start + accepted <- r.UpdateEpochWithAcceptedClaim(t.Context(), app.ID, epoch.Index, new(acceptedHash)) + }() + close(start) + require.NoError(t, <-accepted) + if err := <-refreshed; err != nil { + require.ErrorIs(t, err, repository.ErrNoUpdate) + } + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimAccepted, stored.Status) + require.Equal(t, new(acceptedHash), stored.ClaimTransactionHash) +} diff --git a/internal/repository/postgres/claim_proof.go b/internal/repository/postgres/claim_proof.go new file mode 100644 index 000000000..b4eba405f --- /dev/null +++ b/internal/repository/postgres/claim_proof.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +type claimProofWriter struct { + tx pgx.Tx + appID int64 +} + +func (writer *claimProofWriter) ScanOutputs( + ctx context.Context, first, last uint64, fn repository.OutputScanFunc, +) error { + return scanOutputs(ctx, writer.tx, writer.appID, first, last, fn) +} + +func (writer *claimProofWriter) StoreOutputProofs(ctx context.Context, proofs []repository.OutputProof) error { + if uint64(len(proofs)) > repository.MaxOutputRangeRows { + return fmt.Errorf("output proof batch exceeds %d rows", repository.MaxOutputRangeRows) + } + if len(proofs) == 0 { + return nil + } + batch := &pgx.Batch{} + for _, proof := range proofs { + statement := table.Output.UPDATE(table.Output.Hash, table.Output.OutputHashesSiblings). + SET(proof.Hash.Bytes(), encodeSiblings(proof.Siblings)). + WHERE(table.Output.InputEpochApplicationID.EQ(postgres.Int64(writer.appID)). + AND(table.Output.Index.EQ(uint64Expr(proof.Index)))) + query, args := statement.Sql() + batch.Queue(query, args...) + } + results := writer.tx.SendBatch(ctx, batch) + for _, proof := range proofs { + command, err := results.Exec() + if err != nil { + _ = results.Close() + return fmt.Errorf("failed to insert proof for output '%d': %w", proof.Index, err) + } + if command.RowsAffected() != 1 { + _ = results.Close() + return fmt.Errorf("failed to insert proof for output '%d': expected one affected row", proof.Index) + } + } + return results.Close() +} diff --git a/internal/repository/postgres/claim_proof_test.go b/internal/repository/postgres/claim_proof_test.go new file mode 100644 index 000000000..d664c4bbf --- /dev/null +++ b/internal/repository/postgres/claim_proof_test.go @@ -0,0 +1,78 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "strconv" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/repository" +) + +type proofBatchTx struct { + pgx.Tx + batch *pgx.Batch + result *proofBatchResults +} + +func (tx *proofBatchTx) SendBatch(_ context.Context, batch *pgx.Batch) pgx.BatchResults { + tx.batch = batch + return tx.result +} + +type proofBatchResults struct { + pgx.BatchResults + affected int + execErr, closeErr error + closed bool + calls int +} + +func (results *proofBatchResults) Exec() (pgconn.CommandTag, error) { + results.calls++ + return pgconn.NewCommandTag("UPDATE " + strconv.Itoa(results.affected)), results.execErr +} + +func (results *proofBatchResults) Close() error { + results.closed = true + return results.closeErr +} + +func TestClaimProofWriterClosesResultsOnEveryOutcome(t *testing.T) { + sentinel := errors.New("proof batch failed") + for _, test := range []struct { + name string + affected int + execErr, closeErr error + wantErr bool + }{ + {name: "success", affected: 1}, + {name: "missing row", affected: 0, wantErr: true}, + {name: "multiple rows", affected: 2, wantErr: true}, + {name: "exec error", execErr: sentinel, wantErr: true}, + {name: "close error", affected: 1, closeErr: sentinel, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + results := &proofBatchResults{affected: test.affected, execErr: test.execErr, closeErr: test.closeErr} + tx := &proofBatchTx{result: results} + writer := &claimProofWriter{tx: tx, appID: 81} + err := writer.StoreOutputProofs(t.Context(), []repository.OutputProof{{Index: 9}}) + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + if test.execErr != nil || test.closeErr != nil { + require.ErrorIs(t, err, sentinel) + } + require.True(t, results.closed) + }) + } +} diff --git a/internal/repository/postgres/claim_proof_transaction_test.go b/internal/repository/postgres/claim_proof_transaction_test.go new file mode 100644 index 000000000..406231a88 --- /dev/null +++ b/internal/repository/postgres/claim_proof_transaction_test.go @@ -0,0 +1,163 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +func TestClaimProofWriterIsNotInvokedForComputedEpoch(t *testing.T) { + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + require.NoError(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + epoch.Commitment = new(repotest.UniqueHash()) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), epoch, nil)) + called := false + err := repo.StoreClaimAndProofs(t.Context(), epoch, func(context.Context, repository.ClaimProofWriter) error { + called = true + return nil + }) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.False(t, called) +} + +func TestClaimProofWriterBetweenBatchesDoesNotLockApplication(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + require.NoError(t, repo.UpdateEpochInputsProcessed(ctx, app.Name, epoch.Index, repotest.DummyStateProof())) + epoch.Commitment = new(repotest.UniqueHash()) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + output, err := repo.GetOutput(ctx, app.Name, 0) + require.NoError(t, err) + proof := repository.OutputProof{Index: output.Index, Hash: repotest.UniqueHash()} + paused := make(chan struct{}) + release := make(chan struct{}) + result := make(chan error, 1) + completed := false + t.Cleanup(func() { + cancel() + if !completed { + select { + case err := <-result: + if err != nil { + t.Logf("claim writer stopped during cleanup: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("claim writer did not stop after cancellation") + } + } + }) + go func() { + result <- repo.StoreClaimAndProofs(ctx, epoch, func(ctx context.Context, writer repository.ClaimProofWriter) error { + if err := writer.StoreOutputProofs(ctx, []repository.OutputProof{proof}); err != nil { + return err + } + close(paused) + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + return writer.StoreOutputProofs(ctx, []repository.OutputProof{proof}) + }) + }() + select { + case <-paused: + case err := <-result: + completed = true + t.Fatalf("writer stopped before pause: %v", err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + endpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + control, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + defer control.Close(ctx) + probe, err := control.Begin(ctx) + require.NoError(t, err) + var marker int + lockErr := probe.QueryRow(ctx, "SELECT 1 FROM application WHERE id=$1 FOR UPDATE NOWAIT", app.ID).Scan(&marker) + rollbackErr := probe.Rollback(ctx) + require.NoError(t, lockErr) + require.NoError(t, rollbackErr) + require.Equal(t, 1, marker) + _, err = control.Exec(ctx, "UPDATE application SET status='FAILED', reason='concurrent stop' WHERE id=$1", app.ID) + require.NoError(t, err) + close(release) + select { + case err := <-result: + completed = true + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + state, err := repo.GetEpochCompletionState(ctx, app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Failed, state.Application.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, state.Epoch.Status) + allowed, err := state.AllowsEpochCompletion() + require.NoError(t, err) + require.False(t, allowed) + stored, err := repo.GetOutput(ctx, app.Name, output.Index) + require.NoError(t, err) + require.Equal(t, &proof.Hash, stored.Hash) +} + +func TestClaimProofWriterUpdatesOnlyItsApplication(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch := seedEpochPublication(t, r) + other, _ := seedEpochPublication(t, r, []byte("other application's output")) + require.Equal(t, app.ID+1, other.ID) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + epoch.Commitment = new(repotest.UniqueHash()) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + proof := repository.OutputProof{Index: 0, Hash: repotest.UniqueHash(), Siblings: make([]common.Hash, 63)} + for i := range proof.Siblings { + proof.Siblings[i] = repotest.UniqueHash() + } + err := r.StoreClaimAndProofs(t.Context(), epoch, func(ctx context.Context, writer repository.ClaimProofWriter) error { + seen := false + err := writer.ScanOutputs(ctx, 0, 0, func(index uint64, raw []byte) error { + require.Zero(t, index) + require.Equal(t, []byte("output"), raw) + seen = true + return nil + }) + require.NoError(t, err) + require.True(t, seen) + return writer.StoreOutputProofs(ctx, []repository.OutputProof{proof}) + }) + require.NoError(t, err) + for _, id := range []int64{app.ID, other.ID} { + var hash []byte + var siblings [][]byte + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT hash, output_hashes_siblings FROM output + WHERE input_epoch_application_id=$1 AND index=0`, id).Scan(&hash, &siblings)) + if id == app.ID { + require.Equal(t, proof.Hash.Bytes(), hash) + require.Len(t, siblings, len(proof.Siblings)) + for i := range proof.Siblings { + require.Equal(t, proof.Siblings[i].Bytes(), siblings[i]) + } + } else { + require.Nil(t, hash) + require.Empty(t, siblings) + } + } +} diff --git a/internal/repository/postgres/claim_roots.go b/internal/repository/postgres/claim_roots.go new file mode 100644 index 000000000..155875f7b --- /dev/null +++ b/internal/repository/postgres/claim_roots.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" +) + +// Input coverage, the whole validation prefix and its final roots must use one +// statement snapshot. A/Q omit empty epochs; their last validated roots carry +// forward to a later aligned B. The first blocker seek matches the partial index. +const claimRootsSQL = `SELECT a.epoch_length,a.last_input_check_block,a.template_hash, + last_epoch.index,last_epoch.machine_hash,last_epoch.tx_buffer_data_block, + blocker.index,blocker.status,a.problem_epoch_index,a.problem_kind,a.problem_input_index + FROM application a LEFT JOIN LATERAL ( + SELECT e.index,e.machine_hash,e.tx_buffer_data_block FROM epoch e + WHERE e.application_id=a.id AND e.last_block <= $2::numeric + ORDER BY e.last_block DESC,e.index DESC LIMIT 1) last_epoch ON true + LEFT JOIN LATERAL ( + SELECT e.index,e.status FROM epoch e WHERE e.application_id=a.id + AND e.first_block <= $2::numeric AND NOT e.claim_validated + ORDER BY e.first_block LIMIT 1) blocker ON true WHERE a.id=$1` + +func (r *PostgresRepository) ReadClaimRoots(ctx context.Context, appID int64, covered uint64) (repository.ClaimRoots, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + var result repository.ClaimRoots + var length, inputBlock uint64 + var template common.Hash + var epoch, blocker, problemEpoch, problemInput *uint64 + var machine, outputs *common.Hash + var blockerStatus *model.EpochStatus + var problemKind *model.ApplicationStatus + err := r.db.QueryRow(ctx, claimRootsSQL, appID, strconv.FormatUint(covered, 10)).Scan( + &length, &inputBlock, &template, &epoch, &machine, &outputs, &blocker, &blockerStatus, &problemEpoch, &problemKind, &problemInput) + if errors.Is(err, pgx.ErrNoRows) { + return result, repository.ErrNotFound + } + if err != nil { + return result, err + } + if length == 0 { + result.ReasonClass, result.Reason = "epoch_length", "registered epoch length is zero" + return result, nil + } + if covered%length != length-1 { + result.ReasonClass, result.Reason = "boundary", "covered block is not an A/Q epoch boundary" + return result, nil + } + if inputBlock >= covered && blocker == nil { + result.Ready = true + result.MachineHash = template + result.OutputsRoot = merkle.CreatePostContext()[merkle.TREE_DEPTH] + if epoch != nil { + if machine == nil || outputs == nil { + return repository.ClaimRoots{}, fmt.Errorf("validated epoch %d has no claim roots", *epoch) + } + result.MachineHash, result.OutputsRoot = *machine, *outputs + } + return result, nil + } + problem, err := decodeApplicationProblem(problemEpoch, problemKind, problemInput) + if err != nil { + result.ReasonClass, result.Reason = "stop_metadata", "local stop metadata is inconsistent: "+err.Error() + return result, nil + } + switch { + case problem != nil && problem.EpochIndex <= covered/length: + result.ReasonClass = "local_stop/" + string(problem.Kind) + result.Reason = fmt.Sprintf("local %s at epoch %d prevents validation of the covered prefix", problem.Kind, problem.EpochIndex) + case blocker != nil: + result.ReasonClass = "validation/" + string(*blockerStatus) + result.Reason = fmt.Sprintf("local %s at epoch %d is not validated", *blockerStatus, *blocker) + default: + result.ReasonClass = "input_coverage" + result.Reason = fmt.Sprintf("input coverage ends at block %d, before covered block %d", inputBlock, covered) + } + return result, nil +} diff --git a/internal/repository/postgres/claim_roots_test.go b/internal/repository/postgres/claim_roots_test.go new file mode 100644 index 000000000..f31624ab0 --- /dev/null +++ b/internal/repository/postgres/claim_roots_test.go @@ -0,0 +1,274 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func claimValidated(t *testing.T, r *PostgresRepository, appID int64, index uint64) bool { + t.Helper() + var validated bool + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT claim_validated FROM epoch WHERE application_id=$1 AND index=$2`, appID, index). + Scan(&validated)) + return validated +} + +func TestClaimValidationSurvivesBothForeclosureWriters(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + proofApp, proofEpoch, sourceProof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), proofApp.Name, proofEpoch.Index, sourceProof)) + // These are repository writer/trigger tests, not on-chain exploit fixtures. + for _, writer := range []string{"single", "bulk"} { + for _, state := range []model.EpochStatus{ + model.EpochStatus_Closed, model.EpochStatus_InputsProcessed, model.EpochStatus_ClaimComputed, + } { + t.Run(writer+"/"+state.String(), func(t *testing.T) { + app, epoch, proof := machinePublicationEpoch(t, r, false) + if state == model.EpochStatus_Closed { + // Complete bytes in CLOSED still do not establish validation. + _, err := r.db.Exec(t.Context(), `UPDATE epoch e SET + (machine_hash,tx_buffer_data_block,tx_buffer_proof,iflags_y_data_block,iflags_y_proof,htif_tohost_data_block,htif_tohost_proof)= + (SELECT machine_hash,tx_buffer_data_block,tx_buffer_proof,iflags_y_data_block,iflags_y_proof,htif_tohost_data_block,htif_tohost_proof + FROM epoch WHERE application_id=$3 AND index=$4) WHERE e.application_id=$1 AND e.index=$2`, + app.ID, epoch.Index, proofApp.ID, proofEpoch.Index) + require.NoError(t, err) + } + if state != model.EpochStatus_Closed { + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + } + if state == model.EpochStatus_ClaimComputed { + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + } + want := state == model.EpochStatus_ClaimComputed + require.Equal(t, want, claimValidated(t, r, app.ID, epoch.Index)) + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 49, repotest.UniqueHash(), 80)) + if writer == "single" { + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + } else { + count, err := r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, 49) + require.NoError(t, err) + require.EqualValues(t, 1, count) + } + require.Equal(t, want, claimValidated(t, r, app.ID, epoch.Index)) + // An idempotent CF write must neither erase validation nor infer it from + // the non-null proof carried by CLOSED/IP. It also ignores a stale value. + _, err := r.db.Exec(t.Context(), `UPDATE epoch SET status='CLAIM_FORECLOSED',claim_validated=$3 + WHERE application_id=$1 AND index=$2`, app.ID, epoch.Index, !want) + require.NoError(t, err) + require.Equal(t, want, claimValidated(t, r, app.ID, epoch.Index)) + got, err := r.ReadClaimRoots(t.Context(), app.ID, 59) + require.NoError(t, err) + require.Equal(t, want, got.Ready) + if want { + require.Equal(t, proof.MachineHash, got.MachineHash) + require.Equal(t, proof.TxBufferDataBlock, got.OutputsRoot) + } else { + require.Contains(t, got.Reason, "CLAIM_FORECLOSED") + } + }) + } + } +} + +func TestClaimValidationRollbackAndPrefix(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + _, err = tx.Exec(t.Context(), `UPDATE epoch SET status='CLAIM_COMPUTED' WHERE application_id=$1 AND index=$2`, app.ID, epoch.Index) + require.NoError(t, err) + var validated bool + require.NoError(t, tx.QueryRow(t.Context(), + `SELECT claim_validated FROM epoch WHERE application_id=$1 AND index=$2`, app.ID, epoch.Index). + Scan(&validated)) + require.True(t, validated) + require.NoError(t, tx.Rollback(t.Context())) + require.False(t, claimValidated(t, r, app.ID, epoch.Index)) + got, err := r.ReadClaimRoots(t.Context(), app.ID, 59) + require.NoError(t, err) + require.False(t, got.Ready, "IP proof bytes are not validation") + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + got, err = r.ReadClaimRoots(t.Context(), app.ID, 59) + require.NoError(t, err) + require.True(t, got.Ready) + require.Equal(t, proof.MachineHash, got.MachineHash) + require.Equal(t, proof.TxBufferDataBlock, got.OutputsRoot, "the empty successor has no row and carries the prior roots") + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Diverged, new("unrelated integrity finding"))) + got, err = r.ReadClaimRoots(t.Context(), app.ID, 59) + require.NoError(t, err) + require.True(t, got.Ready, "application health cannot hide already validated local evidence") + got, err = r.ReadClaimRoots(t.Context(), app.ID, 39) + require.NoError(t, err) + require.True(t, got.Ready, "future epochs do not block an empty earlier prefix") + require.Equal(t, app.TemplateHash, got.MachineHash) + require.Equal(t, merkle.CreatePostContext()[merkle.TREE_DEPTH], got.OutputsRoot) + got, err = r.ReadClaimRoots(t.Context(), app.ID, 58) + require.NoError(t, err) + require.False(t, got.Ready, "partial epochs cannot use carry-forward") + _, err = r.db.Exec(t.Context(), `UPDATE application SET last_input_check_block=58 WHERE id=$1`, app.ID) + require.NoError(t, err) + got, err = r.ReadClaimRoots(t.Context(), app.ID, 59) + require.NoError(t, err) + require.False(t, got.Ready, "unread inputs forbid root comparison") + require.Contains(t, got.Reason, "input coverage") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = r.ReadClaimRoots(ctx, app.ID, 59) + require.ErrorIs(t, err, context.Canceled) + _, err = r.ReadClaimRoots(t.Context(), app.ID+100000, 59) + require.ErrorIs(t, err, repository.ErrNotFound) +} + +func TestClaimValidationRequiresEveryPredecessor(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + // A complete last epoch does not validate an earlier unfinished predecessor. + // This explicit repository fixture does not emulate machine execution order. + _, err := r.db.Exec(t.Context(), `INSERT INTO epoch(application_id,index,virtual_index,first_block,last_block, + input_index_lower_bound,input_index_upper_bound,status) VALUES($1,2,2,20,29,0,0,'CLOSED')`, app.ID) + require.NoError(t, err) + got, err := r.ReadClaimRoots(t.Context(), app.ID, 59) + require.NoError(t, err) + require.False(t, got.Ready) + require.Contains(t, got.Reason, "epoch 2") +} + +func TestClaimCheckCursorIsPrivateAndMonotonic(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + cursor, err := r.GetEventLastCheckBlock(t.Context(), app.ID, model.MonitoredEvent_ClaimAccepted) + require.NoError(t, err) + require.Zero(t, cursor) + for _, block := range []uint64{9, 4, 9, 12} { + require.NoError(t, r.UpdateEventLastCheckBlock(t.Context(), []int64{app.ID}, model.MonitoredEvent_ClaimAccepted, block)) + } + cursor, err = r.GetEventLastCheckBlock(t.Context(), app.ID, model.MonitoredEvent_ClaimAccepted) + require.NoError(t, err) + require.EqualValues(t, 12, cursor) + stored, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + raw, err := json.Marshal(stored) + require.NoError(t, err) + require.NotContains(t, string(raw), "last_claim_check_block") + require.Equal(t, app.LastInputCheckBlock, stored.LastInputCheckBlock, "claim progress does not certify input coverage") +} + +func TestClaimRootsPlansBoundFutureAndValidatedHistory(t *testing.T) { + for _, history := range []string{"future backlog", "validated prefix"} { + t.Run(history, func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + _, err := r.db.Exec(t.Context(), `INSERT INTO epoch(application_id,index,virtual_index,first_block,last_block, + input_index_lower_bound,input_index_upper_bound,status) + SELECT $1,i,i,i*10,i*10+9,i,i+1,'CLOSED' FROM generate_series(100,20099) i`, app.ID) + require.NoError(t, err) + covered := uint64(59) + if history == "validated prefix" { + covered = 200999 + // This synthetic history exercises the actual qualification transition. + _, err = r.db.Exec(t.Context(), `UPDATE epoch e SET + (machine_hash,tx_buffer_data_block,tx_buffer_proof,iflags_y_data_block,iflags_y_proof,htif_tohost_data_block,htif_tohost_proof,status)= + (SELECT machine_hash,tx_buffer_data_block,tx_buffer_proof,iflags_y_data_block,iflags_y_proof,htif_tohost_data_block,htif_tohost_proof, + 'INPUTS_PROCESSED'::"EpochStatus" FROM epoch WHERE application_id=$1 AND index=4) + WHERE e.application_id=$1 AND e.index>=100`, app.ID) + require.NoError(t, err) + _, err = r.db.Exec(t.Context(), `UPDATE epoch SET status='CLAIM_COMPUTED' WHERE application_id=$1 AND index>=100`, app.ID) + require.NoError(t, err) + } + // Measure steady history. Qualification creates obsolete partial-index + // entries; they cannot be vacuumed inside the transaction being measured. + _, err = r.db.Exec(t.Context(), `VACUUM ANALYZE epoch`) + require.NoError(t, err) + _, err = r.db.Exec(t.Context(), `ANALYZE application`) + require.NoError(t, err) + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + _, err = tx.Exec(t.Context(), "PREPARE claim_roots AS "+claimRootsSQL) + require.NoError(t, err) + defer func() { _, e := tx.Exec(t.Context(), "DEALLOCATE claim_roots"); require.NoError(t, e) }() + for _, mode := range []string{epochCustomPlanMode, epochGenericPlanMode} { + _, err = tx.Exec(t.Context(), "SET LOCAL plan_cache_mode="+mode) + require.NoError(t, err) + execute := fmt.Sprintf("EXECUTE claim_roots(%d,%d)", app.ID, covered) + var raw []byte + require.NoError(t, tx.QueryRow(t.Context(), "EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) "+execute).Scan(&raw)) + require.Contains(t, string(raw), "epoch_unvalidated_idx", "the real query must use the partial prefix index") + requireBoundedEpochPlan(t, tx, execute) + } + }) + } +} + +func TestClaimValidatedProofSurvivesForeclosure(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 80, repotest.UniqueHash(), 80)) + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + _, err := r.db.Exec(t.Context(), `UPDATE epoch SET machine_hash=NULL,tx_buffer_data_block=NULL,tx_buffer_proof=NULL, + iflags_y_data_block=NULL,iflags_y_proof=NULL,htif_tohost_data_block=NULL,htif_tohost_proof=NULL + WHERE application_id=$1 AND index=$2`, app.ID, epoch.Index) + require.ErrorContains(t, err, "epoch_published_validity_proof_check") + require.True(t, claimValidated(t, r, app.ID, epoch.Index)) + roots, err := r.ReadClaimRoots(t.Context(), app.ID, 59) + require.NoError(t, err) + require.True(t, roots.Ready) + require.Equal(t, proof.MachineHash, roots.MachineHash) +} + +// Repository fixtures test row selection. The integration positives obtain +// authentic roots from the machine and stage their complete on-chain proof. +func TestClaimRootsSelectLatestValidatedEpochBeforeCoveredBlock(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, first, firstProof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, first.Index, firstProof)) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), first, nil)) + second := repotest.NewEpochBuilder(app.ID).WithIndex(5).WithBlocks(50, 59). + WithStatus(model.EpochStatus_Closed).WithInputBounds(1, 2).Build() + input := repotest.NewInputBuilder().WithIndex(1).WithBlockNumber(55).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*model.Epoch][]*model.Input{second: {input}}, 80)) + secondProof := repotest.DummyStateProof() + secondProof.MachineHash, secondProof.TxBufferDataBlock = repotest.UniqueHash(), repotest.UniqueHash() + require.NoError(t, r.StoreAdvanceResult(t.Context(), app.ID, &model.AdvanceResult{ + EpochIndex: second.Index, InputIndex: 1, Status: model.InputCompletionStatus_Accepted, StateProof: *secondProof, + })) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, second.Index, secondProof)) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), second, nil)) + _, err := r.db.Exec(t.Context(), `INSERT INTO epoch(application_id,index,virtual_index,first_block,last_block, + input_index_lower_bound,input_index_upper_bound,status) VALUES($1,7,7,70,79,2,3,'CLOSED')`, app.ID) + require.NoError(t, err) + for _, test := range []struct { + covered uint64 + proof *model.StateProof + }{ + {49, firstProof}, {59, secondProof}, {69, secondProof}, + } { + got, readErr := r.ReadClaimRoots(t.Context(), app.ID, test.covered) + require.NoError(t, readErr) + require.True(t, got.Ready, "a future unvalidated row must not block this prefix") + require.Equal(t, test.proof.MachineHash, got.MachineHash) + require.Equal(t, test.proof.TxBufferDataBlock, got.OutputsRoot) + } + got, err := r.ReadClaimRoots(t.Context(), app.ID, 79) + require.NoError(t, err) + require.False(t, got.Ready) + require.Equal(t, "validation/CLOSED", got.ReasonClass) +} diff --git a/internal/repository/postgres/claim_validation_concurrency_test.go b/internal/repository/postgres/claim_validation_concurrency_test.go new file mode 100644 index 000000000..d9a45a52e --- /dev/null +++ b/internal/repository/postgres/claim_validation_concurrency_test.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +const validationWins = "validation" + +func TestClaimValidationConcurrentForeclosure(t *testing.T) { + for _, first := range []string{validationWins, "foreclosure"} { + t.Run(first+" wins", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(ctx, app.Name, epoch.Index, proof)) + require.NoError(t, r.UpdateApplicationForeclosure(ctx, app.ID, 49, repotest.UniqueHash(), 80)) + endpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + worker := func() (*PostgresRepository, uint32) { + cfg, e := pgxpool.ParseConfig(endpoint) + require.NoError(t, e) + cfg.MaxConns = 1 + pool, e := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(t, e) + t.Cleanup(pool.Close) + lease, e := pool.Acquire(ctx) + require.NoError(t, e) + pid := lease.Conn().PgConn().PID() + lease.Release() + return &PostgresRepository{db: pool}, pid + } + publisher, publisherPID := worker() + forecloser, forecloserPID := worker() + control, err := r.db.Begin(ctx) + require.NoError(t, err) + defer rollbackDeliveryTx(control) + var index uint64 + require.NoError(t, control.QueryRow(ctx, `SELECT index FROM epoch WHERE application_id=$1 AND index=$2 FOR UPDATE`, + app.ID, epoch.Index).Scan(&index)) + waitBlocked := func(pid uint32) { + require.Eventually(t, func() bool { + var blocked bool + e := control.QueryRow(ctx, `SELECT cardinality(pg_blocking_pids($1))>0`, int64(pid)).Scan(&blocked) + return e == nil && blocked + }, 3*time.Second, 5*time.Millisecond, "writer %d must be blocked on the real epoch update", pid) + } + published := make(chan error, 1) + foreclosed := make(chan error, 1) + startPublication := func() { + go func() { published <- publisher.StoreClaimAndProofs(ctx, epoch, nil) }() + waitBlocked(publisherPID) + } + startForeclosure := func() { + go func() { foreclosed <- forecloser.UpdateEpochWithForeclosedClaim(ctx, app.ID, epoch.Index) }() + waitBlocked(forecloserPID) + } + if first == validationWins { + startPublication() + startForeclosure() + } else { + startForeclosure() + startPublication() + } + // Both SQL updates are in flight before the lock is released. PostgreSQL + // rechecks the losing writer against the row version committed by the winner. + require.NoError(t, control.Commit(ctx)) + select { + case e := <-foreclosed: + require.NoError(t, e) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + select { + case e := <-published: + if first == validationWins { + require.NoError(t, e) + } else { + require.ErrorIs(t, e, repository.ErrEpochForeclosed) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + require.Equal(t, first == validationWins, claimValidated(t, r, app.ID, epoch.Index)) + roots, err := r.ReadClaimRoots(ctx, app.ID, 59) + require.NoError(t, err) + require.Equal(t, first == validationWins, roots.Ready) + if roots.Ready { + require.Equal(t, proof.MachineHash, roots.MachineHash) + require.Equal(t, proof.TxBufferDataBlock, roots.OutputsRoot) + } + }) + } +} diff --git a/internal/repository/postgres/claim_write_concurrency_test.go b/internal/repository/postgres/claim_write_concurrency_test.go new file mode 100644 index 000000000..7e339f16a --- /dev/null +++ b/internal/repository/postgres/claim_write_concurrency_test.go @@ -0,0 +1,132 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStoreClaimAndProofsDoesNotLockApplicationAcrossProofBatch(t *testing.T) { + for _, stop := range []model.ApplicationStatus{ + model.ApplicationStatus_Failed, model.ApplicationStatus_Diverged, model.ApplicationStatus_Corrupted, + } { + t.Run(stop.String(), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + require.NoError(t, repo.UpdateEpochInputsProcessed(ctx, app.Name, epoch.Index, repotest.DummyStateProof())) + epoch, err := repo.GetEpoch(ctx, app.Name, epoch.Index) + require.NoError(t, err) + epoch.Commitment = new(repotest.UniqueHash()) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + output, err := repo.GetOutput(ctx, app.Name, 0) + require.NoError(t, err) + output.Hash = new(repotest.UniqueHash()) + output.OutputHashesSiblings = []common.Hash{repotest.UniqueHash()} + + endpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + control, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + result := make(chan error, 1) + started := false + finished := false + t.Cleanup(func() { + cancel() + cleanup, stopCleanup := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCleanup() + _, unlockErr := control.Exec(cleanup, `SELECT pg_advisory_unlock_all()`) + assert.NoError(t, unlockErr) + if started && !finished { + select { + case claimErr := <-result: + if claimErr != nil { + t.Logf("claim writer stopped during cleanup: %v", claimErr) + } + case <-cleanup.Done(): + t.Error("claim writer did not stop after cancellation") + } + } + _, dropErr := control.Exec(cleanup, `DROP TRIGGER IF EXISTS pause_claim_proofs_for_test ON output; + DROP FUNCTION IF EXISTS pause_claim_proofs_for_test()`) + assert.NoError(t, dropErr) + assert.NoError(t, control.Close(cleanup)) + }) + // Pause the real proof write after its epoch CAS and snapshot guard. + _, err = control.Exec(ctx, ` + CREATE FUNCTION pause_claim_proofs_for_test() RETURNS TRIGGER AS $$ + BEGIN + IF NEW.hash IS DISTINCT FROM OLD.hash AND NEW.hash IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(3890004); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER pause_claim_proofs_for_test AFTER UPDATE ON output + FOR EACH ROW EXECUTE FUNCTION pause_claim_proofs_for_test(); + SELECT pg_advisory_lock(3890004)`) + require.NoError(t, err) + started = true + go func() { result <- repo.StoreClaimAndProofs(ctx, epoch, repotest.WriteProofs(output)) }() + require.Eventually(t, func() bool { + var waiting bool + err := control.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_locks + WHERE locktype='advisory' AND objid=3890004 AND NOT granted)`).Scan(&waiting) + return err == nil && waiting + }, 5*time.Second, 10*time.Millisecond, "claim writer must reach the proof batch") + + // FOR UPDATE also conflicts with KEY SHARE, unlike the status + // update below. Roll the probe back before testing that update. + probe, err := control.Begin(ctx) + require.NoError(t, err) + var marker int + lockErr := probe.QueryRow(ctx, `SELECT 1 FROM application WHERE id=$1 FOR UPDATE NOWAIT`, app.ID).Scan(&marker) + rollbackErr := probe.Rollback(ctx) + require.NoError(t, lockErr, "claim proof batch must not hold any application row lock") + require.NoError(t, rollbackErr) + require.Equal(t, 1, marker) + + stopCtx, stopCancel := context.WithTimeout(ctx, 3*time.Second) + err = repo.UpdateApplicationStatus(stopCtx, app.ID, stop, new("concurrent stop")) + stopCancel() + require.NoError(t, err, "application updates must not wait for the proof batch") + _, err = control.Exec(ctx, `SELECT pg_advisory_unlock_all()`) + require.NoError(t, err) + select { + case err := <-result: + finished = true + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal("claim writer did not finish: ", ctx.Err()) + } + + state, err := repo.GetEpochCompletionState(ctx, app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, stop, state.Application.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, state.Epoch.Status, + "valid local work can commit after a concurrent stop") + require.True(t, claimValidated(t, repo.(*PostgresRepository), app.ID, epoch.Index), + "a completed validation remains qualified after the concurrent application stop") + require.Equal(t, epoch.Commitment, state.Epoch.Commitment) + storedOutput, err := repo.GetOutput(ctx, app.Name, output.Index) + require.NoError(t, err) + require.Equal(t, output.Hash, storedOutput.Hash) + require.Equal(t, output.OutputHashesSiblings, storedOutput.OutputHashesSiblings) + allowed, err := state.AllowsEpochCompletion() + require.NoError(t, err) + require.False(t, allowed, "persisted local work does not authorize publication under the stop") + }) + } +} diff --git a/internal/repository/postgres/claimer.go b/internal/repository/postgres/claimer.go new file mode 100644 index 000000000..545be09e7 --- /dev/null +++ b/internal/repository/postgres/claimer.go @@ -0,0 +1,610 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/enum" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +func (r *PostgresRepository) UpdateEpochWithSubmittedClaim( + ctx context.Context, + applicationID int64, + index uint64, + transactionHash common.Hash, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + updStmt := table.Epoch. + UPDATE( + table.Epoch.ClaimTransactionHash, + table.Epoch.Status, + ). + SET( + transactionHash, + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), + ). + FROM( + table.Application, + ). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.EQ(postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing update for submitted claim (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// RefreshClaimObservation corrects same-content re-inclusion metadata without +// rolling claim progress back or replacing a concurrent observation. +func (r *PostgresRepository) RefreshClaimObservation( + ctx context.Context, expected *model.Epoch, transactionHash *common.Hash, stagedAtBlock *uint64, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if expected == nil || expected.MachineHash == nil || expected.TxBufferDataBlock == nil || + (expected.Status != model.EpochStatus_ClaimSubmitted && expected.Status != model.EpochStatus_ClaimStaged) || + (expected.Status == model.EpochStatus_ClaimStaged && stagedAtBlock == nil) || + (transactionHash != nil && *transactionHash == (common.Hash{})) { + return fmt.Errorf("claim observation refresh requires an identified submitted or staged claim") + } + status := enum.EpochStatus.ClaimSubmitted + if expected.Status == model.EpochStatus_ClaimStaged { + status = enum.EpochStatus.ClaimStaged + } + conditions := []postgres.BoolExpression{ + table.Epoch.ApplicationID.EQ(postgres.Int64(expected.ApplicationID)), + table.Epoch.Index.EQ(uint64Expr(expected.Index)), + table.Epoch.Status.EQ(status), + table.Epoch.LastBlock.EQ(uint64Expr(expected.LastBlock)), + table.Epoch.MachineHash.EQ(postgres.Bytea(expected.MachineHash.Bytes())), + table.Epoch.TxBufferDataBlock.EQ(postgres.Bytea(expected.TxBufferDataBlock.Bytes())), + table.Application.ID.EQ(table.Epoch.ApplicationID), + table.Application.ConsensusType.IN(enum.Consensus.Authority, enum.Consensus.Quorum), + } + if expected.ClaimTransactionHash == nil { + conditions = append(conditions, table.Epoch.ClaimTransactionHash.IS_NULL()) + } else { + conditions = append(conditions, table.Epoch.ClaimTransactionHash.EQ(postgres.Bytea(expected.ClaimTransactionHash.Bytes()))) + } + if expected.StagedAtBlock == nil { + conditions = append(conditions, table.Epoch.StagedAtBlock.IS_NULL()) + } else { + conditions = append(conditions, table.Epoch.StagedAtBlock.EQ(uint64Expr(*expected.StagedAtBlock))) + } + stmt := table.Epoch.UPDATE(table.Epoch.ClaimTransactionHash).SET(hashToBytes(transactionHash)). + FROM(table.Application).WHERE(postgres.AND(conditions...)) + if stagedAtBlock != nil { + stmt = table.Epoch.UPDATE(table.Epoch.ClaimTransactionHash, table.Epoch.StagedAtBlock). + SET(hashToBytes(transactionHash), uint64Expr(*stagedAtBlock)).FROM(table.Application).WHERE(postgres.AND(conditions...)) + } + query, args := stmt.Sql() + command, err := r.db.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("refreshing claim observation (app=%d, index=%d): %w", expected.ApplicationID, expected.Index, err) + } + if command.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// UpdateEpochWithAcceptedClaim transitions an epoch to CLAIM_ACCEPTED. The +// source state may be CLAIM_SUBMITTED, CLAIM_STAGED, or CLAIM_COMPUTED — the +// trigger enforces validity per the v3 state machine: +// +// - CLAIM_STAGED → CLAIM_ACCEPTED is the normal v3 path after the staging +// period elapses and the consensus accepts the staged result. +// - CLAIM_COMPUTED → CLAIM_ACCEPTED is the deep reader-mode catch-up path, +// including a PRT node that first observes an already accepted result. +// - CLAIM_SUBMITTED → CLAIM_ACCEPTED is permitted by the trigger but not +// reached by the v3 happy path. Kept for resilience. +// +// staged_at_block is intentionally left untouched: it is a permanent fact +// (the chain block at which staging happened), kept across the transition +// to ACCEPTED for audit/forensics — same convention as +// claim_transaction_hash. The relaxed staged_requires_block CHECK permits +// this. +// +// txHash is optional: +// - When non-nil, claim_transaction_hash is set to the supplied value. +// This is the catch-up path: an epoch coming directly from +// CLAIM_COMPUTED never went through CLAIM_SUBMITTED, so the column was +// never populated. Callers that observed the ClaimAccepted event pass +// the event's tx hash here for forensic continuity. +// - When nil, claim_transaction_hash is left untouched. This is the +// normal-flow path: the column was set during the CLAIM_SUBMITTED +// transition and carries through the rest of the FSM. +func (r *PostgresRepository) UpdateEpochWithAcceptedClaim( + ctx context.Context, + applicationID int64, + index uint64, + txHash *common.Hash, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + whereClause := table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.IN( + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), + postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()), + )) + + var updStmt postgres.UpdateStatement + if txHash == nil { + updStmt = table.Epoch. + UPDATE(table.Epoch.Status). + SET(postgres.NewEnumValue(model.EpochStatus_ClaimAccepted.String())). + FROM(table.Application). + WHERE(whereClause) + } else { + updStmt = table.Epoch. + UPDATE(table.Epoch.Status, table.Epoch.ClaimTransactionHash). + SET(postgres.NewEnumValue(model.EpochStatus_ClaimAccepted.String()), *txHash). + FROM(table.Application). + WHERE(whereClause) + } + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing update for accepted claim (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// UpdateEpochWithForeclosedClaim transitions a single epoch's non-terminal claim +// status to CLAIM_FORECLOSED after application foreclosure makes the claim path +// impossible on chain. It is keyed by (application, epoch index) and applies to +// any consensus type: the Authority/Quorum claimer terminalizes a claim it can +// no longer submit, and the PRT drain terminalizes a pre-foreclosure epoch the +// tournament never accepted. Earlier non-terminal states are allowed because an +// epoch that overlaps the foreclosure block may never reach a computable claim. +// Existing CLAIM_* observations remain reconcilable under every health status; +// earlier states retain the drain guards and pending-validation protection. +func (r *PostgresRepository) UpdateEpochWithForeclosedClaim( + ctx context.Context, + applicationID int64, + index uint64, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + updStmt := table.Epoch. + UPDATE(table.Epoch.Status). + SET(postgres.NewEnumValue(model.EpochStatus_ClaimForeclosed.String())). + FROM(table.Application). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions...)). + AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). + AND(table.Application.ForecloseBlock.GT(uint64Expr(0))). + AND(table.Epoch.Status.IN(enum.EpochStatus.ClaimComputed, enum.EpochStatus.ClaimSubmitted, enum.EpochStatus.ClaimStaged). + OR(table.Application.Enabled.EQ(postgres.Bool(true)). + AND(table.Application.Status.IN( + append([]postgres.Expression{enum.ApplicationStatus.Ok}, executionStopStatusExpressions...)...)). + AND(table.Epoch.Status.NOT_EQ(enum.EpochStatus.InputsProcessed). + OR(table.Epoch.LastBlock.GT_EQ(table.Application.ForecloseBlock)). + OR(table.Application.Status.IN(executionStopStatusExpressions...). + AND(table.Epoch.Index.GT_EQ(table.Application.ProblemEpochIndex)))))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing update for foreclosed claim (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// RejectEpochAndSetApplicationDiverged atomically records that the local claim +// lost the applicable consensus/dispute process. Quorum rejection is only a +// normal outcome before the local claim has staged; once CLAIM_STAGED is +// recorded, a different staged or accepted claim for the same epoch would +// violate the contract's single-staged claim invariant. +// +// Losing consensus does not prove local storage corruption. Record DIVERGED +// even under an execution/root stop so earlier publication cannot reopen; keep +// the first integrity diagnosis. The halt does not depend on an epoch rejection +// matching a row. The returned flags identify the writes that committed. +func (r *PostgresRepository) RejectEpochAndSetApplicationDiverged( + ctx context.Context, + applicationID int64, + index uint64, + reason string, +) (repository.RejectEpochAndDivergeResult, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + var result repository.RejectEpochAndDivergeResult + tx, err := r.db.Begin(ctx) + if err != nil { + return result, fmt.Errorf("beginning transaction for rejected claim update: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + // Take the shared aggregate lock before updating the epoch. SELECT FOR NO KEY + // UPDATE reads the current status without changing the application row, but + // its transaction-scoped row lock conflicts with concurrent application + // updates. Matching StoreAdvanceResult and CreateEpochsAndInputs' Application + // -> child-row order prevents an Epoch -> Application deadlock cycle. + // + // Commit or rollback releases the lock. PostgreSQL also aborts the + // transaction after detecting a lost node connection, and crash recovery + // discards it after a database restart. Detection can be delayed by a network + // failure, but the lock is not persisted independently of the transaction. + lockAppStmt := table.Application. + SELECT(table.Application.Status). + WHERE(table.Application.ID.EQ(postgres.Int64(applicationID))). + FOR(postgres.NO_KEY_UPDATE()) + lockAppSQL, lockAppArgs := lockAppStmt.Sql() + var applicationStatus model.ApplicationStatus + if err := tx.QueryRow(ctx, lockAppSQL, lockAppArgs...).Scan(&applicationStatus); errors.Is(err, pgx.ErrNoRows) { + return result, repository.ErrNotFound + } else if err != nil { + return result, fmt.Errorf("locking application for rejected claim update (app=%d, index=%d): %w", applicationID, index, err) + } + + rejectStmt := table.Epoch. + UPDATE(table.Epoch.Status). + SET(postgres.NewEnumValue(model.EpochStatus_ClaimRejected.String())). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.IN( + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()), + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()), + )), + ) + + // The epoch reject is best-effort: an epoch outside CLAIM_COMPUTED/ + // CLAIM_SUBMITTED is left untouched. For a non-terminal application, the + // DIVERGED transition below is independent of whether this update matched. + sqlStr, args := rejectStmt.Sql() + rejectCmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return result, fmt.Errorf("executing rejected claim update (app=%d, index=%d): %w", applicationID, index, err) + } + result.EpochRejected = rejectCmd.RowsAffected() == 1 + + // Preserve the first integrity stop. A genuine mismatch under ET/IOR must + // become durable so the next tick cannot reopen earlier publication. + if applicationStatus != model.ApplicationStatus_Diverged && applicationStatus != model.ApplicationStatus_Corrupted { + appStmt := table.Application. + UPDATE( + table.Application.Status, + table.Application.Reason, + ). + SET( + model.ApplicationStatus_Diverged, + &reason, + ). + WHERE(table.Application.ID.EQ(postgres.Int64(applicationID))) + + sqlStr, args = appStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return repository.RejectEpochAndDivergeResult{}, fmt.Errorf( + "executing diverged application update (app=%d, index=%d): %w", + applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.RejectEpochAndDivergeResult{}, repository.ErrNotFound + } + result.ApplicationDiverged = true + } + + if err := tx.Commit(ctx); err != nil { + return repository.RejectEpochAndDivergeResult{}, fmt.Errorf( + "committing rejected claim update (app=%d, index=%d): %w", + applicationID, index, err) + } + return result, nil +} + +// UpdateEpochToStaged transitions an epoch from CLAIM_SUBMITTED to +// CLAIM_STAGED, recording the on-chain staging block. +func (r *PostgresRepository) UpdateEpochToStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + updStmt := table.Epoch. + UPDATE( + table.Epoch.Status, + table.Epoch.StagedAtBlock, + ). + SET( + postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), + uint64Expr(stagedAtBlock), + ). + FROM( + table.Application, + ). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.EQ( + postgres.NewEnumValue(model.EpochStatus_ClaimSubmitted.String()))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing update to staged (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// UpdateEpochStagingBlock records same-content re-inclusion without replacing +// an accepted epoch or a staging observation committed by another writer. +func (r *PostgresRepository) UpdateEpochStagingBlock( + ctx context.Context, applicationID int64, index, expectedOldBlock, newBlock uint64, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + stmt := table.Epoch.UPDATE(table.Epoch.StagedAtBlock).SET(uint64Expr(newBlock)).FROM(table.Application).WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.EQ(enum.EpochStatus.ClaimStaged)). + AND(table.Epoch.StagedAtBlock.EQ(uint64Expr(expectedOldBlock))). + AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). + AND(table.Application.ConsensusType.EQ(enum.Consensus.Prt))) + query, args := stmt.Sql() + command, err := r.db.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("refreshing epoch staging block (app=%d, index=%d): %w", applicationID, index, err) + } + if command.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// UpdateEpochReconciledStaged transitions an epoch from CLAIM_COMPUTED to +// CLAIM_STAGED without setting a claim_transaction_hash. Used by the +// chain reconciliation path when the chain has already staged the local +// result. Authority/Quorum and PRT can use this path after a restart or in +// reader mode. +func (r *PostgresRepository) UpdateEpochReconciledStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + updStmt := table.Epoch. + UPDATE( + table.Epoch.Status, + table.Epoch.StagedAtBlock, + ). + SET( + postgres.NewEnumValue(model.EpochStatus_ClaimStaged.String()), + uint64Expr(stagedAtBlock), + ). + FROM( + table.Application, + ). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Epoch.Index.EQ(uint64Expr(index))). + AND(table.Epoch.Status.EQ( + postgres.NewEnumValue(model.EpochStatus_ClaimComputed.String()))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("executing reconciled-staged update (app=%d, index=%d): %w", applicationID, index, err) + } + if cmd.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return nil +} + +// selectClaimsPage visits one next application, then reads its oldest row plus +// a resumed later window. A busy app cannot keep the next app off the next pass. +func (r *PostgresRepository) selectClaimsPage( + ctx context.Context, cursor repository.ClaimCursor, limit uint64, status model.EpochStatus, +) (repository.ClaimPage, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if cursor.AfterApplicationID < 0 || limit < 2 || limit > math.MaxInt64 { + return repository.ClaimPage{}, fmt.Errorf("claim page requires a nonnegative app cursor and a limit of at least two") + } + filter := repository.ApplicationFilter{Enabled: new(true), + ConsensusTypes: []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum}} + // Rotate over applications with work in this source. Observers keep every + // health status; publisher cursors exclude applications that cannot send. + next, err := r.nextClaimApplication(ctx, cursor.AfterApplicationID, status, cursor.PublishableOnly) + if err != nil { + return repository.ClaimPage{}, err + } + if next == 0 && cursor.AfterApplicationID != 0 { + next, err = r.nextClaimApplication(ctx, 0, status, cursor.PublishableOnly) + if err != nil { + return repository.ClaimPage{}, err + } + } + if next == 0 { + return repository.ClaimPage{}, nil + } + apps, err := r.ListApplicationsByID(ctx, filter, next-1, next, 1) + if err != nil || len(apps) == 0 { + // Deletion or disabling between these reads simply leaves this pass empty. + return repository.ClaimPage{ApplicationID: next}, err + } + app := apps[0] + page := repository.ClaimPage{ApplicationID: app.ID} + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return page, err + } + defer rollbackDeliveryTx(tx) + oldest, err := claimWindowRows(ctx, tx, app.ID, status, nil, 1, cursor.PublishableOnly) + if err != nil { + return page, err + } + if len(oldest) == 0 { + return page, nil + } + page.Work = append(page.Work, repository.ClaimWork{Application: app, Epoch: oldest[0]}) + after := oldest[0].Index + if saved, ok := cursor.Epochs[app.ID]; ok && saved > after { + after = saved + } + later, err := claimWindowRows(ctx, tx, app.ID, status, &after, int64(limit), cursor.PublishableOnly) + if err != nil { + return repository.ClaimPage{}, err + } + if uint64(len(later)) >= limit { + later = later[:limit-1] + page.NextEpoch = new(later[len(later)-1].Index) + } + for _, epoch := range later { + page.Work = append(page.Work, repository.ClaimWork{Application: app, Epoch: epoch}) + } + return page, nil +} + +func (r *PostgresRepository) nextClaimApplication( + ctx context.Context, after int64, status model.EpochStatus, publishableOnly bool, +) (int64, error) { + pendingConditions := []postgres.BoolExpression{ + table.Epoch.ApplicationID.EQ(table.Application.ID), + table.Epoch.Status.EQ(postgres.NewEnumValue(status.String())), + } + if publishableOnly { + pendingConditions = append(pendingConditions, claimPublicationCondition()) + } + pending := table.Epoch.SELECT(table.Epoch.ApplicationID).WHERE(postgres.AND(pendingConditions...)) + conditions := []postgres.BoolExpression{ + table.Application.Enabled.EQ(postgres.Bool(true)), + table.Application.ConsensusType.IN(enum.Consensus.Authority, enum.Consensus.Quorum), + table.Application.ID.GT(postgres.Int64(after)), + postgres.EXISTS(pending), + } + stmt := table.Application.SELECT(table.Application.ID).WHERE(postgres.AND(conditions...)). + ORDER_BY(table.Application.ID.ASC()).LIMIT(1) + query, args := stmt.Sql() + var id int64 + if err := r.db.QueryRow(ctx, query, args...).Scan(&id); errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } else if err != nil { + return 0, fmt.Errorf("selecting next claim application: %w", err) + } + return id, nil +} + +// Discovery must not spend publisher turns on FAILED/DC, foreclosure, or a +// suffix with no permitted epoch. The domain rechecks the coherent source +// before signing and again before POST. +func claimPublicationCondition() postgres.BoolExpression { + return table.Application.Status.IN(enum.ApplicationStatus.Ok, enum.ApplicationStatus.GuestException, + enum.ApplicationStatus.MachineHalted, enum.ApplicationStatus.McycleOverflow, + enum.ApplicationStatus.UnexpectedYield, enum.ApplicationStatus.InvalidOutputsRoot). + AND(table.Application.ForecloseBlock.EQ(uint64Expr(0))). + AND(table.Application.ProblemEpochIndex.IS_NULL().AND(table.Application.Status.EQ(enum.ApplicationStatus.Ok)). + OR(table.Epoch.Index.LT(table.Application.ProblemEpochIndex))) +} + +func claimWindowRows( + ctx context.Context, tx pgx.Tx, appID int64, status model.EpochStatus, after *uint64, limit int64, publishableOnly bool, +) ([]*model.Epoch, error) { + conditions := []postgres.BoolExpression{table.Epoch.ApplicationID.EQ(postgres.Int64(appID)), + table.Epoch.Status.EQ(postgres.NewEnumValue(status.String()))} + if after != nil { + conditions = append(conditions, table.Epoch.Index.GT(uint64Expr(*after))) + } + if publishableOnly { + conditions = append(conditions, postgres.EXISTS(table.Application.SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(table.Epoch.ApplicationID).AND(claimPublicationCondition())))) + } + stmt := table.Epoch.SELECT(table.Epoch.ApplicationID, table.Epoch.Index, table.Epoch.FirstBlock, table.Epoch.LastBlock, + table.Epoch.MachineHash, table.Epoch.TxBufferDataBlock, table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, table.Epoch.IflagsYProof, table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, table.Epoch.ClaimTransactionHash, table.Epoch.Status, + table.Epoch.StagedAtBlock, table.Epoch.VirtualIndex, table.Epoch.CreatedAt, table.Epoch.UpdatedAt). + WHERE(postgres.AND(conditions...)).ORDER_BY(table.Epoch.Index.ASC()).LIMIT(limit) + query, args := stmt.Sql() + rows, err := tx.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("reading claim window for app %d: %w", appID, err) + } + defer rows.Close() + var result []*model.Epoch + for rows.Next() { + epoch := new(model.Epoch) + if err := rows.Scan(&epoch.ApplicationID, &epoch.Index, &epoch.FirstBlock, &epoch.LastBlock, + &epoch.MachineHash, &epoch.TxBufferDataBlock, &epoch.TxBufferProof, + &epoch.IflagsYDataBlock, &epoch.IflagsYProof, &epoch.HtifTohostDataBlock, &epoch.HtifTohostProof, + &epoch.ClaimTransactionHash, &epoch.Status, &epoch.StagedAtBlock, &epoch.VirtualIndex, + &epoch.CreatedAt, &epoch.UpdatedAt); err != nil { + return nil, err + } + result = append(result, epoch) + } + return result, rows.Err() +} + +func (r *PostgresRepository) SelectClaimsToSubmitPage( + ctx context.Context, cursor repository.ClaimCursor, limit uint64, +) (repository.ClaimPage, error) { + return r.selectClaimsPage(ctx, cursor, limit, model.EpochStatus_ClaimComputed) +} + +func (r *PostgresRepository) SelectClaimsToStagePage( + ctx context.Context, cursor repository.ClaimCursor, limit uint64, +) (repository.ClaimPage, error) { + return r.selectClaimsPage(ctx, cursor, limit, model.EpochStatus_ClaimSubmitted) +} + +func (r *PostgresRepository) SelectClaimsToAcceptPage( + ctx context.Context, cursor repository.ClaimCursor, limit uint64, +) (repository.ClaimPage, error) { + return r.selectClaimsPage(ctx, cursor, limit, model.EpochStatus_ClaimStaged) +} diff --git a/internal/repository/postgres/commitment.go b/internal/repository/postgres/commitment.go new file mode 100644 index 000000000..5d67610fc --- /dev/null +++ b/internal/repository/postgres/commitment.go @@ -0,0 +1,115 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func (r *PostgresRepository) CreateCommitment(ctx context.Context, nameOrAddress string, value *Commitment) error { + if value == nil { + return fmt.Errorf("cannot create a nil commitment") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := commitmentValues(value) + values[0] = applicationID + stmt := table.Commitments.INSERT(commitmentColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +func (r *PostgresRepository) GetCommitment( + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, commitmentHex string, +) (*Commitment, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + sel := table.Commitments.SELECT(commitmentColumns, table.Commitments.CreatedAt, table.Commitments.UpdatedAt). + FROM(table.Commitments.INNER_JOIN(table.Application, table.Commitments.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Commitments.EpochIndex.EQ(uint64Expr(epochIndex)). + AND(table.Commitments.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes()))). + AND(table.Commitments.Commitment.EQ(postgres.Bytea(common.HexToHash(commitmentHex).Bytes()))), + )) + sqlStr, args := sel.Sql() + value, err := scanCommitment(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListCommitments( + ctx context.Context, nameOrAddress string, f repository.CommitmentFilter, p repository.Pagination, descending bool, +) ([]*Commitment, uint64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Commitments.INNER_JOIN(table.Application, table.Commitments.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} + if f.EpochIndex != nil { + conditions = append(conditions, table.Commitments.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + if f.TournamentAddress != nil { + conditions = append(conditions, + table.Commitments.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(*f.TournamentAddress).Bytes()))) + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.Commitments.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.Commitments.SELECT(commitmentColumns, table.Commitments.CreatedAt, table.Commitments.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY( + table.Commitments.EpochIndex.DESC(), table.Commitments.TournamentAddress.DESC(), table.Commitments.Commitment.DESC()) + } else { + sel = sel.ORDER_BY( + table.Commitments.EpochIndex.ASC(), table.Commitments.TournamentAddress.ASC(), table.Commitments.Commitment.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*Commitment + for rows.Next() { + value, err := scanCommitment(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} diff --git a/internal/repository/postgres/connection_error.go b/internal/repository/postgres/connection_error.go new file mode 100644 index 000000000..a6c85f4f3 --- /dev/null +++ b/internal/repository/postgres/connection_error.go @@ -0,0 +1,61 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "net" + "strings" + "syscall" + + "github.com/jackc/pgx/v5/pgconn" +) + +// Driver error strings can carry hostnames, usernames, database names or DSN +// values. Only typed causes and a validated SQLSTATE may enter diagnostics. +func safePostgresConnectCause(err error) string { + if errors.Is(err, context.Canceled) { + return "cancelled" + } + if errors.Is(err, context.DeadlineExceeded) { + return "connection timeout" + } + var pgError *pgconn.PgError + if errors.As(err, &pgError) { + if pgError.Code == "28P01" || pgError.Code == "28000" { + return "authentication refused" + } + if len(pgError.Code) == 5 && strings.Trim(pgError.Code, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ") == "" { + return "Postgres refused connection (SQLSTATE " + pgError.Code + ")" + } + } + var dns *net.DNSError + if errors.As(err, &dns) { + return "DNS resolution failed" + } + var certificate *tls.CertificateVerificationError + var authority x509.UnknownAuthorityError + if errors.As(err, &certificate) || errors.As(err, &authority) { + return "TLS certificate verification failed" + } + var network net.Error + if errors.As(err, &network) && network.Timeout() { + return "network timeout" + } + for _, cause := range []struct { + err error + name string + }{ + {syscall.ECONNREFUSED, "connection refused"}, {syscall.ECONNRESET, "connection reset"}, + {syscall.ENETUNREACH, "network unreachable"}, {syscall.EHOSTUNREACH, "host unreachable"}, + } { + if errors.Is(err, cause.err) { + return cause.name + } + } + return "connection failed; inspect database and network health" +} diff --git a/internal/repository/postgres/copy_cancellation_test.go b/internal/repository/postgres/copy_cancellation_test.go new file mode 100644 index 000000000..44cbed3a0 --- /dev/null +++ b/internal/repository/postgres/copy_cancellation_test.go @@ -0,0 +1,230 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "syscall" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +func TestNormalizeCopyError(t *testing.T) { + t.Parallel() + canceled, cancel := context.WithCancel(t.Context()) + cancel() + deadline, stopDeadline := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer stopDeadline() + const network = "tcp" + closed := &net.OpError{Op: "read", Net: network, Err: net.ErrClosed} + timeout := &net.OpError{Op: "write", Net: network, Err: os.ErrDeadlineExceeded} + constraint := &pgconn.PgError{Code: "23502", Message: "null raw_data"} + for _, tc := range []struct { + name string + ctx context.Context + err error + normalized bool + }{ + {"success", canceled, nil, false}, + {"cancellation", canceled, context.Canceled, false}, + {"closed socket during shutdown", canceled, closed, true}, + {"wrapped closed socket", canceled, fmt.Errorf("copy: %w", closed), true}, + {"timeout during shutdown", canceled, timeout, true}, + {"closed socket on live context", t.Context(), closed, false}, + {"timeout on live context", t.Context(), timeout, false}, + {"closed socket on deadline", deadline, closed, false}, + {"context deadline during shutdown", canceled, context.DeadlineExceeded, false}, + {"server error during shutdown", canceled, constraint, false}, + {"driver connection already closed", canceled, pgconn.ErrConnClosed, false}, + {"encoding error during shutdown", canceled, errors.New("cannot encode row"), false}, + {"peer EOF during shutdown", canceled, io.EOF, false}, + {"peer reset during shutdown", canceled, &net.OpError{Op: "read", Net: network, Err: syscall.ECONNRESET}, false}, + {"peer timeout during shutdown", canceled, &net.OpError{Op: "write", Net: network, Err: syscall.ETIMEDOUT}, false}, + {"mixed server error", canceled, errors.Join(closed, constraint), false}, + {"mixed peer error", canceled, fmt.Errorf("copy: %w", errors.Join(closed, io.EOF)), false}, + {"mixed cancellation", canceled, errors.Join(closed, context.Canceled), false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeCopyError(tc.ctx, tc.err) + if !tc.normalized { + require.Equal(t, tc.err, got, "independent failures must keep their causes") + return + } + require.ErrorIs(t, got, context.Canceled) + require.True(t, errutil.IsOnlyCancellation(got)) + require.ErrorContains(t, got, tc.err.Error(), "keep the transport diagnostic") + }) + } +} + +func TestAdvanceCopyCancellationCause(t *testing.T) { + for _, tc := range []struct { + name string + insert func(context.Context, pgx.Tx) error + }{ + {table.Output.TableName(), func(ctx context.Context, tx pgx.Tx) error { + return insertOutputs(ctx, tx, 1, 0, [][]byte{[]byte("output")}) + }}, + {table.Report.TableName(), func(ctx context.Context, tx pgx.Tx) error { + return insertReports(ctx, tx, 1, 0, [][]byte{[]byte("report")}) + }}, + {table.StateHashes.TableName(), func(ctx context.Context, tx pgx.Tx) error { + return insertStateHashes(ctx, tx, 1, 0, 0, nil, repotest.UniqueHash(), model.InputHashCollectionCapacity) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + err := tc.insert(ctx, &canceledCopyTx{cancel: cancel}) + require.ErrorIs(t, err, context.Canceled) + require.True(t, errutil.IsOnlyCancellation(err)) + require.ErrorContains(t, err, net.ErrClosed.Error()) + }) + } +} + +// canceledCopyTx returns the closed-socket variant of a canceled COPY, +// independently of pgx's reader/writer scheduling in the live database test. +type canceledCopyTx struct { + pgx.Tx + cancel context.CancelFunc +} + +func (tx *canceledCopyTx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) { + tx.cancel() + return 0, fmt.Errorf("COPY socket: %w", net.ErrClosed) +} + +func (tx *canceledCopyTx) QueryRow(context.Context, string, ...any) pgx.Row { + return copyIndexRow{} +} + +type copyIndexRow struct{} + +func (copyIndexRow) Scan(dest ...any) error { + *dest[0].(*uint64) = 0 + return nil +} + +func TestStoreAdvanceResultCancellationDuringCopy(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + for _, tableName := range []string{table.Output.TableName(), table.Report.TableName()} { + t.Run(tableName, func(t *testing.T) { + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx := t.Context() + repo, err := NewPostgresRepository(ctx, endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + control, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, control.Close(context.Background())) }) + seed := repotest.Seed(ctx, t, repo) + const copyLock = 739107 + _, err = control.Exec(ctx, `CREATE FUNCTION pause_advance_copy() RETURNS trigger AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(739107); + RETURN NEW; + END; + $$ LANGUAGE plpgsql`) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := control.Exec(cleanupCtx, "DROP FUNCTION pause_advance_copy() CASCADE") + require.NoError(t, err) + }) + _, err = control.Exec(ctx, "CREATE TRIGGER pause_advance_copy BEFORE INSERT ON "+tableName+ + " FOR EACH ROW EXECUTE FUNCTION pause_advance_copy()") + require.NoError(t, err) + _, err = control.Exec(ctx, "SELECT pg_advisory_lock($1)", copyLock) + require.NoError(t, err) + // The trigger pauses COPY at its first row so cancellation happens before Commit. + const payloadCount = 16 + payload := bytes.Repeat([]byte("x"), 2*1024*1024) + payloads := make([][]byte, payloadCount) + for i := range payloads { + payloads[i] = payload + } + result := &model.AdvanceResult{ + Status: model.InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output before reports")}, + Reports: payloads, + StateProof: *repotest.DummyStateProof(), + } + if tableName == table.Output.TableName() { + result.Outputs, result.Reports = result.Reports, result.Outputs + } + writeCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + finished := make(chan struct{}) + t.Cleanup(func() { + cancel() + cleanupCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + // Release the backend's trigger wait before dropping its function. + _, err := control.Exec(cleanupCtx, "SELECT pg_advisory_unlock($1)", copyLock) + require.NoError(t, err) + select { + case <-finished: + case <-cleanupCtx.Done(): + t.Error("COPY goroutine did not stop during cleanup") + } + }) + go func() { + defer close(finished) + done <- repo.StoreAdvanceResult(writeCtx, seed.App.ID, result) + }() + require.Eventually(t, func() bool { + var paused bool + err := control.QueryRow(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE datname = current_database() AND query LIKE 'copy %' AND wait_event = 'advisory' + )`).Scan(&paused) + return err == nil && paused + }, 5*time.Second, 10*time.Millisecond, "COPY must reach the paused trigger") + cancel() + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + require.True(t, errutil.IsOnlyCancellation(err), "%v", err) + case <-time.After(5 * time.Second): + t.Fatal("canceled COPY did not return") + } + _, err = control.Exec(ctx, "SELECT pg_advisory_unlock($1)", copyLock) + require.NoError(t, err) + for _, tableName := range []string{table.Output.TableName(), table.Report.TableName()} { + var rows uint64 + require.NoError(t, control.QueryRow(ctx, "SELECT count(*) FROM "+tableName).Scan(&rows)) + require.Zero(t, rows) + } + input, err := repo.GetInput(ctx, seed.App.Name, 0) + require.NoError(t, err) + require.Equal(t, model.InputCompletionStatus_None, input.Status) + app, err := repo.GetApplication(ctx, seed.App.Name) + require.NoError(t, err) + require.Zero(t, app.ProcessedInputs) + // A fresh connection can persist the result after the aborted transaction. + require.NoError(t, repo.StoreAdvanceResult(ctx, seed.App.ID, result)) + }) + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/applicationstatus.go b/internal/repository/postgres/db/rollupsdb/public/enum/applicationstatus.go new file mode 100644 index 000000000..58b9a4fd8 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/applicationstatus.go @@ -0,0 +1,32 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var ApplicationStatus = &struct { + Ok postgres.StringExpression + Failed postgres.StringExpression + Diverged postgres.StringExpression + Corrupted postgres.StringExpression + GuestException postgres.StringExpression + MachineHalted postgres.StringExpression + McycleOverflow postgres.StringExpression + UnexpectedYield postgres.StringExpression + InvalidOutputsRoot postgres.StringExpression +}{ + Ok: postgres.NewEnumValue("OK"), + Failed: postgres.NewEnumValue("FAILED"), + Diverged: postgres.NewEnumValue("DIVERGED"), + Corrupted: postgres.NewEnumValue("CORRUPTED"), + GuestException: postgres.NewEnumValue("GUEST_EXCEPTION"), + MachineHalted: postgres.NewEnumValue("MACHINE_HALTED"), + McycleOverflow: postgres.NewEnumValue("MCYCLE_OVERFLOW"), + UnexpectedYield: postgres.NewEnumValue("UNEXPECTED_YIELD"), + InvalidOutputsRoot: postgres.NewEnumValue("INVALID_OUTPUTS_ROOT"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go b/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go new file mode 100644 index 000000000..809e4b5d4 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/bonddisposition.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var BondDisposition = &struct { + TournamentRunning postgres.StringExpression + NoWinner postgres.StringExpression + Recoverable postgres.StringExpression + Recovered postgres.StringExpression +}{ + TournamentRunning: postgres.NewEnumValue("TOURNAMENT_RUNNING"), + NoWinner: postgres.NewEnumValue("NO_WINNER"), + Recoverable: postgres.NewEnumValue("RECOVERABLE"), + Recovered: postgres.NewEnumValue("RECOVERED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go b/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go new file mode 100644 index 000000000..aed36dcd2 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/bondeventtype.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var BondEventType = &struct { + PartialBondRefund postgres.StringExpression + BondRecovered postgres.StringExpression +}{ + PartialBondRefund: postgres.NewEnumValue("PARTIAL_BOND_REFUND"), + BondRecovered: postgres.NewEnumValue("BOND_RECOVERED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go b/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go new file mode 100644 index 000000000..2732af031 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/commitmentside.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var CommitmentSide = &struct { + One postgres.StringExpression + Two postgres.StringExpression +}{ + One: postgres.NewEnumValue("ONE"), + Two: postgres.NewEnumValue("TWO"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/consensus.go b/internal/repository/postgres/db/rollupsdb/public/enum/consensus.go new file mode 100644 index 000000000..2e52efa8f --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/consensus.go @@ -0,0 +1,20 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var Consensus = &struct { + Authority postgres.StringExpression + Quorum postgres.StringExpression + Prt postgres.StringExpression +}{ + Authority: postgres.NewEnumValue("AUTHORITY"), + Quorum: postgres.NewEnumValue("QUORUM"), + Prt: postgres.NewEnumValue("PRT"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/defaultblock.go b/internal/repository/postgres/db/rollupsdb/public/enum/defaultblock.go new file mode 100644 index 000000000..9708d01c4 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/defaultblock.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var DefaultBlock = &struct { + Finalized postgres.StringExpression + Latest postgres.StringExpression + Pending postgres.StringExpression + Safe postgres.StringExpression +}{ + Finalized: postgres.NewEnumValue("FINALIZED"), + Latest: postgres.NewEnumValue("LATEST"), + Pending: postgres.NewEnumValue("PENDING"), + Safe: postgres.NewEnumValue("SAFE"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/epochstatus.go b/internal/repository/postgres/db/rollupsdb/public/enum/epochstatus.go new file mode 100644 index 000000000..a1e63d2ea --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/epochstatus.go @@ -0,0 +1,32 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var EpochStatus = &struct { + Open postgres.StringExpression + Closed postgres.StringExpression + InputsProcessed postgres.StringExpression + ClaimComputed postgres.StringExpression + ClaimSubmitted postgres.StringExpression + ClaimStaged postgres.StringExpression + ClaimAccepted postgres.StringExpression + ClaimRejected postgres.StringExpression + ClaimForeclosed postgres.StringExpression +}{ + Open: postgres.NewEnumValue("OPEN"), + Closed: postgres.NewEnumValue("CLOSED"), + InputsProcessed: postgres.NewEnumValue("INPUTS_PROCESSED"), + ClaimComputed: postgres.NewEnumValue("CLAIM_COMPUTED"), + ClaimSubmitted: postgres.NewEnumValue("CLAIM_SUBMITTED"), + ClaimStaged: postgres.NewEnumValue("CLAIM_STAGED"), + ClaimAccepted: postgres.NewEnumValue("CLAIM_ACCEPTED"), + ClaimRejected: postgres.NewEnumValue("CLAIM_REJECTED"), + ClaimForeclosed: postgres.NewEnumValue("CLAIM_FORECLOSED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go b/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go new file mode 100644 index 000000000..09089eb1e --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/innertournamentdisposition.go @@ -0,0 +1,20 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var InnerTournamentDisposition = &struct { + Unsettled postgres.StringExpression + Winner postgres.StringExpression + Eliminable postgres.StringExpression +}{ + Unsettled: postgres.NewEnumValue("UNSETTLED"), + Winner: postgres.NewEnumValue("WINNER"), + Eliminable: postgres.NewEnumValue("ELIMINABLE"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/inputcompletionstatus.go b/internal/repository/postgres/db/rollupsdb/public/enum/inputcompletionstatus.go new file mode 100644 index 000000000..30ebe2284 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/inputcompletionstatus.go @@ -0,0 +1,30 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var InputCompletionStatus = &struct { + None postgres.StringExpression + Accepted postgres.StringExpression + Rejected postgres.StringExpression + Exception postgres.StringExpression + MachineHalted postgres.StringExpression + Overflow postgres.StringExpression + UnexpectedYield postgres.StringExpression + InvalidOutputsRoot postgres.StringExpression +}{ + None: postgres.NewEnumValue("NONE"), + Accepted: postgres.NewEnumValue("ACCEPTED"), + Rejected: postgres.NewEnumValue("REJECTED"), + Exception: postgres.NewEnumValue("EXCEPTION"), + MachineHalted: postgres.NewEnumValue("MACHINE_HALTED"), + Overflow: postgres.NewEnumValue("OVERFLOW"), + UnexpectedYield: postgres.NewEnumValue("UNEXPECTED_YIELD"), + InvalidOutputsRoot: postgres.NewEnumValue("INVALID_OUTPUTS_ROOT"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/matchdeletionreason.go b/internal/repository/postgres/db/rollupsdb/public/enum/matchdeletionreason.go new file mode 100644 index 000000000..45b52d52b --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/matchdeletionreason.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var MatchDeletionReason = &struct { + Step postgres.StringExpression + Timeout postgres.StringExpression + ChildTournament postgres.StringExpression + NotDeleted postgres.StringExpression +}{ + Step: postgres.NewEnumValue("STEP"), + Timeout: postgres.NewEnumValue("TIMEOUT"), + ChildTournament: postgres.NewEnumValue("CHILD_TOURNAMENT"), + NotDeleted: postgres.NewEnumValue("NOT_DELETED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go b/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go new file mode 100644 index 000000000..204a1ba82 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/matchphase.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var MatchPhase = &struct { + Uninitialized postgres.StringExpression + Bisecting postgres.StringExpression + ReadyToSeal postgres.StringExpression + Sealed postgres.StringExpression +}{ + Uninitialized: postgres.NewEnumValue("UNINITIALIZED"), + Bisecting: postgres.NewEnumValue("BISECTING"), + ReadyToSeal: postgres.NewEnumValue("READY_TO_SEAL"), + Sealed: postgres.NewEnumValue("SEALED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go b/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go new file mode 100644 index 000000000..2dd230bee --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/matchtimeoutoutcome.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var MatchTimeoutOutcome = &struct { + None postgres.StringExpression + OneWins postgres.StringExpression + TwoWins postgres.StringExpression + EliminateBoth postgres.StringExpression +}{ + None: postgres.NewEnumValue("NONE"), + OneWins: postgres.NewEnumValue("ONE_WINS"), + TwoWins: postgres.NewEnumValue("TWO_WINS"), + EliminateBoth: postgres.NewEnumValue("ELIMINATE_BOTH"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/snapshotpolicy.go b/internal/repository/postgres/db/rollupsdb/public/enum/snapshotpolicy.go new file mode 100644 index 000000000..fd2c91541 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/snapshotpolicy.go @@ -0,0 +1,20 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var SnapshotPolicy = &struct { + None postgres.StringExpression + EveryInput postgres.StringExpression + EveryEpoch postgres.StringExpression +}{ + None: postgres.NewEnumValue("NONE"), + EveryInput: postgres.NewEnumValue("EVERY_INPUT"), + EveryEpoch: postgres.NewEnumValue("EVERY_EPOCH"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go new file mode 100644 index 000000000..20b2f5107 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentkind.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var TournamentKind = &struct { + Leaf postgres.StringExpression + NonLeaf postgres.StringExpression +}{ + Leaf: postgres.NewEnumValue("LEAF"), + NonLeaf: postgres.NewEnumValue("NON_LEAF"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go new file mode 100644 index 000000000..483feed12 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/tournamentstandingstate.go @@ -0,0 +1,28 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var TournamentStandingState = &struct { + MatchesActive postgres.StringExpression + AwaitingClosure postgres.StringExpression + RootWinner postgres.StringExpression + RootFailed postgres.StringExpression + InnerWinner postgres.StringExpression + InnerEliminableNoWinner postgres.StringExpression + InnerEliminableWinnerExpired postgres.StringExpression +}{ + MatchesActive: postgres.NewEnumValue("MATCHES_ACTIVE"), + AwaitingClosure: postgres.NewEnumValue("AWAITING_CLOSURE"), + RootWinner: postgres.NewEnumValue("ROOT_WINNER"), + RootFailed: postgres.NewEnumValue("ROOT_FAILED"), + InnerWinner: postgres.NewEnumValue("INNER_WINNER"), + InnerEliminableNoWinner: postgres.NewEnumValue("INNER_ELIMINABLE_NO_WINNER"), + InnerEliminableWinnerExpired: postgres.NewEnumValue("INNER_ELIMINABLE_WINNER_EXPIRED"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/enum/winnercommitment.go b/internal/repository/postgres/db/rollupsdb/public/enum/winnercommitment.go new file mode 100644 index 000000000..29be1aa40 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/enum/winnercommitment.go @@ -0,0 +1,20 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package enum + +import "github.com/go-jet/jet/v2/postgres" + +var WinnerCommitment = &struct { + None postgres.StringExpression + One postgres.StringExpression + Two postgres.StringExpression +}{ + None: postgres.NewEnumValue("NONE"), + One: postgres.NewEnumValue("ONE"), + Two: postgres.NewEnumValue("TWO"), +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/application.go b/internal/repository/postgres/db/rollupsdb/public/table/application.go new file mode 100644 index 000000000..e17da0ad6 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/application.go @@ -0,0 +1,189 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Application = newApplicationTable("public", "application", "") + +type applicationTable struct { + postgres.Table + + // Columns + ID postgres.ColumnInteger + Name postgres.ColumnString + IapplicationAddress postgres.ColumnBytea + IconsensusAddress postgres.ColumnBytea + IinputboxAddress postgres.ColumnBytea + IinputboxBlock postgres.ColumnFloat + TemplateHash postgres.ColumnBytea + TemplateURI postgres.ColumnString + EpochLength postgres.ColumnFloat + ClaimStagingPeriod postgres.ColumnFloat + WithdrawalGuardian postgres.ColumnBytea + WithdrawalLog2LeavesPerAccount postgres.ColumnInteger + WithdrawalLog2MaxNumOfAccounts postgres.ColumnInteger + WithdrawalAccountsDriveStartIndex postgres.ColumnFloat + WithdrawalOutputBuilder postgres.ColumnBytea + ConsensusType postgres.ColumnString + Enabled postgres.ColumnBool + Status postgres.ColumnString + Reason postgres.ColumnString + ProblemEpochIndex postgres.ColumnFloat + ProblemKind postgres.ColumnString + ProblemInputIndex postgres.ColumnFloat + LastEpochCheckBlock postgres.ColumnFloat + LastInputCheckBlock postgres.ColumnFloat + LastClaimCheckBlock postgres.ColumnFloat + LastOutputCheckBlock postgres.ColumnFloat + LastTournamentCheckBlock postgres.ColumnFloat + LastForecloseCheckBlock postgres.ColumnFloat + LastAccountsDriveProvedCheckBlock postgres.ColumnFloat + LastWithdrawalCheckBlock postgres.ColumnFloat + ProcessedInputs postgres.ColumnFloat + ForecloseBlock postgres.ColumnFloat + ForecloseTransaction postgres.ColumnBytea + AccountsDriveProvedBlock postgres.ColumnFloat + AccountsDriveProvedTransaction postgres.ColumnBytea + AccountsDriveMerkleRoot postgres.ColumnBytea + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type ApplicationTable struct { + applicationTable + + EXCLUDED applicationTable +} + +// AS creates new ApplicationTable with assigned alias +func (a ApplicationTable) AS(alias string) *ApplicationTable { + return newApplicationTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new ApplicationTable with assigned schema name +func (a ApplicationTable) FromSchema(schemaName string) *ApplicationTable { + return newApplicationTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new ApplicationTable with assigned table prefix +func (a ApplicationTable) WithPrefix(prefix string) *ApplicationTable { + return newApplicationTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new ApplicationTable with assigned table suffix +func (a ApplicationTable) WithSuffix(suffix string) *ApplicationTable { + return newApplicationTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newApplicationTable(schemaName, tableName, alias string) *ApplicationTable { + return &ApplicationTable{ + applicationTable: newApplicationTableImpl(schemaName, tableName, alias), + EXCLUDED: newApplicationTableImpl("", "excluded", ""), + } +} + +func newApplicationTableImpl(schemaName, tableName, alias string) applicationTable { + var ( + IDColumn = postgres.IntegerColumn("id") + NameColumn = postgres.StringColumn("name") + IapplicationAddressColumn = postgres.ByteaColumn("iapplication_address") + IconsensusAddressColumn = postgres.ByteaColumn("iconsensus_address") + IinputboxAddressColumn = postgres.ByteaColumn("iinputbox_address") + IinputboxBlockColumn = postgres.FloatColumn("iinputbox_block") + TemplateHashColumn = postgres.ByteaColumn("template_hash") + TemplateURIColumn = postgres.StringColumn("template_uri") + EpochLengthColumn = postgres.FloatColumn("epoch_length") + ClaimStagingPeriodColumn = postgres.FloatColumn("claim_staging_period") + WithdrawalGuardianColumn = postgres.ByteaColumn("withdrawal_guardian") + WithdrawalLog2LeavesPerAccountColumn = postgres.IntegerColumn("withdrawal_log2_leaves_per_account") + WithdrawalLog2MaxNumOfAccountsColumn = postgres.IntegerColumn("withdrawal_log2_max_num_of_accounts") + WithdrawalAccountsDriveStartIndexColumn = postgres.FloatColumn("withdrawal_accounts_drive_start_index") + WithdrawalOutputBuilderColumn = postgres.ByteaColumn("withdrawal_output_builder") + ConsensusTypeColumn = postgres.StringColumn("consensus_type") + EnabledColumn = postgres.BoolColumn("enabled") + StatusColumn = postgres.StringColumn("status") + ReasonColumn = postgres.StringColumn("reason") + ProblemEpochIndexColumn = postgres.FloatColumn("problem_epoch_index") + ProblemKindColumn = postgres.StringColumn("problem_kind") + ProblemInputIndexColumn = postgres.FloatColumn("problem_input_index") + LastEpochCheckBlockColumn = postgres.FloatColumn("last_epoch_check_block") + LastInputCheckBlockColumn = postgres.FloatColumn("last_input_check_block") + LastClaimCheckBlockColumn = postgres.FloatColumn("last_claim_check_block") + LastOutputCheckBlockColumn = postgres.FloatColumn("last_output_check_block") + LastTournamentCheckBlockColumn = postgres.FloatColumn("last_tournament_check_block") + LastForecloseCheckBlockColumn = postgres.FloatColumn("last_foreclose_check_block") + LastAccountsDriveProvedCheckBlockColumn = postgres.FloatColumn("last_accounts_drive_proved_check_block") + LastWithdrawalCheckBlockColumn = postgres.FloatColumn("last_withdrawal_check_block") + ProcessedInputsColumn = postgres.FloatColumn("processed_inputs") + ForecloseBlockColumn = postgres.FloatColumn("foreclose_block") + ForecloseTransactionColumn = postgres.ByteaColumn("foreclose_transaction") + AccountsDriveProvedBlockColumn = postgres.FloatColumn("accounts_drive_proved_block") + AccountsDriveProvedTransactionColumn = postgres.ByteaColumn("accounts_drive_proved_transaction") + AccountsDriveMerkleRootColumn = postgres.ByteaColumn("accounts_drive_merkle_root") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{IDColumn, NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, ProblemEpochIndexColumn, ProblemKindColumn, ProblemInputIndexColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastClaimCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{NameColumn, IapplicationAddressColumn, IconsensusAddressColumn, IinputboxAddressColumn, IinputboxBlockColumn, TemplateHashColumn, TemplateURIColumn, EpochLengthColumn, ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, ConsensusTypeColumn, EnabledColumn, StatusColumn, ReasonColumn, ProblemEpochIndexColumn, ProblemKindColumn, ProblemInputIndexColumn, LastEpochCheckBlockColumn, LastInputCheckBlockColumn, LastClaimCheckBlockColumn, LastOutputCheckBlockColumn, LastTournamentCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ProcessedInputsColumn, ForecloseBlockColumn, ForecloseTransactionColumn, AccountsDriveProvedBlockColumn, AccountsDriveProvedTransactionColumn, AccountsDriveMerkleRootColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{ClaimStagingPeriodColumn, WithdrawalGuardianColumn, WithdrawalLog2LeavesPerAccountColumn, WithdrawalLog2MaxNumOfAccountsColumn, WithdrawalAccountsDriveStartIndexColumn, WithdrawalOutputBuilderColumn, EnabledColumn, StatusColumn, LastClaimCheckBlockColumn, LastForecloseCheckBlockColumn, LastAccountsDriveProvedCheckBlockColumn, LastWithdrawalCheckBlockColumn, ForecloseBlockColumn, AccountsDriveProvedBlockColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return applicationTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + Name: NameColumn, + IapplicationAddress: IapplicationAddressColumn, + IconsensusAddress: IconsensusAddressColumn, + IinputboxAddress: IinputboxAddressColumn, + IinputboxBlock: IinputboxBlockColumn, + TemplateHash: TemplateHashColumn, + TemplateURI: TemplateURIColumn, + EpochLength: EpochLengthColumn, + ClaimStagingPeriod: ClaimStagingPeriodColumn, + WithdrawalGuardian: WithdrawalGuardianColumn, + WithdrawalLog2LeavesPerAccount: WithdrawalLog2LeavesPerAccountColumn, + WithdrawalLog2MaxNumOfAccounts: WithdrawalLog2MaxNumOfAccountsColumn, + WithdrawalAccountsDriveStartIndex: WithdrawalAccountsDriveStartIndexColumn, + WithdrawalOutputBuilder: WithdrawalOutputBuilderColumn, + ConsensusType: ConsensusTypeColumn, + Enabled: EnabledColumn, + Status: StatusColumn, + Reason: ReasonColumn, + ProblemEpochIndex: ProblemEpochIndexColumn, + ProblemKind: ProblemKindColumn, + ProblemInputIndex: ProblemInputIndexColumn, + LastEpochCheckBlock: LastEpochCheckBlockColumn, + LastInputCheckBlock: LastInputCheckBlockColumn, + LastClaimCheckBlock: LastClaimCheckBlockColumn, + LastOutputCheckBlock: LastOutputCheckBlockColumn, + LastTournamentCheckBlock: LastTournamentCheckBlockColumn, + LastForecloseCheckBlock: LastForecloseCheckBlockColumn, + LastAccountsDriveProvedCheckBlock: LastAccountsDriveProvedCheckBlockColumn, + LastWithdrawalCheckBlock: LastWithdrawalCheckBlockColumn, + ProcessedInputs: ProcessedInputsColumn, + ForecloseBlock: ForecloseBlockColumn, + ForecloseTransaction: ForecloseTransactionColumn, + AccountsDriveProvedBlock: AccountsDriveProvedBlockColumn, + AccountsDriveProvedTransaction: AccountsDriveProvedTransactionColumn, + AccountsDriveMerkleRoot: AccountsDriveMerkleRootColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go b/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go new file mode 100644 index 000000000..2da37d50d --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/bond_events.go @@ -0,0 +1,123 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var BondEvents = newBondEventsTable("public", "bond_events", "") + +type bondEventsTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + Type postgres.ColumnString + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + Recipient postgres.ColumnBytea + Value postgres.ColumnFloat + Success postgres.ColumnBool + Commitment postgres.ColumnBytea + Claimer postgres.ColumnBytea + Payment postgres.ColumnFloat + Burned postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type BondEventsTable struct { + bondEventsTable + + EXCLUDED bondEventsTable +} + +// AS creates new BondEventsTable with assigned alias +func (a BondEventsTable) AS(alias string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new BondEventsTable with assigned schema name +func (a BondEventsTable) FromSchema(schemaName string) *BondEventsTable { + return newBondEventsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new BondEventsTable with assigned table prefix +func (a BondEventsTable) WithPrefix(prefix string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new BondEventsTable with assigned table suffix +func (a BondEventsTable) WithSuffix(suffix string) *BondEventsTable { + return newBondEventsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newBondEventsTable(schemaName, tableName, alias string) *BondEventsTable { + return &BondEventsTable{ + bondEventsTable: newBondEventsTableImpl(schemaName, tableName, alias), + EXCLUDED: newBondEventsTableImpl("", "excluded", ""), + } +} + +func newBondEventsTableImpl(schemaName, tableName, alias string) bondEventsTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + TypeColumn = postgres.StringColumn("type") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + RecipientColumn = postgres.ByteaColumn("recipient") + ValueColumn = postgres.FloatColumn("value") + SuccessColumn = postgres.BoolColumn("success") + CommitmentColumn = postgres.ByteaColumn("commitment") + ClaimerColumn = postgres.ByteaColumn("claimer") + PaymentColumn = postgres.FloatColumn("payment") + BurnedColumn = postgres.FloatColumn("burned") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, TypeColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, RecipientColumn, ValueColumn, SuccessColumn, CommitmentColumn, ClaimerColumn, PaymentColumn, BurnedColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, TournamentAddressColumn, TypeColumn, BlockNumberColumn, RecipientColumn, ValueColumn, SuccessColumn, CommitmentColumn, ClaimerColumn, PaymentColumn, BurnedColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return bondEventsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + Type: TypeColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + Recipient: RecipientColumn, + Value: ValueColumn, + Success: SuccessColumn, + Commitment: CommitmentColumn, + Claimer: ClaimerColumn, + Payment: PaymentColumn, + Burned: BurnedColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/commitments.go b/internal/repository/postgres/db/rollupsdb/public/table/commitments.go new file mode 100644 index 000000000..2b55d01dc --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/commitments.go @@ -0,0 +1,123 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Commitments = newCommitmentsTable("public", "commitments", "") + +type commitmentsTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + Commitment postgres.ColumnBytea + FinalStateHash postgres.ColumnBytea + SubmitterAddress postgres.ColumnBytea + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Claimer postgres.ColumnBytea + ClockRunning postgres.ColumnBool + ClockDeadline postgres.ColumnFloat + ClockAllowance postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type CommitmentsTable struct { + commitmentsTable + + EXCLUDED commitmentsTable +} + +// AS creates new CommitmentsTable with assigned alias +func (a CommitmentsTable) AS(alias string) *CommitmentsTable { + return newCommitmentsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new CommitmentsTable with assigned schema name +func (a CommitmentsTable) FromSchema(schemaName string) *CommitmentsTable { + return newCommitmentsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new CommitmentsTable with assigned table prefix +func (a CommitmentsTable) WithPrefix(prefix string) *CommitmentsTable { + return newCommitmentsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new CommitmentsTable with assigned table suffix +func (a CommitmentsTable) WithSuffix(suffix string) *CommitmentsTable { + return newCommitmentsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newCommitmentsTable(schemaName, tableName, alias string) *CommitmentsTable { + return &CommitmentsTable{ + commitmentsTable: newCommitmentsTableImpl(schemaName, tableName, alias), + EXCLUDED: newCommitmentsTableImpl("", "excluded", ""), + } +} + +func newCommitmentsTableImpl(schemaName, tableName, alias string) commitmentsTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + CommitmentColumn = postgres.ByteaColumn("commitment") + FinalStateHashColumn = postgres.ByteaColumn("final_state_hash") + SubmitterAddressColumn = postgres.ByteaColumn("submitter_address") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + ClaimerColumn = postgres.ByteaColumn("claimer") + ClockRunningColumn = postgres.BoolColumn("clock_running") + ClockDeadlineColumn = postgres.FloatColumn("clock_deadline") + ClockAllowanceColumn = postgres.FloatColumn("clock_allowance") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, CommitmentColumn, FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, AsOfBlockColumn, ClaimerColumn, ClockRunningColumn, ClockDeadlineColumn, ClockAllowanceColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{FinalStateHashColumn, SubmitterAddressColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, AsOfBlockColumn, ClaimerColumn, ClockRunningColumn, ClockDeadlineColumn, ClockAllowanceColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return commitmentsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + Commitment: CommitmentColumn, + FinalStateHash: FinalStateHashColumn, + SubmitterAddress: SubmitterAddressColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Claimer: ClaimerColumn, + ClockRunning: ClockRunningColumn, + ClockDeadline: ClockDeadlineColumn, + ClockAllowance: ClockAllowanceColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/deployment_admission_state.go b/internal/repository/postgres/db/rollupsdb/public/table/deployment_admission_state.go new file mode 100644 index 000000000..d1c7aba43 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/deployment_admission_state.go @@ -0,0 +1,87 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DeploymentAdmissionState = newDeploymentAdmissionStateTable("public", "deployment_admission_state", "") + +type deploymentAdmissionStateTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + ResetGeneration postgres.ColumnInteger + ConfirmedTier postgres.ColumnString + DeploymentReason postgres.ColumnString + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DeploymentAdmissionStateTable struct { + deploymentAdmissionStateTable + + EXCLUDED deploymentAdmissionStateTable +} + +// AS creates new DeploymentAdmissionStateTable with assigned alias +func (a DeploymentAdmissionStateTable) AS(alias string) *DeploymentAdmissionStateTable { + return newDeploymentAdmissionStateTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DeploymentAdmissionStateTable with assigned schema name +func (a DeploymentAdmissionStateTable) FromSchema(schemaName string) *DeploymentAdmissionStateTable { + return newDeploymentAdmissionStateTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DeploymentAdmissionStateTable with assigned table prefix +func (a DeploymentAdmissionStateTable) WithPrefix(prefix string) *DeploymentAdmissionStateTable { + return newDeploymentAdmissionStateTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DeploymentAdmissionStateTable with assigned table suffix +func (a DeploymentAdmissionStateTable) WithSuffix(suffix string) *DeploymentAdmissionStateTable { + return newDeploymentAdmissionStateTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDeploymentAdmissionStateTable(schemaName, tableName, alias string) *DeploymentAdmissionStateTable { + return &DeploymentAdmissionStateTable{ + deploymentAdmissionStateTable: newDeploymentAdmissionStateTableImpl(schemaName, tableName, alias), + EXCLUDED: newDeploymentAdmissionStateTableImpl("", "excluded", ""), + } +} + +func newDeploymentAdmissionStateTableImpl(schemaName, tableName, alias string) deploymentAdmissionStateTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + ResetGenerationColumn = postgres.IntegerColumn("reset_generation") + ConfirmedTierColumn = postgres.StringColumn("confirmed_tier") + DeploymentReasonColumn = postgres.StringColumn("deployment_reason") + allColumns = postgres.ColumnList{ApplicationIDColumn, ResetGenerationColumn, ConfirmedTierColumn, DeploymentReasonColumn} + mutableColumns = postgres.ColumnList{ResetGenerationColumn, ConfirmedTierColumn, DeploymentReasonColumn} + defaultColumns = postgres.ColumnList{ResetGenerationColumn} + ) + + return deploymentAdmissionStateTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + ResetGeneration: ResetGenerationColumn, + ConfirmedTier: ConfirmedTierColumn, + DeploymentReason: DeploymentReasonColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/epoch.go b/internal/repository/postgres/db/rollupsdb/public/table/epoch.go new file mode 100644 index 000000000..bc433479b --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/epoch.go @@ -0,0 +1,144 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Epoch = newEpochTable("public", "epoch", "") + +type epochTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + Index postgres.ColumnFloat + FirstBlock postgres.ColumnFloat + LastBlock postgres.ColumnFloat + InputIndexLowerBound postgres.ColumnFloat + InputIndexUpperBound postgres.ColumnFloat + MachineHash postgres.ColumnBytea + TxBufferDataBlock postgres.ColumnBytea + TxBufferProof postgres.ColumnByteaArray + IflagsYDataBlock postgres.ColumnBytea + IflagsYProof postgres.ColumnByteaArray + HtifTohostDataBlock postgres.ColumnBytea + HtifTohostProof postgres.ColumnByteaArray + Commitment postgres.ColumnBytea + CommitmentProof postgres.ColumnByteaArray + TournamentAddress postgres.ColumnBytea + ClaimTransactionHash postgres.ColumnBytea + Status postgres.ColumnString + ClaimValidated postgres.ColumnBool + StagedAtBlock postgres.ColumnFloat + VirtualIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type EpochTable struct { + epochTable + + EXCLUDED epochTable +} + +// AS creates new EpochTable with assigned alias +func (a EpochTable) AS(alias string) *EpochTable { + return newEpochTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new EpochTable with assigned schema name +func (a EpochTable) FromSchema(schemaName string) *EpochTable { + return newEpochTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new EpochTable with assigned table prefix +func (a EpochTable) WithPrefix(prefix string) *EpochTable { + return newEpochTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new EpochTable with assigned table suffix +func (a EpochTable) WithSuffix(suffix string) *EpochTable { + return newEpochTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newEpochTable(schemaName, tableName, alias string) *EpochTable { + return &EpochTable{ + epochTable: newEpochTableImpl(schemaName, tableName, alias), + EXCLUDED: newEpochTableImpl("", "excluded", ""), + } +} + +func newEpochTableImpl(schemaName, tableName, alias string) epochTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + IndexColumn = postgres.FloatColumn("index") + FirstBlockColumn = postgres.FloatColumn("first_block") + LastBlockColumn = postgres.FloatColumn("last_block") + InputIndexLowerBoundColumn = postgres.FloatColumn("input_index_lower_bound") + InputIndexUpperBoundColumn = postgres.FloatColumn("input_index_upper_bound") + MachineHashColumn = postgres.ByteaColumn("machine_hash") + TxBufferDataBlockColumn = postgres.ByteaColumn("tx_buffer_data_block") + TxBufferProofColumn = postgres.ByteaArrayColumn("tx_buffer_proof") + IflagsYDataBlockColumn = postgres.ByteaColumn("iflags_y_data_block") + IflagsYProofColumn = postgres.ByteaArrayColumn("iflags_y_proof") + HtifTohostDataBlockColumn = postgres.ByteaColumn("htif_tohost_data_block") + HtifTohostProofColumn = postgres.ByteaArrayColumn("htif_tohost_proof") + CommitmentColumn = postgres.ByteaColumn("commitment") + CommitmentProofColumn = postgres.ByteaArrayColumn("commitment_proof") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + ClaimTransactionHashColumn = postgres.ByteaColumn("claim_transaction_hash") + StatusColumn = postgres.StringColumn("status") + ClaimValidatedColumn = postgres.BoolColumn("claim_validated") + StagedAtBlockColumn = postgres.FloatColumn("staged_at_block") + VirtualIndexColumn = postgres.FloatColumn("virtual_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, IndexColumn, FirstBlockColumn, LastBlockColumn, InputIndexLowerBoundColumn, InputIndexUpperBoundColumn, MachineHashColumn, TxBufferDataBlockColumn, TxBufferProofColumn, IflagsYDataBlockColumn, IflagsYProofColumn, HtifTohostDataBlockColumn, HtifTohostProofColumn, CommitmentColumn, CommitmentProofColumn, TournamentAddressColumn, ClaimTransactionHashColumn, StatusColumn, ClaimValidatedColumn, StagedAtBlockColumn, VirtualIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{FirstBlockColumn, LastBlockColumn, InputIndexLowerBoundColumn, InputIndexUpperBoundColumn, MachineHashColumn, TxBufferDataBlockColumn, TxBufferProofColumn, IflagsYDataBlockColumn, IflagsYProofColumn, HtifTohostDataBlockColumn, HtifTohostProofColumn, CommitmentColumn, CommitmentProofColumn, TournamentAddressColumn, ClaimTransactionHashColumn, StatusColumn, ClaimValidatedColumn, StagedAtBlockColumn, VirtualIndexColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{ClaimValidatedColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return epochTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + Index: IndexColumn, + FirstBlock: FirstBlockColumn, + LastBlock: LastBlockColumn, + InputIndexLowerBound: InputIndexLowerBoundColumn, + InputIndexUpperBound: InputIndexUpperBoundColumn, + MachineHash: MachineHashColumn, + TxBufferDataBlock: TxBufferDataBlockColumn, + TxBufferProof: TxBufferProofColumn, + IflagsYDataBlock: IflagsYDataBlockColumn, + IflagsYProof: IflagsYProofColumn, + HtifTohostDataBlock: HtifTohostDataBlockColumn, + HtifTohostProof: HtifTohostProofColumn, + Commitment: CommitmentColumn, + CommitmentProof: CommitmentProofColumn, + TournamentAddress: TournamentAddressColumn, + ClaimTransactionHash: ClaimTransactionHashColumn, + Status: StatusColumn, + ClaimValidated: ClaimValidatedColumn, + StagedAtBlock: StagedAtBlockColumn, + VirtualIndex: VirtualIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/execution_parameters.go b/internal/repository/postgres/db/rollupsdb/public/table/execution_parameters.go new file mode 100644 index 000000000..9487582f5 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/execution_parameters.go @@ -0,0 +1,123 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var ExecutionParameters = newExecutionParametersTable("public", "execution_parameters", "") + +type executionParametersTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + SnapshotPolicy postgres.ColumnString + AdvanceIncCycles postgres.ColumnInteger + AdvanceMaxCycles postgres.ColumnInteger + InspectIncCycles postgres.ColumnInteger + InspectMaxCycles postgres.ColumnInteger + AdvanceIncDeadline postgres.ColumnInteger + AdvanceMaxDeadline postgres.ColumnInteger + InspectIncDeadline postgres.ColumnInteger + InspectMaxDeadline postgres.ColumnInteger + LoadDeadline postgres.ColumnInteger + StoreDeadline postgres.ColumnInteger + FastDeadline postgres.ColumnInteger + MaxConcurrentInspects postgres.ColumnInteger + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type ExecutionParametersTable struct { + executionParametersTable + + EXCLUDED executionParametersTable +} + +// AS creates new ExecutionParametersTable with assigned alias +func (a ExecutionParametersTable) AS(alias string) *ExecutionParametersTable { + return newExecutionParametersTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new ExecutionParametersTable with assigned schema name +func (a ExecutionParametersTable) FromSchema(schemaName string) *ExecutionParametersTable { + return newExecutionParametersTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new ExecutionParametersTable with assigned table prefix +func (a ExecutionParametersTable) WithPrefix(prefix string) *ExecutionParametersTable { + return newExecutionParametersTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new ExecutionParametersTable with assigned table suffix +func (a ExecutionParametersTable) WithSuffix(suffix string) *ExecutionParametersTable { + return newExecutionParametersTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newExecutionParametersTable(schemaName, tableName, alias string) *ExecutionParametersTable { + return &ExecutionParametersTable{ + executionParametersTable: newExecutionParametersTableImpl(schemaName, tableName, alias), + EXCLUDED: newExecutionParametersTableImpl("", "excluded", ""), + } +} + +func newExecutionParametersTableImpl(schemaName, tableName, alias string) executionParametersTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + SnapshotPolicyColumn = postgres.StringColumn("snapshot_policy") + AdvanceIncCyclesColumn = postgres.IntegerColumn("advance_inc_cycles") + AdvanceMaxCyclesColumn = postgres.IntegerColumn("advance_max_cycles") + InspectIncCyclesColumn = postgres.IntegerColumn("inspect_inc_cycles") + InspectMaxCyclesColumn = postgres.IntegerColumn("inspect_max_cycles") + AdvanceIncDeadlineColumn = postgres.IntegerColumn("advance_inc_deadline") + AdvanceMaxDeadlineColumn = postgres.IntegerColumn("advance_max_deadline") + InspectIncDeadlineColumn = postgres.IntegerColumn("inspect_inc_deadline") + InspectMaxDeadlineColumn = postgres.IntegerColumn("inspect_max_deadline") + LoadDeadlineColumn = postgres.IntegerColumn("load_deadline") + StoreDeadlineColumn = postgres.IntegerColumn("store_deadline") + FastDeadlineColumn = postgres.IntegerColumn("fast_deadline") + MaxConcurrentInspectsColumn = postgres.IntegerColumn("max_concurrent_inspects") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, SnapshotPolicyColumn, AdvanceIncCyclesColumn, AdvanceMaxCyclesColumn, InspectIncCyclesColumn, InspectMaxCyclesColumn, AdvanceIncDeadlineColumn, AdvanceMaxDeadlineColumn, InspectIncDeadlineColumn, InspectMaxDeadlineColumn, LoadDeadlineColumn, StoreDeadlineColumn, FastDeadlineColumn, MaxConcurrentInspectsColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{SnapshotPolicyColumn, AdvanceIncCyclesColumn, AdvanceMaxCyclesColumn, InspectIncCyclesColumn, InspectMaxCyclesColumn, AdvanceIncDeadlineColumn, AdvanceMaxDeadlineColumn, InspectIncDeadlineColumn, InspectMaxDeadlineColumn, LoadDeadlineColumn, StoreDeadlineColumn, FastDeadlineColumn, MaxConcurrentInspectsColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{SnapshotPolicyColumn, AdvanceIncCyclesColumn, AdvanceMaxCyclesColumn, InspectIncCyclesColumn, InspectMaxCyclesColumn, AdvanceIncDeadlineColumn, AdvanceMaxDeadlineColumn, InspectIncDeadlineColumn, InspectMaxDeadlineColumn, LoadDeadlineColumn, StoreDeadlineColumn, FastDeadlineColumn, MaxConcurrentInspectsColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return executionParametersTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + SnapshotPolicy: SnapshotPolicyColumn, + AdvanceIncCycles: AdvanceIncCyclesColumn, + AdvanceMaxCycles: AdvanceMaxCyclesColumn, + InspectIncCycles: InspectIncCyclesColumn, + InspectMaxCycles: InspectMaxCyclesColumn, + AdvanceIncDeadline: AdvanceIncDeadlineColumn, + AdvanceMaxDeadline: AdvanceMaxDeadlineColumn, + InspectIncDeadline: InspectIncDeadlineColumn, + InspectMaxDeadline: InspectMaxDeadlineColumn, + LoadDeadline: LoadDeadlineColumn, + StoreDeadline: StoreDeadlineColumn, + FastDeadline: FastDeadlineColumn, + MaxConcurrentInspects: MaxConcurrentInspectsColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/input.go b/internal/repository/postgres/db/rollupsdb/public/table/input.go new file mode 100644 index 000000000..62dfa9526 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/input.go @@ -0,0 +1,117 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Input = newInputTable("public", "input", "") + +type inputTable struct { + postgres.Table + + // Columns + EpochApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + Index postgres.ColumnFloat + BlockNumber postgres.ColumnFloat + RawData postgres.ColumnBytea + Status postgres.ColumnString + ExceptionData postgres.ColumnBytea + MachineHash postgres.ColumnBytea + TxBufferDataBlock postgres.ColumnBytea + TransactionHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + SnapshotURI postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type InputTable struct { + inputTable + + EXCLUDED inputTable +} + +// AS creates new InputTable with assigned alias +func (a InputTable) AS(alias string) *InputTable { + return newInputTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new InputTable with assigned schema name +func (a InputTable) FromSchema(schemaName string) *InputTable { + return newInputTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new InputTable with assigned table prefix +func (a InputTable) WithPrefix(prefix string) *InputTable { + return newInputTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new InputTable with assigned table suffix +func (a InputTable) WithSuffix(suffix string) *InputTable { + return newInputTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newInputTable(schemaName, tableName, alias string) *InputTable { + return &InputTable{ + inputTable: newInputTableImpl(schemaName, tableName, alias), + EXCLUDED: newInputTableImpl("", "excluded", ""), + } +} + +func newInputTableImpl(schemaName, tableName, alias string) inputTable { + var ( + EpochApplicationIDColumn = postgres.IntegerColumn("epoch_application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + IndexColumn = postgres.FloatColumn("index") + BlockNumberColumn = postgres.FloatColumn("block_number") + RawDataColumn = postgres.ByteaColumn("raw_data") + StatusColumn = postgres.StringColumn("status") + ExceptionDataColumn = postgres.ByteaColumn("exception_data") + MachineHashColumn = postgres.ByteaColumn("machine_hash") + TxBufferDataBlockColumn = postgres.ByteaColumn("tx_buffer_data_block") + TransactionHashColumn = postgres.ByteaColumn("transaction_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + SnapshotURIColumn = postgres.StringColumn("snapshot_uri") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{EpochApplicationIDColumn, EpochIndexColumn, IndexColumn, BlockNumberColumn, RawDataColumn, StatusColumn, ExceptionDataColumn, MachineHashColumn, TxBufferDataBlockColumn, TransactionHashColumn, LogIndexColumn, SnapshotURIColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, BlockNumberColumn, RawDataColumn, StatusColumn, ExceptionDataColumn, MachineHashColumn, TxBufferDataBlockColumn, TransactionHashColumn, LogIndexColumn, SnapshotURIColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return inputTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + EpochApplicationID: EpochApplicationIDColumn, + EpochIndex: EpochIndexColumn, + Index: IndexColumn, + BlockNumber: BlockNumberColumn, + RawData: RawDataColumn, + Status: StatusColumn, + ExceptionData: ExceptionDataColumn, + MachineHash: MachineHashColumn, + TxBufferDataBlock: TxBufferDataBlockColumn, + TransactionHash: TransactionHashColumn, + LogIndex: LogIndexColumn, + SnapshotURI: SnapshotURIColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go b/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go new file mode 100644 index 000000000..1b8fd8fe3 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/match_advances.go @@ -0,0 +1,120 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var MatchAdvances = newMatchAdvancesTable("public", "match_advances", "") + +type matchAdvancesTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + IDHash postgres.ColumnBytea + OtherParent postgres.ColumnBytea + LeftNode postgres.ColumnBytea + SegmentStartPosition postgres.ColumnFloat + EliminableAt postgres.ColumnFloat + CurrentHeight postgres.ColumnFloat + ResponderDeadline postgres.ColumnFloat + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type MatchAdvancesTable struct { + matchAdvancesTable + + EXCLUDED matchAdvancesTable +} + +// AS creates new MatchAdvancesTable with assigned alias +func (a MatchAdvancesTable) AS(alias string) *MatchAdvancesTable { + return newMatchAdvancesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new MatchAdvancesTable with assigned schema name +func (a MatchAdvancesTable) FromSchema(schemaName string) *MatchAdvancesTable { + return newMatchAdvancesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new MatchAdvancesTable with assigned table prefix +func (a MatchAdvancesTable) WithPrefix(prefix string) *MatchAdvancesTable { + return newMatchAdvancesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new MatchAdvancesTable with assigned table suffix +func (a MatchAdvancesTable) WithSuffix(suffix string) *MatchAdvancesTable { + return newMatchAdvancesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newMatchAdvancesTable(schemaName, tableName, alias string) *MatchAdvancesTable { + return &MatchAdvancesTable{ + matchAdvancesTable: newMatchAdvancesTableImpl(schemaName, tableName, alias), + EXCLUDED: newMatchAdvancesTableImpl("", "excluded", ""), + } +} + +func newMatchAdvancesTableImpl(schemaName, tableName, alias string) matchAdvancesTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + IDHashColumn = postgres.ByteaColumn("id_hash") + OtherParentColumn = postgres.ByteaColumn("other_parent") + LeftNodeColumn = postgres.ByteaColumn("left_node") + SegmentStartPositionColumn = postgres.FloatColumn("segment_start_position") + EliminableAtColumn = postgres.FloatColumn("eliminable_at") + CurrentHeightColumn = postgres.FloatColumn("current_height") + ResponderDeadlineColumn = postgres.FloatColumn("responder_deadline") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, SegmentStartPositionColumn, EliminableAtColumn, CurrentHeightColumn, ResponderDeadlineColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, TournamentAddressColumn, IDHashColumn, OtherParentColumn, LeftNodeColumn, SegmentStartPositionColumn, EliminableAtColumn, CurrentHeightColumn, ResponderDeadlineColumn, BlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return matchAdvancesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + IDHash: IDHashColumn, + OtherParent: OtherParentColumn, + LeftNode: LeftNodeColumn, + SegmentStartPosition: SegmentStartPositionColumn, + EliminableAt: EliminableAtColumn, + CurrentHeight: CurrentHeightColumn, + ResponderDeadline: ResponderDeadlineColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/matches.go b/internal/repository/postgres/db/rollupsdb/public/table/matches.go new file mode 100644 index 000000000..8b7ac0be9 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/matches.go @@ -0,0 +1,210 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Matches = newMatchesTable("public", "matches", "") + +type matchesTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + IDHash postgres.ColumnBytea + CommitmentOne postgres.ColumnBytea + CommitmentTwo postgres.ColumnBytea + LeftOfTwo postgres.ColumnBytea + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + EliminableAt postgres.ColumnFloat + ResponderDeadline postgres.ColumnFloat + SealEliminableAt postgres.ColumnFloat + SealAgreeState postgres.ColumnBytea + SealDivergencePosition postgres.ColumnFloat + SealFinalStateOne postgres.ColumnBytea + SealFinalStateTwo postgres.ColumnBytea + SealDeadlineOne postgres.ColumnFloat + SealDeadlineTwo postgres.ColumnFloat + SealBlockNumber postgres.ColumnFloat + SealTxHash postgres.ColumnBytea + SealLogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Phase postgres.ColumnString + TimeoutOutcome postgres.ColumnString + DeferredCharge postgres.ColumnFloat + RevealingParent postgres.ColumnBytea + WaitingLeft postgres.ColumnBytea + WaitingRight postgres.ColumnBytea + SegmentStartPosition postgres.ColumnFloat + SegmentStartCycle postgres.ColumnFloat + CurrentHeight postgres.ColumnFloat + Responder postgres.ColumnString + AgreeState postgres.ColumnBytea + DivergencePosition postgres.ColumnFloat + DivergenceCycle postgres.ColumnFloat + FinalStateOne postgres.ColumnBytea + FinalStateTwo postgres.ColumnBytea + Winner postgres.ColumnString + DeletionReason postgres.ColumnString + DeletionBlockNumber postgres.ColumnFloat + DeletionTxHash postgres.ColumnBytea + DeletionLogIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type MatchesTable struct { + matchesTable + + EXCLUDED matchesTable +} + +// AS creates new MatchesTable with assigned alias +func (a MatchesTable) AS(alias string) *MatchesTable { + return newMatchesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new MatchesTable with assigned schema name +func (a MatchesTable) FromSchema(schemaName string) *MatchesTable { + return newMatchesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new MatchesTable with assigned table prefix +func (a MatchesTable) WithPrefix(prefix string) *MatchesTable { + return newMatchesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new MatchesTable with assigned table suffix +func (a MatchesTable) WithSuffix(suffix string) *MatchesTable { + return newMatchesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newMatchesTable(schemaName, tableName, alias string) *MatchesTable { + return &MatchesTable{ + matchesTable: newMatchesTableImpl(schemaName, tableName, alias), + EXCLUDED: newMatchesTableImpl("", "excluded", ""), + } +} + +func newMatchesTableImpl(schemaName, tableName, alias string) matchesTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + IDHashColumn = postgres.ByteaColumn("id_hash") + CommitmentOneColumn = postgres.ByteaColumn("commitment_one") + CommitmentTwoColumn = postgres.ByteaColumn("commitment_two") + LeftOfTwoColumn = postgres.ByteaColumn("left_of_two") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + EliminableAtColumn = postgres.FloatColumn("eliminable_at") + ResponderDeadlineColumn = postgres.FloatColumn("responder_deadline") + SealEliminableAtColumn = postgres.FloatColumn("seal_eliminable_at") + SealAgreeStateColumn = postgres.ByteaColumn("seal_agree_state") + SealDivergencePositionColumn = postgres.FloatColumn("seal_divergence_position") + SealFinalStateOneColumn = postgres.ByteaColumn("seal_final_state_one") + SealFinalStateTwoColumn = postgres.ByteaColumn("seal_final_state_two") + SealDeadlineOneColumn = postgres.FloatColumn("seal_deadline_one") + SealDeadlineTwoColumn = postgres.FloatColumn("seal_deadline_two") + SealBlockNumberColumn = postgres.FloatColumn("seal_block_number") + SealTxHashColumn = postgres.ByteaColumn("seal_tx_hash") + SealLogIndexColumn = postgres.FloatColumn("seal_log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + PhaseColumn = postgres.StringColumn("phase") + TimeoutOutcomeColumn = postgres.StringColumn("timeout_outcome") + DeferredChargeColumn = postgres.FloatColumn("deferred_charge") + RevealingParentColumn = postgres.ByteaColumn("revealing_parent") + WaitingLeftColumn = postgres.ByteaColumn("waiting_left") + WaitingRightColumn = postgres.ByteaColumn("waiting_right") + SegmentStartPositionColumn = postgres.FloatColumn("segment_start_position") + SegmentStartCycleColumn = postgres.FloatColumn("segment_start_cycle") + CurrentHeightColumn = postgres.FloatColumn("current_height") + ResponderColumn = postgres.StringColumn("responder") + AgreeStateColumn = postgres.ByteaColumn("agree_state") + DivergencePositionColumn = postgres.FloatColumn("divergence_position") + DivergenceCycleColumn = postgres.FloatColumn("divergence_cycle") + FinalStateOneColumn = postgres.ByteaColumn("final_state_one") + FinalStateTwoColumn = postgres.ByteaColumn("final_state_two") + WinnerColumn = postgres.StringColumn("winner") + DeletionReasonColumn = postgres.StringColumn("deletion_reason") + DeletionBlockNumberColumn = postgres.FloatColumn("deletion_block_number") + DeletionTxHashColumn = postgres.ByteaColumn("deletion_tx_hash") + DeletionLogIndexColumn = postgres.FloatColumn("deletion_log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, IDHashColumn, CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, EliminableAtColumn, ResponderDeadlineColumn, SealEliminableAtColumn, SealAgreeStateColumn, SealDivergencePositionColumn, SealFinalStateOneColumn, SealFinalStateTwoColumn, SealDeadlineOneColumn, SealDeadlineTwoColumn, SealBlockNumberColumn, SealTxHashColumn, SealLogIndexColumn, AsOfBlockColumn, PhaseColumn, TimeoutOutcomeColumn, DeferredChargeColumn, RevealingParentColumn, WaitingLeftColumn, WaitingRightColumn, SegmentStartPositionColumn, SegmentStartCycleColumn, CurrentHeightColumn, ResponderColumn, AgreeStateColumn, DivergencePositionColumn, DivergenceCycleColumn, FinalStateOneColumn, FinalStateTwoColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, DeletionLogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{CommitmentOneColumn, CommitmentTwoColumn, LeftOfTwoColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, EliminableAtColumn, ResponderDeadlineColumn, SealEliminableAtColumn, SealAgreeStateColumn, SealDivergencePositionColumn, SealFinalStateOneColumn, SealFinalStateTwoColumn, SealDeadlineOneColumn, SealDeadlineTwoColumn, SealBlockNumberColumn, SealTxHashColumn, SealLogIndexColumn, AsOfBlockColumn, PhaseColumn, TimeoutOutcomeColumn, DeferredChargeColumn, RevealingParentColumn, WaitingLeftColumn, WaitingRightColumn, SegmentStartPositionColumn, SegmentStartCycleColumn, CurrentHeightColumn, ResponderColumn, AgreeStateColumn, DivergencePositionColumn, DivergenceCycleColumn, FinalStateOneColumn, FinalStateTwoColumn, WinnerColumn, DeletionReasonColumn, DeletionBlockNumberColumn, DeletionTxHashColumn, DeletionLogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{DeletionBlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return matchesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + IDHash: IDHashColumn, + CommitmentOne: CommitmentOneColumn, + CommitmentTwo: CommitmentTwoColumn, + LeftOfTwo: LeftOfTwoColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + EliminableAt: EliminableAtColumn, + ResponderDeadline: ResponderDeadlineColumn, + SealEliminableAt: SealEliminableAtColumn, + SealAgreeState: SealAgreeStateColumn, + SealDivergencePosition: SealDivergencePositionColumn, + SealFinalStateOne: SealFinalStateOneColumn, + SealFinalStateTwo: SealFinalStateTwoColumn, + SealDeadlineOne: SealDeadlineOneColumn, + SealDeadlineTwo: SealDeadlineTwoColumn, + SealBlockNumber: SealBlockNumberColumn, + SealTxHash: SealTxHashColumn, + SealLogIndex: SealLogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Phase: PhaseColumn, + TimeoutOutcome: TimeoutOutcomeColumn, + DeferredCharge: DeferredChargeColumn, + RevealingParent: RevealingParentColumn, + WaitingLeft: WaitingLeftColumn, + WaitingRight: WaitingRightColumn, + SegmentStartPosition: SegmentStartPositionColumn, + SegmentStartCycle: SegmentStartCycleColumn, + CurrentHeight: CurrentHeightColumn, + Responder: ResponderColumn, + AgreeState: AgreeStateColumn, + DivergencePosition: DivergencePositionColumn, + DivergenceCycle: DivergenceCycleColumn, + FinalStateOne: FinalStateOneColumn, + FinalStateTwo: FinalStateTwoColumn, + Winner: WinnerColumn, + DeletionReason: DeletionReasonColumn, + DeletionBlockNumber: DeletionBlockNumberColumn, + DeletionTxHash: DeletionTxHashColumn, + DeletionLogIndex: DeletionLogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/node_config.go b/internal/repository/postgres/db/rollupsdb/public/table/node_config.go new file mode 100644 index 000000000..ddccf842e --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/node_config.go @@ -0,0 +1,87 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var NodeConfig = newNodeConfigTable("public", "node_config", "") + +type nodeConfigTable struct { + postgres.Table + + // Columns + Key postgres.ColumnString + Value postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type NodeConfigTable struct { + nodeConfigTable + + EXCLUDED nodeConfigTable +} + +// AS creates new NodeConfigTable with assigned alias +func (a NodeConfigTable) AS(alias string) *NodeConfigTable { + return newNodeConfigTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new NodeConfigTable with assigned schema name +func (a NodeConfigTable) FromSchema(schemaName string) *NodeConfigTable { + return newNodeConfigTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new NodeConfigTable with assigned table prefix +func (a NodeConfigTable) WithPrefix(prefix string) *NodeConfigTable { + return newNodeConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new NodeConfigTable with assigned table suffix +func (a NodeConfigTable) WithSuffix(suffix string) *NodeConfigTable { + return newNodeConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newNodeConfigTable(schemaName, tableName, alias string) *NodeConfigTable { + return &NodeConfigTable{ + nodeConfigTable: newNodeConfigTableImpl(schemaName, tableName, alias), + EXCLUDED: newNodeConfigTableImpl("", "excluded", ""), + } +} + +func newNodeConfigTableImpl(schemaName, tableName, alias string) nodeConfigTable { + var ( + KeyColumn = postgres.StringColumn("key") + ValueColumn = postgres.StringColumn("value") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{KeyColumn, ValueColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{ValueColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return nodeConfigTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + Key: KeyColumn, + Value: ValueColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/output.go b/internal/repository/postgres/db/rollupsdb/public/table/output.go new file mode 100644 index 000000000..f491b6beb --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/output.go @@ -0,0 +1,102 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Output = newOutputTable("public", "output", "") + +type outputTable struct { + postgres.Table + + // Columns + InputEpochApplicationID postgres.ColumnInteger + InputIndex postgres.ColumnFloat + Index postgres.ColumnFloat + RawData postgres.ColumnBytea + Hash postgres.ColumnBytea + OutputHashesSiblings postgres.ColumnByteaArray + ExecutionTransactionHash postgres.ColumnBytea + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type OutputTable struct { + outputTable + + EXCLUDED outputTable +} + +// AS creates new OutputTable with assigned alias +func (a OutputTable) AS(alias string) *OutputTable { + return newOutputTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new OutputTable with assigned schema name +func (a OutputTable) FromSchema(schemaName string) *OutputTable { + return newOutputTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new OutputTable with assigned table prefix +func (a OutputTable) WithPrefix(prefix string) *OutputTable { + return newOutputTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new OutputTable with assigned table suffix +func (a OutputTable) WithSuffix(suffix string) *OutputTable { + return newOutputTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newOutputTable(schemaName, tableName, alias string) *OutputTable { + return &OutputTable{ + outputTable: newOutputTableImpl(schemaName, tableName, alias), + EXCLUDED: newOutputTableImpl("", "excluded", ""), + } +} + +func newOutputTableImpl(schemaName, tableName, alias string) outputTable { + var ( + InputEpochApplicationIDColumn = postgres.IntegerColumn("input_epoch_application_id") + InputIndexColumn = postgres.FloatColumn("input_index") + IndexColumn = postgres.FloatColumn("index") + RawDataColumn = postgres.ByteaColumn("raw_data") + HashColumn = postgres.ByteaColumn("hash") + OutputHashesSiblingsColumn = postgres.ByteaArrayColumn("output_hashes_siblings") + ExecutionTransactionHashColumn = postgres.ByteaColumn("execution_transaction_hash") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{InputEpochApplicationIDColumn, InputIndexColumn, IndexColumn, RawDataColumn, HashColumn, OutputHashesSiblingsColumn, ExecutionTransactionHashColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{InputIndexColumn, RawDataColumn, HashColumn, OutputHashesSiblingsColumn, ExecutionTransactionHashColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return outputTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + InputEpochApplicationID: InputEpochApplicationIDColumn, + InputIndex: InputIndexColumn, + Index: IndexColumn, + RawData: RawDataColumn, + Hash: HashColumn, + OutputHashesSiblings: OutputHashesSiblingsColumn, + ExecutionTransactionHash: ExecutionTransactionHashColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/report.go b/internal/repository/postgres/db/rollupsdb/public/table/report.go new file mode 100644 index 000000000..66eed96fe --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/report.go @@ -0,0 +1,93 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Report = newReportTable("public", "report", "") + +type reportTable struct { + postgres.Table + + // Columns + InputEpochApplicationID postgres.ColumnInteger + InputIndex postgres.ColumnFloat + Index postgres.ColumnFloat + RawData postgres.ColumnBytea + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type ReportTable struct { + reportTable + + EXCLUDED reportTable +} + +// AS creates new ReportTable with assigned alias +func (a ReportTable) AS(alias string) *ReportTable { + return newReportTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new ReportTable with assigned schema name +func (a ReportTable) FromSchema(schemaName string) *ReportTable { + return newReportTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new ReportTable with assigned table prefix +func (a ReportTable) WithPrefix(prefix string) *ReportTable { + return newReportTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new ReportTable with assigned table suffix +func (a ReportTable) WithSuffix(suffix string) *ReportTable { + return newReportTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newReportTable(schemaName, tableName, alias string) *ReportTable { + return &ReportTable{ + reportTable: newReportTableImpl(schemaName, tableName, alias), + EXCLUDED: newReportTableImpl("", "excluded", ""), + } +} + +func newReportTableImpl(schemaName, tableName, alias string) reportTable { + var ( + InputEpochApplicationIDColumn = postgres.IntegerColumn("input_epoch_application_id") + InputIndexColumn = postgres.FloatColumn("input_index") + IndexColumn = postgres.FloatColumn("index") + RawDataColumn = postgres.ByteaColumn("raw_data") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{InputEpochApplicationIDColumn, InputIndexColumn, IndexColumn, RawDataColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{InputIndexColumn, RawDataColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return reportTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + InputEpochApplicationID: InputEpochApplicationIDColumn, + InputIndex: InputIndexColumn, + Index: IndexColumn, + RawData: RawDataColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/schema_migrations.go b/internal/repository/postgres/db/rollupsdb/public/table/schema_migrations.go new file mode 100644 index 000000000..96ff11f1f --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/schema_migrations.go @@ -0,0 +1,81 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var SchemaMigrations = newSchemaMigrationsTable("public", "schema_migrations", "") + +type schemaMigrationsTable struct { + postgres.Table + + // Columns + Version postgres.ColumnInteger + Dirty postgres.ColumnBool + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type SchemaMigrationsTable struct { + schemaMigrationsTable + + EXCLUDED schemaMigrationsTable +} + +// AS creates new SchemaMigrationsTable with assigned alias +func (a SchemaMigrationsTable) AS(alias string) *SchemaMigrationsTable { + return newSchemaMigrationsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new SchemaMigrationsTable with assigned schema name +func (a SchemaMigrationsTable) FromSchema(schemaName string) *SchemaMigrationsTable { + return newSchemaMigrationsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new SchemaMigrationsTable with assigned table prefix +func (a SchemaMigrationsTable) WithPrefix(prefix string) *SchemaMigrationsTable { + return newSchemaMigrationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new SchemaMigrationsTable with assigned table suffix +func (a SchemaMigrationsTable) WithSuffix(suffix string) *SchemaMigrationsTable { + return newSchemaMigrationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newSchemaMigrationsTable(schemaName, tableName, alias string) *SchemaMigrationsTable { + return &SchemaMigrationsTable{ + schemaMigrationsTable: newSchemaMigrationsTableImpl(schemaName, tableName, alias), + EXCLUDED: newSchemaMigrationsTableImpl("", "excluded", ""), + } +} + +func newSchemaMigrationsTableImpl(schemaName, tableName, alias string) schemaMigrationsTable { + var ( + VersionColumn = postgres.IntegerColumn("version") + DirtyColumn = postgres.BoolColumn("dirty") + allColumns = postgres.ColumnList{VersionColumn, DirtyColumn} + mutableColumns = postgres.ColumnList{DirtyColumn} + defaultColumns = postgres.ColumnList{} + ) + + return schemaMigrationsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + Version: VersionColumn, + Dirty: DirtyColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/standing_changes.go b/internal/repository/postgres/db/rollupsdb/public/table/standing_changes.go new file mode 100644 index 000000000..299d8625c --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/standing_changes.go @@ -0,0 +1,114 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var StandingChanges = newStandingChangesTable("public", "standing_changes", "") + +type standingChangesTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + TournamentAddress postgres.ColumnBytea + MatchCount postgres.ColumnFloat + Dangling postgres.ColumnBytea + ParentCommitment postgres.ColumnBytea + ResultAt postgres.ColumnFloat + WinnerExpiresAt postgres.ColumnFloat + BlockNumber postgres.ColumnFloat + TxHash postgres.ColumnBytea + LogIndex postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type StandingChangesTable struct { + standingChangesTable + + EXCLUDED standingChangesTable +} + +// AS creates new StandingChangesTable with assigned alias +func (a StandingChangesTable) AS(alias string) *StandingChangesTable { + return newStandingChangesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new StandingChangesTable with assigned schema name +func (a StandingChangesTable) FromSchema(schemaName string) *StandingChangesTable { + return newStandingChangesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new StandingChangesTable with assigned table prefix +func (a StandingChangesTable) WithPrefix(prefix string) *StandingChangesTable { + return newStandingChangesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new StandingChangesTable with assigned table suffix +func (a StandingChangesTable) WithSuffix(suffix string) *StandingChangesTable { + return newStandingChangesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newStandingChangesTable(schemaName, tableName, alias string) *StandingChangesTable { + return &StandingChangesTable{ + standingChangesTable: newStandingChangesTableImpl(schemaName, tableName, alias), + EXCLUDED: newStandingChangesTableImpl("", "excluded", ""), + } +} + +func newStandingChangesTableImpl(schemaName, tableName, alias string) standingChangesTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + TournamentAddressColumn = postgres.ByteaColumn("tournament_address") + MatchCountColumn = postgres.FloatColumn("match_count") + DanglingColumn = postgres.ByteaColumn("dangling") + ParentCommitmentColumn = postgres.ByteaColumn("parent_commitment") + ResultAtColumn = postgres.FloatColumn("result_at") + WinnerExpiresAtColumn = postgres.FloatColumn("winner_expires_at") + BlockNumberColumn = postgres.FloatColumn("block_number") + TxHashColumn = postgres.ByteaColumn("tx_hash") + LogIndexColumn = postgres.FloatColumn("log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, TournamentAddressColumn, MatchCountColumn, DanglingColumn, ParentCommitmentColumn, ResultAtColumn, WinnerExpiresAtColumn, BlockNumberColumn, TxHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{EpochIndexColumn, TournamentAddressColumn, MatchCountColumn, DanglingColumn, ParentCommitmentColumn, ResultAtColumn, WinnerExpiresAtColumn, BlockNumberColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return standingChangesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + TournamentAddress: TournamentAddressColumn, + MatchCount: MatchCountColumn, + Dangling: DanglingColumn, + ParentCommitment: ParentCommitmentColumn, + ResultAt: ResultAtColumn, + WinnerExpiresAt: WinnerExpiresAtColumn, + BlockNumber: BlockNumberColumn, + TxHash: TxHashColumn, + LogIndex: LogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/state_hashes.go b/internal/repository/postgres/db/rollupsdb/public/table/state_hashes.go new file mode 100644 index 000000000..1b4c15c77 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/state_hashes.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var StateHashes = newStateHashesTable("public", "state_hashes", "") + +type stateHashesTable struct { + postgres.Table + + // Columns + InputEpochApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + InputIndex postgres.ColumnFloat + Index postgres.ColumnFloat + MachineHash postgres.ColumnBytea + Repetitions postgres.ColumnInteger + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type StateHashesTable struct { + stateHashesTable + + EXCLUDED stateHashesTable +} + +// AS creates new StateHashesTable with assigned alias +func (a StateHashesTable) AS(alias string) *StateHashesTable { + return newStateHashesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new StateHashesTable with assigned schema name +func (a StateHashesTable) FromSchema(schemaName string) *StateHashesTable { + return newStateHashesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new StateHashesTable with assigned table prefix +func (a StateHashesTable) WithPrefix(prefix string) *StateHashesTable { + return newStateHashesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new StateHashesTable with assigned table suffix +func (a StateHashesTable) WithSuffix(suffix string) *StateHashesTable { + return newStateHashesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newStateHashesTable(schemaName, tableName, alias string) *StateHashesTable { + return &StateHashesTable{ + stateHashesTable: newStateHashesTableImpl(schemaName, tableName, alias), + EXCLUDED: newStateHashesTableImpl("", "excluded", ""), + } +} + +func newStateHashesTableImpl(schemaName, tableName, alias string) stateHashesTable { + var ( + InputEpochApplicationIDColumn = postgres.IntegerColumn("input_epoch_application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + InputIndexColumn = postgres.FloatColumn("input_index") + IndexColumn = postgres.FloatColumn("index") + MachineHashColumn = postgres.ByteaColumn("machine_hash") + RepetitionsColumn = postgres.IntegerColumn("repetitions") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{InputEpochApplicationIDColumn, EpochIndexColumn, InputIndexColumn, IndexColumn, MachineHashColumn, RepetitionsColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{InputIndexColumn, MachineHashColumn, RepetitionsColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return stateHashesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + InputEpochApplicationID: InputEpochApplicationIDColumn, + EpochIndex: EpochIndexColumn, + InputIndex: InputIndexColumn, + Index: IndexColumn, + MachineHash: MachineHashColumn, + Repetitions: RepetitionsColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go b/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go new file mode 100644 index 000000000..a9368b8de --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/table_use_schema.go @@ -0,0 +1,30 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke +// this method only once at the beginning of the program. +func UseSchema(schema string) { + Application = Application.FromSchema(schema) + BondEvents = BondEvents.FromSchema(schema) + Commitments = Commitments.FromSchema(schema) + DeploymentAdmissionState = DeploymentAdmissionState.FromSchema(schema) + Epoch = Epoch.FromSchema(schema) + ExecutionParameters = ExecutionParameters.FromSchema(schema) + Input = Input.FromSchema(schema) + MatchAdvances = MatchAdvances.FromSchema(schema) + Matches = Matches.FromSchema(schema) + NodeConfig = NodeConfig.FromSchema(schema) + Output = Output.FromSchema(schema) + Report = Report.FromSchema(schema) + SchemaMigrations = SchemaMigrations.FromSchema(schema) + StandingChanges = StandingChanges.FromSchema(schema) + StateHashes = StateHashes.FromSchema(schema) + Tournaments = Tournaments.FromSchema(schema) + Withdrawal = Withdrawal.FromSchema(schema) +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go b/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go new file mode 100644 index 000000000..10e3e8c0e --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/tournaments.go @@ -0,0 +1,186 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Tournaments = newTournamentsTable("public", "tournaments", "") + +type tournamentsTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + EpochIndex postgres.ColumnFloat + Address postgres.ColumnBytea + ParentTournamentAddress postgres.ColumnBytea + ParentMatchIDHash postgres.ColumnBytea + MaxLevel postgres.ColumnInteger + Level postgres.ColumnInteger + Log2step postgres.ColumnInteger + Height postgres.ColumnInteger + InitialHash postgres.ColumnBytea + BaseCycle postgres.ColumnFloat + Kind postgres.ColumnString + StartInstant postgres.ColumnFloat + Allowance postgres.ColumnFloat + BondValue postgres.ColumnFloat + ContestedFinalStateOne postgres.ColumnBytea + ContestedFinalStateTwo postgres.ColumnBytea + CreationBlockNumber postgres.ColumnFloat + CreationTxHash postgres.ColumnBytea + CreationLogIndex postgres.ColumnFloat + AsOfBlock postgres.ColumnFloat + Standing postgres.ColumnString + AcceptsJoins postgres.ColumnBool + Candidate postgres.ColumnBytea + WinnerCommitment postgres.ColumnBytea + FinalStateHash postgres.ColumnBytea + FinishedAtBlock postgres.ColumnFloat + ParentCommitment postgres.ColumnBytea + WinnerExpiresAt postgres.ColumnFloat + InnerDisposition postgres.ColumnString + InnerParentCommitment postgres.ColumnBytea + InnerPausedAllowance postgres.ColumnFloat + BondDisposition postgres.ColumnString + BondClaimer postgres.ColumnBytea + BondPayment postgres.ColumnFloat + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type TournamentsTable struct { + tournamentsTable + + EXCLUDED tournamentsTable +} + +// AS creates new TournamentsTable with assigned alias +func (a TournamentsTable) AS(alias string) *TournamentsTable { + return newTournamentsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new TournamentsTable with assigned schema name +func (a TournamentsTable) FromSchema(schemaName string) *TournamentsTable { + return newTournamentsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new TournamentsTable with assigned table prefix +func (a TournamentsTable) WithPrefix(prefix string) *TournamentsTable { + return newTournamentsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new TournamentsTable with assigned table suffix +func (a TournamentsTable) WithSuffix(suffix string) *TournamentsTable { + return newTournamentsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newTournamentsTable(schemaName, tableName, alias string) *TournamentsTable { + return &TournamentsTable{ + tournamentsTable: newTournamentsTableImpl(schemaName, tableName, alias), + EXCLUDED: newTournamentsTableImpl("", "excluded", ""), + } +} + +func newTournamentsTableImpl(schemaName, tableName, alias string) tournamentsTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + EpochIndexColumn = postgres.FloatColumn("epoch_index") + AddressColumn = postgres.ByteaColumn("address") + ParentTournamentAddressColumn = postgres.ByteaColumn("parent_tournament_address") + ParentMatchIDHashColumn = postgres.ByteaColumn("parent_match_id_hash") + MaxLevelColumn = postgres.IntegerColumn("max_level") + LevelColumn = postgres.IntegerColumn("level") + Log2stepColumn = postgres.IntegerColumn("log2step") + HeightColumn = postgres.IntegerColumn("height") + InitialHashColumn = postgres.ByteaColumn("initial_hash") + BaseCycleColumn = postgres.FloatColumn("base_cycle") + KindColumn = postgres.StringColumn("kind") + StartInstantColumn = postgres.FloatColumn("start_instant") + AllowanceColumn = postgres.FloatColumn("allowance") + BondValueColumn = postgres.FloatColumn("bond_value") + ContestedFinalStateOneColumn = postgres.ByteaColumn("contested_final_state_one") + ContestedFinalStateTwoColumn = postgres.ByteaColumn("contested_final_state_two") + CreationBlockNumberColumn = postgres.FloatColumn("creation_block_number") + CreationTxHashColumn = postgres.ByteaColumn("creation_tx_hash") + CreationLogIndexColumn = postgres.FloatColumn("creation_log_index") + AsOfBlockColumn = postgres.FloatColumn("as_of_block") + StandingColumn = postgres.StringColumn("standing") + AcceptsJoinsColumn = postgres.BoolColumn("accepts_joins") + CandidateColumn = postgres.ByteaColumn("candidate") + WinnerCommitmentColumn = postgres.ByteaColumn("winner_commitment") + FinalStateHashColumn = postgres.ByteaColumn("final_state_hash") + FinishedAtBlockColumn = postgres.FloatColumn("finished_at_block") + ParentCommitmentColumn = postgres.ByteaColumn("parent_commitment") + WinnerExpiresAtColumn = postgres.FloatColumn("winner_expires_at") + InnerDispositionColumn = postgres.StringColumn("inner_disposition") + InnerParentCommitmentColumn = postgres.ByteaColumn("inner_parent_commitment") + InnerPausedAllowanceColumn = postgres.FloatColumn("inner_paused_allowance") + BondDispositionColumn = postgres.StringColumn("bond_disposition") + BondClaimerColumn = postgres.ByteaColumn("bond_claimer") + BondPaymentColumn = postgres.FloatColumn("bond_payment") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, EpochIndexColumn, AddressColumn, ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, InitialHashColumn, BaseCycleColumn, KindColumn, StartInstantColumn, AllowanceColumn, BondValueColumn, ContestedFinalStateOneColumn, ContestedFinalStateTwoColumn, CreationBlockNumberColumn, CreationTxHashColumn, CreationLogIndexColumn, AsOfBlockColumn, StandingColumn, AcceptsJoinsColumn, CandidateColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, ParentCommitmentColumn, WinnerExpiresAtColumn, InnerDispositionColumn, InnerParentCommitmentColumn, InnerPausedAllowanceColumn, BondDispositionColumn, BondClaimerColumn, BondPaymentColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{ParentTournamentAddressColumn, ParentMatchIDHashColumn, MaxLevelColumn, LevelColumn, Log2stepColumn, HeightColumn, InitialHashColumn, BaseCycleColumn, KindColumn, StartInstantColumn, AllowanceColumn, BondValueColumn, ContestedFinalStateOneColumn, ContestedFinalStateTwoColumn, CreationBlockNumberColumn, CreationTxHashColumn, CreationLogIndexColumn, AsOfBlockColumn, StandingColumn, AcceptsJoinsColumn, CandidateColumn, WinnerCommitmentColumn, FinalStateHashColumn, FinishedAtBlockColumn, ParentCommitmentColumn, WinnerExpiresAtColumn, InnerDispositionColumn, InnerParentCommitmentColumn, InnerPausedAllowanceColumn, BondDispositionColumn, BondClaimerColumn, BondPaymentColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{FinishedAtBlockColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return tournamentsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + EpochIndex: EpochIndexColumn, + Address: AddressColumn, + ParentTournamentAddress: ParentTournamentAddressColumn, + ParentMatchIDHash: ParentMatchIDHashColumn, + MaxLevel: MaxLevelColumn, + Level: LevelColumn, + Log2step: Log2stepColumn, + Height: HeightColumn, + InitialHash: InitialHashColumn, + BaseCycle: BaseCycleColumn, + Kind: KindColumn, + StartInstant: StartInstantColumn, + Allowance: AllowanceColumn, + BondValue: BondValueColumn, + ContestedFinalStateOne: ContestedFinalStateOneColumn, + ContestedFinalStateTwo: ContestedFinalStateTwoColumn, + CreationBlockNumber: CreationBlockNumberColumn, + CreationTxHash: CreationTxHashColumn, + CreationLogIndex: CreationLogIndexColumn, + AsOfBlock: AsOfBlockColumn, + Standing: StandingColumn, + AcceptsJoins: AcceptsJoinsColumn, + Candidate: CandidateColumn, + WinnerCommitment: WinnerCommitmentColumn, + FinalStateHash: FinalStateHashColumn, + FinishedAtBlock: FinishedAtBlockColumn, + ParentCommitment: ParentCommitmentColumn, + WinnerExpiresAt: WinnerExpiresAtColumn, + InnerDisposition: InnerDispositionColumn, + InnerParentCommitment: InnerParentCommitmentColumn, + InnerPausedAllowance: InnerPausedAllowanceColumn, + BondDisposition: BondDispositionColumn, + BondClaimer: BondClaimerColumn, + BondPayment: BondPaymentColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/db/rollupsdb/public/table/withdrawal.go b/internal/repository/postgres/db/rollupsdb/public/table/withdrawal.go new file mode 100644 index 000000000..1686ad8a1 --- /dev/null +++ b/internal/repository/postgres/db/rollupsdb/public/table/withdrawal.go @@ -0,0 +1,102 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Withdrawal = newWithdrawalTable("public", "withdrawal", "") + +type withdrawalTable struct { + postgres.Table + + // Columns + ApplicationID postgres.ColumnInteger + AccountIndex postgres.ColumnFloat + Account postgres.ColumnBytea + Output postgres.ColumnBytea + BlockNumber postgres.ColumnFloat + TransactionHash postgres.ColumnBytea + LogIndex postgres.ColumnInteger + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type WithdrawalTable struct { + withdrawalTable + + EXCLUDED withdrawalTable +} + +// AS creates new WithdrawalTable with assigned alias +func (a WithdrawalTable) AS(alias string) *WithdrawalTable { + return newWithdrawalTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new WithdrawalTable with assigned schema name +func (a WithdrawalTable) FromSchema(schemaName string) *WithdrawalTable { + return newWithdrawalTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new WithdrawalTable with assigned table prefix +func (a WithdrawalTable) WithPrefix(prefix string) *WithdrawalTable { + return newWithdrawalTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new WithdrawalTable with assigned table suffix +func (a WithdrawalTable) WithSuffix(suffix string) *WithdrawalTable { + return newWithdrawalTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newWithdrawalTable(schemaName, tableName, alias string) *WithdrawalTable { + return &WithdrawalTable{ + withdrawalTable: newWithdrawalTableImpl(schemaName, tableName, alias), + EXCLUDED: newWithdrawalTableImpl("", "excluded", ""), + } +} + +func newWithdrawalTableImpl(schemaName, tableName, alias string) withdrawalTable { + var ( + ApplicationIDColumn = postgres.IntegerColumn("application_id") + AccountIndexColumn = postgres.FloatColumn("account_index") + AccountColumn = postgres.ByteaColumn("account") + OutputColumn = postgres.ByteaColumn("output") + BlockNumberColumn = postgres.FloatColumn("block_number") + TransactionHashColumn = postgres.ByteaColumn("transaction_hash") + LogIndexColumn = postgres.IntegerColumn("log_index") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ApplicationIDColumn, AccountIndexColumn, AccountColumn, OutputColumn, BlockNumberColumn, TransactionHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{AccountColumn, OutputColumn, BlockNumberColumn, TransactionHashColumn, LogIndexColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return withdrawalTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ApplicationID: ApplicationIDColumn, + AccountIndex: AccountIndexColumn, + Account: AccountColumn, + Output: OutputColumn, + BlockNumber: BlockNumberColumn, + TransactionHash: TransactionHashColumn, + LogIndex: LogIndexColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/internal/repository/postgres/delivery_claim_barrier_test.go b/internal/repository/postgres/delivery_claim_barrier_test.go new file mode 100644 index 000000000..c97428c60 --- /dev/null +++ b/internal/repository/postgres/delivery_claim_barrier_test.go @@ -0,0 +1,45 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func TestDeliveryClaimSelectorsAreIndependentOfOtherEpochStates(t *testing.T) { + for _, outstanding := range []model.EpochStatus{ + model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged, + } { + t.Run(outstanding.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_Quorum).Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, app, outstanding, model.EpochStatus_ClaimAccepted, model.EpochStatus_ClaimComputed) + + candidates, _, err := claimPageMaps(r.SelectClaimsToSubmitPage)(t.Context(), 0, 16) + require.NoError(t, err) + require.Equal(t, uint64(2), candidates[app.ID].Index) + + // An unrelated later accepted epoch cannot hide an older observation candidate. + selectObservation := claimPageMaps(r.SelectClaimsToStagePage) + if outstanding == model.EpochStatus_ClaimStaged { + selectObservation = claimPageMaps(r.SelectClaimsToAcceptPage) + } + candidates, _, err = selectObservation(t.Context(), 0, 16) + require.NoError(t, err) + require.Equal(t, uint64(0), candidates[app.ID].Index) + + require.NoError(t, r.UpdateEpochWithAcceptedClaim(t.Context(), app.ID, 0, nil)) + candidates, _, err = claimPageMaps(r.SelectClaimsToSubmitPage)(t.Context(), 0, 16) + require.NoError(t, err) + require.Equal(t, uint64(2), candidates[app.ID].Index) + observed, _, err := selectObservation(t.Context(), 0, 16) + require.NoError(t, err) + require.Empty(t, observed, "acceptance retires only the observation candidate") + }) + } +} diff --git a/internal/repository/postgres/delivery_claim_observation_test.go b/internal/repository/postgres/delivery_claim_observation_test.go new file mode 100644 index 000000000..cf59e75f0 --- /dev/null +++ b/internal/repository/postgres/delivery_claim_observation_test.go @@ -0,0 +1,53 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" + "testing" +) + +func TestDeliveryClaimPagesObserveEnabledApplicationsAtEveryStatus(t *testing.T) { + for _, status := range model.ApplicationStatusAllValues { + for _, enabled := range []bool{false, true} { + name := status.String() + "/disabled" + if enabled { + name = status.String() + "/enabled" + } + t.Run(name, func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithEnabled(enabled).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*model.Epoch][]*model.Input{epoch: {}}, 10)) + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, model.EpochStatus_ClaimComputed) + if status != model.ApplicationStatus_OK { + reason := "execution stopped" + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, status, &reason)) + } + later := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + laterEpoch := repotest.NewEpochBuilder(later.ID).WithStatus(model.EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), later.Name, map[*model.Epoch][]*model.Input{laterEpoch: {}}, 10)) + repotest.AdvanceEpochStatus(t.Context(), t, r, later.Name, laterEpoch, model.EpochStatus_ClaimComputed) + candidates, apps, err := claimPageMaps(r.SelectClaimsToSubmitPage)(t.Context(), 0, 1) + require.NoError(t, err) + require.Len(t, candidates, 1) + if enabled { + require.Contains(t, candidates, app.ID) + require.True(t, apps[app.ID].Enabled) + require.Equal(t, status, apps[app.ID].Status) + } else { + require.NotContains(t, candidates, app.ID) + require.NotContains(t, apps, app.ID) + require.Contains(t, candidates, later.ID) + } + candidates, _, err = claimPageMaps(r.SelectClaimsToSubmitPage)(t.Context(), app.ID, 1) + require.NoError(t, err) + require.Len(t, candidates, 1) + require.Contains(t, candidates, later.ID) + }) + } + } +} diff --git a/internal/repository/postgres/delivery_claim_pages_test.go b/internal/repository/postgres/delivery_claim_pages_test.go new file mode 100644 index 000000000..9f3204b3f --- /dev/null +++ b/internal/repository/postgres/delivery_claim_pages_test.go @@ -0,0 +1,406 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + "math" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + jet "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +type claimPageSelector func(context.Context, int64, uint64) ( + map[int64]*model.Epoch, map[int64]*model.Application, error, +) + +func claimPageMaps( + query func(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error), +) claimPageSelector { + return func(ctx context.Context, afterID int64, limit uint64) ( + map[int64]*model.Epoch, map[int64]*model.Application, error, + ) { + page, err := query(ctx, repository.ClaimCursor{AfterApplicationID: afterID}, max(limit, 2)) + epochs := make(map[int64]*model.Epoch, len(page.Work)) + apps := make(map[int64]*model.Application, len(page.Work)) + for _, work := range page.Work { + if _, exists := epochs[work.Application.ID]; !exists { + epochs[work.Application.ID], apps[work.Application.ID] = work.Epoch, work.Application + } + } + return epochs, apps, err + } +} + +func seedClaimPageEpochs( + t *testing.T, r *PostgresRepository, app *model.Application, statuses ...model.EpochStatus, +) []*model.Epoch { + t.Helper() + epochs := make([]*model.Epoch, len(statuses)) + inputs := make(map[*model.Epoch][]*model.Input, len(statuses)) + for i := range statuses { + index := uint64(i) + epochs[i] = repotest.NewEpochBuilder(app.ID).WithIndex(index).WithStatus(model.EpochStatus_Closed). + WithBlocks(index*10, index*10+9).WithInputBounds(index, index).Build() + inputs[epochs[i]] = []*model.Input{repotest.NewInputBuilder().WithIndex(index).WithBlockNumber(index*10 + 5).Build()} + } + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, inputs, uint64(len(statuses))*10)) + for i, status := range statuses { + if status == model.EpochStatus_ClaimStaged { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epochs[i], model.EpochStatus_ClaimComputed) + require.NoError(t, r.UpdateEpochWithSubmittedClaim(t.Context(), app.ID, uint64(i), repotest.UniqueHash())) + require.NoError(t, r.UpdateEpochToStaged(t.Context(), app.ID, uint64(i), 100+uint64(i))) + } else { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epochs[i], status) + } + } + return epochs +} + +func consumeClaimPage(cursor *repository.ClaimCursor, page repository.ClaimPage) { + cursor.AfterApplicationID = page.ApplicationID + for _, work := range page.Work { + cursor.Advance(work, page) + } +} + +func TestClaimApplicationRotationAndDeepWindows(t *testing.T) { + for _, test := range []struct{ apps, depth int }{{0, 0}, {1, 1000}, {16, 1}, {17, 300}, {40, 1}} { + t.Run(fmt.Sprintf("apps-%d-depth-%d", test.apps, test.depth), func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + expected := make(map[int64]map[uint64]bool) + for appIndex := range test.apps { + app := repotest.NewApplicationBuilder().WithName(fmt.Sprintf("rotation-%d", appIndex)).Create(t.Context(), t, r) + statuses := make([]model.EpochStatus, test.depth) + for index := range statuses { + statuses[index] = model.EpochStatus_ClaimComputed + } + epochs := seedClaimPageEpochs(t, r, app, statuses...) + expected[app.ID] = make(map[uint64]bool, len(epochs)) + for _, epoch := range epochs { + expected[app.ID][epoch.Index] = false + } + } + cursor := repository.ClaimCursor{} + // Every app receives one window per rotation. The oldest row is + // retried each time, while at least 15 later positions advance. + turns := max(test.apps, 1) * (1 + (test.depth+14)/15) + var previous int64 + for turn := range turns { + page, err := r.SelectClaimsToSubmitPage(t.Context(), cursor, 16) + require.NoError(t, err) + require.LessOrEqual(t, len(page.Work), 16) + if test.apps == 0 { + require.Empty(t, page.Work) + continue + } + require.NotEmpty(t, page.Work) + if test.apps > 1 { + require.NotEqual(t, previous, page.ApplicationID, "turn %d revisited an app too early", turn) + } + previous = page.ApplicationID + require.Zero(t, page.Work[0].Epoch.Index, "each app turn retries its own oldest") + for _, work := range page.Work { + expected[work.Application.ID][work.Epoch.Index] = true + } + consumeClaimPage(&cursor, page) + } + for appID, epochs := range expected { + for index, visited := range epochs { + require.True(t, visited, "app %d epoch %d was never inspected", appID, index) + } + } + }) + } +} + +func TestClaimPageSourcesAreIndependentAndContainCompleteProofs(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, app, model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, + model.EpochStatus_ClaimStaged, model.EpochStatus_ClaimComputed) + for _, query := range []func(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error){ + r.SelectClaimsToSubmitPage, r.SelectClaimsToStagePage, r.SelectClaimsToAcceptPage, + } { + page, err := query(t.Context(), repository.ClaimCursor{}, 16) + require.NoError(t, err) + require.NotEmpty(t, page.Work) + for _, work := range page.Work { + stored, err := r.GetEpoch(t.Context(), app.Name, work.Epoch.Index) + require.NoError(t, err) + actual, err := work.Epoch.StateProof() + require.NoError(t, err) + expected, err := stored.StateProof() + require.NoError(t, err) + require.Equal(t, expected, actual) + require.Equal(t, stored.ClaimTransactionHash, work.Epoch.ClaimTransactionHash) + require.Equal(t, stored.StagedAtBlock, work.Epoch.StagedAtBlock) + } + } +} + +func TestClaimPageInsertionDeletionAndDisabledApplications(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + first := repotest.NewApplicationBuilder().WithName("first").Create(t.Context(), t, r) + second := repotest.NewApplicationBuilder().WithName("second").Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, first, model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimComputed) + seedClaimPageEpochs(t, r, second, model.EpochStatus_ClaimComputed) + cursor := repository.ClaimCursor{} + page, err := r.SelectClaimsToSubmitPage(t.Context(), cursor, 16) + require.NoError(t, err) + require.Equal(t, first.ID, page.ApplicationID) + consumeClaimPage(&cursor, page) + third := repotest.NewApplicationBuilder().WithName("newcomer").Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, third, model.EpochStatus_ClaimComputed) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), first.ID, false)) + page, err = r.SelectClaimsToSubmitPage(t.Context(), cursor, 16) + require.NoError(t, err) + require.Equal(t, second.ID, page.ApplicationID) + consumeClaimPage(&cursor, page) + // This selector fixture deletes a row directly; application-removal gates + // have their own production-path fixtures. + statement := table.Application.DELETE().WHERE(table.Application.ID.EQ(jet.Int64(second.ID))) + query, args := statement.Sql() + _, err = r.db.Exec(t.Context(), query, args...) + require.NoError(t, err) + page, err = r.SelectClaimsToSubmitPage(t.Context(), cursor, 16) + require.NoError(t, err) + require.Equal(t, third.ID, page.ApplicationID) + consumeClaimPage(&cursor, page) + page, err = r.SelectClaimsToSubmitPage(t.Context(), cursor, 16) + require.NoError(t, err) + require.Equal(t, third.ID, page.ApplicationID, "disabled and deleted apps do not occupy future turns") +} + +func TestClaimPageRejectsInvalidBounds(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, test := range []struct { + after int64 + limit uint64 + }{{-1, 16}, {0, 0}, {0, 1}, {0, math.MaxUint64}} { + _, err := r.SelectClaimsToSubmitPage(t.Context(), repository.ClaimCursor{AfterApplicationID: test.after}, test.limit) + require.Error(t, err) + } +} + +func TestClaimPageUsesSparseApplicationLocalEpochIndices(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seeded := make([]*model.Epoch, 0, 4) + inputs := make(map[*model.Epoch][]*model.Input) + for offset, index := range []uint64{2, 700, 90000, math.MaxUint64} { + epoch := repotest.NewEpochBuilder(app.ID).WithIndex(index).WithStatus(model.EpochStatus_Closed). + WithBlocks(uint64(offset*10), uint64(offset*10+9)).WithInputBounds(0, 0).Build() + seeded = append(seeded, epoch) + inputs[epoch] = nil + } + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, inputs, 40)) + for _, epoch := range seeded { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, model.EpochStatus_ClaimComputed) + } + var cursor repository.ClaimCursor + page, err := r.SelectClaimsToSubmitPage(t.Context(), cursor, 3) + require.NoError(t, err) + require.Equal(t, []uint64{2, 700, 90000}, []uint64{page.Work[0].Epoch.Index, page.Work[1].Epoch.Index, page.Work[2].Epoch.Index}) + consumeClaimPage(&cursor, page) + page, err = r.SelectClaimsToSubmitPage(t.Context(), cursor, 3) + require.NoError(t, err) + require.Len(t, page.Work, 2) + require.Equal(t, uint64(2), page.Work[0].Epoch.Index) + require.Equal(t, uint64(math.MaxUint64), page.Work[1].Epoch.Index) +} + +func TestClaimPagesSkipIdleApplicationsForEverySource(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for index := range 40 { + app := repotest.NewApplicationBuilder().WithName(fmt.Sprintf("idle-%d", index)).Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, app, model.EpochStatus_ClaimAccepted) + } + prt := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, prt, model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, + model.EpochStatus_ClaimStaged) + for _, test := range []struct { + status model.EpochStatus + query func(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error) + }{ + {model.EpochStatus_ClaimComputed, r.SelectClaimsToSubmitPage}, + {model.EpochStatus_ClaimSubmitted, r.SelectClaimsToStagePage}, + {model.EpochStatus_ClaimStaged, r.SelectClaimsToAcceptPage}, + } { + t.Run(test.status.String(), func(t *testing.T) { + otherStatus := model.EpochStatus_ClaimComputed + if test.status == otherStatus { + otherStatus = model.EpochStatus_ClaimStaged + } + other := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, other, otherStatus) + first := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_Authority).Create(t.Context(), t, r) + second := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_Quorum).Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, first, test.status) + seedClaimPageEpochs(t, r, second, test.status) + cursor := repository.ClaimCursor{} + for _, expected := range []int64{first.ID, second.ID, first.ID} { + page, err := test.query(t.Context(), cursor, 16) + require.NoError(t, err) + require.Equal(t, expected, page.ApplicationID, "accepted history, another source, and PRT consume no turn") + require.Len(t, page.Work, 1) + consumeClaimPage(&cursor, page) + } + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), first.ID, false)) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), second.ID, false)) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), other.ID, false)) + page, err := test.query(t.Context(), cursor, 16) + require.NoError(t, err) + require.Empty(t, page.Work) + }) + } +} + +func TestClaimPagesKeepUnhealthyApplicationsObservable(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, status := range model.ApplicationStatusAllValues { + t.Run(status.String(), func(t *testing.T) { + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, app, model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, + model.EpochStatus_ClaimStaged) + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, status, new("selector observation fixture"))) + for _, query := range []func(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error){ + r.SelectClaimsToSubmitPage, r.SelectClaimsToStagePage, r.SelectClaimsToAcceptPage, + } { + page, err := query(t.Context(), repository.ClaimCursor{}, 16) + require.NoError(t, err) + require.Equal(t, app.ID, page.ApplicationID) + require.Len(t, page.Work, 1) + require.Equal(t, status, page.Work[0].Application.Status) + } + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + }) + } +} + +func TestClaimPublisherPagesSkipStoppedApplicationsBeforePagination(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + statuses := []model.EpochStatus{ + model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged, + } + stopped := make([]*model.Application, 0, 41) + for index := range 40 { + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, app, statuses...) + status := model.ApplicationStatusAllValues[1+index%(len(model.ApplicationStatusAllValues)-1)] + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, status, new("publisher selection fixture"))) + stopped = append(stopped, app) + } + foreclosed := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, foreclosed, statuses...) + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), foreclosed.ID, 100, repotest.UniqueHash(), 100)) + stopped = append(stopped, foreclosed) + first := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + second := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_Quorum).Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, first, statuses...) + seedClaimPageEpochs(t, r, second, statuses...) + for _, query := range []func(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error){ + r.SelectClaimsToSubmitPage, r.SelectClaimsToStagePage, r.SelectClaimsToAcceptPage, + } { + cursor := repository.ClaimCursor{PublishableOnly: true} + for _, expected := range []int64{first.ID, second.ID, first.ID} { + page, err := query(t.Context(), cursor, 2) + require.NoError(t, err) + require.Equal(t, expected, page.ApplicationID, "stopped applications must consume no publisher turn") + require.Len(t, page.Work, 1) + consumeClaimPage(&cursor, page) + } + // The same source without the publisher flag must still observe every + // stopped application, including the foreclosed one, in ID order. + cursor = repository.ClaimCursor{} + for _, app := range append(stopped, first, second) { + page, err := query(t.Context(), cursor, 2) + require.NoError(t, err) + require.Equal(t, app.ID, page.ApplicationID) + require.Len(t, page.Work, 1) + consumeClaimPage(&cursor, page) + } + } +} + +func TestClaimPublisherPageMembershipChangesAcrossWrap(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + first := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + second := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + for _, app := range []*model.Application{first, second} { + seedClaimPageEpochs(t, r, app, model.EpochStatus_ClaimComputed) + } + cursor := repository.ClaimCursor{PublishableOnly: true} + page, err := r.SelectClaimsToSubmitPage(t.Context(), cursor, 2) + require.NoError(t, err) + require.Equal(t, first.ID, page.ApplicationID) + consumeClaimPage(&cursor, page) + require.NoError(t, r.UpdateApplicationStatus(t.Context(), second.ID, model.ApplicationStatus_Failed, new("stopped"))) + third := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, third, model.EpochStatus_ClaimComputed) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), first.ID, false)) + page, err = r.SelectClaimsToSubmitPage(t.Context(), cursor, 2) + require.NoError(t, err) + require.Equal(t, third.ID, page.ApplicationID, "an inserted app is reached past the newly stopped app") + consumeClaimPage(&cursor, page) + require.NoError(t, r.UpdateApplicationStatus(t.Context(), second.ID, model.ApplicationStatus_OK, nil)) + page, err = r.SelectClaimsToSubmitPage(t.Context(), cursor, 2) + require.NoError(t, err) + require.Equal(t, second.ID, page.ApplicationID, "a repaired app rejoins after wrapping; disabled apps stay excluded") +} + +func TestClaimPublisherPagesSelectOnlyTerminalPrefix(t *testing.T) { + for _, kind := range []model.ApplicationStatus{model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + for _, status := range []model.EpochStatus{model.EpochStatus_ClaimComputed, + model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged} { + t.Run(kind.String()+"/"+status.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + seedClaimPageEpochs(t, r, app, status, status, status, status, status) + // Source production/rollback has separate real-writer tests. + // Keep suffix rows here to check both SQL pagination gates. + var input *uint64 + if kind.IsExecutionTerminal() { + input = new(uint64(99)) + } + _, err := r.db.Exec(t.Context(), `UPDATE application SET status=$2, reason='prefix pagination fixture', + problem_epoch_index=2, + problem_kind=$2, problem_input_index=$3 WHERE id=$1`, app.ID, kind, input) + require.NoError(t, err) + query := r.SelectClaimsToSubmitPage + if status == model.EpochStatus_ClaimSubmitted { + query = r.SelectClaimsToStagePage + } + if status == model.EpochStatus_ClaimStaged { + query = r.SelectClaimsToAcceptPage + } + cursor := repository.ClaimCursor{PublishableOnly: true} + for range 3 { + page, err := query(t.Context(), cursor, 2) + require.NoError(t, err) + require.Equal(t, app.ID, page.ApplicationID) + require.Len(t, page.Work, 2) + require.Equal(t, uint64(0), page.Work[0].Epoch.Index) + require.Equal(t, uint64(1), page.Work[1].Epoch.Index) + consumeClaimPage(&cursor, page) + } + page, err := query(t.Context(), repository.ClaimCursor{}, 16) + require.NoError(t, err) + require.Len(t, page.Work, 5, "observation keeps every stored claim") + _, err = r.db.Exec(t.Context(), `UPDATE application SET problem_epoch_index=0 WHERE id=$1`, app.ID) + require.NoError(t, err) + page, err = query(t.Context(), repository.ClaimCursor{PublishableOnly: true}, 16) + require.NoError(t, err) + require.Empty(t, page.Work, "an application with no eligible prefix consumes no turn") + require.Zero(t, page.ApplicationID) + }) + } + } +} diff --git a/internal/repository/postgres/delivery_config.go b/internal/repository/postgres/delivery_config.go new file mode 100644 index 000000000..00c467413 --- /dev/null +++ b/internal/repository/postgres/delivery_config.go @@ -0,0 +1,356 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/enum" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Row locks do not protect absent configuration. This documented raw SQL +// exception uses PostgreSQL's two-key advisory-lock function with bound keys. +const registrationLockNamespace int32 = 1415074375 // TXRG +const deliveryClaimerKey = "claimer" +const deliveryPRTKey = "prt" +const deliveryObserverKey = "evm-reader" +const deliveryDatabaseTimeout = 5 * time.Second + +func registrationLock(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1,$2)", registrationLockNamespace, int32(0)) + return err +} + +func rollbackDeliveryTx(tx pgx.Tx) { + ctx, cancel := context.WithTimeout(context.Background(), deliveryDatabaseTimeout) + defer cancel() + if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + level := slog.LevelError + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + (tx.Conn() != nil && tx.Conn().IsClosed()) { + level = slog.LevelDebug + } + slog.Log(ctx, level, "failed to roll back delivery transaction", "error", err) + } +} + +func validateObserverPolicy(c config.PersistentChainConfig) error { + if err := c.Validate(); err != nil { + return err + } + if c.DefaultBlock == model.DefaultBlock_Pending { + return errors.New("pending durable observation is unsupported") + } + return nil +} + +// Registration locks the complete key set in the same order. The advisory +// lock also protects configuration keys that do not exist yet. +func lockDeliveryConfigs(ctx context.Context, tx pgx.Tx) (map[string][]byte, error) { + stmt := table.NodeConfig.SELECT(table.NodeConfig.Key, table.NodeConfig.Value). + WHERE(table.NodeConfig.Key.IN( + postgres.String(deliveryClaimerKey), postgres.String(deliveryObserverKey), postgres.String(deliveryPRTKey), + )). + ORDER_BY(table.NodeConfig.Key.ASC()).FOR(postgres.UPDATE()) + query, args := stmt.Sql() + rows, err := tx.Query(ctx, query, args...) + return deliveryConfigRecords(rows, err) +} + +func deliveryConfigRecords(rows pgx.Rows, err error) (map[string][]byte, error) { + if err != nil { + return nil, err + } + defer rows.Close() + records := make(map[string][]byte) + for rows.Next() { + var key string + var raw []byte + if err = rows.Scan(&key, &raw); err != nil { + return nil, err + } + records[key] = raw + } + return records, rows.Err() +} + +func hasUnprovenData(ctx context.Context, tx pgx.Tx, key string) (bool, error) { + existsEpoch := postgres.EXISTS(table.Epoch.SELECT(postgres.CAST(postgres.Int(1)).AS_BIGINT())) + existsInput := postgres.EXISTS(table.Input.SELECT(postgres.CAST(postgres.Int(1)).AS_BIGINT())) + var predicate postgres.BoolExpression + switch key { + case deliveryObserverKey: + predicate = existsEpoch.OR(existsInput).OR(postgres.EXISTS(table.Application. + SELECT(postgres.CAST(postgres.Int(1)).AS_BIGINT()).WHERE( + table.Application.LastEpochCheckBlock.GT(uint64Expr(0)).OR(table.Application.LastInputCheckBlock.GT(uint64Expr(0))). + OR(table.Application.LastOutputCheckBlock.GT(uint64Expr(0))). + OR(table.Application.LastForecloseCheckBlock.GT(uint64Expr(0))), + ))) + case deliveryPRTKey: + predicate = postgres.EXISTS(table.Tournaments.SELECT(postgres.CAST(postgres.Int(1)).AS_BIGINT())).OR(postgres.EXISTS( + table.Application.SELECT(postgres.CAST(postgres.Int(1)).AS_BIGINT()). + WHERE(table.Application.ConsensusType.EQ(enum.Consensus.Prt). + AND(table.Application.LastTournamentCheckBlock.GT(uint64Expr(0)))), + )) + case deliveryClaimerKey: + predicate = postgres.EXISTS(table.Epoch.SELECT(postgres.CAST(postgres.Int(1)).AS_BIGINT()).FROM(table.Epoch.INNER_JOIN( + table.Application, table.Application.ID.EQ(table.Epoch.ApplicationID), + )).WHERE(table.Application.ConsensusType.IN(enum.Consensus.Authority, enum.Consensus.Quorum). + AND(table.Epoch.Status.NOT_IN( + enum.EpochStatus.Open, enum.EpochStatus.Closed, enum.EpochStatus.InputsProcessed, enum.EpochStatus.ClaimComputed, + )))) + default: + return false, fmt.Errorf("unsupported delivery configuration key %q", key) + } + stmt := postgres.SELECT(predicate) + query, args := stmt.Sql() + var exists bool + err := tx.QueryRow(ctx, query, args...).Scan(&exists) + return exists, err +} + +func configChain(c config.PersistentSubmitterConfig) config.PersistentChainConfig { + return config.PersistentChainConfig{DefaultBlock: c.DefaultBlock, ChainID: c.ChainID} +} + +func insertDeliveryConfig(ctx context.Context, tx pgx.Tx, key string, raw []byte) error { + stmt := table.NodeConfig.INSERT(table.NodeConfig.Key, table.NodeConfig.Value). + VALUES(key, postgres.Json(raw)).ON_CONFLICT(table.NodeConfig.Key).DO_NOTHING() + query, args := stmt.Sql() + _, err := tx.Exec(ctx, query, args...) + return err +} + +func (r *PostgresRepository) RegisterObserver( + ctx context.Context, key string, requested config.PersistentChainConfig, +) (config.PersistentChainConfig, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if key != deliveryObserverKey { + return requested, fmt.Errorf("unsupported observer %q", key) + } + if err := validateObserverPolicy(requested); err != nil { + return requested, err + } + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return requested, err + } + defer rollbackDeliveryTx(tx) + if err = registrationLock(ctx, tx); err != nil { + return requested, err + } + records, err := lockDeliveryConfigs(ctx, tx) + if err != nil { + return requested, err + } + raw, exists := records[key] + if !exists { + data, checkErr := hasUnprovenData(ctx, tx, key) + if checkErr != nil { + return requested, checkErr + } + if data { + return requested, errors.New("observer provenance is absent for existing domain facts") + } + raw, err = json.Marshal(requested) + if err != nil { + return requested, err + } + } + var saved config.PersistentChainConfig + if err = json.Unmarshal(raw, &saved); err != nil { + return saved, err + } + if err = saved.CheckRequested(requested); err != nil { + return saved, err + } + if err = validateObserverPolicy(saved); err != nil { + return saved, err + } + for _, role := range []string{deliveryClaimerKey, deliveryPRTKey} { + roleRaw, exists := records[role] + if !exists { + continue + } + var roleConfig config.PersistentSubmitterConfig + if err = json.Unmarshal(roleRaw, &roleConfig); err != nil { + return saved, err + } + if err = configChain(roleConfig).CheckRequested(saved); err != nil { + return saved, err + } + } + if err = insertDeliveryConfig(ctx, tx, key, raw); err != nil { + return saved, err + } + return saved, tx.Commit(ctx) +} + +func (r *PostgresRepository) RegisterSubmitter( + ctx context.Context, key string, requested config.PersistentSubmitterConfig, +) (config.PersistentSubmitterConfig, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if key != deliveryClaimerKey && key != deliveryPRTKey { + return requested, fmt.Errorf("unsupported submitter %q", key) + } + if err := validateObserverPolicy(configChain(requested)); err != nil { + return requested, err + } + if err := requested.Validate(); err != nil { + return requested, err + } + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return requested, err + } + defer rollbackDeliveryTx(tx) + if err = registrationLock(ctx, tx); err != nil { + return requested, err + } + records, err := lockDeliveryConfigs(ctx, tx) + if err != nil { + return requested, err + } + raw, exists := records[key] + if !exists { + data, checkErr := hasUnprovenData(ctx, tx, key) + if checkErr != nil { + return requested, checkErr + } + if data { + return requested, errors.New("submitter provenance is absent for existing domain facts") + } + + raw, err = json.Marshal(requested) + if err != nil { + return requested, err + } + } + var saved config.PersistentSubmitterConfig + err = json.Unmarshal(raw, &saved) + if err != nil { + return saved, err + } + if err = saved.CheckRequested(requested); err != nil { + return saved, err + } + other := deliveryClaimerKey + if key == deliveryClaimerKey { + other = deliveryPRTKey + } + if otherRaw, exists := records[other]; exists { + var otherConfig config.PersistentSubmitterConfig + if err = json.Unmarshal(otherRaw, &otherConfig); err != nil { + return saved, err + } + if err = configChain(otherConfig).CheckRequested(configChain(saved)); err != nil { + return saved, fmt.Errorf("incompatible %s and %s registrations: %w", other, key, err) + } + bothSubmitting := saved.ClaimSubmissionEnabled && otherConfig.ClaimSubmissionEnabled + if bothSubmitting && saved.ResolvedSignerAddress == otherConfig.ResolvedSignerAddress { + return saved, errors.New("claimer and PRT require distinct submitting addresses") + } + } + if err = checkReaderProvenance(ctx, tx, records, saved); err != nil { + return saved, err + } + if err = insertDeliveryConfig(ctx, tx, key, raw); err != nil { + return saved, err + } + return saved, tx.Commit(ctx) +} + +func checkReaderProvenance( + ctx context.Context, tx pgx.Tx, records map[string][]byte, saved config.PersistentSubmitterConfig, +) error { + raw, exists := records[deliveryObserverKey] + if !exists { + data, err := hasUnprovenData(ctx, tx, deliveryObserverKey) + if err != nil { + return err + } + if data { + return errors.New("contributing observer provenance is absent for existing domain facts") + } + return nil + } + var reader config.PersistentChainConfig + if err := json.Unmarshal(raw, &reader); err != nil { + return err + } + if err := reader.CheckRequested(configChain(saved)); err != nil { + return err + } + return validateObserverPolicy(reader) +} + +func (r *PostgresRepository) CheckSubmitterProvenance(ctx context.Context, key string) (err error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + defer func() { + if err != nil { + err = errors.Join(repository.ErrSubmitterProvenance, err) + } + }() + if key != deliveryClaimerKey && key != deliveryPRTKey { + return fmt.Errorf("unsupported submitter %q", key) + } + // Registration records are insert-once. Read the role and contributing + // reader in one statement snapshot without locking unrelated publication. + records, err := readDeliveryConfigs(ctx, r.db) + if err != nil { + return err + } + raw, exists := records[key] + if !exists { + return repository.ErrNotFound + } + var saved config.PersistentSubmitterConfig + err = json.Unmarshal(raw, &saved) + if err != nil { + return err + } + if err = validateObserverPolicy(configChain(saved)); err != nil { + return err + } + readerRaw, exists := records[deliveryObserverKey] + if !exists { + return repository.ErrObserverNotRegistered + } + var reader config.PersistentChainConfig + if err = json.Unmarshal(readerRaw, &reader); err != nil { + return err + } + if err = reader.CheckRequested(configChain(saved)); err != nil { + return err + } + return validateObserverPolicy(reader) +} + +func readDeliveryConfigs(ctx context.Context, pool *pgxpool.Pool) (map[string][]byte, error) { + stmt := table.NodeConfig.SELECT(table.NodeConfig.Key, table.NodeConfig.Value). + WHERE(table.NodeConfig.Key.IN( + postgres.String(deliveryClaimerKey), postgres.String(deliveryObserverKey), postgres.String(deliveryPRTKey), + )) + query, args := stmt.Sql() + rows, err := pool.Query(ctx, query, args...) + return deliveryConfigRecords(rows, err) +} diff --git a/internal/repository/postgres/delivery_deletion_serialization_test.go b/internal/repository/postgres/delivery_deletion_serialization_test.go new file mode 100644 index 000000000..9ffa23cab --- /dev/null +++ b/internal/repository/postgres/delivery_deletion_serialization_test.go @@ -0,0 +1,77 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +type deletionGateTracer struct { + arrived chan struct{} + resume chan struct{} +} + +func (g *deletionGateTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, query pgx.TraceQueryStartData) context.Context { + if strings.Contains(query.SQL, "DELETE FROM public.application") { + g.arrived <- struct{}{} + select { + case <-g.resume: + case <-ctx.Done(): + } + } + return ctx +} + +func (*deletionGateTracer) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {} + +func TestDeliveryOverlappingDeletionsSerializeBeforeDelete(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + registerDeliveryTest(t, r, repotest.UniqueAddress()) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).WithEnabled(false).Create(t.Context(), t, r) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + gate := &deletionGateTracer{arrived: make(chan struct{}, 1), resume: make(chan struct{})} + newRepository := func(tracer pgx.QueryTracer) *PostgresRepository { + t.Helper() + cfg := r.db.Config() + cfg.MaxConns, cfg.ConnConfig.Tracer = 1, tracer + pool, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(t, err) + t.Cleanup(pool.Close) + return &PostgresRepository{db: pool} + } + first, second := newRepository(gate), newRepository(nil) + var secondPID int32 + require.NoError(t, second.db.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&secondPID)) + firstResult, secondResult := make(chan error, 1), make(chan error, 1) + go func() { firstResult <- first.DeleteApplication(ctx, app.ID) }() + select { + case <-gate.arrived: + case <-ctx.Done(): + t.Fatal("first deletion did not reach DELETE with its application lock held") + } + go func() { secondResult <- second.DeleteApplication(ctx, app.ID) }() + var blockedQuery string + require.Eventually(t, func() bool { + err := r.db.QueryRow(ctx, "SELECT query FROM pg_stat_activity WHERE pid=$1 AND wait_event_type='Lock'", secondPID). + Scan(&blockedQuery) + return err == nil + }, 5*time.Second, 10*time.Millisecond) + // pg_stat_activity can truncate the long projection before its FOR clause. + // The application SELECT still distinguishes waiting at the gate from DELETE. + require.Contains(t, blockedQuery, "SELECT application.enabled", "the second gate must wait before it can reach DELETE") + close(gate.resume) + require.NoError(t, <-firstResult) + require.ErrorIs(t, <-secondResult, repository.ErrNotFound) +} diff --git a/internal/repository/postgres/delivery_lifecycle.go b/internal/repository/postgres/delivery_lifecycle.go new file mode 100644 index 000000000..b98fc72f5 --- /dev/null +++ b/internal/repository/postgres/delivery_lifecycle.go @@ -0,0 +1,78 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +// The caller bounds cascade execution. Lock acquisition remains short even for +// applications whose history takes several minutes to delete. +func setDeletionTimeouts(ctx context.Context, tx pgx.Tx) error { + settings := []postgres.Projection{postgres.Func("set_config", postgres.String("lock_timeout"), + postgres.String("2s"), postgres.Bool(true))} + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining <= 0 { + return context.DeadlineExceeded + } + settings = append(settings, postgres.Func("set_config", postgres.String("statement_timeout"), + postgres.String(fmt.Sprintf("%dms", min(int64(math.MaxInt32), max(int64(1), remaining.Milliseconds())))), postgres.Bool(true))) + } + stmt := postgres.SELECT(settings[0], settings[1:]...) + query, args := stmt.Sql() + _, err := tx.Exec(ctx, query, args...) + return err +} + +// DeleteApplication serializes with observation and enable/disable changes. +// The enabled predicate is checked again after taking the application row lock. +func (r *PostgresRepository) DeleteApplication(ctx context.Context, id int64) error { + if _, bounded := ctx.Deadline(); !bounded { + var cancel context.CancelFunc + ctx, cancel = beginDatabaseOperation(ctx) + defer cancel() + } + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return err + } + defer rollbackDeliveryTx(tx) + if err = setDeletionTimeouts(ctx, tx); err != nil { + return fmt.Errorf("setting deletion timeouts: %w", err) + } + lock := table.Application.SELECT(table.Application.Enabled). + WHERE(table.Application.ID.EQ(postgres.Int64(id))).FOR(postgres.UPDATE()) + query, args := lock.Sql() + var enabled bool + if err = tx.QueryRow(ctx, query, args...).Scan(&enabled); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } + return err + } + if enabled { + return repository.ErrApplicationEnabled + } + stmt := table.Application.DELETE().WHERE(table.Application.ID.EQ(postgres.Int64(id)). + AND(table.Application.Enabled.EQ(postgres.Bool(false)))) + query, args = stmt.Sql() + command, err := tx.Exec(ctx, query, args...) + if err != nil { + return err + } + if command.RowsAffected() != 1 { + return repository.ErrNoUpdate + } + return tx.Commit(ctx) +} diff --git a/internal/repository/postgres/delivery_operations_test.go b/internal/repository/postgres/delivery_operations_test.go new file mode 100644 index 000000000..bd67da7c0 --- /dev/null +++ b/internal/repository/postgres/delivery_operations_test.go @@ -0,0 +1,159 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math" + "net" + "syscall" + "testing" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const ( + operationCancelled = "cancelled" + operationDeadline = "deadline" +) + +func TestConnectionDiagnosticsClassifyWithoutLeakingValues(t *testing.T) { + secret := "password=sensitive database=private host=internal" + for _, test := range []struct { + name, want string + cause error + }{ + {"authentication", "authentication refused", &pgconn.PgError{Code: "28P01", Message: secret}}, + {"database", "SQLSTATE 3D000", &pgconn.PgError{Code: "3D000", Message: secret}}, + {"invalid code", "connection failed", &pgconn.PgError{Code: secret, Message: secret}}, + {"DNS", "DNS resolution failed", &net.DNSError{Name: secret, Err: secret}}, + {"refused", "connection refused", fmt.Errorf("%s: %w", secret, syscall.ECONNREFUSED)}, + {operationCancelled, operationCancelled, fmt.Errorf("%s: %w", secret, context.Canceled)}, + {operationDeadline, "connection timeout", fmt.Errorf("%s: %w", secret, context.DeadlineExceeded)}, + {"unclassified", "connection failed", errors.New(secret)}, + } { + t.Run(test.name, func(t *testing.T) { + diagnostic := safePostgresConnectCause(test.cause) + require.Contains(t, diagnostic, test.want) + require.NotContains(t, diagnostic, secret) + require.NotContains(t, diagnostic, "sensitive") + }) + } +} + +func TestDeletionLocalTimeoutsDoNotLeakAfterCommitOnTheSameBackend(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + conn, err := r.db.Acquire(t.Context()) + require.NoError(t, err) + defer conn.Release() + setting := func(key, value string) postgres.Expression { + return postgres.Func("set_config", postgres.String(key), postgres.String(value), postgres.Bool(false)) + } + stmt := postgres.SELECT(setting("lock_timeout", "7s"), setting("statement_timeout", "30s")) + query, args := stmt.Sql() + _, err = conn.Exec(t.Context(), query, args...) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + tx, err := conn.Begin(ctx) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + require.NoError(t, setDeletionTimeouts(ctx, tx)) + var lockTimeout, statementTimeout string + querySettings := postgres.SELECT(postgres.Func("current_setting", postgres.String("lock_timeout")), + postgres.Func("current_setting", postgres.String("statement_timeout"))) + query, args = querySettings.Sql() + require.NoError(t, tx.QueryRow(ctx, query, args...).Scan(&lockTimeout, &statementTimeout)) + require.Equal(t, "2s", lockTimeout) + require.NotEqual(t, "30s", statementTimeout) + require.NoError(t, tx.Commit(ctx)) + require.NoError(t, conn.QueryRow(ctx, query, args...).Scan(&lockTimeout, &statementTimeout)) + require.Equal(t, "7s", lockTimeout) + require.Equal(t, "30s", statementTimeout, "the same connection must retain its pre-transaction settings") +} + +func TestDeletionTimeoutClampsLongCallerDeadlines(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, duration := range []time.Duration{596 * time.Hour, 597 * time.Hour, 1000 * time.Hour} { + t.Run(duration.String(), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), duration) + defer cancel() + tx, err := r.db.Begin(ctx) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + require.NoError(t, setDeletionTimeouts(ctx, tx)) + stmt := postgres.SELECT(postgres.CAST(postgres.Func("current_setting", postgres.String("statement_timeout"))).AS_TEXT()) + query, args := stmt.Sql() + var setting string + require.NoError(t, tx.QueryRow(ctx, query, args...).Scan(&setting)) + if duration.Milliseconds() > math.MaxInt32 { + require.Equal(t, "2147483647ms", setting) + } else { + require.NotEqual(t, "2147483647ms", setting) + } + }) + } + ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer cancel() + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + require.ErrorIs(t, setDeletionTimeouts(ctx, tx), context.DeadlineExceeded) +} + +type rollbackLogTransaction struct { + pgx.Tx + cause error + connection *pgx.Conn +} + +func (t rollbackLogTransaction) Rollback(context.Context) error { return t.cause } +func (t rollbackLogTransaction) Conn() *pgx.Conn { return t.connection } + +func TestCancelledRollbackIsDebugAndUnexpectedRollbackIsError(t *testing.T) { + for _, test := range []struct { + name, level string + cause error + }{ + {operationCancelled, "DEBUG", context.Canceled}, {operationDeadline, "DEBUG", context.DeadlineExceeded}, + {"unexpected", "ERROR", errors.New("unexpected rollback failure")}, + {"already complete", "", pgx.ErrTxClosed}, + } { + t.Run(test.name, func(t *testing.T) { + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + rollbackDeliveryTx(rollbackLogTransaction{cause: test.cause}) + if test.level == "" { + require.Empty(t, output.String()) + } else { + require.Contains(t, output.String(), "level="+test.level) + } + }) + } +} + +func TestRollbackAfterConnectionLossIsDebug(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + conn, err := r.db.Acquire(t.Context()) + require.NoError(t, err) + defer conn.Release() + require.NoError(t, conn.Conn().Close(t.Context())) + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + rollbackDeliveryTx(rollbackLogTransaction{cause: net.ErrClosed, connection: conn.Conn()}) + require.Contains(t, output.String(), "level=DEBUG") + require.NotContains(t, output.String(), "level=ERROR") +} diff --git a/internal/repository/postgres/delivery_policy_test.go b/internal/repository/postgres/delivery_policy_test.go new file mode 100644 index 000000000..482173d2c --- /dev/null +++ b/internal/repository/postgres/delivery_policy_test.go @@ -0,0 +1,125 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "encoding/json" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func TestDeliveryRegistrationSupportsSelectedNonPendingPolicy(t *testing.T) { + for _, policy := range []model.DefaultBlock{model.DefaultBlock_Latest, model.DefaultBlock_Safe, model.DefaultBlock_Finalized} { + for _, submitting := range []bool{false, true} { + name := policy.String() + "/observer" + if submitting { + name = policy.String() + "/submitting" + } + t.Run(name, func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + chain := config.PersistentChainConfig{DefaultBlock: policy, ChainID: 1} + _, err := r.RegisterObserver(t.Context(), deliveryObserverKey, chain) + require.NoError(t, err) + role := config.PersistentSubmitterConfig{DefaultBlock: policy, ChainID: 1, ClaimSubmissionEnabled: submitting} + if submitting { + role.ResolvedSignerAddress = repotest.UniqueAddress() + } + _, err = r.RegisterSubmitter(t.Context(), deliveryPRTKey, role) + require.NoError(t, err) + require.NoError(t, r.CheckSubmitterProvenance(t.Context(), deliveryPRTKey)) + // Explicit policy selection does not permit changing a saved deployment. + role.DefaultBlock = model.DefaultBlock_Latest + if policy == model.DefaultBlock_Latest { + role.DefaultBlock = model.DefaultBlock_Finalized + } + _, err = r.RegisterSubmitter(t.Context(), deliveryPRTKey, role) + require.ErrorContains(t, err, "observation policy mismatch") + chain.DefaultBlock = model.DefaultBlock_Pending + _, err = r.RegisterObserver(t.Context(), deliveryObserverKey, chain) + require.ErrorContains(t, err, "pending") + }) + } + } +} + +func TestDeliveryRegistrationRejectsCrossRecordPolicyMismatch(t *testing.T) { + role := func(policy model.DefaultBlock, chain uint64) config.PersistentSubmitterConfig { + return config.PersistentSubmitterConfig{DefaultBlock: policy, ChainID: chain, + ClaimSubmissionEnabled: true, ResolvedSignerAddress: repotest.UniqueAddress()} + } + for _, first := range []string{deliveryClaimerKey, deliveryPRTKey} { + for _, otherPolicy := range []model.DefaultBlock{model.DefaultBlock_Safe, model.DefaultBlock_Finalized} { + t.Run(first+"/"+otherPolicy.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + second := deliveryClaimerKey + if first == second { + second = deliveryPRTKey + } + _, err := r.RegisterSubmitter(t.Context(), first, role(model.DefaultBlock_Latest, 1)) + require.NoError(t, err) + _, err = r.RegisterSubmitter(t.Context(), second, role(otherPolicy, 1)) + require.ErrorContains(t, err, "observation policy mismatch") + _, _, _, err = r.LoadNodeConfigRaw(t.Context(), second) + require.ErrorIs(t, err, repository.ErrNotFound, "rejected registration must not persist") + _, err = r.RegisterSubmitter(t.Context(), second, role(model.DefaultBlock_Latest, 2)) + require.ErrorContains(t, err, "chain ID mismatch") + _, err = r.RegisterSubmitter(t.Context(), second, role(model.DefaultBlock_Latest, 1)) + require.NoError(t, err) + _, err = r.RegisterObserver(t.Context(), deliveryObserverKey, + config.PersistentChainConfig{DefaultBlock: model.DefaultBlock_Latest, ChainID: 1}) + require.NoError(t, err, "a rejected incompatible role must not prevent compatible startup") + }) + } + } + for _, readerFirst := range []bool{false, true} { + t.Run(map[bool]string{false: "role before reader", true: "reader before role"}[readerFirst], func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + reader := config.PersistentChainConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1} + if readerFirst { + _, err := r.RegisterObserver(t.Context(), deliveryObserverKey, reader) + require.NoError(t, err) + _, err = r.RegisterSubmitter(t.Context(), deliveryPRTKey, role(model.DefaultBlock_Latest, 1)) + require.ErrorContains(t, err, "observation policy mismatch") + } else { + _, err := r.RegisterSubmitter(t.Context(), deliveryPRTKey, role(model.DefaultBlock_Latest, 1)) + require.NoError(t, err) + _, err = r.RegisterObserver(t.Context(), deliveryObserverKey, reader) + require.ErrorContains(t, err, "observation policy mismatch") + } + }) + } +} + +func TestDeliveryProvenanceRejectsSavedReaderPolicyMismatch(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + reader, err := json.Marshal(config.PersistentChainConfig{DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1}) + require.NoError(t, err) + role, err := json.Marshal(config.PersistentSubmitterConfig{DefaultBlock: model.DefaultBlock_Latest, ChainID: 1, + ClaimSubmissionEnabled: true, ResolvedSignerAddress: repotest.UniqueAddress()}) + require.NoError(t, err) + require.NoError(t, insertDeliveryConfig(t.Context(), tx, deliveryObserverKey, reader)) + require.NoError(t, insertDeliveryConfig(t.Context(), tx, deliveryPRTKey, role)) + require.NoError(t, tx.Commit(t.Context())) + err = r.CheckSubmitterProvenance(t.Context(), deliveryPRTKey) + require.ErrorIs(t, err, repository.ErrSubmitterProvenance) + require.ErrorContains(t, err, "observation policy mismatch") +} + +func TestDeliveryObserverOnlyRolesStillRequireOnePolicy(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + _, err := r.RegisterSubmitter(t.Context(), deliveryClaimerKey, + config.PersistentSubmitterConfig{DefaultBlock: model.DefaultBlock_Latest, ChainID: 1}) + require.NoError(t, err) + _, err = r.RegisterSubmitter(t.Context(), deliveryPRTKey, + config.PersistentSubmitterConfig{DefaultBlock: model.DefaultBlock_Safe, ChainID: 1}) + require.ErrorContains(t, err, "observation policy mismatch", "shared domain facts need one policy even without publication") +} diff --git a/internal/repository/postgres/delivery_provenance_test.go b/internal/repository/postgres/delivery_provenance_test.go new file mode 100644 index 000000000..e8eb7c5b3 --- /dev/null +++ b/internal/repository/postgres/delivery_provenance_test.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestDeliveryProvenanceAndRegistrationDoNotWaitForApplicationLock(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + signer := repotest.UniqueAddress() + registerDeliveryTest(t, r, signer) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT). + WithEnabled(false).Create(t.Context(), t, r) + gate, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(gate) + stmt := table.Application.SELECT(table.Application.ID).WHERE(table.Application.ID.EQ(postgres.Int64(app.ID))).FOR(postgres.UPDATE()) + query, args := stmt.Sql() + _, err = gate.Exec(t.Context(), query, args...) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + require.NoError(t, r.CheckSubmitterProvenance(ctx, deliveryPRTKey)) + _, err = r.RegisterSubmitter(ctx, deliveryPRTKey, config.PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + ClaimSubmissionEnabled: true, ResolvedSignerAddress: signer, + }) + require.NoError(t, err, "startup registration must no longer share application-deletion locks") +} + +func TestDeliveryTerminalClaimsRequireExistingProvenance(t *testing.T) { + for _, status := range []model.EpochStatus{model.EpochStatus_ClaimRejected, model.EpochStatus_ClaimForeclosed} { + t.Run(status.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + _, err := r.RegisterObserver(t.Context(), deliveryObserverKey, config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + }) + require.NoError(t, err) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*model.Epoch][]*model.Input{epoch: {}}, 10)) + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, status) + _, err = r.RegisterSubmitter(t.Context(), deliveryClaimerKey, config.PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + ClaimSubmissionEnabled: true, ResolvedSignerAddress: repotest.UniqueAddress(), + }) + require.ErrorContains(t, err, "provenance is absent") + }) + } +} + +func TestDeliveryDeletionBoundsLockWaitWithoutLeakingSetting(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithEnabled(false).Create(t.Context(), t, r) + var defaultTimeout string + require.NoError(t, r.db.QueryRow(t.Context(), "SHOW lock_timeout").Scan(&defaultTimeout)) + locker, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(locker) + stmt := table.Application.SELECT(table.Application.ID).WHERE(table.Application.ID.EQ(postgres.Int64(app.ID))). + FOR(postgres.UPDATE()) + query, args := stmt.Sql() + _, err = locker.Exec(t.Context(), query, args...) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + err = r.DeleteApplication(ctx, app.ID) + var pgError *pgconn.PgError + require.ErrorAs(t, err, &pgError) + require.Equal(t, "55P03", pgError.Code) + require.NoError(t, ctx.Err(), "server lock timeout must precede the caller deadline") + var afterTimeout string + require.NoError(t, r.db.QueryRow(t.Context(), "SHOW lock_timeout").Scan(&afterTimeout)) + require.Equal(t, defaultTimeout, afterTimeout, "SET LOCAL must not alter subsequent pool users") + require.NoError(t, locker.Rollback(t.Context())) + require.NoError(t, r.DeleteApplication(t.Context(), app.ID)) +} diff --git a/internal/repository/postgres/delivery_queries.go b/internal/repository/postgres/delivery_queries.go new file mode 100644 index 000000000..ce75fbe04 --- /dev/null +++ b/internal/repository/postgres/delivery_queries.go @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "math" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" +) + +func (r *PostgresRepository) GetApplicationIDBoundary(ctx context.Context, f repository.ApplicationFilter) (int64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + stmt := table.Application.SELECT(postgres.COALESCE(postgres.MAX(table.Application.ID), postgres.Int64(0))) + if conditions := applicationConditions(f); len(conditions) > 0 { + stmt = stmt.WHERE(postgres.AND(conditions...)) + } + query, args := stmt.Sql() + var boundary int64 + err := r.db.QueryRow(ctx, query, args...).Scan(&boundary) + return boundary, err +} + +func (r *PostgresRepository) ListApplicationsByID( + ctx context.Context, f repository.ApplicationFilter, afterID, throughID int64, limit uint64, +) ([]*model.Application, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if limit == 0 || limit > math.MaxInt64 { + return nil, errors.New("application page requires a bounded positive limit") + } + if afterID < 0 || throughID < 0 { + return nil, errors.New("application cursor cannot be negative") + } + if afterID >= throughID { + return nil, nil + } + f.AfterID = &afterID + f.ThroughID = &throughID + apps, _, err := r.listApplications(ctx, f, repository.Pagination{Limit: limit}, false, true, true) + return apps, err +} diff --git a/internal/repository/postgres/delivery_scope_test.go b/internal/repository/postgres/delivery_scope_test.go new file mode 100644 index 000000000..09de2d34d --- /dev/null +++ b/internal/repository/postgres/delivery_scope_test.go @@ -0,0 +1,48 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "encoding/json" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func TestDeliveryFixedRegistrationPreservesUnknownSettings(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + signer := repotest.UniqueAddress() + registerDeliveryTest(t, r, signer) + raw, _, _, err := r.LoadNodeConfigRaw(t.Context(), deliveryPRTKey) + require.NoError(t, err) + var settings map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &settings)) + settings["FutureField"] = json.RawMessage(`{"value":42}`) + raw, err = json.Marshal(settings) + require.NoError(t, err) + stmt := table.NodeConfig.UPDATE(table.NodeConfig.Value).SET(postgres.Json(raw)). + WHERE(table.NodeConfig.Key.EQ(postgres.String(deliveryPRTKey))) + query, args := stmt.Sql() + _, err = r.db.Exec(t.Context(), query, args...) + require.NoError(t, err) + requested := config.PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + ClaimSubmissionEnabled: true, ResolvedSignerAddress: signer, + } + _, err = r.RegisterSubmitter(t.Context(), deliveryPRTKey, requested) + require.NoError(t, err) + raw, _, _, err = r.LoadNodeConfigRaw(t.Context(), deliveryPRTKey) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &fields)) + require.JSONEq(t, `{"value":42}`, string(fields["FutureField"])) + requested.ResolvedSignerAddress = repotest.UniqueAddress() + _, err = r.RegisterSubmitter(t.Context(), deliveryPRTKey, requested) + require.ErrorContains(t, err, "resolved signer address mismatch") +} diff --git a/internal/repository/postgres/delivery_test.go b/internal/repository/postgres/delivery_test.go new file mode 100644 index 000000000..95bfcb3b9 --- /dev/null +++ b/internal/repository/postgres/delivery_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func registerDeliveryTest(t *testing.T, r *PostgresRepository, signer common.Address) { + t.Helper() + _, err := r.RegisterObserver(t.Context(), "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + }) + require.NoError(t, err) + _, err = r.RegisterSubmitter(t.Context(), "prt", config.PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, ClaimSubmissionEnabled: true, ResolvedSignerAddress: signer, + }) + require.NoError(t, err) +} + +func TestDeliveryConcurrentRegistrationAndProvenance(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + signer := repotest.UniqueAddress() + start := make(chan struct{}) + results := make(chan error, 2) + for _, key := range []string{"claimer", "prt"} { + go func() { + <-start + _, err := r.RegisterSubmitter(t.Context(), key, config.PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, ClaimSubmissionEnabled: true, ResolvedSignerAddress: signer, + }) + results <- err + }() + } + close(start) + a, b := <-results, <-results + require.True(t, (a == nil) != (b == nil), "only one role may register a shared signer: %v / %v", a, b) + key := "claimer" + var exists bool + require.NoError(t, r.db.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM node_config WHERE key='prt')").Scan(&exists)) + if exists { + key = "prt" + } + require.ErrorIs(t, r.CheckSubmitterProvenance(t.Context(), key), repository.ErrObserverNotRegistered) + _, err := r.RegisterObserver(t.Context(), "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + }) + require.NoError(t, err) + require.NoError(t, r.CheckSubmitterProvenance(t.Context(), key)) + _, err = r.RegisterObserver(t.Context(), "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Pending, ChainID: 1, + }) + require.Error(t, err) +} + +func TestDeliveryCannotInventObserverProvenance(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*model.Epoch][]*model.Input{epoch: {}}, 10)) + _, err := r.RegisterObserver(t.Context(), "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + }) + require.ErrorContains(t, err, "provenance is absent") + _, _, _, err = r.LoadNodeConfigRaw(t.Context(), "evm-reader") + require.ErrorIs(t, err, repository.ErrNotFound) +} + +func TestDeliveryApplicationKeysetIgnoresRenames(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + apps := make([]*model.Application, 0, 3) + for _, name := range []string{"z", "m", "a"} { + apps = append(apps, repotest.NewApplicationBuilder().WithName(name).Create(t.Context(), t, r)) + } + boundary, err := r.GetApplicationIDBoundary(t.Context(), repository.ApplicationFilter{}) + require.NoError(t, err) + page, err := r.ListApplicationsByID(t.Context(), repository.ApplicationFilter{}, 0, boundary, 1) + require.NoError(t, err) + require.Equal(t, apps[0].ID, page[0].ID) + apps[1].Name = "zz" + require.NoError(t, r.UpdateApplication(t.Context(), apps[1])) + page, err = r.ListApplicationsByID(t.Context(), repository.ApplicationFilter{}, apps[0].ID, boundary, 1) + require.NoError(t, err) + require.Equal(t, apps[1].ID, page[0].ID) +} + +func TestObserverApplicationRemovalNeedsNoSubmissionRegistration(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + _, err := r.RegisterObserver(t.Context(), "evm-reader", config.PersistentChainConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + }) + require.NoError(t, err) + _, err = r.RegisterSubmitter(t.Context(), "prt", config.PersistentSubmitterConfig{ + DefaultBlock: model.DefaultBlock_Finalized, ChainID: 1, + }) + require.NoError(t, err) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).WithEnabled(false).Create(t.Context(), t, r) + require.NoError(t, r.DeleteApplication(t.Context(), app.ID)) +} diff --git a/internal/repository/postgres/deployment.go b/internal/repository/postgres/deployment.go new file mode 100644 index 000000000..713fa54da --- /dev/null +++ b/internal/repository/postgres/deployment.go @@ -0,0 +1,156 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func deploymentIdentityPredicate(key repository.DeploymentIdentity) postgres.BoolExpression { + return table.Application.ID.EQ(postgres.Int64(key.ApplicationID)). + AND(table.Application.IapplicationAddress.EQ(postgres.Bytea(key.ApplicationAddress.Bytes()))). + AND(table.Application.IconsensusAddress.EQ(postgres.Bytea(key.ConsensusAddress.Bytes()))) +} + +func deploymentSelect(key repository.DeploymentIdentity) postgres.SelectStatement { + control := table.DeploymentAdmissionState + return control.SELECT(control.AllColumns). + FROM(control.INNER_JOIN(table.Application, control.ApplicationID.EQ(table.Application.ID))). + WHERE(deploymentIdentityPredicate(key)) +} + +func readDeploymentState(ctx context.Context, queryer rowQueryer, stmt postgres.SelectStatement) (*model.DeploymentAdmissionState, error) { + query, args := stmt.Sql() + return scanDeploymentState(queryer.QueryRow(ctx, query, args...)) +} + +func scanDeploymentState(row pgx.Row) (*model.DeploymentAdmissionState, error) { + var value model.DeploymentAdmissionState + if err := row.Scan(&value.ApplicationID, &value.ResetGeneration, &value.ConfirmedTier, &value.DeploymentReason); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &value, nil +} + +func (r *PostgresRepository) GetDeploymentAdmissionState( + ctx context.Context, key repository.DeploymentIdentity, +) (*model.DeploymentAdmissionState, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + return readDeploymentState(ctx, r.db, deploymentSelect(key)) +} + +func (r *PostgresRepository) BeginDeploymentStartup( + ctx context.Context, key repository.DeploymentIdentity, expectedGeneration int64, +) (*model.DeploymentAdmissionState, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return nil, err + } + defer rollbackDeliveryTx(tx) + control := table.DeploymentAdmissionState + query, args := control.UPDATE(control.ResetGeneration).SET(control.ResetGeneration.ADD(postgres.Int64(1))). + WHERE(control.ApplicationID.EQ(postgres.Int64(key.ApplicationID)). + AND(control.ResetGeneration.EQ(postgres.Int64(expectedGeneration))). + AND(postgres.EXISTS(table.Application.SELECT(table.Application.ID).WHERE(deploymentIdentityPredicate(key))))). + RETURNING(control.AllColumns).Sql() + state, err := scanDeploymentState(tx.QueryRow(ctx, query, args...)) + if err != nil { + return nil, err + } + if state == nil { + return nil, repository.ErrNoUpdate + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return state, nil +} + +func (r *PostgresRepository) AcknowledgeDeploymentAssessment( + ctx context.Context, request repository.DeploymentAssessment, +) (*model.DeploymentAdmissionState, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return nil, err + } + defer rollbackDeliveryTx(tx) + control := table.DeploymentAdmissionState + stmt := deploymentSelect(request.Identity). + WHERE(deploymentIdentityPredicate(request.Identity).AND(control.ResetGeneration.EQ(postgres.Int64(request.ResetGeneration)))). + FOR(postgres.NO_KEY_UPDATE().OF(control)) + before, err := readDeploymentState(ctx, tx, stmt) + if err != nil { + return nil, err + } + if before == nil { + return nil, repository.ErrNoUpdate + } + var tier, reason any + change := before.ConfirmedTier != nil + if request.ConfirmedTier != nil { + tier, reason = string(*request.ConfirmedTier), request.Reason + change = before.ConfirmedTier == nil || *before.ConfirmedTier != *request.ConfirmedTier || + *before.DeploymentReason != request.Reason + } + if change { + query, args := control.UPDATE(control.ConfirmedTier, control.DeploymentReason).SET(tier, reason). + WHERE(control.ApplicationID.EQ(postgres.Int64(request.Identity.ApplicationID))).Sql() + if _, err := tx.Exec(ctx, query, args...); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return before, nil +} + +// All reads share one repeatable-read snapshot. The transaction closes before return. +func (r *PostgresRepository) GetDeploymentCompletionState( + ctx context.Context, key repository.DeploymentIdentity, index uint64, +) (*repository.DeploymentCompletionState, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, err + } + defer rollbackDeliveryTx(tx) + completion, err := readEpochCompletion(ctx, tx, key.ApplicationAddress.Hex(), index) + if err != nil || completion == nil || completion.Application == nil { + return nil, err + } + app := completion.Application + if app.ID != key.ApplicationID || app.IConsensusAddress != key.ConsensusAddress { + return nil, repository.ErrNoUpdate + } + control, err := readDeploymentState(ctx, tx, table.DeploymentAdmissionState.SELECT(table.DeploymentAdmissionState.AllColumns). + WHERE(table.DeploymentAdmissionState.ApplicationID.EQ(postgres.Int64(app.ID)))) + if err != nil { + return nil, err + } + value := &repository.DeploymentCompletionState{ + Completion: completion, + Deployment: control, + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return value, nil +} diff --git a/internal/repository/postgres/deployment_drain.go b/internal/repository/postgres/deployment_drain.go new file mode 100644 index 000000000..023cace8f --- /dev/null +++ b/internal/repository/postgres/deployment_drain.go @@ -0,0 +1,90 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/enum" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func deploymentDrainSQL(request repository.DeploymentDrainRequest) (string, []any) { + predicate := deploymentIdentityPredicate(request.Identity). + AND(table.Application.ConsensusType.EQ(enum.Consensus.Prt)). + AND(table.Epoch.ApplicationID.EQ(postgres.Int64(request.Identity.ApplicationID))). + AND(table.Epoch.Index.EQ(uint64Expr(request.EpochIndex))). + AND(table.Epoch.FirstBlock.LT_EQ(table.Application.ForecloseBlock)). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions...)). + AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). + AND(table.Application.ForecloseBlock.GT(uint64Expr(0))). + AND(table.Epoch.Status.IN(enum.EpochStatus.ClaimComputed, enum.EpochStatus.ClaimSubmitted, enum.EpochStatus.ClaimStaged). + OR(table.Application.Enabled.EQ(postgres.Bool(true)). + AND(table.Application.Status.IN( + append([]postgres.Expression{enum.ApplicationStatus.Ok}, executionStopStatusExpressions...)...)). + AND(table.Epoch.Status.NOT_EQ(enum.EpochStatus.InputsProcessed). + OR(table.Epoch.LastBlock.GT_EQ(table.Application.ForecloseBlock)). + OR(table.Application.Status.IN(executionStopStatusExpressions...). + AND(table.Epoch.Index.GT_EQ(table.Application.ProblemEpochIndex)))))) + return table.Epoch.UPDATE(table.Epoch.Status).SET(enum.EpochStatus.ClaimForeclosed). + FROM(table.Application).WHERE(predicate).Sql() +} + +// ForecloseDeploymentEpoch sets CLAIM_FORECLOSED at the selected generation and generation tier. +// It locks the epoch, then control with NOWAIT so application deletion cannot form a lock cycle. +func (r *PostgresRepository) ForecloseDeploymentEpoch( + ctx context.Context, request repository.DeploymentDrainRequest, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return err + } + defer rollbackDeliveryTx(tx) + if err := setLocalLockTimeout(ctx, tx); err != nil { + return err + } + epoch := table.Epoch + lockEpoch := epoch.SELECT(epoch.ApplicationID). + WHERE(epoch.ApplicationID.EQ(postgres.Int64(request.Identity.ApplicationID)). + AND(epoch.Index.EQ(uint64Expr(request.EpochIndex))). + AND(epoch.Status.IN(nonTerminalEpochStatusExpressions...))).FOR(postgres.NO_KEY_UPDATE().OF(epoch)) + query, args := lockEpoch.Sql() + var applicationID int64 + if err := tx.QueryRow(ctx, query, args...).Scan(&applicationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNoUpdate + } + return err + } + // No Ensure, application write, or application row lock is permitted after epoch ownership. + control := table.DeploymentAdmissionState + lockControl := deploymentSelect(request.Identity). + WHERE(deploymentIdentityPredicate(request.Identity). + AND(control.ResetGeneration.EQ(postgres.Int64(request.ResetGeneration))). + AND(control.ConfirmedTier.EQ(postgres.String(string(model.DeploymentTierGeneration))))). + FOR(postgres.SHARE().OF(control).NOWAIT()) + state, err := readDeploymentState(ctx, tx, lockControl) + if err != nil { + return err + } + if state == nil { + return repository.ErrNoUpdate + } + query, args = deploymentDrainSQL(request) + updated, err := tx.Exec(ctx, query, args...) + if err != nil { + return err + } + if updated.RowsAffected() == 0 { + return repository.ErrNoUpdate + } + return tx.Commit(ctx) +} diff --git a/internal/repository/postgres/deployment_drain_test.go b/internal/repository/postgres/deployment_drain_test.go new file mode 100644 index 000000000..a0ae1124b --- /dev/null +++ b/internal/repository/postgres/deployment_drain_test.go @@ -0,0 +1,316 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const ( + deploymentDrainMissingCase = "missing" + deploymentDrainTableCase = "table" +) + +func deploymentDrainFixture(t *testing.T, computed bool) ( + *PostgresRepository, *model.Application, *model.Epoch, repository.DeploymentDrainRequest, +) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch := seedEpochPublication(t, r) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 20, repotest.UniqueHash(), 20)) + _, err := r.db.Exec(t.Context(), `UPDATE application SET last_epoch_check_block=20,last_input_check_block=20 WHERE id=$1`, app.ID) + require.NoError(t, err) + key := deploymentKey(app) + readDeployment(t, r, key) + deploymentRestrict(t, r, key, 0, model.DeploymentTierGeneration, "fresh confirmed generation") + if computed { + completeDeploymentDrainProofs(t, r, app, epoch) + } + epoch, err = r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + return r, app, epoch, repository.DeploymentDrainRequest{Identity: key, EpochIndex: epoch.Index} +} + +func completeDeploymentDrainProofs(t *testing.T, r *PostgresRepository, app *model.Application, epoch *model.Epoch) { + t.Helper() + current, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + current.Commitment = new(repotest.UniqueHash()) + current.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + output, err := r.GetOutput(t.Context(), app.Name, 0) + require.NoError(t, err) + output.Hash = new(repotest.UniqueHash()) + output.OutputHashesSiblings = []common.Hash{repotest.UniqueHash()} + // Use a complete nonempty callback. Arbitrary nil callers do not prove proof completeness. + require.NoError(t, r.StoreClaimAndProofs(t.Context(), current, repotest.WriteProofs(output))) +} + +func TestDeploymentDrainWaitsForProtectedIPAndPreservesCompletedProofs(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, false) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_InputsProcessed, before.Status) + require.ErrorIs(t, r.ForecloseDeploymentEpoch(t.Context(), request), repository.ErrNoUpdate) + pending, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before, pending, "generation must retain the valid IP proof producer") + completeDeploymentDrainProofs(t, r, app, epoch) + computed, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + outputBefore, err := r.GetOutput(t.Context(), app.Name, 0) + require.NoError(t, err) + require.NotNil(t, outputBefore.Hash) + require.NotEmpty(t, outputBefore.OutputHashesSiblings) + appBefore, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.True(t, appBefore.ForeclosureScanCaughtUp()) + require.NoError(t, r.ForecloseDeploymentEpoch(t.Context(), request)) + after, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + expected := *computed + expected.Status, expected.UpdatedAt = model.EpochStatus_ClaimForeclosed, after.UpdatedAt + require.Equal(t, expected, *after, "the transition changes only status and its inherited timestamp") + outputAfter, err := r.GetOutput(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, outputBefore, outputAfter) + appAfter, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, appBefore, appAfter) + control, err := r.GetDeploymentAdmissionState(t.Context(), request.Identity) + require.NoError(t, err) + require.EqualValues(t, 0, control.ResetGeneration) + require.Equal(t, "fresh confirmed generation", *control.DeploymentReason) +} + +func TestDeploymentDrainRequiresActualCurrentGenerationTierAndIdentity(t *testing.T) { + for _, name := range []string{ + deploymentDrainMissingCase, "neutral", deploymentDrainTableCase, + "stale generation", "application identity", "consensus identity", + } { + t.Run(name, func(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + switch name { + case deploymentDrainMissingCase: + _, err := r.db.Exec(t.Context(), `DELETE FROM deployment_admission_state WHERE application_id=$1`, app.ID) + require.NoError(t, err) + case "neutral": + _, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{ + Identity: request.Identity}) + require.NoError(t, err) + case deploymentDrainTableCase: + deploymentRestrict(t, r, request.Identity, 0, model.DeploymentTierTable, "new confirmed table") + case "stale generation": + request.ResetGeneration = 1 + case "application identity": + request.Identity.ApplicationAddress = repotest.UniqueAddress() + case "consensus identity": + request.Identity.ConsensusAddress = repotest.UniqueAddress() + } + require.ErrorIs(t, r.ForecloseDeploymentEpoch(t.Context(), request), repository.ErrNoUpdate) + after, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, epoch, after) + if name == deploymentDrainMissingCase { + control, err := r.GetDeploymentAdmissionState(t.Context(), deploymentKey(app)) + require.NoError(t, err) + require.Nil(t, control, "the epoch owner must never Ensure a private row") + } + }) + } +} + +func TestDeploymentDrainRetainsOrdinaryTypedSuffixAndAcceptedExclusion(t *testing.T) { + t.Run("typed suffix", func(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, false) + require.NoError(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, epoch.Index, "cut makes this prefix invalid")) + require.NoError(t, r.ForecloseDeploymentEpoch(t.Context(), request)) + current, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimForeclosed, current.Status) + }) + t.Run("accepted is terminal", func(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + require.NoError(t, r.UpdateEpochWithAcceptedClaim(t.Context(), app.ID, epoch.Index, new(repotest.UniqueHash()))) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.ErrorIs(t, r.ForecloseDeploymentEpoch(t.Context(), request), repository.ErrNoUpdate) + after, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before, after) + }) + t.Run("known transaction remains reconcilable", func(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + require.NoError(t, r.UpdateEpochWithSubmittedClaim(t.Context(), app.ID, epoch.Index, repotest.UniqueHash())) + require.NoError(t, r.ForecloseDeploymentEpoch(t.Context(), request)) + current, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimForeclosed, current.Status) + require.NotNil(t, current.ClaimTransactionHash) + }) +} + +func waitDeploymentEpochBlocker(t *testing.T, r *PostgresRepository, pid uint32) { + t.Helper() + require.Eventually(t, func() bool { + var waiting bool + err := r.db.QueryRow(t.Context(), `SELECT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)))`, pid).Scan(&waiting) + return err == nil && waiting + }, time.Second, 10*time.Millisecond) +} + +func TestDeploymentDrainOwnsNoControlDuringEpochWaitAndRejectsReset(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + lock, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(lock) + var marker int + require.NoError(t, lock.QueryRow(t.Context(), `SELECT 1 FROM epoch WHERE application_id=$1 AND index=$2 FOR UPDATE`, + app.ID, epoch.Index).Scan(&marker)) + done := make(chan error, 1) + go func() { done <- r.ForecloseDeploymentEpoch(t.Context(), request) }() + waitDeploymentEpochBlocker(t, r, lock.Conn().PgConn().PID()) + resetCtx, cancel := context.WithTimeout(t.Context(), time.Second) + err = r.EnableApplicationAndResetDeployment(resetCtx, app.ID, false) + cancel() + require.NoError(t, err, "waiting for epoch must not own control and block reset") + require.NoError(t, lock.Rollback(t.Context())) + require.ErrorIs(t, <-done, repository.ErrNoUpdate) + current, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, epoch, current) +} + +func TestDeploymentDrainSharedControlFencesSameGenerationTierChange(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + owned, barrier := deploymentBarrierRepository(t, r, func(query string) bool { return strings.Contains(query, "UPDATE public.epoch") }) + done := make(chan error, 1) + go func() { done <- owned.ForecloseDeploymentEpoch(t.Context(), request) }() + awaitDeploymentBarrier(t, barrier) + type result struct { + receipt *model.DeploymentAdmissionState + err error + } + restrict := make(chan result, 1) + go func() { + receipt, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: request.Identity, + ConfirmedTier: new(model.DeploymentTierTable), Reason: "same generation new tier"}) + restrict <- result{receipt, err} + }() + waitDeploymentRowLock(t, r) + // The statement snapshot permits a later health stop; control serialization is a separate boundary. + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Failed, new("after drain statement"))) + close(barrier.release) + require.NoError(t, <-done) + changed := <-restrict + require.NoError(t, changed.err) + require.EqualValues(t, 0, changed.receipt.ResetGeneration) + require.Equal(t, model.DeploymentTierGeneration, *changed.receipt.ConfirmedTier) + current, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimForeclosed, current.Status) + stopped, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Failed, stopped.Status) +} + +func TestDeploymentDrainControlNOWAITAvoidsApplicationDeleteCycle(t *testing.T) { + r, app, _, request := deploymentDrainFixture(t, true) + owned, barrier := deploymentBarrierRepository(t, r, func(query string) bool { + return strings.Contains(query, "FOR NO KEY UPDATE OF epoch") + }) + var ownedPID uint32 + require.NoError(t, owned.db.QueryRow(t.Context(), `SELECT pg_backend_pid()`).Scan(&ownedPID)) + done := make(chan error, 1) + go func() { done <- owned.ForecloseDeploymentEpoch(t.Context(), request) }() + awaitDeploymentBarrier(t, barrier) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + deleted := make(chan error, 1) + go func() { deleted <- r.DeleteApplication(t.Context(), app.ID) }() + waitDeploymentEpochBlocker(t, r, ownedPID) + close(barrier.release) + err := <-done + var pgError *pgconn.PgError + require.ErrorAs(t, err, &pgError) + require.Equal(t, "55P03", pgError.Code) + require.NoError(t, <-deleted, "drain must release the epoch so the cascade can finish") + current, err := r.GetDeploymentAdmissionState(t.Context(), request.Identity) + require.NoError(t, err) + require.Nil(t, current) +} + +func TestDeploymentDrainSharedControlKeepsResetAfterCFCommit(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + owned, barrier := deploymentBarrierRepository(t, r, func(query string) bool { return strings.Contains(query, "UPDATE public.epoch") }) + done := make(chan error, 1) + go func() { done <- owned.ForecloseDeploymentEpoch(t.Context(), request) }() + awaitDeploymentBarrier(t, barrier) + reset := make(chan error, 1) + go func() { reset <- r.EnableApplicationAndResetDeployment(t.Context(), app.ID, false) }() + waitDeploymentRowLock(t, r) + close(barrier.release) + require.NoError(t, <-done) + require.NoError(t, <-reset) + current, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimForeclosed, current.Status) + control, err := r.GetDeploymentAdmissionState(t.Context(), request.Identity) + require.NoError(t, err) + require.EqualValues(t, 1, control.ResetGeneration) + require.Nil(t, control.ConfirmedTier, "a later reset does not retract the already committed CF") +} + +func TestDeploymentDrainEpochWaitIsBoundedAndRetainsPGCause(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + lock, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(lock) + _, err = lock.Exec(t.Context(), `SELECT 1 FROM epoch WHERE application_id=$1 AND index=$2 FOR UPDATE`, app.ID, epoch.Index) + require.NoError(t, err) + started := time.Now() + err = r.ForecloseDeploymentEpoch(t.Context(), request) + var pgError *pgconn.PgError + require.ErrorAs(t, err, &pgError) + require.Equal(t, "55P03", pgError.Code) + require.Less(t, time.Since(started), 5*time.Second) + require.NoError(t, lock.Rollback(t.Context())) + require.NoError(t, r.ForecloseDeploymentEpoch(t.Context(), request), "a bounded failed acquisition must release its transaction") +} + +func TestDeploymentDrainDeferredCommitFailurePreservesEpochAndReleasesControl(t *testing.T) { + r, app, epoch, request := deploymentDrainFixture(t, true) + _, err := r.db.Exec(t.Context(), `CREATE FUNCTION reject_deployment_drain_commit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.status='CLAIM_FORECLOSED' THEN RAISE EXCEPTION 'test drain commit rejection'; END IF; + RETURN NEW; + END $$; + CREATE CONSTRAINT TRIGGER reject_deployment_drain_commit AFTER UPDATE ON epoch + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION reject_deployment_drain_commit()`) + require.NoError(t, err) + t.Cleanup(func() { + _, err := r.db.Exec(context.Background(), `DROP TRIGGER IF EXISTS reject_deployment_drain_commit ON epoch; + DROP FUNCTION IF EXISTS reject_deployment_drain_commit()`) + require.NoError(t, err) + }) + err = r.ForecloseDeploymentEpoch(t.Context(), request) + require.ErrorContains(t, err, "test drain commit rejection") + require.NotErrorIs(t, err, repository.ErrNoUpdate) + current, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, epoch, current) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + require.NoError(t, r.EnableApplicationAndResetDeployment(ctx, app.ID, false)) +} diff --git a/internal/repository/postgres/deployment_schema_test.go b/internal/repository/postgres/deployment_schema_test.go new file mode 100644 index 000000000..76e5f6e01 --- /dev/null +++ b/internal/repository/postgres/deployment_schema_test.go @@ -0,0 +1,54 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestDeploymentSchemaRejectsInvalidControlValues(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + _, err := r.db.Exec(t.Context(), `INSERT INTO deployment_admission_state(application_id) VALUES ($1) + ON CONFLICT (application_id) DO NOTHING`, app.ID) + require.NoError(t, err) + for _, test := range []struct { + name, constraint string + generation int64 + tier, reason any + }{ + {name: "negative generation", generation: -1, constraint: "deployment_admission_state_reset_generation_check"}, + {name: "unknown tier", tier: "unknown", reason: "mismatch", constraint: "deployment_admission_state_confirmed_tier_check"}, + {name: "tier without reason", tier: "table", constraint: "deployment_admission_state_restriction_pair_check"}, + {name: "reason without tier", reason: "mismatch", constraint: "deployment_admission_state_restriction_pair_check"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := r.db.Exec(t.Context(), `UPDATE deployment_admission_state + SET reset_generation=$2,confirmed_tier=$3,deployment_reason=$4 WHERE application_id=$1`, + app.ID, test.generation, test.tier, test.reason) + var failure *pgconn.PgError + require.ErrorAs(t, err, &failure) + require.Equal(t, "23514", failure.Code) + require.Equal(t, test.constraint, failure.ConstraintName) + }) + } +} + +func TestDeploymentSchemaApplicationRemovalCascades(t *testing.T) { + repo := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, repo) + _, err := repo.db.Exec(t.Context(), `UPDATE deployment_admission_state + SET confirmed_tier='table',deployment_reason='table mismatch' WHERE application_id=$1`, app.ID) + require.NoError(t, err) + require.NoError(t, repo.UpdateApplicationEnabled(t.Context(), app.ID, false)) + require.NoError(t, repo.DeleteApplication(t.Context(), app.ID)) + var count uint64 + require.NoError(t, repo.db.QueryRow(t.Context(), `SELECT count(*) FROM deployment_admission_state + WHERE application_id = $1`, app.ID).Scan(&count)) + require.Zero(t, count) +} diff --git a/internal/repository/postgres/deployment_test.go b/internal/repository/postgres/deployment_test.go new file mode 100644 index 000000000..3e89be349 --- /dev/null +++ b/internal/repository/postgres/deployment_test.go @@ -0,0 +1,532 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + "math" + "strings" + "sync" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func deploymentKey(app *model.Application) repository.DeploymentIdentity { + return repository.DeploymentIdentity{ApplicationID: app.ID, + ApplicationAddress: app.IApplicationAddress, ConsensusAddress: app.IConsensusAddress} +} + +func deploymentFixture(t *testing.T) (*PostgresRepository, *model.Application, repository.DeploymentIdentity) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + return r, app, deploymentKey(app) +} + +func deploymentRestrict(t *testing.T, r *PostgresRepository, key repository.DeploymentIdentity, generation int64, + tier model.DeploymentTier, reason string, +) *model.DeploymentAdmissionState { + t.Helper() + got, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{ + Identity: key, ResetGeneration: generation, ConfirmedTier: new(tier), Reason: reason, + }) + require.NoError(t, err) + require.NotNil(t, got) + return got +} + +func TestDeploymentStoreMissingIdentityAndNeutralState(t *testing.T) { + r, app, key := deploymentFixture(t) + neutral, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Equal(t, &model.DeploymentAdmissionState{ApplicationID: key.ApplicationID}, neutral) + for _, wrong := range []repository.DeploymentIdentity{ + {ApplicationID: key.ApplicationID + 1, ApplicationAddress: key.ApplicationAddress, ConsensusAddress: key.ConsensusAddress}, + {ApplicationID: key.ApplicationID, ApplicationAddress: repotest.UniqueAddress(), ConsensusAddress: key.ConsensusAddress}, + {ApplicationID: key.ApplicationID, ApplicationAddress: key.ApplicationAddress, ConsensusAddress: repotest.UniqueAddress()}, + } { + got, err := r.GetDeploymentAdmissionState(t.Context(), wrong) + require.NoError(t, err) + require.Nil(t, got) + got, err = r.BeginDeploymentStartup(t.Context(), wrong, 0) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Nil(t, got) + ack, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: wrong}) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Nil(t, ack) + } + _, err = r.db.Exec(t.Context(), `DELETE FROM deployment_admission_state WHERE application_id=$1`, app.ID) + require.NoError(t, err) + missing, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Nil(t, missing) + startup, err := r.BeginDeploymentStartup(t.Context(), key, 0) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Nil(t, startup) + previous, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key}) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Nil(t, previous) + require.NoError(t, r.EnableApplicationAndResetDeployment(t.Context(), app.ID, false)) + repaired := readDeployment(t, r, key) + require.EqualValues(t, 1, repaired.ResetGeneration) + require.Nil(t, repaired.ConfirmedTier) +} + +func readDeployment(t *testing.T, r *PostgresRepository, key repository.DeploymentIdentity) *model.DeploymentAdmissionState { + t.Helper() + row, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.NotNil(t, row) + return row +} + +func TestDeploymentAssessmentStoresRestrictionAndRecovers(t *testing.T) { + r, _, key := deploymentFixture(t) + readDeployment(t, r, key) + startup, err := r.BeginDeploymentStartup(t.Context(), key, 0) + require.NoError(t, err) + require.EqualValues(t, 1, startup.ResetGeneration) + previous := deploymentRestrict(t, r, key, startup.ResetGeneration, model.DeploymentTierGeneration, "old generation") + require.Nil(t, previous.ConfirmedTier) + saved := readDeployment(t, r, key) + require.Equal(t, model.DeploymentTierGeneration, *saved.ConfirmedTier) + require.Equal(t, "old generation", *saved.DeploymentReason) + for _, tier := range []model.DeploymentTier{model.DeploymentTierTable, model.DeploymentTierGeneration} { + deploymentRestrict(t, r, key, startup.ResetGeneration, tier, "new confirmation") + saved, err = r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Equal(t, tier, *saved.ConfirmedTier) + } + recovered, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key, + ResetGeneration: startup.ResetGeneration}) + require.NoError(t, err) + require.NotNil(t, recovered.ConfirmedTier) + saved, err = r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Nil(t, saved.ConfirmedTier) + require.Nil(t, saved.DeploymentReason) + require.Equal(t, startup.ResetGeneration, saved.ResetGeneration) +} + +func TestDeploymentEnableResetAndStaleAssessment(t *testing.T) { + r, app, key := deploymentFixture(t) + require.NoError(t, r.EnableApplicationAndResetDeployment(t.Context(), app.ID, false)) + state, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.NotNil(t, state, "explicit reset must retain a control row") + require.EqualValues(t, 1, state.ResetGeneration, "the first explicit reset creates initial generation plus one") + deploymentRestrict(t, r, key, state.ResetGeneration, model.DeploymentTierTable, "old table") + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + disabled, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Equal(t, model.DeploymentTierTable, *disabled.ConfirmedTier) + require.Equal(t, state.ResetGeneration, disabled.ResetGeneration) + // Explicit recovery authorization permits clearing the stored FAILED status. + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Failed, new("new failure"))) + require.NoError(t, r.EnableApplicationAndResetDeployment(t.Context(), app.ID, true)) + enabled, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.True(t, enabled.Enabled) + require.Equal(t, model.ApplicationStatus_OK, enabled.Status) + require.Nil(t, enabled.Reason) + state, err = r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 2, state.ResetGeneration) + require.Nil(t, state.ConfirmedTier) + for _, tier := range []*model.DeploymentTier{new(model.DeploymentTierGeneration), nil} { + receipt, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key, + ResetGeneration: disabled.ResetGeneration, ConfirmedTier: tier}) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Nil(t, receipt) + } + stale, err := r.BeginDeploymentStartup(t.Context(), key, disabled.ResetGeneration) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Nil(t, stale) + require.NoError(t, r.EnableApplicationAndResetDeployment(t.Context(), app.ID, false)) + state, err = r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 3, state.ResetGeneration, "healthy already-enabled reset still advances the fence") +} + +func TestDeploymentEnableOverflowRollsBackApplicationAndControl(t *testing.T) { + r, app, key := deploymentFixture(t) + readDeployment(t, r, key) + _, err := r.db.Exec(t.Context(), `UPDATE deployment_admission_state SET reset_generation=$2, + confirmed_tier='generation', deployment_reason='retain on overflow' WHERE application_id=$1`, app.ID, int64(math.MaxInt64)) + require.NoError(t, err) + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Failed, new("retain failure"))) + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + before, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + control, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + err = r.EnableApplicationAndResetDeployment(t.Context(), app.ID, true) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "22003", pgErr.Code) + after, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, before, after, "application enable and FAILED clear must roll back together") + got, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Equal(t, control, got) + episode, err := r.BeginDeploymentStartup(t.Context(), key, math.MaxInt64) + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "22003", pgErr.Code) + require.Nil(t, episode) +} + +type deploymentBarrierKey struct{} + +type deploymentBarrier struct { + match func(string) bool + once sync.Once + reached chan struct{} + release chan struct{} +} + +func (b *deploymentBarrier) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context { + return context.WithValue(ctx, deploymentBarrierKey{}, b.match(data.SQL)) +} + +func (b *deploymentBarrier) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) { + if match, _ := ctx.Value(deploymentBarrierKey{}).(bool); match { + b.once.Do(func() { + close(b.reached) + select { + case <-b.release: + case <-ctx.Done(): + } + }) + } +} + +func deploymentBarrierRepository(t *testing.T, r *PostgresRepository, match func(string) bool) (*PostgresRepository, *deploymentBarrier) { + t.Helper() + barrier := &deploymentBarrier{match: match, reached: make(chan struct{}), release: make(chan struct{})} + cfg := r.db.Config() + cfg.MaxConns, cfg.ConnConfig.Tracer = 1, barrier + pool, err := pgxpool.NewWithConfig(t.Context(), cfg) + require.NoError(t, err) + t.Cleanup(pool.Close) + t.Cleanup(func() { + select { + case <-barrier.release: + default: + close(barrier.release) + } + }) + return &PostgresRepository{db: pool}, barrier +} + +func awaitDeploymentBarrier(t *testing.T, barrier *deploymentBarrier) { + t.Helper() + select { + case <-barrier.reached: + case <-time.After(5 * time.Second): + t.Fatal("deployment query did not reach its actual SQL boundary") + } +} + +func waitDeploymentRowLock(t *testing.T, r *PostgresRepository) { + t.Helper() + require.Eventually(t, func() bool { + var waiting bool + err := r.db.QueryRow(t.Context(), `SELECT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%deployment_admission_state%')`).Scan(&waiting) + return err == nil && waiting + }, 5*time.Second, 10*time.Millisecond) +} + +func TestDeploymentStartupRetainsLateCommittedRestriction(t *testing.T) { + r, _, key := deploymentFixture(t) + readDeployment(t, r, key) + old, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(old) + _, err = old.Exec(t.Context(), `UPDATE deployment_admission_state SET confirmed_tier='table', + deployment_reason='old backend committed late' WHERE application_id=$1 AND reset_generation=0`, key.ApplicationID) + require.NoError(t, err) + type result struct { + state *model.DeploymentAdmissionState + err error + } + done := make(chan result, 1) + go func() { + state, err := r.BeginDeploymentStartup(t.Context(), key, 0) + done <- result{state, err} + }() + waitDeploymentRowLock(t, r) + require.NoError(t, old.Commit(t.Context())) + got := <-done + require.NoError(t, got.err) + require.EqualValues(t, 1, got.state.ResetGeneration) + require.Equal(t, model.DeploymentTierTable, *got.state.ConfirmedTier) + require.Equal(t, "old backend committed late", *got.state.DeploymentReason) +} + +func TestDeploymentQueuedOldAssessmentLosesToStartup(t *testing.T) { + r, _, key := deploymentFixture(t) + readDeployment(t, r, key) + fenced, barrier := deploymentBarrierRepository(t, r, func(query string) bool { + return strings.HasPrefix(strings.TrimSpace(query), "UPDATE public.deployment_admission_state") + }) + type result struct { + state *model.DeploymentAdmissionState + err error + } + fenceDone := make(chan result, 1) + go func() { + state, err := fenced.BeginDeploymentStartup(t.Context(), key, 0) + fenceDone <- result{state, err} + }() + awaitDeploymentBarrier(t, barrier) + ackDone := make(chan error, 1) + go func() { + receipt, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key, + ConfirmedTier: new(model.DeploymentTierGeneration), Reason: "stale"}) + if receipt != nil { + ackDone <- fmt.Errorf("stale assessment returned an owned receipt") + return + } + ackDone <- err + }() + waitDeploymentRowLock(t, r) + close(barrier.release) + got := <-fenceDone + require.NoError(t, got.err) + require.EqualValues(t, 1, got.state.ResetGeneration) + require.ErrorIs(t, <-ackDone, repository.ErrNoUpdate) +} + +func TestDeploymentAssessmentOwnsOnlyControlAndResetFollowsCommit(t *testing.T) { + r, app, key := deploymentFixture(t) + readDeployment(t, r, key) + deploymentRestrict(t, r, key, 0, model.DeploymentTierTable, "before reset") + owned, barrier := deploymentBarrierRepository(t, r, func(query string) bool { + return strings.Contains(query, "FOR NO KEY UPDATE OF deployment_admission_state") + }) + type result struct { + receipt *model.DeploymentAdmissionState + err error + } + done := make(chan result, 1) + go func() { + receipt, err := owned.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{ + Identity: key}) + done <- result{receipt, err} + }() + awaitDeploymentBarrier(t, barrier) + probe, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(probe) + var marker int + require.NoError(t, probe.QueryRow(t.Context(), `SELECT 1 FROM application WHERE id=$1 FOR UPDATE NOWAIT`, app.ID).Scan(&marker), + "assessment must not own application") + require.NoError(t, probe.Rollback(t.Context())) + reset := make(chan error, 1) + go func() { reset <- r.EnableApplicationAndResetDeployment(t.Context(), app.ID, false) }() + waitDeploymentRowLock(t, r) + close(barrier.release) + got := <-done + require.NoError(t, got.err) + require.Equal(t, "before reset", *got.receipt.DeploymentReason) + require.NoError(t, <-reset) + state, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 1, state.ResetGeneration) + require.Nil(t, state.ConfirmedTier) +} + +func TestDeploymentCompletionSnapshotSurvivesLaterResetAndStop(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch := seedEpochPublication(t, r) + key := deploymentKey(app) + readDeployment(t, r, key) + deploymentRestrict(t, r, key, 0, model.DeploymentTierTable, "at snapshot") + coherent, barrier := deploymentBarrierRepository(t, r, func(query string) bool { + return strings.Contains(query, "execution_parameters.application_id") && strings.Contains(query, "application.name") + }) + type result struct { + state *repository.DeploymentCompletionState + err error + } + done := make(chan result, 1) + go func() { + state, err := coherent.GetDeploymentCompletionState(t.Context(), key, epoch.Index) + done <- result{state, err} + }() + awaitDeploymentBarrier(t, barrier) + require.NoError(t, r.EnableApplicationAndResetDeployment(t.Context(), app.ID, false)) + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Failed, new("after S"))) + close(barrier.release) + got := <-done + require.NoError(t, got.err) + require.Equal(t, model.ApplicationStatus_OK, got.state.Completion.Application.Status) + require.Equal(t, epoch.Index, got.state.Completion.Epoch.Index) + require.EqualValues(t, 0, got.state.Deployment.ResetGeneration) + require.Equal(t, "at snapshot", *got.state.Deployment.DeploymentReason) + require.Zero(t, coherent.db.Stat().AcquiredConns(), "the RR connection must close before return") + fresh, err := coherent.GetDeploymentCompletionState(t.Context(), key, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.ApplicationStatus_Failed, fresh.Completion.Application.Status) + require.EqualValues(t, 1, fresh.Deployment.ResetGeneration) + require.Nil(t, fresh.Deployment.ConfirmedTier) + wrong := key + wrong.ConsensusAddress = repotest.UniqueAddress() + bad, err := coherent.GetDeploymentCompletionState(t.Context(), wrong, epoch.Index) + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.Nil(t, bad) + require.Zero(t, coherent.db.Stat().AcquiredConns()) +} + +func TestDeploymentEnablePreservesHealthBoundariesForeclosureAndClaimEvidence(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_OK, model.ApplicationStatus_Failed, + model.ApplicationStatus_Corrupted, model.ApplicationStatus_Diverged, + model.ApplicationStatus_GuestException, model.ApplicationStatus_MachineHalted, + model.ApplicationStatus_McycleOverflow, model.ApplicationStatus_UnexpectedYield, model.ApplicationStatus_InvalidOutputsRoot} { + for _, clearFailed := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/recovery=%t", status, clearFailed), func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, false) + key := deploymentKey(app) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + readDeployment(t, r, key) + deploymentRestrict(t, r, key, 0, model.DeploymentTierGeneration, "incompatible generation") + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 100, repotest.UniqueHash(), 100)) + kind := model.ApplicationStatus_MachineHalted + var input any = uint64(2) + if status == model.ApplicationStatus_InvalidOutputsRoot { + kind, input = status, nil + } + _, err := r.db.Exec(t.Context(), `UPDATE application SET enabled=false,status=$2,reason='retained health', + problem_epoch_index=3,problem_kind=$3,problem_input_index=$4, + last_input_check_block=120,last_output_check_block=130,last_claim_check_block=50,processed_inputs=2 WHERE id=$1`, + app.ID, status, kind, input) + require.NoError(t, err) + before, problem, err := getApplication(t.Context(), r.db, app.Name) + require.NoError(t, err) + require.True(t, claimValidated(t, r, app.ID, epoch.Index)) + require.NoError(t, r.EnableApplicationAndResetDeployment(t.Context(), app.ID, clearFailed)) + after, afterProblem, err := getApplication(t.Context(), r.db, app.Name) + require.NoError(t, err) + expected := *before + expected.Enabled = true + if status == model.ApplicationStatus_Failed && clearFailed { + expected.Status, expected.Reason = model.ApplicationStatus_OK, nil + } + after.UpdatedAt = expected.UpdatedAt + require.Equal(t, expected, *after, "only enabled and authorized current FAILED status/reason may change") + require.Equal(t, problem, afterProblem) + require.True(t, claimValidated(t, r, app.ID, epoch.Index)) + claimBlock, err := r.GetEventLastCheckBlock(t.Context(), app.ID, model.MonitoredEvent_ClaimAccepted) + require.NoError(t, err) + require.EqualValues(t, 50, claimBlock) + state, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 1, state.ResetGeneration) + require.Nil(t, state.ConfirmedTier) + require.Nil(t, state.DeploymentReason) + }) + } + } +} + +func TestDeploymentActualCommitFailureReturnsNoReceiptOrEpisode(t *testing.T) { + r, _, key := deploymentFixture(t) + readDeployment(t, r, key) + _, err := r.db.Exec(t.Context(), `CREATE FUNCTION reject_deployment_commit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.deployment_reason='reject at commit' OR NEW.reset_generation=1 THEN + RAISE EXCEPTION 'test deferred commit rejection'; + END IF; + RETURN NEW; + END $$; + CREATE CONSTRAINT TRIGGER reject_deployment_commit AFTER UPDATE ON deployment_admission_state + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION reject_deployment_commit()`) + require.NoError(t, err) + t.Cleanup(func() { + _, err := r.db.Exec(context.Background(), `DROP TRIGGER IF EXISTS reject_deployment_commit ON deployment_admission_state; + DROP FUNCTION IF EXISTS reject_deployment_commit()`) + require.NoError(t, err) + }) + receipt, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{Identity: key, + ConfirmedTier: new(model.DeploymentTierTable), Reason: "reject at commit"}) + require.ErrorContains(t, err, "test deferred commit rejection") + require.Nil(t, receipt) + episode, err := r.BeginDeploymentStartup(t.Context(), key, 0) + require.ErrorContains(t, err, "test deferred commit rejection") + require.Nil(t, episode) + state, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.Equal(t, &model.DeploymentAdmissionState{ApplicationID: key.ApplicationID}, state) +} + +func TestDeploymentControlWaitRespectsCallerCancellation(t *testing.T) { + r, _, key := deploymentFixture(t) + readDeployment(t, r, key) + lock, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(lock) + _, err = lock.Exec(t.Context(), `SELECT 1 FROM deployment_admission_state WHERE application_id=$1 FOR UPDATE`, key.ApplicationID) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + episode, err := r.BeginDeploymentStartup(ctx, key, 0) + cancel() + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, episode) + ctx, cancel = context.WithTimeout(t.Context(), 50*time.Millisecond) + receipt, err := r.AcknowledgeDeploymentAssessment(ctx, repository.DeploymentAssessment{ + Identity: key}) + cancel() + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, receipt) + require.NoError(t, lock.Rollback(t.Context())) + episode, err = r.BeginDeploymentStartup(t.Context(), key, 0) + require.NoError(t, err) + require.EqualValues(t, 1, episode.ResetGeneration) +} + +func TestDeploymentResetMayFollowAcknowledgedStartup(t *testing.T) { + r, app, key := deploymentFixture(t) + readDeployment(t, r, key) + deploymentRestrict(t, r, key, 0, model.DeploymentTierGeneration, "startup retains restriction") + fenced, barrier := deploymentBarrierRepository(t, r, func(query string) bool { + return strings.HasPrefix(strings.TrimSpace(query), "UPDATE public.deployment_admission_state") + }) + type result struct { + state *model.DeploymentAdmissionState + err error + } + done := make(chan result, 1) + go func() { state, err := fenced.BeginDeploymentStartup(t.Context(), key, 0); done <- result{state, err} }() + awaitDeploymentBarrier(t, barrier) + reset := make(chan error, 1) + go func() { reset <- r.EnableApplicationAndResetDeployment(t.Context(), app.ID, false) }() + waitDeploymentRowLock(t, r) + close(barrier.release) + got := <-done + require.NoError(t, got.err) + require.EqualValues(t, 1, got.state.ResetGeneration) + require.Equal(t, "startup retains restriction", *got.state.DeploymentReason) + require.NoError(t, <-reset) + current, err := r.GetDeploymentAdmissionState(t.Context(), key) + require.NoError(t, err) + require.EqualValues(t, 2, current.ResetGeneration) + require.Nil(t, current.ConfirmedTier) + receipt, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{ + Identity: key, ResetGeneration: got.state.ResetGeneration}) + require.ErrorIs(t, err, repository.ErrNoUpdate, "a committed historical startup receipt cannot authorize after reset") + require.Nil(t, receipt) +} diff --git a/internal/repository/postgres/deployment_value_test.go b/internal/repository/postgres/deployment_value_test.go new file mode 100644 index 000000000..7fa450c36 --- /dev/null +++ b/internal/repository/postgres/deployment_value_test.go @@ -0,0 +1,25 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestDeploymentAssessmentUsesTierConstraint(t *testing.T) { + r, _, key := deploymentFixture(t) + _, err := r.AcknowledgeDeploymentAssessment(t.Context(), repository.DeploymentAssessment{ + Identity: key, ConfirmedTier: new(model.DeploymentTier("unknown")), Reason: "confirmed mismatch", + }) + var failure *pgconn.PgError + require.ErrorAs(t, err, &failure) + require.Equal(t, "23514", failure.Code) + require.Equal(t, "deployment_admission_state_confirmed_tier_check", failure.ConstraintName) + require.Equal(t, &model.DeploymentAdmissionState{ApplicationID: key.ApplicationID}, readDeployment(t, r, key)) +} diff --git a/internal/repository/postgres/dispute_facts_test.go b/internal/repository/postgres/dispute_facts_test.go new file mode 100644 index 000000000..e9aef7ff5 --- /dev/null +++ b/internal/repository/postgres/dispute_facts_test.go @@ -0,0 +1,404 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + disputeApplicationScope = "application scope" + disputeEpochScope = "epoch scope" + disputeTournamentScope = "tournament scope" +) + +func TestDisputeFactsProductionIntervalStoresEveryNewColumn(t *testing.T) { + r, app, batches := newTournamentInterval(t) + root, child := batches[0].Tournament, batches[1].Tournament + root.BondValue, child.BondValue = model.Uint256{19}, model.Uint256{23} + child.ContestedFinalStateOne = new(common.Hash{}) + child.ContestedFinalStateTwo = new(common.HexToHash("0x31")) + batches[0].Matches[0].ResponderDeadline = 17 + match := batches[1].Matches[0] + match.ResponderDeadline = 19 + seal := match.LeafSeal + seal.AgreeState, seal.FinalStateOne, seal.FinalStateTwo = common.Hash{}, common.HexToHash("0x41"), common.HexToHash("0x42") + seal.DivergencePosition = model.Uint256{7} + standing := &model.StandingChanged{ + ApplicationID: app.ID, EpochIndex: child.EpochIndex, TournamentAddress: child.Address, + Dangling: new(batches[1].Commitments[0].Commitment), ParentCommitment: new(batches[0].Matches[0].CommitmentOne), + ResultAt: 80, WinnerExpiresAt: 150, BlockNumber: 70, TxHash: common.HexToHash("0x51"), LogIndex: 3, + } + batches[1].StandingChanges = []*model.StandingChanged{standing} + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + var rootCreationFields int + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT num_nonnulls(creation_block_number,creation_tx_hash,creation_log_index) + FROM tournaments WHERE address=$1`, root.Address.Bytes()).Scan(&rootCreationFields)) + require.Zero(t, rootCreationFields, "root creation provenance must be physically absent") + + // Literal SQL is independent of the repository column lists and scanners. + var bond, one, two string + var creationBlock uint64 + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT bond_value::text, + encode(contested_final_state_one,'hex'),encode(contested_final_state_two,'hex'),creation_block_number + FROM tournaments WHERE address=$1`, child.Address.Bytes()).Scan(&bond, &one, &two, &creationBlock)) + require.Equal(t, "23", bond) + require.Equal(t, common.Hash{}.Hex()[2:], one) + require.Equal(t, common.HexToHash("0x31").Hex()[2:], two) + require.Equal(t, uint64(50), creationBlock) + var responder, deadlineOne, deadlineTwo, eliminable uint64 + var agree, finalOne, finalTwo, position string + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT responder_deadline,seal_deadline_one,seal_deadline_two, + seal_eliminable_at,encode(seal_agree_state,'hex'),encode(seal_final_state_one,'hex'), + encode(seal_final_state_two,'hex'),seal_divergence_position::text FROM matches WHERE id_hash=$1`, + match.IDHash.Bytes()).Scan(&responder, &deadlineOne, &deadlineTwo, &eliminable, &agree, &finalOne, &finalTwo, &position)) + require.Equal(t, uint64(19), responder) + require.Equal(t, uint64(85), deadlineOne) + require.Equal(t, uint64(90), deadlineTwo) + require.Equal(t, uint64(90), eliminable) + require.Equal(t, common.Hash{}.Hex()[2:], agree) + require.Equal(t, common.HexToHash("0x41").Hex()[2:], finalOne) + require.Equal(t, common.HexToHash("0x42").Hex()[2:], finalTwo) + require.Equal(t, "7", position) + for index, expected := range []struct{ height, deadline uint64 }{{2, 84}, {1, 85}} { + advance := batches[1].MatchAdvances[index] + var height, deadline uint64 + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT current_height,responder_deadline + FROM match_advances WHERE tx_hash=$1 AND log_index=$2`, advance.TxHash.Bytes(), advance.LogIndex). + Scan(&height, &deadline)) + require.Equal(t, expected.height, height) + require.Equal(t, expected.deadline, deadline) + } + var count, result, expiry, block, log uint64 + var dangling, parent, transaction string + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT match_count,result_at,winner_expires_at,block_number,log_index, + encode(dangling,'hex'),encode(parent_commitment,'hex'),encode(tx_hash,'hex') FROM standing_changes`). + Scan(&count, &result, &expiry, &block, &log, &dangling, &parent, &transaction)) + require.Zero(t, count) + require.Equal(t, uint64(80), result) + require.Equal(t, uint64(150), expiry) + require.Equal(t, uint64(70), block) + require.Equal(t, uint64(3), log) + require.Equal(t, standing.Dangling.Hex()[2:], dangling) + require.Equal(t, standing.ParentCommitment.Hex()[2:], parent) + require.Equal(t, common.HexToHash("0x51").Hex()[2:], transaction) + + stored, err := r.GetMatch(t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, seal, stored.LeafSeal, "the deleted view must retain every historical seal field") + storedChild, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, child.BondValue, storedChild.BondValue) + require.Equal(t, child.ContestedFinalStateOne, storedChild.ContestedFinalStateOne) + require.Equal(t, child.ContestedFinalStateTwo, storedChild.ContestedFinalStateTwo) + counts, err := r.GetTournamentEventCounts(t.Context(), app.ID, child.EpochIndex, child.Address, 69) + require.NoError(t, err) + require.Zero(t, counts.StandingChanged) + counts, err = r.GetTournamentEventCounts(t.Context(), app.ID, child.EpochIndex, child.Address, 70) + require.NoError(t, err) + require.Equal(t, uint64(1), counts.StandingChanged) +} + +func TestStandingChangeReadsLiteralRowsAndExactScope(t *testing.T) { + r, app, batch := newRootEventInterval(t) + commitment := addIntervalCommitment(batch) + commitment.Commitment = common.Hash{} + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + // A stored-count query must not invent standing events from participant counts. + counts, err := r.GetTournamentEventCounts(t.Context(), app.ID, 0, batch.Tournament.Address, 100) + require.NoError(t, err) + require.Equal(t, uint64(1), counts.CommitmentJoined) + require.Zero(t, counts.StandingChanged) + transaction := common.HexToHash("0x44") + _, err = r.db.Exec(t.Context(), `INSERT INTO standing_changes(application_id,epoch_index,tournament_address, + match_count,dangling,parent_commitment,result_at,winner_expires_at,block_number,tx_hash,log_index) + VALUES($1,0,$2,0,$3,$4,120,140,100,$5,2)`, app.ID, batch.Tournament.Address.Bytes(), + common.Hash{}.Bytes(), common.HexToHash("0x33").Bytes(), transaction.Bytes()) + require.NoError(t, err) + value, err := r.GetStandingChange(t.Context(), app.Name, 0, batch.Tournament.Address.Hex(), transaction, 2) + require.NoError(t, err) + require.NotNil(t, value) + require.Equal(t, app.ID, value.ApplicationID) + require.Equal(t, uint64(0), value.EpochIndex) + require.Equal(t, batch.Tournament.Address, value.TournamentAddress) + require.Equal(t, model.Uint256{}, value.MatchCount) + require.Equal(t, new(common.Hash{}), value.Dangling) + require.Equal(t, new(common.HexToHash("0x33")), value.ParentCommitment) + require.Equal(t, uint64(120), value.ResultAt) + require.Equal(t, uint64(140), value.WinnerExpiresAt) + require.Equal(t, uint64(100), value.BlockNumber) + require.Equal(t, transaction, value.TxHash) + require.Equal(t, uint64(2), value.LogIndex) + require.False(t, value.CreatedAt.IsZero()) + require.Equal(t, value.CreatedAt, value.UpdatedAt) + for _, scope := range []struct { + name, application, tournament string + epoch, log uint64 + tx common.Hash + }{ + {disputeApplicationScope, "missing", batch.Tournament.Address.Hex(), 0, 2, transaction}, + {disputeEpochScope, app.Name, batch.Tournament.Address.Hex(), 1, 2, transaction}, + {disputeTournamentScope, app.Name, repotest.UniqueAddress().Hex(), 0, 2, transaction}, + {"transaction", app.Name, batch.Tournament.Address.Hex(), 0, 2, common.Hash{}}, + {"log", app.Name, batch.Tournament.Address.Hex(), 0, 3, transaction}, + } { + t.Run(scope.name, func(t *testing.T) { + got, err := r.GetStandingChange(t.Context(), scope.application, scope.epoch, scope.tournament, scope.tx, scope.log) + require.NoError(t, err) + require.Nil(t, got) + }) + } + // Tied block/log rows require the final transaction-hash ordering key. + for _, row := range []struct { + tx string + block, log uint64 + }{{"0x11", 100, 2}, {"0x22", 100, 1}, {"0x66", 99, 9}} { + _, err = r.db.Exec(t.Context(), `INSERT INTO standing_changes(application_id,epoch_index,tournament_address, + match_count,result_at,winner_expires_at,block_number,tx_hash,log_index) + VALUES($1,0,$2,18446744073709551616,0,0,$3,$4,$5)`, app.ID, batch.Tournament.Address.Bytes(), + row.block, common.HexToHash(row.tx).Bytes(), row.log) + require.NoError(t, err) + } + for _, descending := range []bool{false, true} { + values, total, err := r.ListStandingChanges(t.Context(), app.IApplicationAddress.Hex(), 0, + batch.Tournament.Address.Hex(), repository.Pagination{}, descending) + require.NoError(t, err) + require.Equal(t, uint64(4), total) + want := []common.Hash{common.HexToHash("0x66"), common.HexToHash("0x22"), common.HexToHash("0x11"), transaction} + if descending { + for i, j := 0, len(want)-1; i < j; i, j = i+1, j-1 { + want[i], want[j] = want[j], want[i] + } + } + require.Len(t, values, len(want)) + for i := range want { + require.Equal(t, want[i], values[i].TxHash) + } + paged, total, err := r.ListStandingChanges(t.Context(), app.Name, 0, + batch.Tournament.Address.Hex(), repository.Pagination{Limit: 2, Offset: 1}, descending) + require.NoError(t, err) + require.Equal(t, uint64(4), total) + require.Equal(t, values[1:3], paged) + } + values, total, err := r.ListStandingChanges(t.Context(), app.Name, 1, batch.Tournament.Address.Hex(), repository.Pagination{}, false) + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, values) + for _, pagination := range []repository.Pagination{{Limit: math.MaxUint64}, {Offset: math.MaxUint64}} { + _, _, err = r.ListStandingChanges(t.Context(), app.Name, 0, batch.Tournament.Address.Hex(), pagination, false) + require.ErrorContains(t, err, "pagination") + } +} + +func TestStandingChangesProductionChunksReplayAndRollback(t *testing.T) { + r, app, batch := newRootEventInterval(t) + addIntervalMatch(batch) + batch.Tournament.Snapshot.Standing = model.TournamentStandingMatchesActive + for range tournamentRowsPerStatement { + batch.StandingChanges = append(batch.StandingChanges, &model.StandingChanged{ + ApplicationID: app.ID, TournamentAddress: batch.Tournament.Address, MatchCount: model.Uint256{1}, + BlockNumber: 100, TxHash: repotest.UniqueHash(), + }) + } + exact := *batch.StandingChanges[0] + batch.StandingChanges = append(batch.StandingChanges, &exact) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + first, err := r.GetStandingChange(t.Context(), app.Name, 0, batch.Tournament.Address.Hex(), exact.TxHash, exact.LogIndex) + require.NoError(t, err) + counts, err := r.GetTournamentEventCounts(t.Context(), app.ID, 0, batch.Tournament.Address, 100) + require.NoError(t, err) + require.EqualValues(t, tournamentRowsPerStatement, counts.StandingChanged) + + root := *batch.Tournament + root.Snapshot.AsOfBlock = 110 + next := &repository.TournamentEventBatch{Tournament: &root} + for range tournamentRowsPerStatement { + next.StandingChanges = append(next.StandingChanges, &model.StandingChanged{ + ApplicationID: app.ID, TournamentAddress: root.Address, MatchCount: model.Uint256{1}, + BlockNumber: 105, TxHash: repotest.UniqueHash(), + }) + } + conflict := *next.StandingChanges[0] + conflict.MatchCount = model.Uint256{2} + next.StandingChanges = append(next.StandingChanges, &conflict) + err = r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), []*repository.TournamentEventBatch{next}) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + counts, err = r.GetTournamentEventCounts(t.Context(), app.ID, 0, root.Address, 110) + require.NoError(t, err) + require.EqualValues(t, tournamentRowsPerStatement, counts.StandingChanged, "the entire first new chunk must roll back") + after, err := r.GetStandingChange(t.Context(), app.Name, 0, root.Address.Hex(), exact.TxHash, exact.LogIndex) + require.NoError(t, err) + require.Equal(t, first, after, "the original immutable row and timestamps survive") + stored, err := r.GetTournament(t.Context(), app.Name, root.Address.Hex()) + require.NoError(t, err) + require.Equal(t, uint64(100), stored.Snapshot.AsOfBlock) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(100), storedApp.LastTournamentCheckBlock) +} + +func TestStandingChangesProductionRejectsInvalidBoundsAndScope(t *testing.T) { + for _, test := range []struct { + name string + change func(*model.StandingChanged) + }{ + {"lower block", func(v *model.StandingChanged) { v.BlockNumber = 98 }}, + {"upper block", func(v *model.StandingChanged) { v.BlockNumber = 101 }}, + {disputeApplicationScope, func(v *model.StandingChanged) { v.ApplicationID++ }}, + {disputeEpochScope, func(v *model.StandingChanged) { v.EpochIndex++ }}, + {disputeTournamentScope, func(v *model.StandingChanged) { v.TournamentAddress = repotest.UniqueAddress() }}, + {"nil", nil}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batch := newRootEventInterval(t) + value := &model.StandingChanged{ApplicationID: app.ID, TournamentAddress: batch.Tournament.Address, + MatchCount: model.Uint256{1}, BlockNumber: 100, TxHash: repotest.UniqueHash()} + if test.change == nil { + value = nil + } else { + test.change(value) + } + batch.StandingChanges = []*model.StandingChanged{value} + require.Error(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Nil(t, stored) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestDisputeFactsRejectsImmutableReplayAndKeepsCursor(t *testing.T) { + for _, test := range []struct { + name string + change func(*model.Tournament, *model.Match) + }{ + {"bond", func(v *model.Tournament, _ *model.Match) { v.BondValue = model.Uint256{2} }}, + {"contested one", func(v *model.Tournament, _ *model.Match) { v.ContestedFinalStateOne = new(common.Hash{}) }}, + {"contested two", func(v *model.Tournament, _ *model.Match) { v.ContestedFinalStateTwo = new(common.Hash{}) }}, + {"creation deadline", func(_ *model.Tournament, v *model.Match) { v.ResponderDeadline = 1 }}, + {"seal agree", func(_ *model.Tournament, v *model.Match) { v.LeafSeal.AgreeState = repotest.UniqueHash() }}, + {"seal position", func(_ *model.Tournament, v *model.Match) { v.LeafSeal.DivergencePosition = model.Uint256{4} }}, + {"seal one", func(_ *model.Tournament, v *model.Match) { v.LeafSeal.FinalStateOne = repotest.UniqueHash() }}, + {"seal two", func(_ *model.Tournament, v *model.Match) { v.LeafSeal.FinalStateTwo = repotest.UniqueHash() }}, + {"seal deadline one", func(_ *model.Tournament, v *model.Match) { v.LeafSeal.DeadlineOne = 84 }}, + {"seal deadline two", func(_ *model.Tournament, v *model.Match) { v.LeafSeal.DeadlineTwo = 89 }}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batches := newTournamentInterval(t) + child, match := batches[1].Tournament, batches[1].Matches[0] + if test.name == "seal deadline two" { + match.LeafSeal.DeadlineOne = 95 + match.LeafSeal.EliminableAt = 95 + } + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + before, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + test.change(child, match) + child.Snapshot.AsOfBlock = 110 + match.Snapshot.AsOfBlock = 110 + next := &repository.TournamentEventBatch{Tournament: child, Matches: []*model.Match{match}} + err = r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), []*repository.TournamentEventBatch{next}) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + after, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, before, after) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(100), storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestDisputeAdvanceNewPayloadConflictsRollBackInterval(t *testing.T) { + for _, field := range []string{"height", "deadline"} { + t.Run(field, func(t *testing.T) { + r, app, batches := newTournamentInterval(t) + conflict := *batches[1].MatchAdvances[0] + if field == "height" { + conflict.CurrentHeight = 3 + } else { + conflict.ResponderDeadline-- + } + batches[1].MatchAdvances = append(batches[1].MatchAdvances, &conflict) + require.ErrorIs(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches), repository.ErrTournamentEventConflict) + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Nil(t, stored) + } + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestStandingChangePayloadComparisonPreservesNullablePresence(t *testing.T) { + first := model.StandingChanged{ApplicationID: 1, EpochIndex: 2, TournamentAddress: common.HexToAddress("0x11"), + MatchCount: model.Uint256{3}, ResultAt: 4, WinnerExpiresAt: 5, BlockNumber: 6, TxHash: common.HexToHash("0x22"), LogIndex: 7} + require.True(t, sameStandingChangedEvent(&first, &first)) + for _, test := range []struct { + name string + change func(*model.StandingChanged) + }{ + {disputeApplicationScope, func(v *model.StandingChanged) { v.ApplicationID++ }}, + {disputeEpochScope, func(v *model.StandingChanged) { v.EpochIndex++ }}, + {disputeTournamentScope, func(v *model.StandingChanged) { v.TournamentAddress = common.Address{} }}, + {"count", func(v *model.StandingChanged) { v.MatchCount = model.Uint256{4} }}, + {"dangling zero", func(v *model.StandingChanged) { v.Dangling = new(common.Hash{}) }}, + {"parent zero", func(v *model.StandingChanged) { v.ParentCommitment = new(common.Hash{}) }}, + {"result", func(v *model.StandingChanged) { v.ResultAt++ }}, + {"expiry", func(v *model.StandingChanged) { v.WinnerExpiresAt++ }}, + {"block", func(v *model.StandingChanged) { v.BlockNumber++ }}, + {"transaction", func(v *model.StandingChanged) { v.TxHash = common.Hash{} }}, + {"log", func(v *model.StandingChanged) { v.LogIndex++ }}, + } { + t.Run(test.name, func(t *testing.T) { + second := first + test.change(&second) + require.False(t, sameStandingChangedEvent(&first, &second)) + require.False(t, sameStandingChangedEvent(&second, &first)) + }) + } + withZero := first + withZero.Dangling = new(common.Hash{}) + copyZero := withZero + copyZero.Dangling = new(common.Hash{}) + require.True(t, sameStandingChangedEvent(&withZero, ©Zero)) + copyZero.Dangling = new(common.HexToHash("0x11")) + require.False(t, sameStandingChangedEvent(&withZero, ©Zero)) +} + +func TestDisputeRootCreationRequiresObservedEpochSeal(t *testing.T) { + for _, test := range []struct { + name string + change func(*model.Tournament) + }{ + {"unexpected root creation", func(v *model.Tournament) { v.CreationEvent = &model.TournamentCreationEvent{} }}, + {"wrong start", func(v *model.Tournament) { v.StartInstant-- }}, + {"wrong root identity", func(v *model.Tournament) { v.Address = repotest.UniqueAddress() }}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batch := newRootEventInterval(t) + test.change(batch.Tournament) + require.Error(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Nil(t, stored) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, storedApp.LastTournamentCheckBlock) + }) + } +} diff --git a/internal/repository/postgres/dispute_schema_test.go b/internal/repository/postgres/dispute_schema_test.go new file mode 100644 index 000000000..192d0e4a1 --- /dev/null +++ b/internal/repository/postgres/dispute_schema_test.go @@ -0,0 +1,310 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +type disputeSchemaFixture struct { + repo *PostgresRepository + appID int64 + tournament common.Address + one common.Hash + two common.Hash + match common.Hash +} + +func newDisputeSchemaFixture(t *testing.T) disputeSchemaFixture { + t.Helper() + repo := newEpochPublicationRepository(t).(*PostgresRepository) + app, _ := seedEpochPublication(t, repo) + f := disputeSchemaFixture{repo: repo, appID: app.ID, tournament: common.Address{1}, + one: common.Hash{2}, two: common.Hash{3}, match: common.Hash{4}} + _, err := repo.db.Exec(t.Context(), `INSERT INTO tournaments + (application_id,epoch_index,address,max_level,level,log2step,height,initial_hash,base_cycle,kind, + start_instant,allowance,bond_value,creation_block_number,creation_tx_hash,creation_log_index, + as_of_block,standing,accepts_joins,winner_expires_at,bond_disposition) + VALUES ($1,0,$2,2,0,38,54,$3,0,'NON_LEAF',10,20,1,NULL,NULL,NULL,20,'MATCHES_ACTIVE',true,0,'TOURNAMENT_RUNNING')`, + app.ID, f.tournament.Bytes(), common.Hash{}.Bytes()) + require.NoError(t, err) + for i, commitment := range []common.Hash{f.one, f.two} { + _, err = repo.db.Exec(t.Context(), `INSERT INTO commitments + (application_id,epoch_index,tournament_address,commitment,final_state_hash,submitter_address, + block_number,tx_hash,log_index,as_of_block,claimer,clock_running,clock_deadline,clock_allowance) + VALUES ($1,0,$2,$3,$4,$5,10,$6,$7,20,$5,false,0,5)`, app.ID, f.tournament.Bytes(), + commitment.Bytes(), common.Hash{}.Bytes(), app.IApplicationAddress.Bytes(), common.Hash{5}.Bytes(), i+1) + require.NoError(t, err) + } + _, err = repo.db.Exec(t.Context(), `INSERT INTO matches + (application_id,epoch_index,tournament_address,id_hash,commitment_one,commitment_two,left_of_two, + block_number,tx_hash,log_index,eliminable_at,responder_deadline,as_of_block,phase,timeout_outcome, + deferred_charge,revealing_parent,waiting_left,waiting_right,segment_start_position,segment_start_cycle, + current_height,responder,winner,deletion_reason) + VALUES ($1,0,$2,$3,$4,$5,$4,11,$6,3,30,20,20,'BISECTING','NONE',0,$4,$4,$5,0,0,54,'ONE','NONE','NOT_DELETED')`, + app.ID, f.tournament.Bytes(), f.match.Bytes(), f.one.Bytes(), f.two.Bytes(), common.Hash{5}.Bytes()) + require.NoError(t, err) + return f +} + +func requireDisputeSchemaError(t *testing.T, err error, code, constraint string) { + t.Helper() + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, code, pgErr.Code) + if constraint != "" { + require.Equal(t, constraint, pgErr.ConstraintName) + } +} + +func TestDisputeSchemaTournamentFacts(t *testing.T) { + const creationEventConstraint = "tournaments_creation_event_check" + f := newDisputeSchemaFixture(t) + for _, test := range []struct { + name, assignment, code, constraint string + }{ + {"positive bond", "bond_value=0", "23514", "tournaments_bond_value_check"}, + {"required bond", "bond_value=NULL", "23502", ""}, + {"root contested states", "contested_final_state_one=initial_hash,contested_final_state_two=initial_hash", + "23514", "tournaments_nested_dispute_check"}, + {"root creation block", "creation_block_number=10", "23514", creationEventConstraint}, + {"root creation transaction", "creation_tx_hash=initial_hash", "23514", creationEventConstraint}, + {"root creation index", "creation_log_index=0", "23514", creationEventConstraint}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := f.repo.db.Exec(t.Context(), "UPDATE tournaments SET "+test.assignment+" WHERE application_id=$1", f.appID) + requireDisputeSchemaError(t, err, test.code, test.constraint) + }) + } + child := common.Address{6} + insert := `INSERT INTO tournaments + (application_id,epoch_index,address,parent_tournament_address,parent_match_id_hash,max_level,level, + log2step,height,initial_hash,base_cycle,kind,start_instant,allowance,bond_value, + contested_final_state_one,contested_final_state_two,creation_block_number,creation_tx_hash,creation_log_index, + as_of_block,standing,accepts_joins,winner_expires_at,inner_disposition,inner_paused_allowance,bond_disposition) + VALUES ($1,0,$2,$3,$4,2,1,0,38,$5,0,'LEAF',12,20,1,$6,$7,12,$8,4,20, + 'AWAITING_CLOSURE',false,0,'UNSETTLED',0,'TOURNAMENT_RUNNING')` + _, err := f.repo.db.Exec(t.Context(), insert, f.appID, child.Bytes(), f.tournament.Bytes(), f.match.Bytes(), + common.Hash{}.Bytes(), nil, nil, common.Hash{5}.Bytes()) + requireDisputeSchemaError(t, err, "23514", "tournaments_nested_dispute_check") + _, err = f.repo.db.Exec(t.Context(), insert, f.appID, child.Bytes(), f.tournament.Bytes(), f.match.Bytes(), + common.Hash{}.Bytes(), common.Hash{}.Bytes(), common.Hash{}.Bytes(), common.Hash{5}.Bytes()) + require.NoError(t, err, "present zero participant states are valid") + for _, test := range []struct{ name, assignment, constraint string }{ + {"wrong creation block", "creation_block_number=11", "tournaments_creation_block_check"}, + {"missing creation block", "creation_block_number=NULL", creationEventConstraint}, + {"missing creation transaction", "creation_tx_hash=NULL", creationEventConstraint}, + {"missing creation index", "creation_log_index=NULL", creationEventConstraint}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := f.repo.db.Exec(t.Context(), "UPDATE tournaments SET "+test.assignment+" WHERE application_id=$1 AND address=$2", + f.appID, child.Bytes()) + requireDisputeSchemaError(t, err, "23514", test.constraint) + }) + } + + _, err = f.repo.db.Exec(t.Context(), `UPDATE tournaments SET contested_final_state_one=NULL + WHERE application_id=$1 AND address=$2`, f.appID, child.Bytes()) + requireDisputeSchemaError(t, err, "23514", "tournaments_nested_dispute_check") + _, err = f.repo.db.Exec(t.Context(), `UPDATE tournaments SET contested_final_state_two=NULL + WHERE application_id=$1 AND address=$2`, f.appID, child.Bytes()) + requireDisputeSchemaError(t, err, "23514", "tournaments_nested_dispute_check") +} + +func TestDisputeSchemaMatchSchedulesAndSeal(t *testing.T) { + f := newDisputeSchemaFixture(t) + for _, test := range []struct { + name, assignment, code, constraint string + }{ + {"required deadline", "responder_deadline=NULL", "23502", ""}, + {"creation schedule", "responder_deadline=31", "23514", "matches_creation_schedule_check"}, + {"partial seal", "seal_eliminable_at=30", "23514", "matches_seal_tuple_check"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := f.repo.db.Exec(t.Context(), "UPDATE matches SET "+test.assignment+" WHERE application_id=$1", f.appID) + requireDisputeSchemaError(t, err, test.code, test.constraint) + }) + } + _, err := f.repo.db.Exec(t.Context(), `UPDATE matches SET responder_deadline=eliminable_at WHERE application_id=$1`, f.appID) + require.NoError(t, err) + _, err = f.repo.db.Exec(t.Context(), `UPDATE matches SET seal_eliminable_at=30,seal_agree_state=$2, + seal_divergence_position=2,seal_final_state_one=$3,seal_final_state_two=$4, + seal_deadline_one=20,seal_deadline_two=30,seal_block_number=15,seal_tx_hash=$5,seal_log_index=5 + WHERE application_id=$1`, f.appID, common.Hash{}.Bytes(), f.one.Bytes(), f.two.Bytes(), common.Hash{5}.Bytes()) + require.NoError(t, err) + _, err = f.repo.db.Exec(t.Context(), `UPDATE matches SET seal_eliminable_at=29 WHERE application_id=$1`, f.appID) + requireDisputeSchemaError(t, err, "23514", "matches_seal_schedule_check") + _, err = f.repo.db.Exec(t.Context(), `UPDATE matches SET seal_deadline_one=40,seal_eliminable_at=40 + WHERE application_id=$1`, f.appID) + require.NoError(t, err) + _, err = f.repo.db.Exec(t.Context(), `UPDATE matches SET seal_eliminable_at=30 WHERE application_id=$1`, f.appID) + requireDisputeSchemaError(t, err, "23514", "matches_seal_schedule_check") + _, err = f.repo.db.Exec(t.Context(), `UPDATE matches SET phase='SEALED',revealing_parent=NULL,waiting_left=NULL, + waiting_right=NULL,segment_start_position=NULL,segment_start_cycle=NULL,current_height=NULL,responder=NULL, + agree_state=seal_agree_state,divergence_position=seal_divergence_position,divergence_cycle=2, + final_state_one=seal_final_state_one,final_state_two=seal_final_state_two WHERE application_id=$1`, f.appID) + require.NoError(t, err) + for _, field := range []string{"agree_state", "divergence_position", "final_state_one", "final_state_two"} { + t.Run("sealed view "+field, func(t *testing.T) { + assignment := field + "=$2" + var value any = common.Hash{9}.Bytes() + if field == "divergence_position" { + value = 3 + } + _, err := f.repo.db.Exec(t.Context(), "UPDATE matches SET "+assignment+" WHERE application_id=$1", f.appID, value) + requireDisputeSchemaError(t, err, "23514", "matches_sealed_view_check") + }) + } + _, err = f.repo.db.Exec(t.Context(), `UPDATE matches SET seal_deadline_two=NULL WHERE application_id=$1`, f.appID) + requireDisputeSchemaError(t, err, "23514", "matches_seal_tuple_check") +} + +func TestDisputeSchemaAdvanceHeightAndSchedule(t *testing.T) { + f := newDisputeSchemaFixture(t) + insert := `INSERT INTO match_advances + (application_id,epoch_index,tournament_address,id_hash,other_parent,left_node,segment_start_position, + eliminable_at,current_height,responder_deadline,block_number,tx_hash,log_index) + VALUES ($1,0,$2,$3,$4,$5,0,30,$6,$7,12,$8,$9) + ON CONFLICT (application_id,tx_hash,log_index) DO NOTHING` + for _, test := range []struct { + name string + height, deadline any + code, constraint string + }{ + {"zero height", 0, 20, "23514", "match_advances_height_check"}, + {"required height", nil, 20, "23502", ""}, + {"required deadline", 53, nil, "23502", ""}, + {"schedule", 53, 31, "23514", "match_advances_schedule_check"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := f.repo.db.Exec(t.Context(), insert, f.appID, f.tournament.Bytes(), f.match.Bytes(), f.one.Bytes(), + f.two.Bytes(), test.height, test.deadline, common.Hash{5}.Bytes(), 6) + requireDisputeSchemaError(t, err, test.code, test.constraint) + }) + } + for i, height := range []int{53, 52, 1} { + deadline := 20 + if height == 1 { + deadline = 30 + } + _, err := f.repo.db.Exec(t.Context(), insert, f.appID, f.tournament.Bytes(), f.match.Bytes(), f.one.Bytes(), + f.two.Bytes(), height, deadline, common.Hash{5}.Bytes(), i+6) + require.NoError(t, err) + } + _, err := f.repo.db.Exec(t.Context(), insert, f.appID, f.tournament.Bytes(), f.match.Bytes(), f.one.Bytes(), + f.two.Bytes(), 53, 20, common.Hash{5}.Bytes(), 9) + requireDisputeSchemaError(t, err, "23505", "match_advances_height_key") +} + +func TestDisputeSchemaStandingFacts(t *testing.T) { + const liveMatchesCheck = "standing_changes_live_matches_check" + const winnerCheck = "standing_changes_winner_check" + f := newDisputeSchemaFixture(t) + insert := `INSERT INTO standing_changes + (application_id,epoch_index,tournament_address,match_count,dangling,parent_commitment,result_at, + winner_expires_at,block_number,tx_hash,log_index) VALUES ($1,0,$2,$3,$4,$5,$6,$7,15,$8,9)` + for _, test := range []struct { + name string + count int + dangling, parent any + result, expires int + constraint string + }{ + {name: "live result", count: 1, result: 20, constraint: liveMatchesCheck}, + {name: "live parent", count: 1, dangling: f.one.Bytes(), parent: f.two.Bytes(), expires: 20, + constraint: liveMatchesCheck}, + {name: "live expiration", count: 1, expires: 20, constraint: liveMatchesCheck}, + {name: "missing result", constraint: "standing_changes_result_check"}, + {name: "expiration without parent", result: 20, expires: 30, constraint: winnerCheck}, + {name: "winner without dangling", parent: f.two.Bytes(), result: 20, expires: 30, + constraint: winnerCheck}, + {name: "winner expiration", dangling: f.one.Bytes(), parent: f.two.Bytes(), result: 20, expires: 19, + constraint: winnerCheck}, + {name: "zero dangling reference", count: 1, dangling: common.Hash{}.Bytes(), + constraint: "standing_changes_dangling_fkey"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := f.repo.db.Exec(t.Context(), insert, f.appID, f.tournament.Bytes(), test.count, test.dangling, + test.parent, test.result, test.expires, common.Hash{5}.Bytes()) + code := "23514" + if test.constraint == "standing_changes_dangling_fkey" { + code = "23503" + } + requireDisputeSchemaError(t, err, code, test.constraint) + }) + } + _, err := f.repo.db.Exec(t.Context(), insert, f.appID, common.Address{9}.Bytes(), 1, nil, nil, 0, 0, + common.Hash{5}.Bytes()) + requireDisputeSchemaError(t, err, "23503", "standing_changes_tournament_fkey") + _, err = f.repo.db.Exec(t.Context(), insert, f.appID, f.tournament.Bytes(), 1, f.one.Bytes(), nil, 0, 0, + common.Hash{5}.Bytes()) + require.NoError(t, err) + _, err = f.repo.db.Exec(t.Context(), insert, f.appID, f.tournament.Bytes(), 1, f.one.Bytes(), nil, 0, 0, + common.Hash{5}.Bytes()) + requireDisputeSchemaError(t, err, "23505", "standing_changes_pkey") + var before, after string + require.NoError(t, f.repo.db.QueryRow(t.Context(), `SELECT updated_at::text FROM standing_changes + WHERE application_id=$1`, f.appID).Scan(&before)) + _, err = f.repo.db.Exec(t.Context(), `UPDATE standing_changes SET block_number=16 WHERE application_id=$1`, f.appID) + require.NoError(t, err) + require.NoError(t, f.repo.db.QueryRow(t.Context(), `SELECT updated_at::text FROM standing_changes + WHERE application_id=$1`, f.appID).Scan(&after)) + require.NotEqual(t, before, after) + _, err = f.repo.db.Exec(t.Context(), `UPDATE standing_changes SET match_count=0,dangling=NULL,result_at=20 + WHERE application_id=$1`, f.appID) + require.NoError(t, err) + _, err = f.repo.db.Exec(t.Context(), `UPDATE standing_changes SET dangling=$2,parent_commitment=$3,winner_expires_at=20 + WHERE application_id=$1`, f.appID, f.one.Bytes(), f.two.Bytes()) + require.NoError(t, err) + _, err = f.repo.db.Exec(t.Context(), `DELETE FROM commitments WHERE application_id=$1 AND commitment=$2`, f.appID, f.one.Bytes()) + require.NoError(t, err) + var count int + require.NoError(t, f.repo.db.QueryRow(t.Context(), `SELECT count(*) FROM standing_changes + WHERE application_id=$1`, f.appID).Scan(&count)) + require.Zero(t, count) +} + +func TestDisputeSchemaMigrationRoundTrip(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + migration, err := schema.New(endpoint) + require.NoError(t, err) + t.Cleanup(migration.Close) + t.Cleanup(func() { require.NoError(t, migration.Upgrade()) }) + conn, err := pgx.Connect(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(context.Background())) }) + for _, omitNewTable := range []bool{false, true} { + t.Run(fmt.Sprintf("new table absent %t", omitNewTable), func(t *testing.T) { + require.NoError(t, migration.Upgrade()) + if omitNewTable { + _, err := conn.Exec(t.Context(), `DROP TABLE standing_changes`) + require.NoError(t, err) + } + require.NoError(t, migration.Downgrade()) + for _, query := range []string{ + `SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND c.relname NOT IN ('schema_migrations','schema_migrations_pkey')`, + `SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public'`, + `SELECT count(*) FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid + JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND NOT t.tgisinternal`, + } { + var count int + require.NoError(t, conn.QueryRow(t.Context(), query).Scan(&count)) + require.Zero(t, count, query) + } + }) + } +} diff --git a/internal/repository/postgres/epoch.go b/internal/repository/postgres/epoch.go new file mode 100644 index 000000000..8e31a7e3f --- /dev/null +++ b/internal/repository/postgres/epoch.go @@ -0,0 +1,1123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "sort" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/enum" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func getEpochNextVirtualIndex( + ctx context.Context, + tx pgx.Tx, + nameOrAddress string, +) (uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + query := table.Epoch.SELECT( + postgres.COALESCE( + postgres.Float(1).ADD(postgres.MAXf(table.Epoch.VirtualIndex)), + postgres.Float(0), + ), + ).FROM( + table.Epoch.INNER_JOIN(table.Application, table.Epoch.ApplicationID.EQ(table.Application.ID)), + ).WHERE( + whereClause, + ) + + queryStr, args := query.Sql() + var currentIndex uint64 + err := tx.QueryRow(ctx, queryStr, args...).Scan(¤tIndex) + if err != nil { + return 0, fmt.Errorf("failed to get the next epoch virtual index: %w", err) + } + return currentIndex, nil +} + +func orderEpochs(epochInputsMap map[*model.Epoch][]*model.Input) []*model.Epoch { + epochs := make([]*model.Epoch, 0, len(epochInputsMap)) + for e := range epochInputsMap { + epochs = append(epochs, e) + } + + sort.Slice(epochs, func(i, j int) bool { + return epochs[i].FirstBlock < epochs[j].FirstBlock + }) + + return epochs +} + +func (r *PostgresRepository) CreateEpochsAndInputs( + ctx context.Context, + nameOrAddress string, + epochInputsMap map[*model.Epoch][]*model.Input, + blockNumber uint64, +) error { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + epochInsertStmt := table.Epoch.INSERT( + table.Epoch.ApplicationID, + table.Epoch.Index, + table.Epoch.FirstBlock, + table.Epoch.LastBlock, + table.Epoch.InputIndexLowerBound, + table.Epoch.InputIndexUpperBound, + table.Epoch.TournamentAddress, + table.Epoch.Status, + table.Epoch.VirtualIndex, + ) + + inputInsertStmt := table.Input. + INSERT( + table.Input.EpochApplicationID, + table.Input.EpochIndex, + table.Input.Index, + table.Input.BlockNumber, + table.Input.RawData, + table.Input.Status, + table.Input.TransactionHash, + table.Input.LogIndex, + ) + + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + // Participate in the same Application -> child-row order as + // StoreAdvanceResult and RejectEpochAndSetApplicationDiverged. SELECT FOR NO + // KEY UPDATE does not change the application row; it takes a + // transaction-scoped row lock that conflicts with application updates. L1 + // ingestion holds it while upserting epochs and inputs and advancing the + // input-scan cursor, so another multi-row writer for this application waits + // before touching a child row instead of forming a child -> Application + // deadlock cycle. + // + // Commit or rollback releases the lock. A lost node connection aborts the + // transaction once PostgreSQL detects it (which a network failure can + // delay), and a PostgreSQL restart discards the uncommitted transaction + // during crash recovery. The lock is not persisted independently. + appLockStmt := table.Application. + SELECT(table.Application.ID). + WHERE(whereClause). + FOR(postgres.NO_KEY_UPDATE()) + appLockSQL, appLockArgs := appLockStmt.Sql() + var appID int64 + if err := tx.QueryRow(ctx, appLockSQL, appLockArgs...).Scan(&appID); errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } else if err != nil { + return err + } + + epochs := orderEpochs(epochInputsMap) + for _, epoch := range epochs { + inputs := epochInputsMap[epoch] + + nextVirtualIndex, err := getEpochNextVirtualIndex(ctx, tx, nameOrAddress) + if err != nil { + return err + } + + var tournamentAddress postgres.ByteaExpression + if epoch.TournamentAddress != nil { + tournamentAddress = postgres.Bytea(epoch.TournamentAddress.Bytes()) + } else { + tournamentAddress = postgres.ByteaExp(postgres.NULL) + } + epochSelectQuery := table.Application.SELECT( + table.Application.ID, + uint64Expr(epoch.Index), + uint64Expr(epoch.FirstBlock), + uint64Expr(epoch.LastBlock), + uint64Expr(epoch.InputIndexLowerBound), + uint64Expr(epoch.InputIndexUpperBound), + tournamentAddress, + postgres.NewEnumValue(epoch.Status.String()), + uint64Expr(nextVirtualIndex), + ).WHERE( + whereClause, + ) + + // Guard: only update epoch fields when the existing row is still OPEN. + // Once an epoch is sealed (CLOSED) or beyond, its status, block range, + // input bounds, and tournament address are finalized and must not be + // overwritten by crash-recovery re-processing. + isOpen := table.Epoch.Status.EQ( + postgres.NewEnumValue(model.EpochStatus_Open.String()), + ) + + sqlStr, args := epochInsertStmt.QUERY(epochSelectQuery). + ON_CONFLICT(table.Epoch.ApplicationID, table.Epoch.Index). + DO_UPDATE(postgres.SET( + table.Epoch.Status.SET(postgres.StringExp( + postgres.CASE(). + WHEN(isOpen).THEN(table.Epoch.EXCLUDED.Status). + ELSE(table.Epoch.Status), + )), + table.Epoch.LastBlock.SET(postgres.FloatExp( + postgres.CASE(). + WHEN(isOpen).THEN(table.Epoch.EXCLUDED.LastBlock). + ELSE(table.Epoch.LastBlock), + )), + table.Epoch.InputIndexUpperBound.SET(postgres.FloatExp( + postgres.CASE(). + WHEN(isOpen).THEN(table.Epoch.EXCLUDED.InputIndexUpperBound). + ELSE(table.Epoch.InputIndexUpperBound), + )), + table.Epoch.TournamentAddress.SET(postgres.ByteaExp( + postgres.CASE(). + WHEN(isOpen).THEN(table.Epoch.EXCLUDED.TournamentAddress). + ELSE(table.Epoch.TournamentAddress), + )), + )).Sql() + _, err = tx.Exec(ctx, sqlStr, args...) + + if err != nil { + return err + } + + if len(inputs) > 0 { + batch := &pgx.Batch{} + for _, input := range inputs { + inputSelectQuery := table.Application.SELECT( + table.Application.ID, + uint64Expr(epoch.Index), + uint64Expr(input.Index), + uint64Expr(input.BlockNumber), + postgres.Bytea(input.RawData), + postgres.NewEnumValue(input.Status.String()), + postgres.Bytea(input.TransactionHash.Bytes()), + uint64Expr(input.LogIndex), + ).WHERE( + whereClause, + ) + + sqlStr, args := inputInsertStmt.QUERY(inputSelectQuery). + ON_CONFLICT(table.Input.EpochApplicationID, table.Input.Index). + DO_NOTHING().Sql() + batch.Queue(sqlStr, args...) + } + + br := tx.SendBatch(ctx, batch) + for range inputs { + _, err := br.Exec() + if err != nil { + br.Close() + return wrapInputLogIdentityConflict(err) + } + } + if err := br.Close(); err != nil { + return wrapInputLogIdentityConflict(err) + } + } + } + + // Update the input cursor monotonically. Dave sealed-epoch catch-up may + // revisit an older block after open-epoch ingestion has already scanned + // farther ahead; that historical write must not rewind drain readiness. + appUpdateStmt := table.Application. + UPDATE( + table.Application.LastInputCheckBlock, + ). + SET( + uint64Expr(blockNumber), + ). + WHERE(whereClause.AND( + table.Application.LastInputCheckBlock.LT(uint64Expr(blockNumber)), + )) + + sqlStr, args := appUpdateStmt.Sql() + _, err = tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + + return tx.Commit(ctx) +} + +// sqlstateUniqueViolation is the PostgreSQL error code for unique constraint +// violations (SQLSTATE 23505). +const sqlstateUniqueViolation = "23505" + +// inputLogIdentityConstraint is the unique constraint enforcing the L1 log +// identity of an input; see the input table in the initial schema migration. +const inputLogIdentityConstraint = "input_application_id_tx_hash_log_index_unique" + +// wrapInputLogIdentityConflict tags unique violations of the input L1 log +// identity constraint with repository.ErrInputLogIdentityConflict so callers +// can distinguish stored-state divergence (unrecoverable by retry) from +// transient insert failures. Conflicts on the input primary key never reach +// this point — they are absorbed by the insert's ON CONFLICT DO NOTHING. +func wrapInputLogIdentityConflict(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && + pgErr.Code == sqlstateUniqueViolation && + pgErr.ConstraintName == inputLogIdentityConstraint { + return fmt.Errorf("%w: %w", repository.ErrInputLogIdentityConflict, err) + } + return err +} + +func (r *PostgresRepository) GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*model.Epoch, error) { + return getEpoch(ctx, r.db, nameOrAddress, index) +} + +func getEpoch( + ctx context.Context, + queryer rowQueryer, + nameOrAddress string, + index uint64, +) (*model.Epoch, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + stmt := table.Epoch. + SELECT( + table.Epoch.ApplicationID, + table.Epoch.Index, + table.Epoch.FirstBlock, + table.Epoch.LastBlock, + table.Epoch.InputIndexLowerBound, + table.Epoch.InputIndexUpperBound, + table.Epoch.MachineHash, + table.Epoch.TxBufferDataBlock, + table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, + table.Epoch.Commitment, + table.Epoch.CommitmentProof, + table.Epoch.ClaimTransactionHash, + table.Epoch.TournamentAddress, + table.Epoch.Status, + table.Epoch.StagedAtBlock, + table.Epoch.VirtualIndex, + table.Epoch.CreatedAt, + table.Epoch.UpdatedAt, + ). + FROM( + table.Epoch. + INNER_JOIN(table.Application, + table.Epoch.ApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Epoch.Index.EQ(uint64Expr(index))), + ) + + sqlStr, args := stmt.Sql() + row := queryer.QueryRow(ctx, sqlStr, args...) + + var ep model.Epoch + err := row.Scan( + &ep.ApplicationID, + &ep.Index, + &ep.FirstBlock, + &ep.LastBlock, + &ep.InputIndexLowerBound, + &ep.InputIndexUpperBound, + &ep.MachineHash, + &ep.TxBufferDataBlock, + &ep.TxBufferProof, + &ep.IflagsYDataBlock, + &ep.IflagsYProof, + &ep.HtifTohostDataBlock, + &ep.HtifTohostProof, + &ep.Commitment, + &ep.CommitmentProof, + &ep.ClaimTransactionHash, + &ep.TournamentAddress, + &ep.Status, + &ep.StagedAtBlock, + &ep.VirtualIndex, + &ep.CreatedAt, + &ep.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &ep, nil +} + +// HasUndrainedEpochsBeforeBlock returns true while any input belonging to +// appID has block_number <= blockBound and is still status='NONE' (i.e. not +// yet advanced by the machine). PRT uses this to keep its post-foreclosure +// drain pending until all executable pre-foreclosure inputs have been advanced. +// Under ET/IOR, NONE inputs at or beyond processed_inputs are unreachable. The +// execution cursor, rather than the possibly earlier publication cutoff, defines +// that suffix. A NONE below the cursor still blocks the drain defensively. +// +// The check is input-level rather than epoch-level for two reasons: +// +// 1. It naturally catches the "straddling open epoch" case: an epoch with +// first_block < blockBound but last_block >= blockBound still contains +// pre-foreclosure inputs that must be processed before drain can +// complete. A predicate on epoch.last_block < blockBound would skip +// such an epoch. +// 2. It correctly tolerates PRT's empty-epoch invariant — an empty open +// epoch straddling the foreclosure block has no inputs to wait on, so +// the gate returns false (whereas a predicate on +// epoch.first_block <= blockBound would incorrectly stall PRT drain on +// the empty straddler). +// +// The block bound is inclusive because any valid InputAdded event in the +// Foreclosure block must have executed before Foreclosure; a later same-block +// addInput call would revert and emit no event. +// +// Authority/Quorum also uses the broader +// [PostgresRepository.HasUnreconciledClaimsBeforeBlock] gate so it waits for +// read-only claim reconciliation or CLAIM_FORECLOSED terminalization. +func (r *PostgresRepository) HasUndrainedEpochsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + stmt := table.Input. + SELECT(table.Input.Index). + FROM( + table.Input.INNER_JOIN(table.Epoch, + table.Input.EpochApplicationID.EQ(table.Epoch.ApplicationID). + AND(table.Input.EpochIndex.EQ(table.Epoch.Index)), + ).INNER_JOIN(table.Application, table.Application.ID.EQ(table.Input.EpochApplicationID)), + ). + WHERE( + table.Input.EpochApplicationID.EQ(postgres.Int(appID)). + AND(table.Input.BlockNumber.LT_EQ(uint64Expr(blockBound))). + AND(table.Input.Status.EQ(enum.InputCompletionStatus.None)). + AND(table.Application.Status.NOT_IN(executionStopStatusExpressions...). + OR(table.Input.Index.LT(table.Application.ProcessedInputs))). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions...)), + ). + LIMIT(1) + + sqlStr, args := stmt.Sql() + rows, err := r.db.Query(ctx, sqlStr, args...) + if err != nil { + return false, err + } + defer rows.Close() + return rows.Next(), rows.Err() +} + +// HasUnpublishedEpochBeforeBlock is the A/Q machine-lifetime gate after input +// drain. A CLOSED epoch still needs prepareEpochPublication's resident machine; +// IP and later stages only need the durable proof and may finish without it. +func (r *PostgresRepository) HasUnpublishedEpochBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + query, args := unpublishedEpochBeforeBlockSQL(appID, blockBound) + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return false, err + } + defer rows.Close() + return rows.Next(), rows.Err() +} + +func unpublishedEpochBeforeBlockSQL(appID int64, blockBound uint64) (string, []any) { + stmt := table.Epoch.SELECT(table.Epoch.Index). + FROM(table.Epoch.INNER_JOIN(table.Application, table.Epoch.ApplicationID.EQ(table.Application.ID))). + WHERE(table.Epoch.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.Epoch.Status.EQ(enum.EpochStatus.Closed)). + AND(table.Epoch.LastBlock.LT(uint64Expr(blockBound))). + AND(table.Epoch.LastBlock.LT(table.Application.ForecloseBlock)). + AND(table.Application.Enabled.EQ(postgres.Bool(true))). + AND(table.Application.Status.EQ(enum.ApplicationStatus.Ok)). + AND(table.Application.ConsensusType.IN(enum.Consensus.Authority, enum.Consensus.Quorum)). + AND(table.Application.ForecloseBlock.GT(uint64Expr(0))). + AND(table.Application.ProblemEpochIndex.IS_NULL().OR(table.Epoch.Index.LT(table.Application.ProblemEpochIndex)))). + ORDER_BY(table.Epoch.FirstBlock). + LIMIT(1) + return stmt.Sql() +} + +// ForecloseUnacceptedEpochsAtOrAfterBlock makes local Authority/Quorum epoch +// rows terminal when their claim cannot be accepted because the application was +// foreclosed before or at the epoch's last block. It leaves earlier epochs alone +// so the claimer can still reconcile claims accepted before foreclosure. With +// ET/IOR, epochs at or beyond the typed cutoff also have no valid local claim +// path. Earlier pending validation and accepted claims remain untouched. +func (r *PostgresRepository) ForecloseUnacceptedEpochsAtOrAfterBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (int64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + updateStmt := table.Epoch. + UPDATE(table.Epoch.Status). + SET(enum.EpochStatus.ClaimForeclosed). + FROM(table.Application). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.Epoch.FirstBlock.LT_EQ(uint64Expr(blockBound))). + AND(table.Epoch.LastBlock.GT_EQ(uint64Expr(blockBound)).OR( + table.Application.Status.IN(executionStopStatusExpressions...). + AND(table.Epoch.Index.GT_EQ(table.Application.ProblemEpochIndex)))). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions...)). + AND(table.Application.ID.EQ(table.Epoch.ApplicationID)). + AND(table.Application.ForecloseBlock.GT(uint64Expr(0))). + AND(table.Application.Enabled.EQ(postgres.Bool(true))). + AND(table.Application.Status.IN( + append([]postgres.Expression{enum.ApplicationStatus.Ok}, executionStopStatusExpressions...)...)). + AND(table.Application.ConsensusType.NOT_EQ(enum.Consensus.Prt)), + ) + + sqlStr, args := updateStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return 0, fmt.Errorf("foreclosing unaccepted epochs (app=%d, block=%d): %w", appID, blockBound, err) + } + return cmd.RowsAffected(), nil +} + +// HasUnreconciledClaimsBeforeBlock returns true while any epoch for appID +// has first_block <= blockBound AND status in OPEN/CLOSED/INPUTS_PROCESSED or +// CLAIM_COMPUTED/CLAIM_SUBMITTED/CLAIM_STAGED. The extra states ensure the +// Authority/Quorum claimer's foreclosure drain waits for the read-only +// CLAIM_COMPUTED → CLAIM_ACCEPTED reconciliation path, or the +// CLAIM_* → CLAIM_FORECLOSED terminalization path, to finish. Otherwise a +// new-node bootstrap against an already-foreclosed app could drain before +// mirroring pre-foreclosure on-chain state into the local DB. +// +// The predicate is `first_block <= blockBound` (not `last_block < blockBound`) +// to catch straddling epochs: an epoch that started before the foreclosure +// block but extends past it is still pre-foreclosure work the claimer must +// drive to CLAIM_ACCEPTED or CLAIM_FORECLOSED. The inclusive bound catches a +// valid same-block input that executed before Foreclosure. Authority/Quorum +// never creates empty epoch rows, so `first_block <= blockBound` does not +// introduce false positives. +func (r *PostgresRepository) HasUnreconciledClaimsBeforeBlock( + ctx context.Context, + appID int64, + blockBound uint64, +) (bool, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + stmt := table.Epoch. + SELECT(table.Epoch.Index). + WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int(appID)). + AND(table.Epoch.FirstBlock.LT_EQ(uint64Expr(blockBound))). + AND(table.Epoch.Status.IN(nonTerminalEpochStatusExpressions...)), + ). + LIMIT(1) + + sqlStr, args := stmt.Sql() + rows, err := r.db.Query(ctx, sqlStr, args...) + if err != nil { + return false, err + } + defer rows.Close() + return rows.Next(), rows.Err() +} + +// nonTerminalEpochStatusExpressions is initialized once and shared read-only by queries. +var nonTerminalEpochStatusExpressions = func() []postgres.Expression { + statuses := model.NonTerminalEpochStatuses() + expressions := make([]postgres.Expression, len(statuses)) + for i, status := range statuses { + expressions[i] = postgres.NewEnumValue(status.String()) + } + return expressions +}() + +func (r *PostgresRepository) GetLastAcceptedEpochIndex( + ctx context.Context, + nameOrAddress string, +) (uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + stmt := table.Epoch. + SELECT( + table.Epoch.Index, + ). + FROM( + table.Epoch. + INNER_JOIN(table.Application, + table.Epoch.ApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Epoch.Status.EQ(enum.EpochStatus.ClaimAccepted)), + ). + ORDER_BY(table.Epoch.Index.DESC()). + LIMIT(1) + + sqlStr, args := stmt.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var index uint64 + err := row.Scan( + &index, + ) + if errors.Is(err, sql.ErrNoRows) { + return 0, repository.ErrNotFound + } + if err != nil { + return 0, err + } + return index, nil +} + +// GetLastNonOpenEpochIndex returns only the sweep boundary, without epoch proofs. +// A nil index means the application has no non-open epoch. Index zero is valid. +func (r *PostgresRepository) GetLastNonOpenEpochIndex(ctx context.Context, nameOrAddress string) (*uint64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + query := table.Epoch.SELECT(table.Epoch.Index). + FROM(table.Epoch.INNER_JOIN(table.Application, table.Epoch.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress). + AND(table.Epoch.Status.NOT_EQ(enum.EpochStatus.Open))). + ORDER_BY(table.Epoch.Index.DESC()).LIMIT(1) + queryStr, args := query.Sql() + var index uint64 + err := r.db.QueryRow(ctx, queryStr, args...).Scan(&index) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get last non-open epoch index: %w", err) + } + return &index, nil +} + +func (r *PostgresRepository) GetLastNonOpenEpoch( + ctx context.Context, + nameOrAddress string, +) (*model.Epoch, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + stmt := table.Epoch. + SELECT( + table.Epoch.ApplicationID, + table.Epoch.Index, + table.Epoch.FirstBlock, + table.Epoch.LastBlock, + table.Epoch.InputIndexLowerBound, + table.Epoch.InputIndexUpperBound, + table.Epoch.MachineHash, + table.Epoch.TxBufferDataBlock, + table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, + table.Epoch.Commitment, + table.Epoch.CommitmentProof, + table.Epoch.ClaimTransactionHash, + table.Epoch.TournamentAddress, + table.Epoch.Status, + table.Epoch.StagedAtBlock, + table.Epoch.VirtualIndex, + table.Epoch.CreatedAt, + table.Epoch.UpdatedAt, + ). + FROM( + table.Epoch. + INNER_JOIN(table.Application, + table.Epoch.ApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Epoch.Status.NOT_EQ(enum.EpochStatus.Open)), + ). + ORDER_BY(table.Epoch.Index.DESC()). + LIMIT(1) + + sqlStr, args := stmt.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var ep model.Epoch + err := row.Scan( + &ep.ApplicationID, + &ep.Index, + &ep.FirstBlock, + &ep.LastBlock, + &ep.InputIndexLowerBound, + &ep.InputIndexUpperBound, + &ep.MachineHash, + &ep.TxBufferDataBlock, + &ep.TxBufferProof, + &ep.IflagsYDataBlock, + &ep.IflagsYProof, + &ep.HtifTohostDataBlock, + &ep.HtifTohostProof, + &ep.Commitment, + &ep.CommitmentProof, + &ep.ClaimTransactionHash, + &ep.TournamentAddress, + &ep.Status, + &ep.StagedAtBlock, + &ep.VirtualIndex, + &ep.CreatedAt, + &ep.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &ep, nil +} + +func (r *PostgresRepository) GetEpochByVirtualIndex( + ctx context.Context, + nameOrAddress string, + index uint64, +) (*model.Epoch, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + stmt := table.Epoch. + SELECT( + table.Epoch.ApplicationID, + table.Epoch.Index, + table.Epoch.FirstBlock, + table.Epoch.LastBlock, + table.Epoch.InputIndexLowerBound, + table.Epoch.InputIndexUpperBound, + table.Epoch.MachineHash, + table.Epoch.TxBufferDataBlock, + table.Epoch.TxBufferProof, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, + table.Epoch.Commitment, + table.Epoch.CommitmentProof, + table.Epoch.ClaimTransactionHash, + table.Epoch.TournamentAddress, + table.Epoch.Status, + table.Epoch.StagedAtBlock, + table.Epoch.VirtualIndex, + table.Epoch.CreatedAt, + table.Epoch.UpdatedAt, + ). + FROM( + table.Epoch. + INNER_JOIN(table.Application, + table.Epoch.ApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Epoch.VirtualIndex.EQ(uint64Expr(index))), + ) + + sqlStr, args := stmt.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var ep model.Epoch + err := row.Scan( + &ep.ApplicationID, + &ep.Index, + &ep.FirstBlock, + &ep.LastBlock, + &ep.InputIndexLowerBound, + &ep.InputIndexUpperBound, + &ep.MachineHash, + &ep.TxBufferDataBlock, + &ep.TxBufferProof, + &ep.IflagsYDataBlock, + &ep.IflagsYProof, + &ep.HtifTohostDataBlock, + &ep.HtifTohostProof, + &ep.Commitment, + &ep.CommitmentProof, + &ep.ClaimTransactionHash, + &ep.TournamentAddress, + &ep.Status, + &ep.StagedAtBlock, + &ep.VirtualIndex, + &ep.CreatedAt, + &ep.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &ep, nil +} + +func (r *PostgresRepository) UpdateEpochClaimTransactionHash( + ctx context.Context, + nameOrAddress string, + e *model.Epoch, +) error { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + updStmt := table.Epoch. + UPDATE( + table.Epoch.ClaimTransactionHash, + ). + SET( + e.ClaimTransactionHash, + ). + FROM( + table.Application, + ). + WHERE( + whereClause. + AND(table.Epoch.ApplicationID.EQ(table.Application.ID)). + AND(table.Epoch.Index.EQ(uint64Expr(e.Index))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +func (r *PostgresRepository) UpdateEpochStatus( + ctx context.Context, + nameOrAddress string, + e *model.Epoch, +) error { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + updStmt := table.Epoch. + UPDATE( + table.Epoch.Status, + ). + SET( + e.Status, + ). + FROM( + table.Application, + ). + WHERE( + whereClause. + AND(table.Epoch.ApplicationID.EQ(table.Application.ID)). + AND(table.Epoch.Index.EQ(uint64Expr(e.Index))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return repository.ErrNotFound + } + return nil +} + +func (r *PostgresRepository) UpdateEpochInputsProcessed( + ctx context.Context, + nameOrAddress string, + epochIndex uint64, + proof *model.StateProof, +) error { + if !proof.IsComplete() { + return repository.ErrInvalidStateProof + } + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + // Subquery to check if the previous epoch is not open or closed + prevTable := table.Epoch.AS("prev") + prevSub := prevTable.SELECT( + prevTable.Status.NOT_IN(enum.EpochStatus.Open, enum.EpochStatus.Closed), + ).WHERE(postgres.AND( + prevTable.ApplicationID.EQ(table.Epoch.ApplicationID), + prevTable.Index.EQ(table.Epoch.Index.SUB(postgres.Int64(1))), + )) + + // Condition using COALESCE for the previous epoch (returns TRUE if no previous epoch exists) + prevCondition := postgres.BoolExp(postgres.COALESCE(prevSub, postgres.Bool(true))) + + // Condition for inputs: either no inputs expected or all inputs are present and processed + hasNoInputs := table.Epoch.InputIndexUpperBound.EQ(table.Epoch.InputIndexLowerBound) + + // Subquery to count total inputs for the epoch + totalInputsSub := postgres.FloatExp(table.Input.SELECT(postgres.COUNT(postgres.STAR)). + WHERE(postgres.AND( + table.Input.EpochApplicationID.EQ(table.Epoch.ApplicationID), + table.Input.EpochIndex.EQ(table.Epoch.Index), + ))) + + // Subquery to count pending inputs (status = 'None') + pendingInputsSub := postgres.IntExp(table.Input.SELECT(postgres.COUNT(postgres.STAR)). + WHERE(postgres.AND( + table.Input.EpochApplicationID.EQ(table.Epoch.ApplicationID), + table.Input.EpochIndex.EQ(table.Epoch.Index), + table.Input.Status.EQ(enum.InputCompletionStatus.None), + ))) + + allInputsPresentAndProcessed := postgres.AND( + totalInputsSub.EQ(table.Epoch.InputIndexUpperBound.SUB(table.Epoch.InputIndexLowerBound)), + pendingInputsSub.EQ(postgres.Int64(0)), + ) + + inputsCondition := hasNoInputs.OR(allInputsPresentAndProcessed) + + // Publish the final state proof and status atomically. Readers can never + // observe INPUTS_PROCESSED without all three canonical proof leaves. + updateStmt := table.Epoch.UPDATE( + table.Epoch.Status, + table.Epoch.TxBufferDataBlock, + table.Epoch.TxBufferProof, + table.Epoch.MachineHash, + table.Epoch.IflagsYDataBlock, + table.Epoch.IflagsYProof, + table.Epoch.HtifTohostDataBlock, + table.Epoch.HtifTohostProof, + ). + SET( + enum.EpochStatus.InputsProcessed, + proof.TxBufferDataBlock[:], + encodeSiblings(proof.TxBufferProof), + proof.MachineHash[:], + proof.IflagsYDataBlock[:], + encodeSiblings(proof.IflagsYProof), + proof.HtifTohostDataBlock[:], + encodeSiblings(proof.HtifTohostProof), + ). + FROM(table.Application). + WHERE(postgres.AND( + table.Epoch.Status.EQ(postgres.NewEnumValue(model.EpochStatus_Closed.String())), + table.Epoch.ApplicationID.EQ(table.Application.ID), + table.Application.Status.EQ(enum.ApplicationStatus.Ok), + table.Epoch.Index.EQ(uint64Expr(epochIndex)), + whereClause, + prevCondition, + inputsCondition, + )). + RETURNING(table.Epoch.Index) + + // Execute the update and capture the returned indexes + sqlStr, args := updateStmt.Sql() + + var index uint64 + err := r.db.QueryRow(ctx, sqlStr, args...).Scan(&index) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + statusQuery := table.Epoch.SELECT(table.Epoch.Status).FROM( + table.Epoch.INNER_JOIN(table.Application, table.Epoch.ApplicationID.EQ(table.Application.ID)), + ).WHERE(postgres.AND(whereClause, table.Epoch.Index.EQ(uint64Expr(epochIndex)))) + return classifyEpochPublicationMiss(ctx, r.db, statusQuery) + } + return err + } + if index != epochIndex { + // should not happen + return fmt.Errorf("updated epoch index mismatch: expected %d, got %d", epochIndex, index) + } + return nil +} + +// classifyEpochPublicationMiss distinguishes a completed foreclosure from a +// missing row or another rejected state transition. The caller must select the +// status with the same application and epoch identity as the rejected update. +func classifyEpochPublicationMiss( + ctx context.Context, + queryer interface { + QueryRow(context.Context, string, ...any) pgx.Row + }, + statusQuery postgres.SelectStatement, +) error { + statusSQL, statusArgs := statusQuery.Sql() + row := queryer.QueryRow(ctx, statusSQL, statusArgs...) + var status string + if err := row.Scan(&status); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNoUpdate + } + return fmt.Errorf("reading epoch status after rejected publication: %w", err) + } + if status == model.EpochStatus_ClaimForeclosed.String() { + return repository.ErrEpochForeclosed + } + return repository.ErrNoUpdate +} + +func (r *PostgresRepository) ListEpochs( + ctx context.Context, + nameOrAddress string, + f repository.EpochFilter, + p repository.Pagination, + descending bool, +) ([]*model.Epoch, uint64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + fromClause := table.Epoch. + INNER_JOIN(table.Application, + table.Epoch.ApplicationID.EQ(table.Application.ID), + ) + + conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Epoch.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Epoch.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } + if len(f.Status) > 0 { + statuses := make([]postgres.Expression, 0, len(f.Status)) + for _, status := range f.Status { + statuses = append(statuses, postgres.NewEnumValue(status.String())) + } + conditions = append(conditions, table.Epoch.Status.IN(statuses...)) + } + + if f.BeforeBlock != nil { + conditions = append(conditions, table.Epoch.LastBlock.LT(uint64Expr(*f.BeforeBlock))) + } + if f.HasTournament != nil { + if *f.HasTournament { + conditions = append(conditions, table.Epoch.TournamentAddress.IS_NOT_NULL()) + } else { + conditions = append(conditions, table.Epoch.TournamentAddress.IS_NULL()) + } + } + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + countStmt := table.Epoch.SELECT(postgres.COUNT(postgres.STAR)). + FROM(fromClause).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil { + return nil, 0, err + } + if total == 0 { + return nil, 0, nil + } + + sel := table.Epoch. + SELECT( + table.Epoch.ApplicationID, + table.Epoch.Index, + table.Epoch.FirstBlock, + table.Epoch.LastBlock, + table.Epoch.InputIndexLowerBound, + table.Epoch.InputIndexUpperBound, + table.Epoch.MachineHash, + table.Epoch.TxBufferDataBlock, + table.Epoch.IflagsYDataBlock, + table.Epoch.HtifTohostDataBlock, + table.Epoch.Commitment, + table.Epoch.ClaimTransactionHash, + table.Epoch.TournamentAddress, + table.Epoch.Status, + table.Epoch.StagedAtBlock, + table.Epoch.VirtualIndex, + table.Epoch.CreatedAt, + table.Epoch.UpdatedAt, + ). + FROM(fromClause). + WHERE(postgres.AND(conditions...)) + + if descending { + sel = sel.ORDER_BY(table.Epoch.Index.DESC()) + } else { + sel = sel.ORDER_BY(table.Epoch.Index.ASC()) + } + + // pagination + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var epochs []*model.Epoch + for rows.Next() { + var ep model.Epoch + err := rows.Scan( + &ep.ApplicationID, + &ep.Index, + &ep.FirstBlock, + &ep.LastBlock, + &ep.InputIndexLowerBound, + &ep.InputIndexUpperBound, + &ep.MachineHash, + &ep.TxBufferDataBlock, + &ep.IflagsYDataBlock, + &ep.HtifTohostDataBlock, + &ep.Commitment, + &ep.ClaimTransactionHash, + &ep.TournamentAddress, + &ep.Status, + &ep.StagedAtBlock, + &ep.VirtualIndex, + &ep.CreatedAt, + &ep.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + epochs = append(epochs, &ep) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return epochs, total, nil +} diff --git a/internal/repository/postgres/epoch_index_test.go b/internal/repository/postgres/epoch_index_test.go new file mode 100644 index 000000000..ef4b6cce6 --- /dev/null +++ b/internal/repository/postgres/epoch_index_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" +) + +func TestGetLastNonOpenEpochIndex(t *testing.T) { + r := newEpochPublicationRepository(t) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(t.Context(), t, r) + other := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(t.Context(), t, r) + check := func(want *uint64) { + t.Helper() + for _, identifier := range []string{app.Name, app.IApplicationAddress.Hex()} { + got, err := r.GetLastNonOpenEpochIndex(t.Context(), identifier) + require.NoError(t, err) + require.Equal(t, want, got) + } + } + store := func(app *model.Application, index uint64, status model.EpochStatus) { + t.Helper() + initialStatus := status + if status == model.EpochStatus_ClaimAccepted || status == model.EpochStatus_ClaimStaged { + initialStatus = model.EpochStatus_Closed + } + epoch := repotest.NewEpochBuilder(app.ID).WithIndex(index).WithStatus(initialStatus).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*model.Epoch][]*model.Input{epoch: {}}, 100)) + if status == model.EpochStatus_ClaimStaged { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, model.EpochStatus_ClaimComputed) + require.NoError(t, r.UpdateEpochReconciledStaged(t.Context(), app.ID, index, 100)) + } else if initialStatus != status { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, status) + } + } + + check(nil) + store(app, 10, model.EpochStatus_Open) + store(other, 50, model.EpochStatus_Closed) + check(nil) // Neither an open epoch nor another application's epoch is a boundary. + store(app, 0, model.EpochStatus_Closed) + check(new(uint64(0))) + store(app, 3, model.EpochStatus_ClaimAccepted) + check(new(uint64(3))) + store(app, 2, model.EpochStatus_ClaimStaged) + check(new(uint64(3))) // Insertion order must not determine the boundary. + store(app, 4, model.EpochStatus_ClaimForeclosed) + check(new(uint64(4))) +} diff --git a/internal/repository/postgres/epoch_machine_publication_plan_test.go b/internal/repository/postgres/epoch_machine_publication_plan_test.go new file mode 100644 index 000000000..dc1e82471 --- /dev/null +++ b/internal/repository/postgres/epoch_machine_publication_plan_test.go @@ -0,0 +1,155 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +const ( + epochCustomPlanMode = "force_custom_plan" + epochGenericPlanMode = "force_generic_plan" +) + +type epochQueryPlan struct { + NodeType string `json:"Node Type"` + IndexName string `json:"Index Name"` + Rows float64 `json:"Actual Rows"` + Loops float64 `json:"Actual Loops"` + Filtered float64 `json:"Rows Removed by Filter"` + JoinRemoved float64 `json:"Rows Removed by Join Filter"` + HitBlocks float64 `json:"Shared Hit Blocks"` + ReadBlocks float64 `json:"Shared Read Blocks"` + Plans []epochQueryPlan `json:"Plans"` +} + +// Count successful scan/sort input as well as rejected rows, including repeated +// subplans. A bitmap scan feeding every claim to a Sort must not pass this gate. +func (p epochQueryPlan) rowWork() float64 { + total := (p.Rows + p.Filtered + p.JoinRemoved) * p.Loops + for _, child := range p.Plans { + total += child.rowWork() + } + return total +} + +func requireBoundedEpochPlan(t *testing.T, tx pgx.Tx, execute string) { + t.Helper() + var raw []byte + require.NoError(t, tx.QueryRow(t.Context(), "EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) "+execute).Scan(&raw)) + var plans []struct{ Plan epochQueryPlan } + require.NoError(t, json.Unmarshal(raw, &plans)) + require.Len(t, plans, 1) + plan := plans[0].Plan + require.Less(t, plan.rowWork(), float64(256), "bounded rows for %s: %s", execute, raw) + require.Less(t, plan.HitBlocks+plan.ReadBlocks, float64(256), "bounded buffers for %s: %s", execute, raw) + t.Logf("%s rows=%g buffers=%g plan=%s", execute, plan.rowWork(), plan.HitBlocks+plan.ReadBlocks, raw) +} + +func TestMachinePublicationGateWithSharedEpochBacklogs(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + published, epoch, proof := machinePublicationEpoch(t, r, false) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), published.Name, epoch.Index, proof)) + terminal := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + closed := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + for _, app := range []*model.Application{published, terminal, closed} { + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 400000, repotest.UniqueHash(), 400000)) + } + _, err := r.db.Exec(t.Context(), `INSERT INTO epoch(application_id,index,virtual_index, + first_block,last_block,input_index_lower_bound,input_index_upper_bound,machine_hash,tx_buffer_data_block, + tx_buffer_proof,iflags_y_data_block,iflags_y_proof,htif_tohost_data_block,htif_tohost_proof,status) + SELECT application_id,i,i,i*10,i*10+9,i,i+1,machine_hash,tx_buffer_data_block,tx_buffer_proof, + iflags_y_data_block,iflags_y_proof,htif_tohost_data_block,htif_tohost_proof,'INPUTS_PROCESSED' + FROM epoch CROSS JOIN generate_series(100,20099) i WHERE application_id=$1 AND index=4`, published.ID) + require.NoError(t, err) + for _, app := range []*model.Application{terminal, closed} { + status := model.EpochStatus_ClaimForeclosed + if app == closed { + status = model.EpochStatus_Closed + } + _, err = r.db.Exec(t.Context(), `INSERT INTO epoch(application_id,index,virtual_index, + first_block,last_block,input_index_lower_bound,input_index_upper_bound,status) + SELECT $1,i,i,i*10,i*10+9,i,i+1,$2::"EpochStatus" FROM generate_series(100,20099) i`, app.ID, status.String()) + require.NoError(t, err) + } + // A caught-up A/Q layout can retain one OPEN epoch at the foreclosure + // boundary. It must not force a walk over all earlier durable IP rows. + _, err = r.db.Exec(t.Context(), `INSERT INTO epoch(application_id,index,virtual_index, + first_block,last_block,input_index_lower_bound,input_index_upper_bound,status) + VALUES ($1,40000,40000,400000,400009,20001,20002,'OPEN')`, published.ID) + require.NoError(t, err) + for range 100 { + peer := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + _, err = r.db.Exec(t.Context(), `INSERT INTO epoch(application_id,index,virtual_index, + first_block,last_block,input_index_lower_bound,input_index_upper_bound,status) + SELECT $1,i,i,i*10,i*10+9,i,i+1,'CLAIM_FORECLOSED' FROM generate_series(0,99) i`, peer.ID) + require.NoError(t, err) + } + _, err = r.db.Exec(t.Context(), "ANALYZE epoch; ANALYZE application") + require.NoError(t, err) + wantPresence := make(map[int64]bool, 3) + for _, app := range []*model.Application{published, terminal, closed} { + got, lookupErr := r.HasUnpublishedEpochBeforeBlock(t.Context(), app.ID, 400000) + require.NoError(t, lookupErr) + require.Equal(t, app == closed, got) + wantPresence[app.ID] = got + } + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + // Prepare one shape; each application supplies its actual Sql() arguments below. + query, _ := unpublishedEpochBeforeBlockSQL(published.ID, 400000) + _, err = tx.Exec(t.Context(), "PREPARE machine_publication AS "+query) + require.NoError(t, err) + defer func() { + _, cleanupErr := tx.Exec(t.Context(), "DEALLOCATE machine_publication") + require.NoError(t, cleanupErr) + }() + for _, mode := range []string{epochCustomPlanMode, epochGenericPlanMode} { + _, err = tx.Exec(t.Context(), "SET LOCAL plan_cache_mode="+mode) + require.NoError(t, err) + for _, app := range []*model.Application{published, terminal, closed} { + appQuery, args := unpublishedEpochBeforeBlockSQL(app.ID, 400000) + require.Equal(t, query, appQuery, "prepared shape must match the real query") + execute := machinePublicationExecute(t, args) + t.Run(mode+"/"+app.Name, func(t *testing.T) { + rows, queryErr := tx.Query(t.Context(), execute) + require.NoError(t, queryErr) + present := rows.Next() + rows.Close() + require.NoError(t, rows.Err()) + require.Equal(t, wantPresence[app.ID], present, "EXPLAIN must measure the real publication predicate") + requireBoundedEpochPlan(t, tx, execute) + }) + } + } +} + +// These are the parameter types emitted by unpublishedEpochBeforeBlockSQL. +// Values and their order come from Sql(), including after harmless query edits. +func machinePublicationExecute(t *testing.T, args []any) string { + t.Helper() + values := make([]string, len(args)) + for i, arg := range args { + switch value := arg.(type) { + case int64: + values[i] = strconv.FormatInt(value, 10) + case bool: + values[i] = strconv.FormatBool(value) + case string: + values[i] = "E'" + strings.NewReplacer("\\", "\\\\", "'", "''").Replace(value) + "'" + default: + t.Fatalf("unexpected publication parameter type %T", arg) + } + } + return "EXECUTE machine_publication(" + strings.Join(values, ",") + ")" +} diff --git a/internal/repository/postgres/epoch_machine_publication_test.go b/internal/repository/postgres/epoch_machine_publication_test.go new file mode 100644 index 000000000..64a70da71 --- /dev/null +++ b/internal/repository/postgres/epoch_machine_publication_test.go @@ -0,0 +1,100 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func machinePublicationEpoch(t *testing.T, r *PostgresRepository, + foreclosed bool, +) (*model.Application, *model.Epoch, *model.StateProof) { + t.Helper() + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_Authority).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithIndex(4).WithBlocks(40, 49). + WithStatus(model.EpochStatus_Closed).WithInputBounds(0, 1).Build() + input := repotest.NewInputBuilder().WithIndex(0).WithBlockNumber(45).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*model.Epoch][]*model.Input{epoch: {input}}, 80)) + proof := repotest.DummyStateProof() + require.NoError(t, r.StoreAdvanceResult(t.Context(), app.ID, &model.AdvanceResult{ + EpochIndex: epoch.Index, InputIndex: 0, Status: model.InputCompletionStatus_Accepted, StateProof: *proof, + })) + if foreclosed { + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 80, repotest.UniqueHash(), 80)) + } + return app, epoch, proof +} + +func TestMachinePublicationGateRetainsAfterLastAcceptedInputUntilIP(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, true) + undrained, err := r.HasUndrainedEpochsBeforeBlock(t.Context(), app.ID, 80) + require.NoError(t, err) + require.False(t, undrained, "all inputs are accepted, but the final epoch proof is not published yet") + unpublished, err := r.HasUnpublishedEpochBeforeBlock(t.Context(), app.ID, 80) + require.NoError(t, err) + require.True(t, unpublished, "CLOSED -> IP still needs the resident machine") + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + unpublished, err = r.HasUnpublishedEpochBeforeBlock(t.Context(), app.ID, 80) + require.NoError(t, err) + require.False(t, unpublished, "IP validates from durable proof without retaining the machine") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = r.HasUnpublishedEpochBeforeBlock(ctx, app.ID, 80) + require.ErrorIs(t, err, context.Canceled, "lookup failure is not evidence that publication is complete") +} + +func TestMachinePublicationGateRequiresAuthorizedCompleteAQEpoch(t *testing.T) { + const computedCase = "computed" + for _, test := range []struct { + name string + want bool + }{ + {"authority", true}, {"quorum", true}, {string(model.Consensus_PRT), false}, {"disabled", false}, + {string(model.ApplicationStatus_Failed), false}, {string(model.ApplicationStatus_Diverged), false}, + {string(model.ApplicationStatus_Corrupted), false}, + {string(model.ApplicationStatus_MachineHalted), false}, {string(model.ApplicationStatus_InvalidOutputsRoot), false}, + {"straddling foreclosure", false}, {"caller bound before epoch end", false}, + {"IP", false}, {computedCase, false}, {"not foreclosed", false}, + } { + t.Run(test.name, func(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch, proof := machinePublicationEpoch(t, r, test.name != "not foreclosed") + bound := uint64(80) + switch test.name { + case "quorum", string(model.Consensus_PRT): + app.ConsensusType = model.Consensus_Quorum + if test.name == string(model.Consensus_PRT) { + app.ConsensusType = model.Consensus_PRT + } + require.NoError(t, r.UpdateApplication(t.Context(), app)) + case "disabled": + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + case string(model.ApplicationStatus_Failed), string(model.ApplicationStatus_Diverged), + string(model.ApplicationStatus_Corrupted), string(model.ApplicationStatus_MachineHalted), + string(model.ApplicationStatus_InvalidOutputsRoot): + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, + model.ApplicationStatus(test.name), new("producer stopped"))) + case "straddling foreclosure": + _, err := r.db.Exec(t.Context(), `UPDATE application SET foreclose_block=49 WHERE id=$1`, app.ID) + require.NoError(t, err) + case "caller bound before epoch end": + bound = 49 + case "IP", computedCase: + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof)) + if test.name == computedCase { + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + } + } + got, err := r.HasUnpublishedEpochBeforeBlock(t.Context(), app.ID, bound) + require.NoError(t, err) + require.Equal(t, test.want, got) + }) + } +} diff --git a/internal/repository/postgres/epoch_publication_foreclosure_test.go b/internal/repository/postgres/epoch_publication_foreclosure_test.go new file mode 100644 index 000000000..9ec8c81cc --- /dev/null +++ b/internal/repository/postgres/epoch_publication_foreclosure_test.go @@ -0,0 +1,175 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func newEpochPublicationRepository(t *testing.T) repository.Repository { + t.Helper() + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + repo, err := NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + return repo +} + +func seedEpochPublication(t *testing.T, repo repository.Repository, outputs ...[]byte) (*Application, *Epoch) { + t.Helper() + if len(outputs) == 0 { + outputs = [][]byte{[]byte("output")} + } + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, repo) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 1).Build() + input := repotest.NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + require.NoError(t, repo.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {input}}, 10)) + require.NoError(t, repo.StoreAdvanceResult(t.Context(), app.ID, &AdvanceResult{ + EpochIndex: epoch.Index, InputIndex: input.Index, Status: InputCompletionStatus_Accepted, + StateProof: *repotest.DummyStateProof(), Outputs: outputs, + })) + return app, epoch +} + +func TestEpochPublicationLosesToForeclosureWithoutWrites(t *testing.T) { + for _, claimPublication := range []bool{false, true} { + name := "closed state proof" + if claimPublication { + name = "inputs processed claim" + } + t.Run(name, func(t *testing.T) { + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + if claimPublication { + require.NoError(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + } + // The publisher holds this snapshot before PRT wins the terminal transition. + stale, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NoError(t, repo.UpdateApplicationForeclosure(t.Context(), app.ID, epoch.LastBlock, repotest.UniqueHash(), 100)) + require.NoError(t, repo.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + before, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + outputsBefore, _, err := repo.ListOutputs(t.Context(), app.Name, repository.OutputFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Len(t, outputsBefore, 1) + + if claimPublication { + stale.Commitment = new(repotest.UniqueHash()) + stale.CommitmentProof = make([]common.Hash, Log2EpochComputationHashLeafCount) + output := *outputsBefore[0] + output.Hash = new(repotest.UniqueHash()) + output.OutputHashesSiblings = []common.Hash{repotest.UniqueHash()} + called := false + err = repo.StoreClaimAndProofs(t.Context(), stale, func(ctx context.Context, writer repository.ClaimProofWriter) error { + called = true + return repotest.WriteProofs(&output)(ctx, writer) + }) + require.False(t, called, "a rejected epoch must not invoke its proof writer") + } else { + proof := repotest.DummyStateProof() + proof.MachineHash = repotest.UniqueHash() + proof.TxBufferDataBlock = repotest.UniqueHash() + err = repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, proof) + } + require.ErrorIs(t, err, repository.ErrEpochForeclosed) + after, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, before, after, "a rejected publication must preserve status, proof fields, and timestamps") + outputsAfter, _, err := repo.ListOutputs(t.Context(), app.Name, repository.OutputFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, outputsBefore, outputsAfter, "claim rejection must occur before any output-proof write") + require.ErrorIs(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, &StateProof{}), + repository.ErrInvalidStateProof, "foreclosure must not bypass proof completeness checks") + }) + } +} + +func TestEpochPublicationDoesNotMisclassifyOtherFailures(t *testing.T) { + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + foreclosedApp, foreclosedEpoch := seedEpochPublication(t, repo) + require.NoError(t, repo.UpdateApplicationForeclosure(t.Context(), foreclosedApp.ID, 100, repotest.UniqueHash(), 100)) + require.NoError(t, repo.UpdateEpochWithForeclosedClaim(t.Context(), foreclosedApp.ID, foreclosedEpoch.Index)) + for _, test := range []struct { + name string + appID int64 + appName string + epoch uint64 + proofOnly bool + }{ + {name: "claim closed epoch", appID: app.ID, epoch: epoch.Index}, + {name: "claim missing epoch", appID: foreclosedApp.ID, epoch: 99}, + {name: "claim missing application", appID: -1, epoch: epoch.Index}, + {name: "proof missing epoch", appName: foreclosedApp.Name, epoch: 99, proofOnly: true}, + {name: "proof missing application", appName: "missing", epoch: epoch.Index, proofOnly: true}, + } { + t.Run(test.name, func(t *testing.T) { + var err error + if test.proofOnly { + err = repo.UpdateEpochInputsProcessed(t.Context(), test.appName, test.epoch, repotest.DummyStateProof()) + } else { + err = repo.StoreClaimAndProofs(t.Context(), &Epoch{ApplicationID: test.appID, Index: test.epoch}, nil) + } + require.ErrorIs(t, err, repository.ErrNoUpdate) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + }) + } + + // Normal publication still works in the presence of another foreclosed app. + require.NoError(t, repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof())) + err := repo.UpdateEpochInputsProcessed(t.Context(), app.Name, epoch.Index, repotest.DummyStateProof()) + require.ErrorIs(t, err, repository.ErrNoUpdate, "an already published proof is not foreclosure") + computed, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + computed.Commitment, computed.CommitmentProof = new(repotest.UniqueHash()), make([]common.Hash, Log2EpochComputationHashLeafCount) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), computed, nil)) + stored, err := repo.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, EpochStatus_ClaimComputed, stored.Status) + require.Equal(t, computed.Commitment, stored.Commitment) + require.Equal(t, computed.CommitmentProof, stored.CommitmentProof) + + canceled, cancel := context.WithCancel(t.Context()) + cancel() + for _, err := range []error{ + repo.UpdateEpochInputsProcessed(canceled, app.Name, epoch.Index, repotest.DummyStateProof()), + repo.StoreClaimAndProofs(canceled, computed, nil), + } { + require.ErrorIs(t, err, context.Canceled) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + } +} + +type failedPublicationStatusRow struct{ err error } + +func (r failedPublicationStatusRow) Scan(...any) error { return r.err } + +func (r failedPublicationStatusRow) QueryRow(context.Context, string, ...any) pgx.Row { return r } + +func TestEpochPublicationStatusReadFailure(t *testing.T) { + failure := errors.New("status read failed") + query := table.Epoch.SELECT(table.Epoch.Status) + err := classifyEpochPublicationMiss(t.Context(), failedPublicationStatusRow{err: failure}, query) + require.ErrorIs(t, err, failure) + require.NotErrorIs(t, err, repository.ErrEpochForeclosed) + require.ErrorIs(t, + classifyEpochPublicationMiss(t.Context(), failedPublicationStatusRow{err: pgx.ErrNoRows}, query), repository.ErrNoUpdate) +} diff --git a/internal/repository/postgres/epoch_staging_refresh_test.go b/internal/repository/postgres/epoch_staging_refresh_test.go new file mode 100644 index 000000000..e22a79eef --- /dev/null +++ b/internal/repository/postgres/epoch_staging_refresh_test.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "strconv" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/require" +) + +func TestEpochStagingBlockRefreshPreservesClaimAndUsesCAS(t *testing.T) { + for _, block := range []uint64{3, 9} { + t.Run(strconv.FormatUint(block, 10), func(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_PRT, model.EpochStatus_ClaimStaged) + before, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.NoError(t, r.UpdateEpochStagingBlock(t.Context(), app.ID, epoch.Index, 4, block)) + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + want := *before + want.StagedAtBlock, want.UpdatedAt = new(block), stored.UpdatedAt + require.Equal(t, want, *stored, "refresh changes only the staging block and update timestamp") + require.ErrorIs(t, r.UpdateEpochStagingBlock(t.Context(), app.ID, epoch.Index, 4, 12), repository.ErrNoUpdate) + require.ErrorIs(t, r.UpdateEpochStagingBlock(t.Context(), app.ID+1, epoch.Index, block, 12), repository.ErrNoUpdate) + require.ErrorIs(t, r.UpdateEpochStagingBlock(t.Context(), app.ID, epoch.Index+1, block, 12), repository.ErrNoUpdate) + require.NoError(t, r.UpdateEpochWithAcceptedClaim(t.Context(), app.ID, epoch.Index, nil)) + require.ErrorIs(t, r.UpdateEpochStagingBlock(t.Context(), app.ID, epoch.Index, block, 12), repository.ErrNoUpdate) + stored, err = r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimAccepted, stored.Status) + require.Equal(t, new(block), stored.StagedAtBlock) + }) + } +} + +func TestEpochStagingBlockConcurrentRefreshHasOneWinner(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epoch := seedForeclosureStatus(t, r, model.Consensus_PRT, model.EpochStatus_ClaimStaged) + start := make(chan struct{}) + results := make(chan error, 2) + for _, block := range []uint64{7, 9} { + go func() { + <-start + results <- r.UpdateEpochStagingBlock(t.Context(), app.ID, epoch.Index, 4, block) + }() + } + close(start) + one, two := <-results, <-results + require.True(t, (one == nil) != (two == nil), "one stored staging observation must win: %v / %v", one, two) + if one == nil { + require.ErrorIs(t, two, repository.ErrNoUpdate) + } else { + require.ErrorIs(t, one, repository.ErrNoUpdate) + } + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Contains(t, []uint64{7, 9}, *stored.StagedAtBlock) +} diff --git a/internal/repository/postgres/epoch_unreconciled_test.go b/internal/repository/postgres/epoch_unreconciled_test.go new file mode 100644 index 000000000..111dd5895 --- /dev/null +++ b/internal/repository/postgres/epoch_unreconciled_test.go @@ -0,0 +1,107 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "fmt" + "slices" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func TestUnreconciledIndexMatchesNonTerminalEpochStatuses(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + var predicate string + err := r.db.QueryRow(t.Context(), ` + SELECT pg_get_expr(indpred, indrelid) FROM pg_index + WHERE indexrelid = 'epoch_unreconciled_idx'::regclass`).Scan(&predicate) + require.NoError(t, err) + require.NotEmpty(t, predicate) + for _, status := range EpochStatusAllValues { + // Evaluate the actual migrated predicate, not a copied status list. + query := fmt.Sprintf(`SELECT %s FROM (SELECT $1::"EpochStatus" AS status) AS epoch`, predicate) + var indexed bool + require.NoError(t, r.db.QueryRow(t.Context(), query, status.String()).Scan(&indexed)) + require.Equal(t, slices.Contains(NonTerminalEpochStatuses(), status), indexed, status) + } +} + +func TestForeclosureQueriesUseNonTerminalEpochStatuses(t *testing.T) { + r := newEpochPublicationRepository(t) + for _, action := range []struct { + name string + consensus Consensus + bulk bool + }{ + {name: "single Authority", consensus: Consensus_Authority}, + {name: "single PRT", consensus: Consensus_PRT}, + {name: "bulk Authority", consensus: Consensus_Authority, bulk: true}, + {name: "bulk Quorum", consensus: Consensus_Quorum, bulk: true}, + {name: "bulk excludes PRT", consensus: Consensus_PRT, bulk: true}, + } { + for _, status := range EpochStatusAllValues { + t.Run(action.name+"/"+status.String(), func(t *testing.T) { + app, epoch := seedForeclosureStatus(t, r, action.consensus, status) + nonTerminal := slices.Contains(NonTerminalEpochStatuses(), status) + pending, err := r.HasUnreconciledClaimsBeforeBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + require.Equal(t, nonTerminal, pending) + canForeclose := nonTerminal && (!action.bulk || action.consensus != Consensus_PRT) + if action.bulk { + count, err := r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + if canForeclose { + require.Equal(t, int64(1), count) + } else { + require.Zero(t, count) + } + } else { + err := r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index) + if canForeclose { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, repository.ErrNoUpdate) + } + } + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + wantStatus := status + if canForeclose { + wantStatus = EpochStatus_ClaimForeclosed + } + require.Equal(t, wantStatus, stored.Status) + pending, err = r.HasUnreconciledClaimsBeforeBlock(t.Context(), app.ID, app.ForecloseBlock) + require.NoError(t, err) + require.Equal(t, nonTerminal && !canForeclose, pending) + }) + } + } +} + +func seedForeclosureStatus(t *testing.T, r repository.Repository, consensus Consensus, status EpochStatus) (*Application, *Epoch) { + t.Helper() + app := repotest.NewApplicationBuilder().WithConsensus(consensus).Create(t.Context(), t, r) + initial := EpochStatus_Closed + if status == EpochStatus_Open { + initial = status + } + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(initial).WithBlocks(0, 9).WithInputBounds(0, 0).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, 10)) + if status == EpochStatus_ClaimStaged { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, EpochStatus_ClaimSubmitted) + require.NoError(t, r.UpdateEpochToStaged(t.Context(), app.ID, epoch.Index, 4)) + } else if status != initial && status != EpochStatus_ClaimForeclosed { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, status) + } + app.ForecloseBlock = 5 + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, app.ForecloseBlock, repotest.UniqueHash(), 10)) + if status == EpochStatus_ClaimForeclosed { + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, epoch.Index)) + } + return app, epoch +} diff --git a/internal/repository/postgres/event_column_test.go b/internal/repository/postgres/event_column_test.go new file mode 100644 index 000000000..8fdbf61b7 --- /dev/null +++ b/internal/repository/postgres/event_column_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func TestGetColumnForEvent(t *testing.T) { + for _, test := range []struct { + event MonitoredEvent + column postgres.ColumnFloat + }{ + {MonitoredEvent_EpochSealed, table.Application.LastEpochCheckBlock}, + {MonitoredEvent_InputAdded, table.Application.LastInputCheckBlock}, + {MonitoredEvent_OutputExecuted, table.Application.LastOutputCheckBlock}, + {MonitoredEvent_CommitmentJoined, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchAdvanced, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchCreated, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_MatchDeleted, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_NewInnerTournament, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_LeafMatchSealed, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_PartialBondRefund, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_BondRecovered, table.Application.LastTournamentCheckBlock}, + {MonitoredEvent_ClaimSubmitted, nil}, + {MonitoredEvent_ClaimAccepted, table.Application.LastClaimCheckBlock}, + {MonitoredEvent_Foreclosure, nil}, + {MonitoredEvent_Withdrawal, nil}, + {MonitoredEvent_AccountsDriveMerkleRootProved, nil}, + {MonitoredEvent("unknown"), nil}, + } { + t.Run(test.event.String(), func(t *testing.T) { + column, err := getColumnForEvent(test.event) + if test.column == nil { + require.EqualError(t, err, "invalid monitored event type: "+test.event.String()) + require.Nil(t, column) + } else { + require.NoError(t, err) + require.Equal(t, test.column, column) + } + }) + } +} diff --git a/internal/repository/postgres/input.go b/internal/repository/postgres/input.go new file mode 100644 index 000000000..b330e7ae7 --- /dev/null +++ b/internal/repository/postgres/input.go @@ -0,0 +1,393 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" +) + +func (r *PostgresRepository) GetInput( + ctx context.Context, + nameOrAddress string, + inputIndex uint64, +) (*model.Input, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Input. + SELECT( + table.Input.EpochApplicationID, + table.Input.EpochIndex, + table.Input.Index, + table.Input.BlockNumber, + table.Input.RawData, + table.Input.Status, + table.Input.ExceptionData, + table.Input.MachineHash, + table.Input.TxBufferDataBlock, + table.Input.TransactionHash, + table.Input.LogIndex, + table.Input.SnapshotURI, + table.Input.CreatedAt, + table.Input.UpdatedAt, + ). + FROM( + table.Input. + INNER_JOIN(table.Application, + table.Input.EpochApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Input.Index.EQ(uint64Expr(inputIndex))), + ) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var inp model.Input + err := row.Scan( + &inp.EpochApplicationID, + &inp.EpochIndex, + &inp.Index, + &inp.BlockNumber, + &inp.RawData, + &inp.Status, + &inp.ExceptionData, + &inp.MachineHash, + &inp.TxBufferDataBlock, + &inp.TransactionHash, + &inp.LogIndex, + &inp.SnapshotURI, + &inp.CreatedAt, + &inp.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &inp, nil +} + +func (r *PostgresRepository) GetLastInput( + ctx context.Context, + nameOrAddress string, + epochIndex uint64, +) (*model.Input, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Input. + SELECT( + table.Input.EpochApplicationID, + table.Input.EpochIndex, + table.Input.Index, + table.Input.BlockNumber, + table.Input.RawData, + table.Input.Status, + table.Input.ExceptionData, + table.Input.MachineHash, + table.Input.TxBufferDataBlock, + table.Input.TransactionHash, + table.Input.LogIndex, + table.Input.SnapshotURI, + table.Input.CreatedAt, + table.Input.UpdatedAt, + ). + FROM( + table.Input. + INNER_JOIN(table.Application, + table.Input.EpochApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Input.EpochIndex.EQ(uint64Expr(epochIndex))), + ). + ORDER_BY(table.Input.Index.DESC()). + LIMIT(1) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var inp model.Input + err := row.Scan( + &inp.EpochApplicationID, + &inp.EpochIndex, + &inp.Index, + &inp.BlockNumber, + &inp.RawData, + &inp.Status, + &inp.ExceptionData, + &inp.MachineHash, + &inp.TxBufferDataBlock, + &inp.TransactionHash, + &inp.LogIndex, + &inp.SnapshotURI, + &inp.CreatedAt, + &inp.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &inp, nil +} + +func (r *PostgresRepository) GetLastProcessedInput( + ctx context.Context, + nameOrAddress string, +) (*model.Input, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Input. + SELECT( + table.Input.EpochApplicationID, + table.Input.EpochIndex, + table.Input.Index, + table.Input.BlockNumber, + table.Input.RawData, + table.Input.Status, + table.Input.ExceptionData, + table.Input.MachineHash, + table.Input.TxBufferDataBlock, + table.Input.TransactionHash, + table.Input.LogIndex, + table.Input.SnapshotURI, + table.Input.CreatedAt, + table.Input.UpdatedAt, + ). + FROM( + table.Input. + INNER_JOIN(table.Application, + table.Input.EpochApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Input.Status.NOT_EQ(postgres.NewEnumValue(model.InputCompletionStatus_None.String()))), + ). + ORDER_BY(table.Input.Index.DESC()). + LIMIT(1) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var inp model.Input + err := row.Scan( + &inp.EpochApplicationID, + &inp.EpochIndex, + &inp.Index, + &inp.BlockNumber, + &inp.RawData, + &inp.Status, + &inp.ExceptionData, + &inp.MachineHash, + &inp.TxBufferDataBlock, + &inp.TransactionHash, + &inp.LogIndex, + &inp.SnapshotURI, + &inp.CreatedAt, + &inp.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &inp, nil +} + +func (r *PostgresRepository) ListInputs( + ctx context.Context, + nameOrAddress string, + f repository.InputFilter, + p repository.Pagination, + descending bool, +) ([]*model.Input, uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + fromClause := table.Input. + INNER_JOIN(table.Application, + table.Input.EpochApplicationID.EQ(table.Application.ID), + ) + + conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Input.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Input.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } + if f.EpochIndex != nil { + conditions = append(conditions, table.Input.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + if f.Status != nil { + conditions = append(conditions, table.Input.Status.EQ(postgres.NewEnumValue(f.Status.String()))) + } + if f.NotStatus != nil { + conditions = append(conditions, table.Input.Status.NOT_EQ(postgres.NewEnumValue(f.NotStatus.String()))) + } + if f.Sender != nil { + conditions = append(conditions, + SubstrBytea(table.Input.RawData, 81, 20).EQ(postgres.Bytea(f.Sender.Bytes())), + ) + } + if f.TransactionHash != nil { + conditions = append(conditions, + table.Input.TransactionHash.EQ(postgres.Bytea(f.TransactionHash.Bytes())), + ) + } + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + countStmt := table.Input.SELECT(postgres.COUNT(postgres.STAR)). + FROM(fromClause).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil { + return nil, 0, err + } + if total == 0 { + return nil, 0, nil + } + + sel := table.Input. + SELECT( + table.Input.EpochApplicationID, + table.Input.EpochIndex, + table.Input.Index, + table.Input.BlockNumber, + table.Input.RawData, + table.Input.Status, + table.Input.ExceptionData, + table.Input.MachineHash, + table.Input.TxBufferDataBlock, + table.Input.TransactionHash, + table.Input.LogIndex, + table.Input.SnapshotURI, + table.Input.CreatedAt, + table.Input.UpdatedAt, + ). + FROM(fromClause). + WHERE(postgres.AND(conditions...)) + + if descending { + sel = sel.ORDER_BY(table.Input.Index.DESC()) + } else { + sel = sel.ORDER_BY(table.Input.Index.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var inputs []*model.Input + for rows.Next() { + var in model.Input + err := rows.Scan( + &in.EpochApplicationID, + &in.EpochIndex, + &in.Index, + &in.BlockNumber, + &in.RawData, + &in.Status, + &in.ExceptionData, + &in.MachineHash, + &in.TxBufferDataBlock, + &in.TransactionHash, + &in.LogIndex, + &in.SnapshotURI, + &in.CreatedAt, + &in.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + inputs = append(inputs, &in) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return inputs, total, nil +} + +func (r *PostgresRepository) GetNumberOfInputs( + ctx context.Context, + nameOrAddress string, +) (uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Input. + SELECT(postgres.COUNT(postgres.STAR)). + FROM( + table.Input. + INNER_JOIN(table.Application, + table.Input.EpochApplicationID.EQ(table.Application.ID), + ), + ). + WHERE(whereClause) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var count uint64 + err := row.Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func (r *PostgresRepository) UpdateInputSnapshotURI(ctx context.Context, appId int64, inputIndex uint64, snapshotURI string) error { + updStmt := table.Input. + UPDATE( + table.Input.SnapshotURI, + ). + SET( + snapshotURI, + ). + WHERE( + table.Input.EpochApplicationID.EQ(postgres.Int64(appId)). + AND(table.Input.Index.EQ(uint64Expr(inputIndex))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return fmt.Errorf("no input found with appId %d and index %d", appId, inputIndex) + } + return nil +} diff --git a/internal/repository/postgres/input_exception_data_test.go b/internal/repository/postgres/input_exception_data_test.go new file mode 100644 index 000000000..8030f1d59 --- /dev/null +++ b/internal/repository/postgres/input_exception_data_test.go @@ -0,0 +1,83 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres_test + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func TestPostgresInputExceptionDataContract(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + + ctx := context.Background() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(ctx)) }) + + seed := repotest.Seed(ctx, t, repo) + machineHash := repotest.UniqueHash() + txBufferDataBlock := repotest.UniqueHash() + + _, err = conn.Exec(ctx, ` + UPDATE input + SET status = 'EXCEPTION', machine_hash = $2, tx_buffer_data_block = $3 + WHERE epoch_application_id = $1 AND index = 0`, + seed.App.ID, machineHash[:], txBufferDataBlock[:], + ) + requirePostgresConstraint(t, err, "input_exception_data_check") + + _, err = conn.Exec(ctx, ` + UPDATE input + SET status = 'ACCEPTED', exception_data = '\x01', machine_hash = $2, tx_buffer_data_block = $3 + WHERE epoch_application_id = $1 AND index = 0`, + seed.App.ID, machineHash[:], txBufferDataBlock[:], + ) + requirePostgresConstraint(t, err, "input_exception_data_check") + + proof := repotest.DummyStateProof() + proof.MachineHash = machineHash + proof.TxBufferDataBlock = txBufferDataBlock + require.NoError(t, repo.StoreAdvanceResult(ctx, seed.App.ID, &model.AdvanceResult{ + EpochIndex: seed.Epoch.Index, + InputIndex: seed.Input.Index, + Status: model.InputCompletionStatus_Exception, + ExceptionData: []byte{}, + StateProof: *proof, + })) + completed, err := repo.GetInput(ctx, seed.App.IApplicationAddress.String(), seed.Input.Index) + require.NoError(t, err) + require.NotNil(t, completed.ExceptionData) + require.Empty(t, completed.ExceptionData) + + _, err = conn.Exec(ctx, ` + UPDATE input SET exception_data = '\x02' + WHERE epoch_application_id = $1 AND index = 0`, seed.App.ID) + require.ErrorContains(t, err, "completed input result is immutable") + + _, err = conn.Exec(ctx, ` + UPDATE input SET raw_data = '\x03' + WHERE epoch_application_id = $1 AND index = 0`, seed.App.ID) + require.ErrorContains(t, err, "completed input result is immutable") + + _, err = conn.Exec(ctx, ` + UPDATE input SET snapshot_uri = '/tmp/snapshot' + WHERE epoch_application_id = $1 AND index = 0`, seed.App.ID) + require.NoError(t, err, "snapshot metadata remains mutable after completion") +} diff --git a/internal/repository/postgres/invalid_outputs_root_test.go b/internal/repository/postgres/invalid_outputs_root_test.go new file mode 100644 index 000000000..bcfe2f22b --- /dev/null +++ b/internal/repository/postgres/invalid_outputs_root_test.go @@ -0,0 +1,109 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres_test + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func TestPostgresInvalidOutputsRootRollback(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx := t.Context() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(context.Background())) }) + + seed := repotest.Seed(ctx, t, repo) + result := &model.AdvanceResult{ + EpochIndex: seed.Epoch.Index, + InputIndex: seed.Input.Index, + Status: model.InputCompletionStatus_InvalidOutputsRoot, + StateProof: *repotest.DummyStateProof(), + IsDaveConsensus: true, + PeriodicStateHashes: [][32]byte{repotest.UniqueHash()}, + PaddingRepetitions: model.InputHashCollectionCapacity - 1, + } + // Reject the final application update after StoreAdvanceResult has written + // the hash collection, input result, and epoch proof. All must roll back. + _, err = conn.Exec(ctx, `ALTER TABLE application ADD CONSTRAINT invalid_outputs_root_test_rejection + CHECK (status <> 'INVALID_OUTPUTS_ROOT') NOT VALID`) + require.NoError(t, err) + t.Cleanup(func() { + _, err := conn.Exec(context.Background(), `ALTER TABLE application DROP CONSTRAINT invalid_outputs_root_test_rejection`) + require.NoError(t, err) + }) + err = repo.StoreAdvanceResult(ctx, seed.App.ID, result) + requirePostgresConstraint(t, err, "invalid_outputs_root_test_rejection") + input, err := repo.GetInput(ctx, seed.App.Name, seed.Input.Index) + require.NoError(t, err) + require.Equal(t, model.InputCompletionStatus_None, input.Status) + require.Nil(t, input.MachineHash) + require.Nil(t, input.TxBufferDataBlock) + epoch, err := repo.GetEpoch(ctx, seed.App.Name, seed.Epoch.Index) + require.NoError(t, err) + require.False(t, epoch.HasCompleteStateProof()) + require.Nil(t, epoch.MachineHash) + require.Nil(t, epoch.TxBufferDataBlock) + app, err := repo.GetApplication(ctx, seed.App.Name) + require.NoError(t, err) + require.Zero(t, app.ProcessedInputs) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.Nil(t, app.Reason) + var total uint64 + err = conn.QueryRow(ctx, `SELECT count(*) FROM state_hashes WHERE input_epoch_application_id = $1`, seed.App.ID).Scan(&total) + require.NoError(t, err) + require.Zero(t, total) +} + +func TestPostgresInvalidOutputsRootInputContract(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx := t.Context() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(context.Background())) }) + seed := repotest.Seed(ctx, t, repo) + + _, err = conn.Exec(ctx, `UPDATE input SET status = 'INVALID_OUTPUTS_ROOT' + WHERE epoch_application_id = $1 AND index = $2`, seed.App.ID, seed.Input.Index) + requirePostgresConstraint(t, err, "input_completed_hashes_check") + proof := repotest.DummyStateProof() + require.NoError(t, repo.StoreAdvanceResult(ctx, seed.App.ID, &model.AdvanceResult{ + EpochIndex: seed.Epoch.Index, + InputIndex: seed.Input.Index, + Status: model.InputCompletionStatus_InvalidOutputsRoot, + StateProof: *proof, + })) + _, err = conn.Exec(ctx, `UPDATE input SET status = 'ACCEPTED' + WHERE epoch_application_id = $1 AND index = $2`, seed.App.ID, seed.Input.Index) + require.ErrorContains(t, err, "completed input result is immutable") + _, err = conn.Exec(ctx, `UPDATE input SET machine_hash = $3 + WHERE epoch_application_id = $1 AND index = $2`, seed.App.ID, seed.Input.Index, repotest.UniqueHash().Bytes()) + require.ErrorContains(t, err, "completed input result is immutable") + stored, err := repo.GetInput(ctx, seed.App.Name, seed.Input.Index) + require.NoError(t, err) + require.Equal(t, model.InputCompletionStatus_InvalidOutputsRoot, stored.Status) + require.Equal(t, &proof.MachineHash, stored.MachineHash) +} diff --git a/internal/repository/postgres/match.go b/internal/repository/postgres/match.go new file mode 100644 index 000000000..9de421aa3 --- /dev/null +++ b/internal/repository/postgres/match.go @@ -0,0 +1,111 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +func (r *PostgresRepository) CreateMatch(ctx context.Context, nameOrAddress string, value *model.Match) error { + if value == nil { + return fmt.Errorf("cannot create a nil match") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := matchValues(value) + values[0] = applicationID + stmt := table.Matches.INSERT(matchColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +func (r *PostgresRepository) GetMatch( + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string, +) (*model.Match, error) { + sel := table.Matches.SELECT(matchColumns, table.Matches.CreatedAt, table.Matches.UpdatedAt). + FROM(table.Matches.INNER_JOIN(table.Application, table.Matches.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Matches.EpochIndex.EQ(uint64Expr(epochIndex)). + AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes()))). + AND(table.Matches.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes()))), + )) + sqlStr, args := sel.Sql() + value, err := scanMatch(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListMatches( + ctx context.Context, nameOrAddress string, f repository.MatchFilter, p repository.Pagination, descending bool, +) ([]*model.Match, uint64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Matches.INNER_JOIN(table.Application, table.Matches.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} + if f.EpochIndex != nil { + conditions = append(conditions, table.Matches.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + if f.TournamentAddress != nil { + conditions = append(conditions, + table.Matches.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(*f.TournamentAddress).Bytes()))) + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.Matches.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.Matches.SELECT(matchColumns, table.Matches.CreatedAt, table.Matches.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY(table.Matches.EpochIndex.DESC(), table.Matches.TournamentAddress.DESC(), table.Matches.IDHash.DESC()) + } else { + sel = sel.ORDER_BY(table.Matches.EpochIndex.ASC(), table.Matches.TournamentAddress.ASC(), table.Matches.IDHash.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*model.Match + for rows.Next() { + value, err := scanMatch(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} diff --git a/internal/repository/postgres/match_advanced.go b/internal/repository/postgres/match_advanced.go new file mode 100644 index 000000000..b0a8e47aa --- /dev/null +++ b/internal/repository/postgres/match_advanced.go @@ -0,0 +1,110 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func (r *PostgresRepository) CreateMatchAdvanced(ctx context.Context, nameOrAddress string, value *MatchAdvanced) error { + if value == nil { + return fmt.Errorf("cannot create a nil matchAdvance") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + values := matchAdvanceValues(value) + values[0] = applicationID + stmt := table.MatchAdvances.INSERT(matchAdvanceColumns).VALUES(values[0], values[1:]...) + sqlStr, args := stmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +func (r *PostgresRepository) GetMatchAdvanced( + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, txHash common.Hash, logIndex uint64, +) (*MatchAdvanced, error) { + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt). + FROM(table.MatchAdvances.INNER_JOIN(table.Application, table.MatchAdvances.ApplicationID.EQ(table.Application.ID))). + WHERE(postgres.AND( + getWhereClauseFromNameOrAddress(nameOrAddress), + table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex)), + table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + table.MatchAdvances.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes())), + table.MatchAdvances.TxHash.EQ(postgres.Bytea(txHash.Bytes())), + table.MatchAdvances.LogIndex.EQ(uint64Expr(logIndex)), + )) + sqlStr, args := sel.Sql() + value, err := scanMatchAdvanced(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListMatchAdvances( + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, p repository.Pagination, descending bool, +) ([]*MatchAdvanced, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.MatchAdvances.INNER_JOIN(table.Application, table.MatchAdvances.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{ + getWhereClauseFromNameOrAddress(nameOrAddress), + table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex)), + table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + table.MatchAdvances.IDHash.EQ(postgres.Bytea(common.HexToHash(idHashHex).Bytes())), + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.MatchAdvances.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY(table.MatchAdvances.BlockNumber.DESC(), table.MatchAdvances.LogIndex.DESC(), table.MatchAdvances.TxHash.DESC()) + } else { + sel = sel.ORDER_BY(table.MatchAdvances.BlockNumber.ASC(), table.MatchAdvances.LogIndex.ASC(), table.MatchAdvances.TxHash.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*MatchAdvanced + for rows.Next() { + value, err := scanMatchAdvanced(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} diff --git a/internal/repository/postgres/node_config.go b/internal/repository/postgres/node_config.go new file mode 100644 index 000000000..d59c9bee4 --- /dev/null +++ b/internal/repository/postgres/node_config.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + + "github.com/go-jet/jet/v2/postgres" +) + +// InitializeNodeConfigRaw provisions test fixtures. Production services use the +// merging registration methods instead of this whole-value initializer. +func (r *PostgresRepository) InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + stmt := table.NodeConfig. + INSERT(table.NodeConfig.Key, table.NodeConfig.Value). + VALUES(key, postgres.Json(rawJSON)). + ON_CONFLICT(table.NodeConfig.Key).DO_NOTHING() + sqlStr, args := stmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +// SaveNodeConfigRaw replaces a test fixture value. Production repository +// interfaces do not expose this whole-value writer. +func (r *PostgresRepository) SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error { + + insertStmt := table.NodeConfig. + INSERT( + table.NodeConfig.Key, + table.NodeConfig.Value, + ). + VALUES( + key, + postgres.Json(rawJSON), + ). + ON_CONFLICT(table.NodeConfig.Key). + DO_UPDATE(postgres.SET(table.NodeConfig.Value.SET(postgres.Json(rawJSON)))) + + sqlStr, args := insertStmt.Sql() + _, err := r.db.Exec(ctx, sqlStr, args...) + return err +} + +func (r *PostgresRepository) LoadNodeConfigRaw(ctx context.Context, key string) ([]byte, time.Time, time.Time, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + sel := table.NodeConfig. + SELECT( + table.NodeConfig.Value, + table.NodeConfig.CreatedAt, + table.NodeConfig.UpdatedAt, + ). + WHERE(table.NodeConfig.Key.EQ(postgres.String(key))). + LIMIT(1) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var ( + value []byte + createdAt time.Time + updatedAt time.Time + ) + err := row.Scan( + &value, + &createdAt, + &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, time.Time{}, time.Time{}, repository.ErrNotFound + } + if err != nil { + return nil, time.Time{}, time.Time{}, err + } + return value, createdAt, updatedAt, nil +} diff --git a/internal/repository/postgres/operation.go b/internal/repository/postgres/operation.go new file mode 100644 index 000000000..f8693be15 --- /dev/null +++ b/internal/repository/postgres/operation.go @@ -0,0 +1,18 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "time" +) + +// Bound each delivery database operation, including pool acquisition and its +// transaction. A shorter caller deadline or service cancellation still wins. +// Observation passes and blockchain calls have independent lifetimes. +const databaseOperationTimeout = 2 * time.Minute + +func beginDatabaseOperation(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, databaseOperationTimeout) +} diff --git a/internal/repository/postgres/operation_test.go b/internal/repository/postgres/operation_test.go new file mode 100644 index 000000000..eb30560ce --- /dev/null +++ b/internal/repository/postgres/operation_test.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func TestDatabaseOperationBoundsAndCancellation(t *testing.T) { + start := time.Now() + ctx, cancel := beginDatabaseOperation(context.Background()) + deadline, bounded := ctx.Deadline() + require.True(t, bounded) + require.WithinDuration(t, start.Add(databaseOperationTimeout), deadline, time.Second) + cancel() + require.ErrorIs(t, ctx.Err(), context.Canceled) + parent, done := context.WithTimeout(t.Context(), time.Second) + defer done() + ctx, cancel = beginDatabaseOperation(parent) + defer cancel() + parentDeadline, _ := parent.Deadline() + deadline, _ = ctx.Deadline() + require.Equal(t, parentDeadline, deadline, "operation bounds must preserve shorter caller deadlines") + done() + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +type operationDeadlineTracer struct { + mu sync.Mutex + deadlines []time.Time +} + +func (t *operationDeadlineTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context { + deadline, _ := ctx.Deadline() + t.mu.Lock() + t.deadlines = append(t.deadlines, deadline) + t.mu.Unlock() + return ctx +} + +func (*operationDeadlineTracer) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {} + +func TestDatabaseOperationBoundsPoolAcquisitionAndActualQuery(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + tracer := &operationDeadlineTracer{} + cfg := r.db.Config() + cfg.MaxConns, cfg.ConnConfig.Tracer = 1, tracer + pool, err := pgxpool.NewWithConfig(t.Context(), cfg) + require.NoError(t, err) + t.Cleanup(pool.Close) + bounded := &PostgresRepository{db: pool} + stored, err := bounded.GetApplication(context.Background(), app.Name) + require.NoError(t, err) + require.Equal(t, app.ID, stored.ID) + tracer.mu.Lock() + deadlines := append([]time.Time(nil), tracer.deadlines...) + tracer.mu.Unlock() + require.NotEmpty(t, deadlines) + for _, deadline := range deadlines { + require.False(t, deadline.IsZero(), "the actual driver query must carry the operation deadline") + require.LessOrEqual(t, time.Until(deadline), databaseOperationTimeout) + } + conn, err := pool.Acquire(t.Context()) + require.NoError(t, err) + defer conn.Release() + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + _, err = bounded.GetApplication(ctx, app.Name) + require.ErrorIs(t, err, context.DeadlineExceeded, "pool exhaustion must obey the caller deadline") + conn.Release() + stored, err = bounded.GetApplication(t.Context(), app.Name) + require.NoError(t, err, "a timed-out acquisition must not prevent later progress") + require.Equal(t, app.ID, stored.ID) +} diff --git a/internal/repository/postgres/output.go b/internal/repository/postgres/output.go new file mode 100644 index 000000000..795a529bb --- /dev/null +++ b/internal/repository/postgres/output.go @@ -0,0 +1,459 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "cmp" + "context" + "database/sql" + "errors" + "fmt" + "slices" + + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +var ( + delegateCallVoucherSelector = []byte{0x10, 0x32, 0x1e, 0x8b} + voucherSelector = []byte{0x23, 0x7a, 0x81, 0x6f} +) + +func (r *PostgresRepository) GetEpochOutputRange( + ctx context.Context, appID int64, epochIndex uint64, +) (repository.EpochOutputRange, error) { + stmt := table.Output.SELECT( + postgres.COUNT(postgres.STAR), + postgres.COALESCE(postgres.MIN(table.Output.Index), postgres.Int64(0)), + postgres.COALESCE(postgres.MAX(table.Output.Index), postgres.Int64(0)), + ).FROM(table.Output.INNER_JOIN(table.Input, + table.Output.InputEpochApplicationID.EQ(table.Input.EpochApplicationID). + AND(table.Output.InputIndex.EQ(table.Input.Index)), + )).WHERE(postgres.AND( + table.Output.InputEpochApplicationID.EQ(postgres.Int64(appID)), + table.Input.EpochIndex.EQ(uint64Expr(epochIndex)), + table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String())), + )) + query, args := stmt.Sql() + var result repository.EpochOutputRange + err := r.db.QueryRow(ctx, query, args...).Scan(&result.Count, &result.First, &result.Last) + return result, err +} + +type outputQueryer interface { + Query(ctx context.Context, query string, args ...any) (pgx.Rows, error) +} + +func (r *PostgresRepository) ScanOutputs( + ctx context.Context, appID int64, first, last uint64, fn repository.OutputScanFunc, +) error { + return scanOutputs(ctx, r.db, appID, first, last, fn) +} + +func scanOutputs( + ctx context.Context, queryer outputQueryer, appID int64, first, last uint64, fn repository.OutputScanFunc, +) error { + span := last - first + if first > last || span >= repository.MaxOutputRangeRows { + return fmt.Errorf("output scan range must contain between 1 and %d indexes", repository.MaxOutputRangeRows) + } + stmt := table.Output.SELECT(postgres.CAST(table.Output.Index).AS_BIGINT(), table.Output.RawData). + WHERE(postgres.AND( + table.Output.InputEpochApplicationID.EQ(postgres.Int64(appID)), + table.Output.Index.BETWEEN(uint64Expr(first), uint64Expr(last)), + )).ORDER_BY(table.Output.Index.ASC()).LIMIT(int64(span) + 1) + query, args := stmt.Sql() + rows, err := queryer.Query(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var index uint64 + var rawData []byte + if err := rows.Scan(&index, &rawData); err != nil { + return err + } + if err := fn(index, rawData); err != nil { + return err + } + } + return rows.Err() +} + +func outputExecutionCondition(executed bool) postgres.BoolExpression { + if executed { + return table.Output.ExecutionTransactionHash.IS_NOT_NULL() + } + return table.Output.ExecutionTransactionHash.IS_NULL() +} + +func outputTypesCondition(selectors [][]byte) postgres.BoolExpression { + values := make([]postgres.Expression, 0, len(selectors)) + for _, selector := range selectors { + values = append(values, ByteaLiteral(selector)) + } + return SubstrBytea(table.Output.RawData, 1, 4).IN(values...) +} + +// outputVoucherTypesCondition uses literals so PostgreSQL can prove +// that the condition implies output_pending_voucher_idx's predicate even when +// pgx executes the query with a generic prepared plan. +func outputVoucherTypesCondition() postgres.BoolExpression { + return outputTypesCondition( + [][]byte{ + voucherSelector, + delegateCallVoucherSelector, + }, + ) +} + +func (r *PostgresRepository) GetOutput( + ctx context.Context, + nameOrAddress string, + outputIndex uint64, +) (*model.Output, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Output. + SELECT( + table.Output.InputEpochApplicationID, + table.Output.InputIndex, + table.Output.Index, + table.Output.RawData, + table.Output.Hash, + table.Output.OutputHashesSiblings, + table.Output.ExecutionTransactionHash, + table.Output.CreatedAt, + table.Output.UpdatedAt, + table.Input.EpochIndex, + ). + FROM( + table.Output.INNER_JOIN( + table.Application, + table.Output.InputEpochApplicationID.EQ(table.Application.ID), + ).INNER_JOIN( + table.Input, + table.Output.InputIndex.EQ(table.Input.Index). + AND(table.Output.InputEpochApplicationID.EQ(table.Input.EpochApplicationID)), + ), + ). + WHERE( + whereClause. + AND(table.Output.Index.EQ(uint64Expr(outputIndex))), + ) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var o model.Output + err := row.Scan( + &o.InputEpochApplicationID, + &o.InputIndex, + &o.Index, + &o.RawData, + &o.Hash, + &o.OutputHashesSiblings, + &o.ExecutionTransactionHash, + &o.CreatedAt, + &o.UpdatedAt, + &o.EpochIndex, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &o, nil +} + +func (r *PostgresRepository) UpdateOutputsExecution( + ctx context.Context, + nameOrAddress string, + outputs []*model.Output, + lastOutputCheckBlock uint64, +) (err error) { + + defer func() { + var failure *pgconn.PgError + if errors.As(err, &failure) && failure.Code == "55P03" { + err = fmt.Errorf("%w: %w", repository.ErrLockTimeout, err) + } + }() + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if err := setLocalLockTimeout(ctx, tx); err != nil { + return fmt.Errorf("setting output execution lock timeout: %w", err) + } + orderedOutputs := slices.Clone(outputs) + slices.SortFunc(orderedOutputs, func(a, b *model.Output) int { + return cmp.Compare(a.Index, b.Index) + }) + for _, o := range orderedOutputs { + if o.ExecutionTransactionHash == nil { + return fmt.Errorf( + "output ExecutionTransactionHash must be not nil when updating app %s output %d", + nameOrAddress, o.Index, + ) + } + updStmt := table.Output. + UPDATE( + table.Output.ExecutionTransactionHash, + ). + SET( + postgres.Bytea(o.ExecutionTransactionHash.Bytes()), + ). + FROM( + table.Application, + ). + WHERE( + whereClause. + AND(table.Output.InputEpochApplicationID.EQ(table.Application.ID)). + AND(table.Output.Index.EQ(uint64Expr(o.Index))), + ) + + sqlStr, args := updStmt.Sql() + cmd, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if cmd.RowsAffected() != 1 { + return fmt.Errorf("no row affected when updating app %s output %d", nameOrAddress, o.Index) + } + } + + // Advance the output scan cursor without moving it backwards. + appUpdateStmt := table.Application. + UPDATE( + table.Application.LastOutputCheckBlock, + ). + SET( + uint64Expr(lastOutputCheckBlock), + ). + WHERE( + whereClause.AND(table.Application.LastOutputCheckBlock.LT(uint64Expr(lastOutputCheckBlock))), + ) + + sqlStr, args := appUpdateStmt.Sql() + _, err = tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (r *PostgresRepository) ListOutputs( + ctx context.Context, + nameOrAddress string, + f repository.OutputFilter, + p repository.Pagination, + descending bool, +) ([]*model.Output, uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + fromClause := table.Output.INNER_JOIN( + table.Application, + table.Output.InputEpochApplicationID.EQ(table.Application.ID), + ).INNER_JOIN( + table.Input, + table.Output.InputIndex.EQ(table.Input.Index). + AND(table.Output.InputEpochApplicationID.EQ(table.Input.EpochApplicationID)), + ) + + conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Output.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Output.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } + if f.BlockRange != nil { + conditions = append(conditions, table.Input.BlockNumber.BETWEEN( + uint64Expr(f.BlockRange.Start), + uint64Expr(f.BlockRange.End), + )) + conditions = append(conditions, table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String()))) + } + + if f.EpochIndex != nil { + conditions = append(conditions, table.Input.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + conditions = append(conditions, table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String()))) + } + + if f.InputIndex != nil { + conditions = append(conditions, table.Output.InputIndex.EQ(uint64Expr(*f.InputIndex))) + } + + if f.OutputType != nil && len(*f.OutputType) > 0 { + conditions = append(conditions, outputTypesCondition(*f.OutputType)) + } + + if f.Executed != nil { + conditions = append(conditions, outputExecutionCondition(*f.Executed)) + } + + if f.VoucherAddress != nil { + // A destination-address filter is only meaningful for output methods + // that carry a destination (Voucher, DelegateCallVoucher); bytes 17..36 + // of other outputs are arbitrary payload. The selector IN list, with + // inline literals, is also what lets the planner prove the partial + // predicate of output_raw_data_address_idx. + conditions = append(conditions, + outputVoucherTypesCondition(), + SubstrBytea(table.Output.RawData, 17, 20).EQ(postgres.Bytea(f.VoucherAddress.Bytes())), + ) + } + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + countStmt := table.Output.SELECT(postgres.COUNT(postgres.STAR)). + FROM(fromClause).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil { + return nil, 0, err + } + if total == 0 { + return nil, 0, nil + } + + sel := table.Output. + SELECT( + table.Output.InputEpochApplicationID, + table.Output.InputIndex, + table.Output.Index, + table.Output.RawData, + table.Output.Hash, + table.Output.OutputHashesSiblings, + table.Output.ExecutionTransactionHash, + table.Output.CreatedAt, + table.Output.UpdatedAt, + table.Input.EpochIndex, + ). + FROM(fromClause). + WHERE(postgres.AND(conditions...)) + + if descending { + sel = sel.ORDER_BY(table.Output.Index.DESC()) + } else { + sel = sel.ORDER_BY(table.Output.Index.ASC()) + } + + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var outputs []*model.Output + for rows.Next() { + var out model.Output + err := rows.Scan( + &out.InputEpochApplicationID, + &out.InputIndex, + &out.Index, + &out.RawData, + &out.Hash, + &out.OutputHashesSiblings, + &out.ExecutionTransactionHash, + &out.CreatedAt, + &out.UpdatedAt, + &out.EpochIndex, + ) + if err != nil { + return nil, 0, err + } + outputs = append(outputs, &out) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return outputs, total, nil +} + +func (r *PostgresRepository) GetNumberOfExecutedOutputs( + ctx context.Context, + nameOrAddress string, +) (uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Output. + SELECT(postgres.COUNT(postgres.STAR)). + FROM( + table.Output. + INNER_JOIN(table.Application, + table.Output.InputEpochApplicationID.EQ(table.Application.ID), + ), + ). + WHERE(whereClause.AND(table.Output.ExecutionTransactionHash.IS_NOT_NULL())) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var count uint64 + err := row.Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func (r *PostgresRepository) GetNumberOfPendingExecutableOutputs( + ctx context.Context, + nameOrAddress string, +) (uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + sel := table.Output. + SELECT(postgres.COUNT(postgres.STAR)). + FROM( + table.Output. + INNER_JOIN(table.Application, + table.Output.InputEpochApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(outputExecutionCondition(false)). + AND(outputVoucherTypesCondition()), + ) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var count uint64 + err := row.Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} diff --git a/internal/repository/postgres/output_execution_concurrency_test.go b/internal/repository/postgres/output_execution_concurrency_test.go new file mode 100644 index 000000000..6c88316a9 --- /dev/null +++ b/internal/repository/postgres/output_execution_concurrency_test.go @@ -0,0 +1,155 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStoreClaimAndProofsConcurrentOutputExecution(t *testing.T) { + for _, test := range []struct { + name string + requestedBlock uint64 + expectedBlock uint64 + }{ + {name: "advancing cursor", requestedBlock: 200, expectedBlock: 200}, + {name: "older cursor", requestedBlock: 50, expectedBlock: 100}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + repo := newEpochPublicationRepository(t) + app, epoch := seedEpochPublication(t, repo) + require.NoError(t, repo.UpdateEventLastCheckBlock(ctx, []int64{app.ID}, model.MonitoredEvent_OutputExecuted, 100)) + require.NoError(t, repo.UpdateEpochInputsProcessed(ctx, app.Name, epoch.Index, repotest.DummyStateProof())) + epoch, err := repo.GetEpoch(ctx, app.Name, epoch.Index) + require.NoError(t, err) + epoch.Commitment = new(repotest.UniqueHash()) + epoch.CommitmentProof = make([]common.Hash, model.Log2EpochComputationHashLeafCount) + output, err := repo.GetOutput(ctx, app.Name, 0) + require.NoError(t, err) + require.NotNil(t, output) + proofOutput := *output + proofOutput.Hash = new(repotest.UniqueHash()) + proofOutput.OutputHashesSiblings = []common.Hash{repotest.UniqueHash()} + executedOutput := *output + executedOutput.ExecutionTransactionHash = new(repotest.UniqueHash()) + + endpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + control, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + unlock := func(ctx context.Context) error { + _, err := control.Exec(ctx, `SELECT pg_advisory_unlock_all()`) + return err + } + var writers sync.WaitGroup + t.Cleanup(func() { + // Cancel and release the gate before repository cleanup, including after a failed assertion. + cancel() + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cleanupCancel() + assert.NoError(t, unlock(cleanupCtx)) + done := make(chan struct{}) + go func() { + writers.Wait() + close(done) + }() + select { + case <-done: + case <-cleanupCtx.Done(): + t.Error("concurrent repository writers did not stop after cancellation") + } + _, err := control.Exec(cleanupCtx, ` + DROP TRIGGER IF EXISTS pause_output_execution_for_test ON output; + DROP FUNCTION IF EXISTS pause_output_execution_for_test()`) + assert.NoError(t, err) + assert.NoError(t, control.Close(cleanupCtx)) + }) + // An AFTER trigger pauses the execution writer with its output row locked. + // Both repository operations use their real transactions and SQL unchanged. + _, err = control.Exec(ctx, ` + CREATE FUNCTION pause_output_execution_for_test() RETURNS TRIGGER AS $$ + BEGIN + IF NEW.execution_transaction_hash IS DISTINCT FROM OLD.execution_transaction_hash + AND NEW.execution_transaction_hash IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(3890003); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER pause_output_execution_for_test AFTER UPDATE ON output + FOR EACH ROW EXECUTE FUNCTION pause_output_execution_for_test()`) + require.NoError(t, err) + _, err = control.Exec(ctx, `SELECT pg_advisory_lock(3890003)`) + require.NoError(t, err) + + type result struct { + writer string + err error + } + results := make(chan result, 2) + writers.Add(1) + go func() { + defer writers.Done() + err := repo.UpdateOutputsExecution(ctx, app.Name, []*model.Output{&executedOutput}, test.requestedBlock) + results <- result{writer: "UpdateOutputsExecution", err: err} + }() + var executionPID int32 + require.Eventually(t, func() bool { + err := control.QueryRow(ctx, `SELECT pid FROM pg_locks + WHERE locktype = 'advisory' AND objid = 3890003 AND NOT granted`).Scan(&executionPID) + return err == nil + }, 5*time.Second, 10*time.Millisecond, "execution writer must reach the gate with its output locked") + writers.Add(1) + go func() { + defer writers.Done() + err := repo.StoreClaimAndProofs(ctx, epoch, repotest.WriteProofs(&proofOutput)) + results <- result{writer: "StoreClaimAndProofs", err: err} + }() + require.Eventually(t, func() bool { + var blocked bool + err := control.QueryRow(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity WHERE datname = current_database() + AND pid <> $1 AND $1 = ANY(pg_blocking_pids(pid)))`, executionPID).Scan(&blocked) + return err == nil && blocked + }, 5*time.Second, 10*time.Millisecond, "claim publisher must contend with the execution writer before release") + require.NoError(t, unlock(ctx)) + for range 2 { + select { + case result := <-results: + require.NoError(t, result.err, result.writer) + case <-ctx.Done(): + t.Fatal("concurrent publication and execution did not complete: ", ctx.Err()) + } + } + + stored, err := repo.GetOutput(ctx, app.Name, output.Index) + require.NoError(t, err) + require.NotNil(t, stored) + require.Equal(t, proofOutput.Hash, stored.Hash) + require.Equal(t, proofOutput.OutputHashesSiblings, stored.OutputHashesSiblings) + require.Equal(t, executedOutput.ExecutionTransactionHash, stored.ExecutionTransactionHash) + storedEpoch, err := repo.GetEpoch(ctx, app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimComputed, storedEpoch.Status) + require.Equal(t, epoch.Commitment, storedEpoch.Commitment) + require.Equal(t, epoch.CommitmentProof, storedEpoch.CommitmentProof) + cursor, err := repo.GetEventLastCheckBlock(ctx, app.ID, model.MonitoredEvent_OutputExecuted) + require.NoError(t, err) + require.Equal(t, test.expectedBlock, cursor, "concurrent writers must not move the cursor backwards") + }) + } +} diff --git a/internal/repository/postgres/output_execution_lock_test.go b/internal/repository/postgres/output_execution_lock_test.go new file mode 100644 index 000000000..545ddd63b --- /dev/null +++ b/internal/repository/postgres/output_execution_lock_test.go @@ -0,0 +1,132 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func seedOutputExecutionLocks(t *testing.T) (*PostgresRepository, *model.Application, []*model.Output, *pgx.Conn) { + t.Helper() + repo := newEpochPublicationRepository(t).(*PostgresRepository) + seed := repotest.Seed(t.Context(), t, repo) + require.NoError(t, repo.StoreAdvanceResult(t.Context(), seed.App.ID, &model.AdvanceResult{ + EpochIndex: seed.Epoch.Index, InputIndex: seed.Input.Index, + Status: model.InputCompletionStatus_Accepted, StateProof: *repotest.DummyStateProof(), + Outputs: [][]byte{[]byte("zero"), []byte("one"), []byte("two")}, + })) + require.NoError(t, repo.UpdateEventLastCheckBlock(t.Context(), []int64{seed.App.ID}, model.MonitoredEvent_OutputExecuted, 100)) + outputs := make([]*model.Output, 3) + for i := range outputs { + output, err := repo.GetOutput(t.Context(), seed.App.Name, uint64(i)) + require.NoError(t, err) + require.NotNil(t, output) + output.ExecutionTransactionHash = new(repotest.UniqueHash()) + outputs[i] = output + } + endpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + control, err := pgx.Connect(t.Context(), endpoint) + require.NoError(t, err) + t.Cleanup(func() { _ = control.Close(context.Background()) }) + return repo, seed.App, outputs, control +} + +func TestOutputsExecutionLocksInIndexOrder(t *testing.T) { + repo, app, outputs, control := seedOutputExecutionLocks(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + locker, err := control.Begin(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = locker.Rollback(context.Background()) }) + _, err = locker.Exec(ctx, `SELECT index FROM output WHERE input_epoch_application_id=$1 AND index=0 FOR UPDATE`, app.ID) + require.NoError(t, err) + eventOrder := []*model.Output{outputs[2], outputs[0]} + done := make(chan error, 1) + go func() { done <- repo.UpdateOutputsExecution(ctx, app.Name, eventOrder, 200) }() + require.Eventually(t, func() bool { + var waiting bool + err := locker.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_stat_activity + WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)))`, control.PgConn().PID()).Scan(&waiting) + return err == nil && waiting + }, time.Second, 10*time.Millisecond) + _, err = locker.Exec(ctx, `SELECT index FROM output + WHERE input_epoch_application_id=$1 AND index=2 FOR UPDATE NOWAIT`, app.ID) + require.NoError(t, err, "the execution writer must wait on output zero before locking output two") + require.NoError(t, locker.Rollback(ctx)) + select { + case err := <-done: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + require.Equal(t, []*model.Output{outputs[2], outputs[0]}, eventOrder, "preserve the caller's event order") + for _, expected := range eventOrder { + stored, err := repo.GetOutput(ctx, app.Name, expected.Index) + require.NoError(t, err) + require.Equal(t, expected.ExecutionTransactionHash, stored.ExecutionTransactionHash) + } + cursor, err := repo.GetEventLastCheckBlock(ctx, app.ID, model.MonitoredEvent_OutputExecuted) + require.NoError(t, err) + require.Equal(t, uint64(200), cursor) +} + +func TestOutputsExecutionBoundsLockWait(t *testing.T) { + for _, applicationLock := range []bool{false, true} { + target := "output row" + if applicationLock { + target = "application row" + } + t.Run(target, func(t *testing.T) { + repo, app, outputs, control := seedOutputExecutionLocks(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + var before string + require.NoError(t, repo.db.QueryRow(ctx, "SHOW lock_timeout").Scan(&before)) + locker, err := control.Begin(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = locker.Rollback(context.Background()) }) + query := `SELECT index FROM output WHERE input_epoch_application_id=$1 AND index=0 FOR UPDATE` + if applicationLock { + query = `SELECT id FROM application WHERE id=$1 FOR UPDATE` + } + _, err = locker.Exec(ctx, query, app.ID) + require.NoError(t, err) + err = repo.UpdateOutputsExecution(ctx, app.Name, outputs[:1], 200) + require.ErrorIs(t, err, repository.ErrLockTimeout) + var pgError *pgconn.PgError + require.True(t, errors.As(err, &pgError)) + require.Equal(t, "55P03", pgError.Code) + require.NoError(t, ctx.Err(), "the local lock bound must expire before the request deadline") + stored, err := repo.GetOutput(ctx, app.Name, 0) + require.NoError(t, err) + require.Nil(t, stored.ExecutionTransactionHash) + cursor, err := repo.GetEventLastCheckBlock(ctx, app.ID, model.MonitoredEvent_OutputExecuted) + require.NoError(t, err) + require.Equal(t, uint64(100), cursor) + var after string + require.NoError(t, repo.db.QueryRow(ctx, "SHOW lock_timeout").Scan(&after)) + require.Equal(t, before, after, "transaction-local timeout must not leak into the pool") + require.NoError(t, locker.Rollback(ctx)) + require.NoError(t, repo.UpdateOutputsExecution(ctx, app.Name, outputs[:1], 200)) + stored, err = repo.GetOutput(ctx, app.Name, 0) + require.NoError(t, err) + require.Equal(t, outputs[0].ExecutionTransactionHash, stored.ExecutionTransactionHash) + cursor, err = repo.GetEventLastCheckBlock(ctx, app.ID, model.MonitoredEvent_OutputExecuted) + require.NoError(t, err) + require.Equal(t, uint64(200), cursor) + }) + } +} diff --git a/internal/repository/postgres/output_range_test.go b/internal/repository/postgres/output_range_test.go new file mode 100644 index 000000000..088652cb7 --- /dev/null +++ b/internal/repository/postgres/output_range_test.go @@ -0,0 +1,151 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const ( + outputScanCallbackEvent = "callback" + outputScanNextEvent = "next" + outputScanReadEvent = "scan" + outputScanCloseEvent = "close" +) + +type outputScanRows struct { + pgx.Rows + indexes []uint64 + data [][]byte + events []string + current int + nexts int + closed bool + scanErr error + rowsErr error +} + +func (r *outputScanRows) Next() bool { + r.events = append(r.events, outputScanNextEvent) + r.current = r.nexts + r.nexts++ + return r.current < len(r.indexes) +} + +func (r *outputScanRows) Scan(destinations ...any) error { + r.events = append(r.events, outputScanReadEvent) + if r.scanErr != nil { + return r.scanErr + } + *destinations[0].(*uint64) = r.indexes[r.current] + *destinations[1].(*[]byte) = append([]byte(nil), r.data[r.current]...) + return nil +} + +func (r *outputScanRows) Close() { + r.events = append(r.events, outputScanCloseEvent) + r.closed = true +} + +func (r *outputScanRows) Err() error { return r.rowsErr } + +type outputScanQuery struct { + rows pgx.Rows + err error +} + +func (q *outputScanQuery) Query(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { + return q.rows, q.err +} + +func TestScanOutputsStreamsBeforeNext(t *testing.T) { + t.Parallel() + rows := &outputScanRows{indexes: []uint64{10, 11}, data: [][]byte{[]byte("first"), []byte("second")}} + query := &outputScanQuery{rows: rows} + var indexes []uint64 + err := scanOutputs(t.Context(), query, 7, 10, 11, func(index uint64, rawData []byte) error { + rows.events = append(rows.events, outputScanCallbackEvent) + require.Equal(t, rows.data[len(indexes)], rawData) + indexes = append(indexes, index) + return nil + }) + require.NoError(t, err) + require.Equal(t, rows.indexes, indexes) + require.Equal(t, []string{ + outputScanNextEvent, outputScanReadEvent, outputScanCallbackEvent, + outputScanNextEvent, outputScanReadEvent, outputScanCallbackEvent, + outputScanNextEvent, outputScanCloseEvent, + }, rows.events) + require.True(t, rows.closed) +} + +func TestScanOutputsCallbackErrorStopsAndCloses(t *testing.T) { + t.Parallel() + rows := &outputScanRows{indexes: []uint64{10, 11}, data: [][]byte{[]byte("first"), []byte("second")}} + want := errors.New("callback failed") + err := scanOutputs(t.Context(), &outputScanQuery{rows: rows}, 7, 10, 11, func(uint64, []byte) error { + rows.events = append(rows.events, outputScanCallbackEvent) + return want + }) + require.ErrorIs(t, err, want) + require.Equal(t, []string{outputScanNextEvent, outputScanReadEvent, outputScanCallbackEvent, outputScanCloseEvent}, rows.events) + require.True(t, rows.closed) +} + +func TestScanOutputsPropagatesReadErrors(t *testing.T) { + t.Parallel() + want := errors.New("read failed") + t.Run("query", func(t *testing.T) { + err := scanOutputs(t.Context(), &outputScanQuery{err: want}, 7, 0, 0, func(uint64, []byte) error { return nil }) + require.ErrorIs(t, err, want) + }) + for _, scanError := range []bool{true, false} { + rows := &outputScanRows{} + if scanError { + rows.indexes, rows.scanErr = []uint64{0}, want + } else { + rows.rowsErr = want + } + err := scanOutputs(t.Context(), &outputScanQuery{rows: rows}, 7, 0, 0, func(uint64, []byte) error { + t.Fatal("callback must not run after a read error") + return nil + }) + require.ErrorIs(t, err, want) + require.True(t, rows.closed) + } +} + +func TestPostgresOutputsRefuseIdentityOrRawDataUpdate(t *testing.T) { + repo := newEpochPublicationRepository(t).(*PostgresRepository) + app, _ := seedEpochPublication(t, repo) + for _, column := range []string{"input_epoch_application_id", "input_index", "index", "raw_data"} { + identifier := pgx.Identifier{column}.Sanitize() + _, err := repo.db.Exec(t.Context(), "UPDATE output SET "+identifier+" = "+identifier+ + " WHERE input_epoch_application_id = $1", app.ID) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "23001", pgErr.Code) + require.Equal(t, "output identity or raw data is immutable", pgErr.Message) + } + hash := repotest.UniqueHash() + _, err := repo.db.Exec(t.Context(), `UPDATE output SET hash = $2 WHERE input_epoch_application_id = $1`, app.ID, hash.Bytes()) + require.NoError(t, err) + siblings := make([][]byte, 63) + for index := range siblings { + siblings[index] = hash.Bytes() + } + _, err = repo.db.Exec(t.Context(), `UPDATE output SET output_hashes_siblings = $2 + WHERE input_epoch_application_id = $1`, app.ID, siblings) + require.NoError(t, err) + _, err = repo.db.Exec(t.Context(), `UPDATE output SET execution_transaction_hash = $2 + WHERE input_epoch_application_id = $1`, app.ID, hash.Bytes()) + require.NoError(t, err) +} diff --git a/internal/repository/postgres/pagination_range_test.go b/internal/repository/postgres/pagination_range_test.go new file mode 100644 index 000000000..559b24927 --- /dev/null +++ b/internal/repository/postgres/pagination_range_test.go @@ -0,0 +1,53 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func TestApplicationAndEpochPaginationIntegerBounds(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, epoch.LastBlock)) + for _, method := range []struct { + name string + list func(repository.Pagination) (int, uint64, error) + }{ + {"applications", func(p repository.Pagination) (int, uint64, error) { + rows, total, err := r.ListApplications(t.Context(), repository.ApplicationFilter{}, p, false) + return len(rows), total, err + }}, + {"epochs", func(p repository.Pagination) (int, uint64, error) { + rows, total, err := r.ListEpochs(t.Context(), app.Name, repository.EpochFilter{}, p, false) + return len(rows), total, err + }}, + } { + t.Run(method.name, func(t *testing.T) { + for _, p := range []repository.Pagination{{Limit: math.MaxInt64}, {Offset: math.MaxInt64}} { + count, total, err := method.list(p) + require.NoError(t, err) + require.Equal(t, uint64(1), total) + if p.Offset == 0 { + require.Equal(t, 1, count) + } else { + require.Zero(t, count) + } + } + for _, p := range []repository.Pagination{{Limit: math.MaxInt64 + 1}, {Offset: math.MaxInt64 + 1}} { + count, total, err := method.list(p) + require.ErrorContains(t, err, "pagination exceeds PostgreSQL integer range") + require.Zero(t, count) + require.Zero(t, total) + } + }) + } +} diff --git a/internal/repository/postgres/passive_observer_test.go b/internal/repository/postgres/passive_observer_test.go new file mode 100644 index 000000000..3d63b7842 --- /dev/null +++ b/internal/repository/postgres/passive_observer_test.go @@ -0,0 +1,472 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "math/big" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const observerCheckpoint = uint64(100) + +func observerUint256(t *testing.T, text string) Uint256 { + t.Helper() + number, ok := new(big.Int).SetString(text, 10) + require.True(t, ok) + value, err := Uint256FromBig(number) + require.NoError(t, err) + return value +} + +// newPassiveObserverWindow has all eight event types and final current views. +// Its inner match was advanced twice, sealed, deleted, and its bond recovered. +func newPassiveObserverWindow(t *testing.T) (*PostgresRepository, *Application, []*repository.TournamentEventBatch) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, observerCheckpoint)) + root := repotest.NewTournamentBuilder(app.ID).Build() + root.MaxLevel = 2 + root.StartInstant = epoch.LastBlock + makePair := func(tournament *Tournament) (*Commitment, *Commitment, *Match) { + one := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(tournament.Address). + WithCommitmentOne(one.Commitment).WithCommitmentTwo(two.Commitment).Build() + one.BlockNumber, two.BlockNumber, match.BlockNumber = 10, 11, 11 + match.TxHash, match.LogIndex = two.TxHash, 1 + match.EliminableAt = 50 + match.Winner = WinnerCommitment_ONE + match.DeletionReason = MatchDeletionReason_CHILD_TOURNAMENT + match.DeletionBlockNumber, match.DeletionTxHash, match.DeletionLogIndex = 80, new(repotest.UniqueHash()), new(uint64(0)) + match.Snapshot = MatchSnapshot{AsOfBlock: observerCheckpoint, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone} + return one, two, match + } + one, two, parentMatch := makePair(root) + child := repotest.NewTournamentBuilder(app.ID).WithLevel(1).WithParent(root.Address, parentMatch.IDHash).Build() + child.MaxLevel, child.Kind, child.Log2Step = 2, TournamentKindLeaf, 0 + child.CreationEvent.BlockNumber = 50 + child.StartInstant = 50 + childOne, childTwo, childMatch := makePair(child) + childOne.BlockNumber, childTwo.BlockNumber, childMatch.BlockNumber = 51, 52, 52 + childMatch.DeletionReason, childMatch.DeletionBlockNumber = MatchDeletionReason_STEP, 70 + childMatch.LeafSeal = &LeafMatchSeal{AgreeState: common.Hash{}, DivergencePosition: observerUint256(t, "3"), + FinalStateOne: repotest.UniqueHash(), FinalStateTwo: repotest.UniqueHash(), + DeadlineOne: 85, DeadlineTwo: 90, EliminableAt: 90, BlockNumber: 60, TxHash: repotest.UniqueHash()} + root.Snapshot = TournamentSnapshot{ + AsOfBlock: observerCheckpoint, Standing: TournamentStandingRootWinner, + Candidate: new(one.Commitment), WinnerCommitment: new(one.Commitment), FinalStateHash: new(one.FinalStateHash), + FinishedAtBlock: 90, + BondRecovery: TournamentBondRecovery{ + Disposition: BondDispositionRecoverable, Claimer: new(one.SubmitterAddress), Payment: new(Uint256), + }, + } + child.Snapshot = TournamentSnapshot{ + AsOfBlock: observerCheckpoint, Standing: TournamentStandingInnerWinner, + Candidate: new(childOne.Commitment), WinnerCommitment: new(childOne.Commitment), FinalStateHash: new(childOne.FinalStateHash), + FinishedAtBlock: 80, ParentCommitment: new(parentMatch.CommitmentOne), WinnerExpiresAt: 150, + InnerResult: &TournamentInnerResult{ + Disposition: InnerTournamentWinner, ParentCommitment: new(parentMatch.CommitmentOne), PausedAllowance: 50, + }, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionRecovered}, + } + childOne.Snapshot.Claimer = common.Address{} + firstAdvance := repotest.NewMatchAdvancedBuilder(app.ID).WithTournamentAddress(child.Address).WithIDHash(childMatch.IDHash).Build() + firstAdvance.BlockNumber, firstAdvance.LogIndex, firstAdvance.EliminableAt = 55, 0, 85 + firstAdvance.CurrentHeight, firstAdvance.ResponderDeadline = 2, 84 + firstAdvance.SegmentStartPosition = observerUint256(t, "18446744073709551616") + secondAdvance := *firstAdvance + secondAdvance.LogIndex, secondAdvance.EliminableAt = 1, 86 + secondAdvance.CurrentHeight, secondAdvance.ResponderDeadline = 1, 85 + secondAdvance.LeftNode = repotest.UniqueHash() + refund := &BondEvent{ + ApplicationID: app.ID, EpochIndex: child.EpochIndex, TournamentAddress: child.Address, Type: BondEventPartialRefund, + BlockNumber: childMatch.DeletionBlockNumber, TxHash: *childMatch.DeletionTxHash, LogIndex: 1, + Refund: &PartialBondRefund{Recipient: childOne.SubmitterAddress, Value: observerUint256(t, "7"), Success: false}, + } + recovery := &BondEvent{ + ApplicationID: app.ID, EpochIndex: child.EpochIndex, TournamentAddress: child.Address, Type: BondEventRecovered, + BlockNumber: 95, TxHash: repotest.UniqueHash(), LogIndex: 0, + Recovery: &BondRecovered{Commitment: childOne.Commitment, Claimer: childOne.SubmitterAddress, + Payment: Uint256{}, Burned: observerUint256(t, "18446744073709551616")}, + } + return r, app, []*repository.TournamentEventBatch{ + {Tournament: root, Commitments: []*Commitment{one, two}, Matches: []*Match{parentMatch}}, + {Tournament: child, Commitments: []*Commitment{childOne, childTwo}, Matches: []*Match{childMatch}, + MatchAdvances: []*MatchAdvanced{firstAdvance, &secondAdvance}, BondEvents: []*BondEvent{refund, recovery}}, + } +} + +func TestPassiveObserverEventsAndCurrentViewsRoundTrip(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + require.Equal(t, batch.Tournament.CreationEvent, stored.CreationEvent) + for _, commitment := range batch.Commitments { + got, err := r.GetCommitment( + t.Context(), app.Name, commitment.EpochIndex, commitment.TournamentAddress.Hex(), commitment.Commitment.Hex(), + ) + require.NoError(t, err) + require.Equal(t, commitment.Snapshot, got.Snapshot) + require.Equal(t, commitment.SubmitterAddress, got.SubmitterAddress) + require.Equal(t, commitment.LogIndex, got.LogIndex) + } + for _, match := range batch.Matches { + got, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, match.TournamentAddress.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.Snapshot, got.Snapshot) + require.Equal(t, match.LeafSeal, got.LeafSeal) + require.Equal(t, match.DeletionLogIndex, got.DeletionLogIndex) + require.Equal(t, match.EliminableAt, got.EliminableAt) + } + } + child := batches[1].Tournament + childMatch := batches[1].Matches[0] + advances, total, err := r.ListMatchAdvances( + t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), repository.Pagination{}, false, + ) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, advances[0].OtherParent, advances[1].OtherParent, "parent hashes are not event identities") + require.Equal(t, uint64(0), advances[0].LogIndex) + require.Equal(t, uint64(1), advances[1].LogIndex) + require.Equal(t, batches[1].MatchAdvances[0].SegmentStartPosition, advances[0].SegmentStartPosition) + for _, advance := range advances { + got, err := r.GetMatchAdvanced(t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), + advance.TxHash, advance.LogIndex) + require.NoError(t, err) + require.Equal(t, advance, got) + } + bonds, total, err := r.ListBondEvents(t.Context(), app.Name, repository.BondEventFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, batches[1].BondEvents[0].Refund, bonds[0].Refund) + require.False(t, bonds[0].Refund.Success, "a failed refund is a recorded request, not a transferred amount") + require.Equal(t, batches[1].BondEvents[1].Recovery, bonds[1].Recovery) + require.Zero(t, bonds[1].Recovery.Payment) + for _, bond := range bonds { + got, err := r.GetBondEvent(t.Context(), app.Name, bond.TxHash, bond.LogIndex) + require.NoError(t, err) + require.Equal(t, bond, got) + } + // Replay must retain event rows, their timestamps, and their log identities. + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + replayed, _, err := r.ListBondEvents(t.Context(), app.Name, repository.BondEventFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, bonds, replayed) + replayedAdvances, _, err := r.ListMatchAdvances( + t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), childMatch.IDHash.Hex(), repository.Pagination{}, false, + ) + require.NoError(t, err) + require.Equal(t, advances, replayedAdvances) +} + +func TestPassiveObserverRefreshClearsExpiredInnerWinner(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 150 + child.Snapshot.Standing = TournamentStandingInnerEliminableWinnerExpired + child.Snapshot.WinnerCommitment, child.Snapshot.FinalStateHash, child.Snapshot.ParentCommitment = nil, nil, nil + child.Snapshot.WinnerExpiresAt = 0 + child.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentEliminable} + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{Tournament: &child}}, 150)) + stored, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, child.Snapshot, stored.Snapshot) + require.NotNil(t, stored.Snapshot.Candidate) + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + parent, err := r.GetTournament(t.Context(), app.Name, batches[0].Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, parent.Snapshot.AsOfBlock, "a child-only batch does not relabel the parent view") + appState, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(150), appState.LastTournamentCheckBlock) +} + +func TestPassiveObserverEmptyWindowKeepsStoredViews(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, nil, observerCheckpoint+1)) + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + } + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint+1, storedApp.LastTournamentCheckBlock) +} + +func TestPassiveObserverAdvanceQueriesStayWithinMatch(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child, match := batches[1].Tournament, batches[1].Matches[0] + advance := batches[1].MatchAdvances[0] + otherApp := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + for _, scope := range []struct { + name string + appName string + epoch uint64 + tournament common.Address + match common.Hash + }{ + {"wrong application", otherApp.Name, child.EpochIndex, child.Address, match.IDHash}, + {"wrong epoch", app.Name, child.EpochIndex + 1, child.Address, match.IDHash}, + {"wrong tournament", app.Name, child.EpochIndex, batches[0].Tournament.Address, match.IDHash}, + {"wrong match", app.Name, child.EpochIndex, child.Address, batches[0].Matches[0].IDHash}, + } { + t.Run(scope.name, func(t *testing.T) { + got, err := r.GetMatchAdvanced(t.Context(), scope.appName, scope.epoch, scope.tournament.Hex(), scope.match.Hex(), + advance.TxHash, advance.LogIndex) + require.NoError(t, err) + require.Nil(t, got) + rows, total, err := r.ListMatchAdvances(t.Context(), scope.appName, scope.epoch, scope.tournament.Hex(), scope.match.Hex(), + repository.Pagination{}, false) + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, rows) + }) + } + for _, descending := range []bool{false, true} { + for offset := uint64(0); offset < 2; offset++ { + rows, total, err := r.ListMatchAdvances(t.Context(), app.Name, child.EpochIndex, child.Address.Hex(), match.IDHash.Hex(), + repository.Pagination{Limit: 1, Offset: offset}, descending) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Len(t, rows, 1) + expectedLog := offset + if descending { + expectedLog = 1 - offset + } + require.Equal(t, expectedLog, rows[0].LogIndex) + } + } +} + +func TestPassiveObserverSQLMatchesModelEnums(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, tx.Rollback(context.WithoutCancel(t.Context()))) }) + for _, enum := range []struct { + name string + values []string + }{ + {"TournamentKind", observerEnumStrings(TournamentKindAllValues)}, + {"TournamentStandingState", observerEnumStrings(TournamentStandingStateAllValues)}, + {"MatchPhase", observerEnumStrings(MatchPhaseAllValues)}, + {"CommitmentSide", observerEnumStrings(CommitmentSideAllValues)}, + {"MatchTimeoutOutcome", observerEnumStrings(MatchTimeoutOutcomeAllValues)}, + {"InnerTournamentDisposition", observerEnumStrings(InnerTournamentDispositionAllValues)}, + {"BondDisposition", observerEnumStrings(BondDispositionAllValues)}, + {"BondEventType", observerEnumStrings(BondEventTypeAllValues)}, + } { + t.Run(enum.name, func(t *testing.T) { + _, err := tx.Exec(t.Context(), "CREATE TYPE "+pgx.Identifier{"pg_temp", enum.name}.Sanitize()+" AS ENUM ('unrelated')") + require.NoError(t, err) + rows, err := tx.Query(t.Context(), ` + SELECT enumlabel FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' AND pg_type.typname = $1 ORDER BY enumsortorder`, enum.name) + require.NoError(t, err) + labels, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + require.Equal(t, enum.values, labels) + }) + } +} + +func observerEnumStrings[T ~string](values []T) []string { + labels := make([]string, len(values)) + for i, value := range values { + labels[i] = string(value) + } + return labels +} + +func TestPassiveObserverRejectsImmutableFactChangesAndOlderViews(t *testing.T) { + for _, test := range []struct { + name string + change func(*repository.TournamentEventBatch) + }{ + {"child creation", func(b *repository.TournamentEventBatch) { b.Tournament.CreationEvent.LogIndex++ }}, + {"commitment join", func(b *repository.TournamentEventBatch) { b.Commitments[0].FinalStateHash = repotest.UniqueHash() }}, + {"match creation", func(b *repository.TournamentEventBatch) { b.Matches[0].LeftOfTwo = repotest.UniqueHash() }}, + {"leaf seal", func(b *repository.TournamentEventBatch) { b.Matches[0].LeafSeal.LogIndex++ }}, + {"match deletion", func(b *repository.TournamentEventBatch) { b.Matches[0].DeletionTxHash = new(repotest.UniqueHash()) }}, + {"older view", func(b *repository.TournamentEventBatch) { + b.Tournament.Snapshot.AsOfBlock = observerCheckpoint - 1 + }}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + batch := batches[1] + before, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + batch.Tournament.Snapshot.AsOfBlock++ + test.change(batch) + head := batch.Tournament.Snapshot.AsOfBlock + for _, commitment := range batch.Commitments { + commitment.Snapshot.AsOfBlock = head + } + for _, match := range batch.Matches { + match.Snapshot.AsOfBlock = head + } + err = r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, head) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + after, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, before, after, "the earlier tournament refresh must roll back with a later conflict") + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestPassiveObserverConflictingEventRollsBackSnapshot(t *testing.T) { + for _, event := range []string{"advance", "bond"} { + t.Run(event, func(t *testing.T) { + r, app, batches := newPassiveObserverWindow(t) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, batches, observerCheckpoint)) + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 101 + child.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentWinner, + ParentCommitment: child.Snapshot.ParentCommitment, PausedAllowance: 49} + batch := &repository.TournamentEventBatch{Tournament: &child} + if event == "advance" { + advance := *batches[1].MatchAdvances[0] + advance.LeftNode = repotest.UniqueHash() + batch.MatchAdvances = []*MatchAdvanced{&advance} + } else { + bond := *batches[1].BondEvents[0] + refund := *bond.Refund + refund.Success = true + bond.Refund = &refund + batch.BondEvents = []*BondEvent{&bond} + } + err := r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, 101) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + stored, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, batches[1].Tournament.Snapshot, stored.Snapshot) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, storedApp.LastTournamentCheckBlock) + }) + } +} + +func TestUint256DomainBoundsAndExactIntegerScale(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + for _, text := range []string{"0", "1.00", "18446744073709551616", + "115792089237316195423570985008687907853269984665640564039457584007913129639935"} { + var value Uint256 + require.NoError(t, r.db.QueryRow(t.Context(), "SELECT $1::uint256", text).Scan(&value)) + } + for _, text := range []string{"-1", "0.5", "NaN", "Infinity", + "115792089237316195423570985008687907853269984665640564039457584007913129639936"} { + var value Uint256 + err := r.db.QueryRow(t.Context(), "SELECT $1::uint256", text).Scan(&value) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr, text) + require.Equal(t, "23514", pgErr.Code, text) + } +} + +func TestPassiveObserverMatchPhaseRoundTrip(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + tournament.Snapshot.Standing = TournamentStandingMatchesActive + for _, phase := range []MatchPhase{MatchPhaseBisecting, MatchPhaseReadyToSeal, MatchPhaseSealed, MatchPhaseUninitialized} { + t.Run(phase.String(), func(t *testing.T) { + head := tournament.Snapshot.AsOfBlock + 1 + tournament.Snapshot.AsOfBlock = head + tournament.Snapshot.AcceptsJoins = false + match.Snapshot.AsOfBlock = head + match.Snapshot.Phase = phase + switch phase { + case MatchPhaseBisecting: + match.Snapshot.Bisection.SegmentStartPosition = observerUint256(t, "18446744073709551616") + match.Snapshot.Bisection.SegmentStartCycle = observerUint256(t, "18446744073709551617") + case MatchPhaseReadyToSeal: + match.Snapshot.Bisection.CurrentHeight = nil + case MatchPhaseSealed: + match.Snapshot.Bisection = nil + match.Snapshot.Sealed = &MatchSealedSnapshot{AgreeState: common.Hash{}, + DivergencePosition: observerUint256(t, "18446744073709551616"), + DivergenceCycle: observerUint256(t, "18446744073709551617"), + FinalStateOne: repotest.UniqueHash(), FinalStateTwo: repotest.UniqueHash()} + case MatchPhaseUninitialized: + match.Snapshot.Sealed = nil + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber, match.DeletionTxHash, match.DeletionLogIndex = head, new(common.Hash), new(uint64(0)) + tournament.Snapshot.Standing = TournamentStandingRootFailed + tournament.Snapshot.FinishedAtBlock = head + tournament.Snapshot.BondRecovery.Disposition = BondDispositionNoWinner + } + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, + []*repository.TournamentEventBatch{{Tournament: tournament, Matches: []*Match{match}}}, head)) + stored, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.Snapshot, stored.Snapshot) + }) + } +} + +func TestPassiveObserverRejectsInvalidPhasePayload(t *testing.T) { + r, app, _, initial := newTournamentIntegrityFixture(t) + for _, test := range []struct { + name string + change func(*Match) + }{ + {"bisecting without payload", func(m *Match) { m.Snapshot.Bisection = nil }}, + {"ready with height", func(m *Match) { m.Snapshot.Phase = MatchPhaseReadyToSeal }}, + {"sealed with bisection", func(m *Match) { m.Snapshot.Phase = MatchPhaseSealed }}, + {"uninitialized without deletion", func(m *Match) { m.Snapshot.Phase = MatchPhaseUninitialized }}, + } { + t.Run(test.name, func(t *testing.T) { + match := *initial + test.change(&match) + err := r.CreateMatch(t.Context(), app.Name, &match) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "matches_current_phase_check", pgErr.ConstraintName) + }) + } +} + +func TestPassiveObserverEpochFilterIncludesTerminalEpochs(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + accepted := repotest.NewEpochBuilder(app.ID).WithIndex(0).WithStatus(EpochStatus_Closed).Build() + closed := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed).Build() + open := repotest.NewEpochBuilder(app.ID).WithIndex(2).WithStatus(EpochStatus_Open).Build() + closed.TournamentAddress, accepted.TournamentAddress = new(repotest.UniqueAddress()), new(repotest.UniqueAddress()) + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*Epoch][]*Input{closed: {}, accepted: {}, open: {}}, observerCheckpoint)) + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, accepted, EpochStatus_ClaimAccepted) + epochs, total, err := r.ListEpochs(t.Context(), app.Name, + repository.EpochFilter{HasTournament: new(true)}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Equal(t, EpochStatus_ClaimAccepted, epochs[0].Status) + require.Equal(t, EpochStatus_Closed, epochs[1].Status) +} diff --git a/internal/repository/postgres/postgres_repo_test.go b/internal/repository/postgres/postgres_repo_test.go new file mode 100644 index 000000000..94f2b7676 --- /dev/null +++ b/internal/repository/postgres/postgres_repo_test.go @@ -0,0 +1,235 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres_test + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + os.Exit(m.Run()) + } + release, err := db.LockTestPostgres(context.Background(), endpoint) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + code := m.Run() + release() + os.Exit(code) +} + +func TestPostgresRepository(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + + repotest.RunAllSuites(t, func(ctx context.Context, t *testing.T) (repository.Repository, func()) { + t.Helper() + err := db.SetupTestPostgres(endpoint) + require.NoError(t, err) + + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + + return repo, func() { repo.Close() } + }) +} + +func TestPostgresSchemaExecutionOutcomeContract(t *testing.T) { + const ( + advanceMaxCyclesColumn = "advance_max_cycles" + inspectMaxCyclesColumn = "inspect_max_cycles" + ) + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + + ctx := context.Background() + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(ctx)) }) + // The catalog also includes other schemas. Their enum names must not + // change the public schema contract checked below. + for _, name := range []string{"InputCompletionStatus", "ApplicationStatus", "EpochStatus"} { + _, err := conn.Exec(ctx, "CREATE TYPE "+pgx.Identifier{"pg_temp", name}.Sanitize()+" AS ENUM ('unrelated')") + require.NoError(t, err) + } + + rows, err := conn.Query(ctx, ` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' AND pg_type.typname = 'InputCompletionStatus' + ORDER BY enumsortorder`) + require.NoError(t, err) + labels, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + require.Equal(t, []string{ + "NONE", + "ACCEPTED", + "REJECTED", + "EXCEPTION", + "MACHINE_HALTED", + "OVERFLOW", + "UNEXPECTED_YIELD", + "INVALID_OUTPUTS_ROOT", + }, labels) + + rows, err = conn.Query(ctx, ` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' AND pg_type.typname = 'ApplicationStatus' + ORDER BY enumsortorder`) + require.NoError(t, err) + labels, err = pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + require.Equal(t, []string{ + "OK", + "FAILED", + "DIVERGED", + "CORRUPTED", + "GUEST_EXCEPTION", + "MACHINE_HALTED", + "MCYCLE_OVERFLOW", + "UNEXPECTED_YIELD", + "INVALID_OUTPUTS_ROOT", + }, labels) + + rows, err = conn.Query(ctx, ` + SELECT enumlabel + FROM pg_enum + JOIN pg_type ON pg_type.oid = pg_enum.enumtypid + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' AND pg_type.typname = 'EpochStatus' + ORDER BY enumsortorder`) + require.NoError(t, err) + labels, err = pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + expectedEpochLabels := make([]string, len(model.EpochStatusAllValues)) + for i, status := range model.EpochStatusAllValues { + expectedEpochLabels[i] = status.String() + } + require.Equal(t, expectedEpochLabels, labels) + + rows, err = conn.Query(ctx, ` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'execution_parameters'`) + require.NoError(t, err) + columns, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + require.Contains(t, columns, advanceMaxCyclesColumn) + require.Contains(t, columns, inspectMaxCyclesColumn) + require.Contains(t, columns, "advance_inc_cycles") + require.Contains(t, columns, "inspect_inc_cycles") + + rows, err = conn.Query(ctx, ` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'epoch'`) + require.NoError(t, err) + epochColumns, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + for _, column := range []string{ + "iflags_y_data_block", + "iflags_y_proof", + "htif_tohost_data_block", + "htif_tohost_proof", + } { + require.Contains(t, epochColumns, column) + } + + for _, column := range []string{advanceMaxCyclesColumn, inspectMaxCyclesColumn} { + var defaultValue string + err := conn.QueryRow(ctx, ` + SELECT column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'execution_parameters' + AND column_name = $1`, column).Scan(&defaultValue) + require.NoError(t, err) + require.Equal(t, "0", defaultValue) + } + + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + app := repotest.NewApplicationBuilder().Create(ctx, t, repo) + for _, column := range []string{advanceMaxCyclesColumn, inspectMaxCyclesColumn} { + for _, value := range []int64{0, int64(model.MaxExecutionCycleSpan)} { + _, err := conn.Exec(ctx, fmt.Sprintf( + `UPDATE execution_parameters SET %s = $1 WHERE application_id = $2`, + column, + ), value, app.ID) + require.NoError(t, err, "%s must accept %d", column, value) + } + } + for _, test := range []struct { + column string + value int64 + }{ + {"advance_inc_cycles", 0}, + {"inspect_inc_cycles", 0}, + {advanceMaxCyclesColumn, -1}, + {inspectMaxCyclesColumn, -1}, + {advanceMaxCyclesColumn, int64(model.MaxExecutionCycles)}, + {inspectMaxCyclesColumn, int64(model.MaxExecutionCycles)}, + } { + _, err := conn.Exec(ctx, fmt.Sprintf( + `UPDATE execution_parameters SET %s = $1 WHERE application_id = $2`, + test.column, + ), test.value, app.ID) + requirePostgresConstraint(t, err, "execution_parameters_"+test.column+"_check") + } + + seed := repotest.Seed(ctx, t, repo) + zeroHash := make([]byte, 32) + _, err = conn.Exec(ctx, ` + UPDATE epoch + SET machine_hash = $1 + WHERE application_id = $2 AND index = $3`, + zeroHash, seed.App.ID, seed.Epoch.Index) + requirePostgresConstraint(t, err, "epoch_state_proof_tuple_check") + + _, err = conn.Exec(ctx, ` + UPDATE epoch + SET machine_hash = $1::bytea, + tx_buffer_data_block = $1::bytea, + tx_buffer_proof = ARRAY[NULL::bytea] || array_fill($1::bytea, ARRAY[58]), + iflags_y_data_block = $1::bytea, + iflags_y_proof = array_fill($1::bytea, ARRAY[59]), + htif_tohost_data_block = $1::bytea, + htif_tohost_proof = array_fill($1::bytea, ARRAY[59]) + WHERE application_id = $2 AND index = $3`, + zeroHash, seed.App.ID, seed.Epoch.Index) + requirePostgresConstraint(t, err, "epoch_tx_buffer_proof_elements_check") +} + +func requirePostgresConstraint(t *testing.T, err error, constraint string) { + t.Helper() + require.Error(t, err) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, constraint, pgErr.ConstraintName, "PostgreSQL error: %s", pgErr.Message) +} diff --git a/internal/repository/postgres/prt_foreclosure_publication_test.go b/internal/repository/postgres/prt_foreclosure_publication_test.go new file mode 100644 index 000000000..63e35ee5a --- /dev/null +++ b/internal/repository/postgres/prt_foreclosure_publication_test.go @@ -0,0 +1,93 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +// PRT's selected CLOSED object can precede the accepted-state proof publication. +// A refused drain must preserve the durable IP prefix, including after a row wait. +func TestPRTForeclosureDrainPreservesConcurrentPublication(t *testing.T) { + for _, wait := range []bool{false, true} { + name := "publication before drain" + if wait { + name = "publication while drain waits" + } + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app, selected := seedEpochPublication(t, r) + require.Equal(t, model.EpochStatus_Closed, selected.Status) + require.NoError(t, r.UpdateApplicationForeclosure(ctx, app.ID, 20, repotest.UniqueHash(), 20)) + proof := repotest.DummyStateProof() + if wait { + publishWhileDrainWaits(ctx, t, r, app, selected, proof) + } else { + require.NoError(t, r.UpdateEpochInputsProcessed(ctx, app.Name, selected.Index, proof)) + require.ErrorIs(t, r.UpdateEpochWithForeclosedClaim(ctx, app.ID, selected.Index), repository.ErrNoUpdate) + } + stored, err := r.GetEpoch(ctx, app.Name, selected.Index) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_InputsProcessed, stored.Status) + require.NotNil(t, stored.MachineHash) + require.Equal(t, proof.MachineHash, *stored.MachineHash) + current, err := r.GetApplication(ctx, app.Name) + require.NoError(t, err) + require.True(t, current.Enabled, "this refusal requires neither removal nor disable") + require.Equal(t, model.ApplicationStatus_OK, current.Status) + + }) + } +} + +func publishWhileDrainWaits( + ctx context.Context, t *testing.T, r *PostgresRepository, app *model.Application, epoch *model.Epoch, proof *model.StateProof, +) { + t.Helper() + endpoint, err := db.GetTestDatabaseEndpoint() + require.NoError(t, err) + control, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + defer func() { _ = control.Close(context.Background()) }() + tx, err := control.Begin(ctx) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + var locked uint64 + require.NoError(t, tx.QueryRow(ctx, "SELECT index FROM epoch WHERE application_id=$1 AND index=$2 FOR UPDATE", + app.ID, epoch.Index).Scan(&locked)) + result := make(chan error, 1) + go func() { result <- r.UpdateEpochWithForeclosedClaim(ctx, app.ID, epoch.Index) }() + require.Eventually(t, func() bool { + var waiting bool + queryErr := r.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_stat_activity + WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)))`, control.PgConn().PID()).Scan(&waiting) + return queryErr == nil && waiting + }, 5*time.Second, 10*time.Millisecond) + // Execute the production publication's atomic status/proof write inside the + // held transaction. The drain began on CLOSED and must recheck it after IP commits. + _, err = tx.Exec(ctx, `UPDATE epoch SET status='INPUTS_PROCESSED',machine_hash=$3, + tx_buffer_data_block=$4,tx_buffer_proof=$5,iflags_y_data_block=$6,iflags_y_proof=$7, + htif_tohost_data_block=$8,htif_tohost_proof=$9 WHERE application_id=$1 AND index=$2`, app.ID, epoch.Index, + proof.MachineHash[:], proof.TxBufferDataBlock[:], encodeSiblings(proof.TxBufferProof), proof.IflagsYDataBlock[:], + encodeSiblings(proof.IflagsYProof), proof.HtifTohostDataBlock[:], encodeSiblings(proof.HtifTohostProof)) + require.NoError(t, err) + require.NoError(t, tx.Commit(ctx)) + select { + case err = <-result: + require.ErrorIs(t, err, repository.ErrNoUpdate) + case <-ctx.Done(): + t.Fatal("drain did not return after publication committed:", ctx.Err()) + } +} diff --git a/internal/repository/postgres/prt_rows.go b/internal/repository/postgres/prt_rows.go new file mode 100644 index 000000000..cf0214de1 --- /dev/null +++ b/internal/repository/postgres/prt_rows.go @@ -0,0 +1,626 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func nullableAddressBytes(value *common.Address) any { + if value == nil { + return nil + } + return value.Bytes() +} + +var tournamentColumns = postgres.ColumnList{ + table.Tournaments.ApplicationID, + table.Tournaments.EpochIndex, + table.Tournaments.Address, + table.Tournaments.ParentTournamentAddress, + table.Tournaments.ParentMatchIDHash, + table.Tournaments.MaxLevel, + table.Tournaments.Level, + table.Tournaments.Log2step, + table.Tournaments.Height, + table.Tournaments.InitialHash, + table.Tournaments.BaseCycle, + table.Tournaments.Kind, + table.Tournaments.StartInstant, + table.Tournaments.Allowance, + table.Tournaments.BondValue, + table.Tournaments.ContestedFinalStateOne, + table.Tournaments.ContestedFinalStateTwo, + table.Tournaments.CreationBlockNumber, + table.Tournaments.CreationTxHash, + table.Tournaments.CreationLogIndex, + table.Tournaments.AsOfBlock, + table.Tournaments.Standing, + table.Tournaments.AcceptsJoins, + table.Tournaments.Candidate, + table.Tournaments.WinnerCommitment, + table.Tournaments.FinalStateHash, + table.Tournaments.FinishedAtBlock, + table.Tournaments.ParentCommitment, + table.Tournaments.WinnerExpiresAt, + table.Tournaments.InnerDisposition, + table.Tournaments.InnerParentCommitment, + table.Tournaments.InnerPausedAllowance, + table.Tournaments.BondDisposition, + table.Tournaments.BondClaimer, + table.Tournaments.BondPayment, +} + +func tournamentValues(v *Tournament) []any { + var creationBlock, creationTx, creationLog any + if v.CreationEvent != nil { + creationBlock = uint64Expr(v.CreationEvent.BlockNumber) + creationTx = v.CreationEvent.TxHash.Bytes() + creationLog = uint64Expr(v.CreationEvent.LogIndex) + } + var innerDisposition, innerParent, innerAllowance any + if v.Snapshot.InnerResult != nil { + innerDisposition = v.Snapshot.InnerResult.Disposition + innerParent = hashToBytes(v.Snapshot.InnerResult.ParentCommitment) + innerAllowance = uint64Expr(v.Snapshot.InnerResult.PausedAllowance) + } + var bondPayment any + if v.Snapshot.BondRecovery.Payment != nil { + bondPayment = *v.Snapshot.BondRecovery.Payment + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.Address.Bytes(), + nullableAddressBytes(v.ParentTournamentAddress), + hashToBytes(v.ParentMatchIDHash), + uint64Expr(v.MaxLevel), + uint64Expr(v.Level), + uint64Expr(v.Log2Step), + uint64Expr(v.Height), + v.InitialHash.Bytes(), + v.BaseCycle, + v.Kind, + uint64Expr(v.StartInstant), + uint64Expr(v.Allowance), + v.BondValue, + hashToBytes(v.ContestedFinalStateOne), + hashToBytes(v.ContestedFinalStateTwo), + creationBlock, + creationTx, + creationLog, + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Standing, + v.Snapshot.AcceptsJoins, + hashToBytes(v.Snapshot.Candidate), + hashToBytes(v.Snapshot.WinnerCommitment), + hashToBytes(v.Snapshot.FinalStateHash), + uint64Expr(v.Snapshot.FinishedAtBlock), + hashToBytes(v.Snapshot.ParentCommitment), + uint64Expr(v.Snapshot.WinnerExpiresAt), + innerDisposition, + innerParent, + innerAllowance, + v.Snapshot.BondRecovery.Disposition, + nullableAddressBytes(v.Snapshot.BondRecovery.Claimer), + bondPayment, + } +} + +func scanTournament(row pgx.Row) (*Tournament, error) { + var v Tournament + var creationBlock, creationLog *uint64 + var creationTx *common.Hash + var innerDisposition *InnerTournamentDisposition + var innerParent *common.Hash + var innerAllowance *uint64 + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.Address, + &v.ParentTournamentAddress, + &v.ParentMatchIDHash, + &v.MaxLevel, + &v.Level, + &v.Log2Step, + &v.Height, + &v.InitialHash, + &v.BaseCycle, + &v.Kind, + &v.StartInstant, + &v.Allowance, + &v.BondValue, + &v.ContestedFinalStateOne, + &v.ContestedFinalStateTwo, + &creationBlock, + &creationTx, + &creationLog, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Standing, + &v.Snapshot.AcceptsJoins, + &v.Snapshot.Candidate, + &v.Snapshot.WinnerCommitment, + &v.Snapshot.FinalStateHash, + &v.Snapshot.FinishedAtBlock, + &v.Snapshot.ParentCommitment, + &v.Snapshot.WinnerExpiresAt, + &innerDisposition, + &innerParent, + &innerAllowance, + &v.Snapshot.BondRecovery.Disposition, + &v.Snapshot.BondRecovery.Claimer, + &v.Snapshot.BondRecovery.Payment, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if creationBlock != nil { + v.CreationEvent = &TournamentCreationEvent{BlockNumber: *creationBlock, TxHash: *creationTx, LogIndex: *creationLog} + } + if innerDisposition != nil { + v.Snapshot.InnerResult = &TournamentInnerResult{ + Disposition: *innerDisposition, ParentCommitment: innerParent, PausedAllowance: *innerAllowance, + } + } + return &v, nil +} + +var commitmentColumns = postgres.ColumnList{ + table.Commitments.ApplicationID, + table.Commitments.EpochIndex, + table.Commitments.TournamentAddress, + table.Commitments.Commitment, + table.Commitments.FinalStateHash, + table.Commitments.SubmitterAddress, + table.Commitments.BlockNumber, + table.Commitments.TxHash, + table.Commitments.LogIndex, + table.Commitments.AsOfBlock, + table.Commitments.Claimer, + table.Commitments.ClockRunning, + table.Commitments.ClockDeadline, + table.Commitments.ClockAllowance, +} + +func commitmentValues(v *Commitment) []any { + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.Commitment.Bytes(), + v.FinalStateHash.Bytes(), + v.SubmitterAddress.Bytes(), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Claimer.Bytes(), + v.Snapshot.ClockRunning, + uint64Expr(v.Snapshot.ClockDeadline), + uint64Expr(v.Snapshot.ClockAllowance), + } +} + +func scanCommitment(row pgx.Row) (*Commitment, error) { + var v Commitment + + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.Commitment, + &v.FinalStateHash, + &v.SubmitterAddress, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Claimer, + &v.Snapshot.ClockRunning, + &v.Snapshot.ClockDeadline, + &v.Snapshot.ClockAllowance, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + + return &v, nil +} + +var matchColumns = postgres.ColumnList{ + table.Matches.ApplicationID, + table.Matches.EpochIndex, + table.Matches.TournamentAddress, + table.Matches.IDHash, + table.Matches.CommitmentOne, + table.Matches.CommitmentTwo, + table.Matches.LeftOfTwo, + table.Matches.BlockNumber, + table.Matches.TxHash, + table.Matches.LogIndex, + table.Matches.EliminableAt, + table.Matches.ResponderDeadline, + table.Matches.SealEliminableAt, + table.Matches.SealAgreeState, + table.Matches.SealDivergencePosition, + table.Matches.SealFinalStateOne, + table.Matches.SealFinalStateTwo, + table.Matches.SealDeadlineOne, + table.Matches.SealDeadlineTwo, + table.Matches.SealBlockNumber, + table.Matches.SealTxHash, + table.Matches.SealLogIndex, + table.Matches.AsOfBlock, + table.Matches.Phase, + table.Matches.TimeoutOutcome, + table.Matches.DeferredCharge, + table.Matches.RevealingParent, + table.Matches.WaitingLeft, + table.Matches.WaitingRight, + table.Matches.SegmentStartPosition, + table.Matches.SegmentStartCycle, + table.Matches.CurrentHeight, + table.Matches.Responder, + table.Matches.AgreeState, + table.Matches.DivergencePosition, + table.Matches.DivergenceCycle, + table.Matches.FinalStateOne, + table.Matches.FinalStateTwo, + table.Matches.Winner, + table.Matches.DeletionReason, + table.Matches.DeletionBlockNumber, + table.Matches.DeletionTxHash, + table.Matches.DeletionLogIndex, +} + +func matchValues(v *Match) []any { + var sealEliminable, sealAgree, sealPosition, sealOne, sealTwo, sealDeadlineOne, sealDeadlineTwo, sealBlock, sealTx, sealLog any + if v.LeafSeal != nil { + sealEliminable = uint64Expr(v.LeafSeal.EliminableAt) + sealAgree = v.LeafSeal.AgreeState.Bytes() + sealPosition = v.LeafSeal.DivergencePosition + sealOne, sealTwo = v.LeafSeal.FinalStateOne.Bytes(), v.LeafSeal.FinalStateTwo.Bytes() + sealDeadlineOne, sealDeadlineTwo = uint64Expr(v.LeafSeal.DeadlineOne), uint64Expr(v.LeafSeal.DeadlineTwo) + sealBlock = uint64Expr(v.LeafSeal.BlockNumber) + sealTx = v.LeafSeal.TxHash.Bytes() + sealLog = uint64Expr(v.LeafSeal.LogIndex) + } + var revealingParent, waitingLeft, waitingRight, segmentPosition, segmentCycle, currentHeight, responder any + if s := v.Snapshot.Bisection; s != nil { + revealingParent, waitingLeft, waitingRight = s.RevealingParent.Bytes(), s.WaitingLeft.Bytes(), s.WaitingRight.Bytes() + segmentPosition, segmentCycle, responder = s.SegmentStartPosition, s.SegmentStartCycle, s.Responder + if s.CurrentHeight != nil { + currentHeight = uint64Expr(*s.CurrentHeight) + } + } + var agreeState, divergencePosition, divergenceCycle, finalStateOne, finalStateTwo any + if s := v.Snapshot.Sealed; s != nil { + agreeState, finalStateOne, finalStateTwo = s.AgreeState.Bytes(), s.FinalStateOne.Bytes(), s.FinalStateTwo.Bytes() + divergencePosition, divergenceCycle = s.DivergencePosition, s.DivergenceCycle + } + var deletionLog any + if v.DeletionLogIndex != nil { + deletionLog = uint64Expr(*v.DeletionLogIndex) + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.IDHash.Bytes(), + v.CommitmentOne.Bytes(), + v.CommitmentTwo.Bytes(), + v.LeftOfTwo.Bytes(), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + uint64Expr(v.EliminableAt), + uint64Expr(v.ResponderDeadline), + sealEliminable, + sealAgree, + sealPosition, + sealOne, + sealTwo, + sealDeadlineOne, + sealDeadlineTwo, + sealBlock, + sealTx, + sealLog, + uint64Expr(v.Snapshot.AsOfBlock), + v.Snapshot.Phase, + v.Snapshot.TimeoutOutcome, + uint64Expr(v.Snapshot.DeferredCharge), + revealingParent, + waitingLeft, + waitingRight, + segmentPosition, + segmentCycle, + currentHeight, + responder, + agreeState, + divergencePosition, + divergenceCycle, + finalStateOne, + finalStateTwo, + v.Winner, + v.DeletionReason, + uint64Expr(v.DeletionBlockNumber), + hashToBytes(v.DeletionTxHash), + deletionLog, + } +} + +func scanMatch(row pgx.Row) (*Match, error) { + var v Match + var sealEliminable, sealDeadlineOne, sealDeadlineTwo, sealBlock, sealLog *uint64 + var sealAgree, sealOne, sealTwo *common.Hash + var sealPosition *Uint256 + var sealTx *common.Hash + var revealingParent, waitingLeft, waitingRight *common.Hash + var segmentPosition, segmentCycle *Uint256 + var currentHeight *uint64 + var responder *CommitmentSide + var agreeState, finalStateOne, finalStateTwo *common.Hash + var divergencePosition, divergenceCycle *Uint256 + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.IDHash, + &v.CommitmentOne, + &v.CommitmentTwo, + &v.LeftOfTwo, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.EliminableAt, + &v.ResponderDeadline, + &sealEliminable, + &sealAgree, + &sealPosition, + &sealOne, + &sealTwo, + &sealDeadlineOne, + &sealDeadlineTwo, + &sealBlock, + &sealTx, + &sealLog, + &v.Snapshot.AsOfBlock, + &v.Snapshot.Phase, + &v.Snapshot.TimeoutOutcome, + &v.Snapshot.DeferredCharge, + &revealingParent, + &waitingLeft, + &waitingRight, + &segmentPosition, + &segmentCycle, + ¤tHeight, + &responder, + &agreeState, + &divergencePosition, + &divergenceCycle, + &finalStateOne, + &finalStateTwo, + &v.Winner, + &v.DeletionReason, + &v.DeletionBlockNumber, + &v.DeletionTxHash, + &v.DeletionLogIndex, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if sealBlock != nil { + v.LeafSeal = &LeafMatchSeal{ + AgreeState: *sealAgree, DivergencePosition: *sealPosition, FinalStateOne: *sealOne, FinalStateTwo: *sealTwo, + DeadlineOne: *sealDeadlineOne, DeadlineTwo: *sealDeadlineTwo, EliminableAt: *sealEliminable, + BlockNumber: *sealBlock, TxHash: *sealTx, LogIndex: *sealLog, + } + } + if revealingParent != nil { + v.Snapshot.Bisection = &MatchBisectionSnapshot{ + RevealingParent: *revealingParent, WaitingLeft: *waitingLeft, WaitingRight: *waitingRight, + SegmentStartPosition: *segmentPosition, SegmentStartCycle: *segmentCycle, CurrentHeight: currentHeight, Responder: *responder, + } + } + if agreeState != nil { + v.Snapshot.Sealed = &MatchSealedSnapshot{ + AgreeState: *agreeState, DivergencePosition: *divergencePosition, DivergenceCycle: *divergenceCycle, + FinalStateOne: *finalStateOne, FinalStateTwo: *finalStateTwo, + } + } + return &v, nil +} + +var matchAdvanceColumns = postgres.ColumnList{ + table.MatchAdvances.ApplicationID, + table.MatchAdvances.EpochIndex, + table.MatchAdvances.TournamentAddress, + table.MatchAdvances.IDHash, + table.MatchAdvances.OtherParent, + table.MatchAdvances.LeftNode, + table.MatchAdvances.SegmentStartPosition, + table.MatchAdvances.EliminableAt, + table.MatchAdvances.CurrentHeight, + table.MatchAdvances.ResponderDeadline, + table.MatchAdvances.BlockNumber, + table.MatchAdvances.TxHash, + table.MatchAdvances.LogIndex, +} + +func matchAdvanceValues(v *MatchAdvanced) []any { + + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.IDHash.Bytes(), + v.OtherParent.Bytes(), + v.LeftNode.Bytes(), + v.SegmentStartPosition, + uint64Expr(v.EliminableAt), + uint64Expr(v.CurrentHeight), + uint64Expr(v.ResponderDeadline), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + } +} + +func scanMatchAdvanced(row pgx.Row) (*MatchAdvanced, error) { + var v MatchAdvanced + + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.IDHash, + &v.OtherParent, + &v.LeftNode, + &v.SegmentStartPosition, + &v.EliminableAt, + &v.CurrentHeight, + &v.ResponderDeadline, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + + return &v, nil +} + +var bondEventColumns = postgres.ColumnList{ + table.BondEvents.ApplicationID, + table.BondEvents.EpochIndex, + table.BondEvents.TournamentAddress, + table.BondEvents.Type, + table.BondEvents.BlockNumber, + table.BondEvents.TxHash, + table.BondEvents.LogIndex, + table.BondEvents.Recipient, + table.BondEvents.Value, + table.BondEvents.Success, + table.BondEvents.Commitment, + table.BondEvents.Claimer, + table.BondEvents.Payment, + table.BondEvents.Burned, +} + +func bondEventValues(v *BondEvent) []any { + var recipient, value, success, commitment, claimer, payment, burned any + if v.Refund != nil { + recipient, value, success = v.Refund.Recipient.Bytes(), v.Refund.Value, v.Refund.Success + } + if v.Recovery != nil { + commitment, claimer = v.Recovery.Commitment.Bytes(), v.Recovery.Claimer.Bytes() + payment, burned = v.Recovery.Payment, v.Recovery.Burned + } + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.Type, + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + recipient, + value, + success, + commitment, + claimer, + payment, + burned, + } +} + +func scanBondEvent(row pgx.Row) (*BondEvent, error) { + var v BondEvent + var recipient, claimer *common.Address + var value, payment, burned *Uint256 + var success *bool + var commitment *common.Hash + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.Type, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &recipient, + &value, + &success, + &commitment, + &claimer, + &payment, + &burned, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + if recipient != nil { + v.Refund = &PartialBondRefund{Recipient: *recipient, Value: *value, Success: *success} + } + if commitment != nil { + v.Recovery = &BondRecovered{Commitment: *commitment, Claimer: *claimer, Payment: *payment, Burned: *burned} + } + return &v, nil +} + +var standingChangeColumns = postgres.ColumnList{ + table.StandingChanges.ApplicationID, + table.StandingChanges.EpochIndex, + table.StandingChanges.TournamentAddress, + table.StandingChanges.MatchCount, + table.StandingChanges.Dangling, + table.StandingChanges.ParentCommitment, + table.StandingChanges.ResultAt, + table.StandingChanges.WinnerExpiresAt, + table.StandingChanges.BlockNumber, + table.StandingChanges.TxHash, + table.StandingChanges.LogIndex, +} + +func standingChangeValues(v *StandingChanged) []any { + return []any{ + v.ApplicationID, + uint64Expr(v.EpochIndex), + v.TournamentAddress.Bytes(), + v.MatchCount, + hashToBytes(v.Dangling), + hashToBytes(v.ParentCommitment), + uint64Expr(v.ResultAt), + uint64Expr(v.WinnerExpiresAt), + uint64Expr(v.BlockNumber), + v.TxHash.Bytes(), + uint64Expr(v.LogIndex), + } +} + +func scanStandingChanged(row pgx.Row) (*StandingChanged, error) { + var v StandingChanged + if err := row.Scan( + &v.ApplicationID, + &v.EpochIndex, + &v.TournamentAddress, + &v.MatchCount, + &v.Dangling, + &v.ParentCommitment, + &v.ResultAt, + &v.WinnerExpiresAt, + &v.BlockNumber, + &v.TxHash, + &v.LogIndex, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + return &v, nil +} diff --git a/internal/repository/postgres/replay.go b/internal/repository/postgres/replay.go new file mode 100644 index 000000000..5171f242d --- /dev/null +++ b/internal/repository/postgres/replay.go @@ -0,0 +1,546 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +var replayCompletedStatuses = []postgres.Expression{ + postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String()), + postgres.NewEnumValue(model.InputCompletionStatus_Rejected.String()), + postgres.NewEnumValue(model.InputCompletionStatus_Exception.String()), + postgres.NewEnumValue(model.InputCompletionStatus_MachineHalted.String()), + postgres.NewEnumValue(model.InputCompletionStatus_Overflow.String()), + postgres.NewEnumValue(model.InputCompletionStatus_UnexpectedYield.String()), + postgres.NewEnumValue(model.InputCompletionStatus_InvalidOutputsRoot.String()), +} + +func replayCompletedStatus(status postgres.StringExpression) postgres.BoolExpression { + return status.IN(replayCompletedStatuses...) +} + +// replayChildSpec captures the schema shared by replay outputs and reports. +// State hashes deliberately use a separate path because their ordering and +// repetition invariants are different. +type replayChildSpec struct { + table postgres.Table + applicationID postgres.IntegerExpression + inputIndex postgres.FloatExpression + index postgres.FloatExpression + rawData postgres.ByteaExpression + childKind repository.ReplayEvidenceKind + appendToRecord func(*model.ReplayRecord, []byte) +} + +var ( + replayOutputSpec = replayChildSpec{ + table: table.Output, + applicationID: table.Output.InputEpochApplicationID, + inputIndex: table.Output.InputIndex, + index: table.Output.Index, + rawData: table.Output.RawData, + childKind: repository.ReplayEvidenceOutput, + appendToRecord: func(record *model.ReplayRecord, data []byte) { + record.Outputs = append(record.Outputs, data) + }, + } + replayReportSpec = replayChildSpec{ + table: table.Report, + applicationID: table.Report.InputEpochApplicationID, + inputIndex: table.Report.InputIndex, + index: table.Report.Index, + rawData: table.Report.RawData, + childKind: repository.ReplayEvidenceReport, + appendToRecord: func(record *model.ReplayRecord, data []byte) { + record.Reports = append(record.Reports, data) + }, + } +) + +// ReplaySummary reads the application identity, consensus, and processed-input +// count from one database snapshot. It checks that the count matches the number +// of completed inputs and that completed input indexes are contiguous from zero. +// In Full mode, it also checks PRT state-hash ordering. For non-PRT applications, +// it checks that no state hashes exist. All queries run in the same short +// repeatable-read transaction. +func (r *PostgresRepository) ReplaySummary( + ctx context.Context, + applicationAddress common.Address, + verification repository.ReplayVerificationLevel, +) (model.ReplaySummary, error) { + if !verification.IsValid() { + return model.ReplaySummary{}, fmt.Errorf("unsupported replay verification level %d", verification) + } + whereApp := table.Application.IapplicationAddress.EQ(postgres.Bytea(applicationAddress.Bytes())) + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return model.ReplaySummary{}, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + appStmt := table.Application.SELECT( + table.Application.ID, + table.Application.ConsensusType, + table.Application.ProcessedInputs, + ).WHERE(whereApp) + appSQL, appArgs := appStmt.Sql() + var summary model.ReplaySummary + if err := tx.QueryRow(ctx, appSQL, appArgs...).Scan( + &summary.ApplicationID, + &summary.Consensus, + &summary.ProcessedInputs, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return model.ReplaySummary{}, repository.ErrNotFound + } + return model.ReplaySummary{}, err + } + + whereInputApp := table.Input.EpochApplicationID.EQ(postgres.Int64(summary.ApplicationID)) + countStmt := table.Input.SELECT(postgres.COUNT(postgres.STAR)). + WHERE(whereInputApp.AND(replayCompletedStatus(table.Input.Status))) + completedInputCount, err := countFromTx(ctx, tx, countStmt) + if err != nil { + return model.ReplaySummary{}, err + } + if completedInputCount != summary.ProcessedInputs { + return model.ReplaySummary{}, &repository.ReplayStructureViolationError{ + Kind: repository.ReplayStructureProcessedInputCount, + InputIndex: completedInputCount, + ApplicationProcessedInputs: summary.ProcessedInputs, + CompletedInputCount: completedInputCount, + } + } + if err := validateReplayCompletedPrefix(ctx, tx, whereInputApp, completedInputCount); err != nil { + return model.ReplaySummary{}, err + } + if verification == repository.ReplayVerificationFull { + whereStateHashApp := table.StateHashes.InputEpochApplicationID.EQ( + postgres.Int64(summary.ApplicationID), + ) + var err error + switch summary.Consensus { + case model.Consensus_PRT: + err = validateReplayStateHashOrdering(ctx, tx, whereStateHashApp) + case model.Consensus_Authority, model.Consensus_Quorum: + err = validateReplayStateHashesAbsent(ctx, tx, whereStateHashApp) + default: + err = fmt.Errorf("unsupported replay consensus %q", summary.Consensus) + } + if err != nil { + return model.ReplaySummary{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return model.ReplaySummary{}, err + } + return summary, nil +} + +func scanOptionalRow( + ctx context.Context, + tx pgx.Tx, + stmt postgres.SelectStatement, + dest ...any, +) (bool, error) { + sqlStr, args := stmt.Sql() + err := tx.QueryRow(ctx, sqlStr, args...).Scan(dest...) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +func replayStructureViolation( + kind repository.ReplayStructureViolationKind, + epochIndex uint64, + inputIndex uint64, + evidenceIndex uint64, +) *repository.ReplayStructureViolationError { + return &repository.ReplayStructureViolationError{ + Kind: kind, + EpochIndex: &epochIndex, + InputIndex: inputIndex, + EvidenceIndex: evidenceIndex, + } +} + +func validateReplayCompletedPrefix( + ctx context.Context, + tx pgx.Tx, + whereInputApp postgres.BoolExpression, + total uint64, +) error { + if total == 0 { + return nil + } + stmt := table.Input.SELECT(table.Input.Index). + WHERE(whereInputApp.AND(replayCompletedStatus(table.Input.Status))). + ORDER_BY(table.Input.Index.DESC()). + LIMIT(1) + sqlStr, args := stmt.Sql() + var lastInputIndex uint64 + if err := tx.QueryRow(ctx, sqlStr, args...).Scan(&lastInputIndex); err != nil { + return err + } + expectedLastInputIndex := total - 1 + // Input indexes are non-negative and unique by primary key. Therefore, + // total distinct rows whose maximum index is total-1 must be exactly the + // contiguous sequence 0..total-1; a gap would force a larger maximum. + if lastInputIndex != expectedLastInputIndex { + return &repository.ReplayStructureViolationError{ + Kind: repository.ReplayStructureCompletedInputSequence, + InputIndex: lastInputIndex, + ExpectedIndex: expectedLastInputIndex, + } + } + return nil +} + +func validateReplayStateHashOrdering( + ctx context.Context, + tx pgx.Tx, + whereStateHashApp postgres.BoolExpression, +) error { + orderedStateHashes := table.StateHashes.SELECT( + table.StateHashes.EpochIndex.AS("epoch_index"), + table.StateHashes.Index.AS("state_hash_index"), + table.StateHashes.InputIndex.AS("input_index"), + postgres.ROW_NUMBER().OVER( + postgres.PARTITION_BY(table.StateHashes.EpochIndex). + ORDER_BY(table.StateHashes.Index), + ).AS("ordinal"), + postgres.LAG(table.StateHashes.InputIndex).OVER( + postgres.PARTITION_BY(table.StateHashes.EpochIndex). + ORDER_BY(table.StateHashes.Index), + ).AS("previous_input_index"), + ).WHERE(whereStateHashApp).AsTable("ordered_state_hash") + + epochIndexColumn := postgres.FloatColumn("epoch_index").From(orderedStateHashes) + stateHashIndexColumn := postgres.FloatColumn("state_hash_index").From(orderedStateHashes) + inputIndexColumn := postgres.FloatColumn("input_index").From(orderedStateHashes) + ordinalColumn := postgres.FloatColumn("ordinal").From(orderedStateHashes) + previousInputIndexColumn := postgres.FloatColumn("previous_input_index").From(orderedStateHashes) + stmt := orderedStateHashes.SELECT( + epochIndexColumn, + stateHashIndexColumn, + inputIndexColumn, + ordinalColumn, + postgres.COALESCE(previousInputIndexColumn, inputIndexColumn), + previousInputIndexColumn.IS_NOT_NULL(), + ).WHERE( + stateHashIndexColumn.NOT_EQ(ordinalColumn.SUB(postgres.Float(1))).OR( + previousInputIndexColumn.IS_NOT_NULL().AND( + inputIndexColumn.LT(previousInputIndexColumn), + ), + ), + ).ORDER_BY(epochIndexColumn.ASC(), stateHashIndexColumn.ASC()).LIMIT(1) + + var epochIndex, stateHashIndex, inputIndex, ordinal, previousInputIndex uint64 + var hasPreviousInput bool + found, err := scanOptionalRow(ctx, tx, stmt, + &epochIndex, + &stateHashIndex, + &inputIndex, + &ordinal, + &previousInputIndex, + &hasPreviousInput, + ) + if err != nil { + return err + } + if !found { + return nil + } + if stateHashIndex != ordinal-1 { + violation := replayStructureViolation( + repository.ReplayStructureStateHashIndexSequence, + epochIndex, + inputIndex, + stateHashIndex, + ) + violation.ExpectedIndex = ordinal - 1 + return violation + } + violation := replayStructureViolation( + repository.ReplayStructureStateHashInputOrder, + epochIndex, + inputIndex, + stateHashIndex, + ) + if hasPreviousInput { + violation.PreviousInputIndex = previousInputIndex + } + return violation +} + +func validateReplayStateHashesAbsent( + ctx context.Context, + tx pgx.Tx, + whereStateHashApp postgres.BoolExpression, +) error { + stmt := table.StateHashes.SELECT( + table.StateHashes.EpochIndex, + table.StateHashes.InputIndex, + table.StateHashes.Index, + ).WHERE(whereStateHashApp). + ORDER_BY(table.StateHashes.EpochIndex.ASC(), table.StateHashes.Index.ASC()). + LIMIT(1) + + var epochIndex, inputIndex, stateHashIndex uint64 + found, err := scanOptionalRow( + ctx, tx, stmt, + &epochIndex, + &inputIndex, + &stateHashIndex, + ) + if err != nil { + return err + } + if !found { + return nil + } + return replayStructureViolation( + repository.ReplayStructureUnexpectedStateHash, + epochIndex, + inputIndex, + stateHashIndex, + ) +} + +// ReplayPage returns one keyset page from the summary's fixed application and +// high-water mark. Canonical pages query inputs only. Full pages load child +// evidence in the same short repeatable-read transaction. +func (r *PostgresRepository) ReplayPage( + ctx context.Context, + request repository.ReplayPageRequest, +) ([]*model.ReplayRecord, error) { + if !request.Verification.IsValid() { + return nil, fmt.Errorf("unsupported replay verification level %d", request.Verification) + } + if request.ApplicationID <= 0 { + return nil, fmt.Errorf("replay application ID must be greater than zero") + } + if request.Limit == 0 { + return nil, fmt.Errorf("replay page limit must be greater than zero") + } + if request.Limit > math.MaxInt64 { + return nil, fmt.Errorf("replay page limit %d exceeds maximum supported value %d", request.Limit, int64(math.MaxInt64)) + } + if request.FromInput > request.ToInputExclusive { + return nil, fmt.Errorf( + "replay input range is invalid: lower bound %d exceeds upper bound %d", + request.FromInput, request.ToInputExclusive, + ) + } + if request.FromInput == request.ToInputExclusive { + return []*model.ReplayRecord{}, nil + } + + whereInputApp := table.Input.EpochApplicationID.EQ(postgres.Int64(request.ApplicationID)) + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + inputStmt := table.Input.SELECT( + table.Input.EpochApplicationID, + table.Input.EpochIndex, + table.Input.Index, + table.Input.RawData, + table.Input.Status, + table.Input.ExceptionData, + table.Input.MachineHash, + table.Input.TxBufferDataBlock, + ). + WHERE( + whereInputApp. + AND(replayCompletedStatus(table.Input.Status)). + AND(table.Input.Index.GT_EQ(uint64Expr(request.FromInput))). + AND(table.Input.Index.LT(uint64Expr(request.ToInputExclusive))), + ). + ORDER_BY(table.Input.Index.ASC()). + LIMIT(int64(request.Limit)) + + records, err := selectReplayInputs(ctx, tx, inputStmt) + if err != nil { + return nil, err + } + + byInput := make(map[uint64]*model.ReplayRecord, len(records)) + for _, record := range records { + byInput[record.Input.InputIndex] = record + } + if request.Verification == repository.ReplayVerificationCanonical { + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return records, nil + } + + childToInputIndexExclusive := request.ToInputExclusive + if uint64(len(records)) == request.Limit { + // A full page covers only through its last returned input. Evidence for + // later completed inputs belongs to a subsequent page and must not be + // mistaken for an unmatched child in this page. + childToInputIndexExclusive = records[len(records)-1].Input.InputIndex + 1 + } + + if err := selectReplayChildren( + ctx, tx, request.ApplicationID, request.FromInput, childToInputIndexExclusive, byInput, replayOutputSpec, + ); err != nil { + return nil, err + } + if err := selectReplayChildren( + ctx, tx, request.ApplicationID, request.FromInput, childToInputIndexExclusive, byInput, replayReportSpec, + ); err != nil { + return nil, err + } + if err := selectReplayStateHashes( + ctx, tx, request.ApplicationID, request.FromInput, childToInputIndexExclusive, byInput, + ); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return records, nil +} + +func selectReplayInputs( + ctx context.Context, + tx pgx.Tx, + stmt postgres.SelectStatement, +) ([]*model.ReplayRecord, error) { + sqlStr, args := stmt.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []*model.ReplayRecord + for rows.Next() { + record := new(model.ReplayRecord) + in := &record.Input + if err := rows.Scan( + &in.ApplicationID, + &in.EpochIndex, + &in.InputIndex, + &in.RawData, + &in.Status, + &in.ExceptionData, + &in.MachineHash, + &in.TxBufferDataBlock, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func replayRecordForChild( + byInput map[uint64]*model.ReplayRecord, + childKind repository.ReplayEvidenceKind, + inputIndex uint64, +) (*model.ReplayRecord, error) { + record := byInput[inputIndex] + if record == nil { + return nil, &repository.ReplayInconsistentEvidenceError{ + Kind: childKind, + InputIndex: inputIndex, + } + } + return record, nil +} + +func selectReplayChildren( + ctx context.Context, + tx pgx.Tx, + applicationID int64, + fromInputIndex, toInputIndexExclusive uint64, + byInput map[uint64]*model.ReplayRecord, + spec replayChildSpec, +) error { + stmt := spec.table.SELECT( + spec.inputIndex, + spec.rawData, + ).WHERE( + spec.applicationID.EQ(postgres.Int64(applicationID)). + AND(spec.inputIndex.GT_EQ(uint64Expr(fromInputIndex))). + AND(spec.inputIndex.LT(uint64Expr(toInputIndexExclusive))), + ).ORDER_BY(spec.inputIndex.ASC(), spec.index.ASC()) + + sqlStr, args := stmt.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var inputIndex uint64 + var data []byte + if err := rows.Scan(&inputIndex, &data); err != nil { + return err + } + record, err := replayRecordForChild(byInput, spec.childKind, inputIndex) + if err != nil { + return err + } + spec.appendToRecord(record, data) + } + return rows.Err() +} + +func selectReplayStateHashes( + ctx context.Context, + tx pgx.Tx, + applicationID int64, + fromInputIndex, toInputIndexExclusive uint64, + byInput map[uint64]*model.ReplayRecord, +) error { + stmt := table.StateHashes.SELECT( + table.StateHashes.InputIndex, + table.StateHashes.Index, + table.StateHashes.MachineHash, + table.StateHashes.Repetitions, + ).WHERE( + table.StateHashes.InputEpochApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.StateHashes.InputIndex.GT_EQ(uint64Expr(fromInputIndex))). + AND(table.StateHashes.InputIndex.LT(uint64Expr(toInputIndexExclusive))), + ).ORDER_BY(table.StateHashes.InputIndex.ASC(), table.StateHashes.Index.ASC()) + + sqlStr, args := stmt.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var inputIndex uint64 + var row model.ReplayStateHash + if err := rows.Scan(&inputIndex, &row.Index, &row.MachineHash, &row.Repetitions); err != nil { + return err + } + record, err := replayRecordForChild(byInput, repository.ReplayEvidenceStateHash, inputIndex) + if err != nil { + return err + } + record.StateHashes = append(record.StateHashes, row) + } + return rows.Err() +} diff --git a/internal/repository/postgres/replay_source_test.go b/internal/repository/postgres/replay_source_test.go new file mode 100644 index 000000000..c17eb5349 --- /dev/null +++ b/internal/repository/postgres/replay_source_test.go @@ -0,0 +1,413 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres_test + +import ( + "context" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/factory" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestPostgresReplayVerificationLevels(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + + ctx := context.Background() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(ctx)) }) + + app := repotest.NewApplicationBuilder().Create(ctx, t, repo) + epoch := repotest.NewEpochBuilder(app.ID). + WithStatus(model.EpochStatus_Closed). + WithInputBounds(0, 1). + Build() + inputs := []*model.Input{ + repotest.NewInputBuilder().WithIndex(0).Build(), + repotest.NewInputBuilder().WithIndex(1).Build(), + } + require.NoError(t, repo.CreateEpochsAndInputs( + ctx, + app.IApplicationAddress.String(), + map[*model.Epoch][]*model.Input{epoch: inputs}, + 10, + )) + require.NoError(t, repo.StoreAdvanceResult(ctx, app.ID, &model.AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: model.InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output")}, + Reports: [][]byte{[]byte("report")}, + StateProof: *repotest.DummyStateProof(), + })) + require.NoError(t, repo.StoreAdvanceResult(ctx, app.ID, &model.AdvanceResult{ + EpochIndex: 0, + InputIndex: 1, + Status: model.InputCompletionStatus_Rejected, + Reports: [][]byte{[]byte("rejection report")}, + StateProof: *repotest.DummyStateProof(), + })) + + canonical, err := repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationCanonical, + ) + require.NoError(t, err) + require.Equal(t, app.ID, canonical.ApplicationID) + require.Equal(t, uint64(2), canonical.ProcessedInputs) + require.Equal(t, model.Consensus_Authority, canonical.Consensus) + + canonicalPage, err := repo.ReplayPage(ctx, repository.ReplayPageRequest{ + ApplicationID: canonical.ApplicationID, + FromInput: 0, + ToInputExclusive: canonical.ProcessedInputs, + Limit: canonical.ProcessedInputs, + Verification: repository.ReplayVerificationCanonical, + }) + require.NoError(t, err) + require.Len(t, canonicalPage, 2) + for _, record := range canonicalPage { + require.Empty(t, record.Outputs) + require.Empty(t, record.Reports) + require.Empty(t, record.StateHashes) + } + + _, err = repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationFull, + ) + require.NoError(t, err) + fullPage, err := repo.ReplayPage(ctx, repository.ReplayPageRequest{ + ApplicationID: canonical.ApplicationID, + FromInput: 0, + ToInputExclusive: canonical.ProcessedInputs, + Limit: canonical.ProcessedInputs, + Verification: repository.ReplayVerificationFull, + }) + require.NoError(t, err) + require.Equal(t, [][]byte{[]byte("output")}, fullPage[0].Outputs) + require.Equal(t, [][]byte{[]byte("report")}, fullPage[0].Reports) + require.Empty(t, fullPage[1].Outputs) + require.Equal(t, [][]byte{[]byte("rejection report")}, fullPage[1].Reports) + + // Poison only full evidence. Canonical reconstruction remains independent + // of child evidence, while a full page exposes it to replay comparison. + _, err = conn.Exec(ctx, ` + INSERT INTO output (input_epoch_application_id, input_index, index, raw_data) + VALUES ($1, 1, 1, $2)`, app.ID, []byte("illegal-output")) + require.NoError(t, err) + _, err = repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationCanonical, + ) + require.NoError(t, err) + fullPage, err = repo.ReplayPage(ctx, repository.ReplayPageRequest{ + ApplicationID: canonical.ApplicationID, + FromInput: 0, + ToInputExclusive: canonical.ProcessedInputs, + Limit: canonical.ProcessedInputs, + Verification: repository.ReplayVerificationFull, + }) + require.NoError(t, err) + require.Equal(t, [][]byte{[]byte("illegal-output")}, fullPage[1].Outputs) + + // State hashes are invalid for every Authority input, including completed + // inputs whose other Full evidence is otherwise valid. + illegalStateHash := repotest.UniqueHash() + _, err = conn.Exec(ctx, `INSERT INTO state_hashes ( + input_epoch_application_id, epoch_index, input_index, index, machine_hash, repetitions + ) VALUES ($1, 0, 1, 0, $2, 1)`, app.ID, illegalStateHash.Bytes()) + require.NoError(t, err) + _, err = repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationCanonical, + ) + require.NoError(t, err) + _, err = repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationFull, + ) + violation := requireReplayViolationKind( + t, err, repository.ReplayStructureUnexpectedStateHash, + ) + require.Equal(t, uint64(1), violation.InputIndex) + require.Equal(t, uint64(0), violation.EvidenceIndex) + _, err = conn.Exec(ctx, `DELETE FROM state_hashes WHERE input_epoch_application_id = $1`, app.ID) + require.NoError(t, err) + + // The persisted application counter and completed input rows are one + // invariant and must agree in the same repository snapshot. + _, err = conn.Exec(ctx, `UPDATE application SET processed_inputs = 1 WHERE id = $1`, app.ID) + require.NoError(t, err) + _, err = repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationCanonical, + ) + violation = requireReplayViolationKind( + t, err, repository.ReplayStructureProcessedInputCount, + ) + require.Equal(t, uint64(1), violation.ApplicationProcessedInputs) + require.Equal(t, uint64(2), violation.CompletedInputCount) +} + +func TestPostgresReplayIncludesNewTerminalStatuses(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + + ctx := context.Background() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + + for _, status := range []model.InputCompletionStatus{ + model.InputCompletionStatus_Overflow, + model.InputCompletionStatus_UnexpectedYield, + model.InputCompletionStatus_InvalidOutputsRoot, + } { + t.Run(status.String(), func(t *testing.T) { + app := repotest.NewApplicationBuilder().Create(ctx, t, repo) + epoch := repotest.NewEpochBuilder(app.ID). + WithStatus(model.EpochStatus_Closed). + WithInputBounds(0, 0). + Build() + input := repotest.NewInputBuilder().WithIndex(0).Build() + require.NoError(t, repo.CreateEpochsAndInputs( + ctx, + app.IApplicationAddress.String(), + map[*model.Epoch][]*model.Input{epoch: {input}}, + 10, + )) + require.NoError(t, repo.StoreAdvanceResult(ctx, app.ID, &model.AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: status, + StateProof: *repotest.DummyStateProof(), + })) + + summary, err := repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationCanonical) + require.NoError(t, err) + require.Equal(t, uint64(1), summary.ProcessedInputs) + page, err := repo.ReplayPage(ctx, repository.ReplayPageRequest{ + ApplicationID: summary.ApplicationID, + FromInput: 0, + ToInputExclusive: 1, + Limit: 1, + Verification: repository.ReplayVerificationCanonical, + }) + require.NoError(t, err) + require.Len(t, page, 1) + require.Equal(t, status, page[0].Input.Status) + }) + } +} + +func TestPostgresReplayRejectsCompletedInputGap(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + + ctx := context.Background() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(ctx)) }) + + app := repotest.NewApplicationBuilder().Create(ctx, t, repo) + epoch := repotest.NewEpochBuilder(app.ID). + WithStatus(model.EpochStatus_Closed). + WithInputBounds(0, 1). + Build() + inputs := []*model.Input{ + repotest.NewInputBuilder().WithIndex(0).Build(), + repotest.NewInputBuilder().WithIndex(1).Build(), + } + require.NoError(t, repo.CreateEpochsAndInputs( + ctx, + app.IApplicationAddress.String(), + map[*model.Epoch][]*model.Input{epoch: inputs}, + 10, + )) + + // Create two completed rows at indexes 0 and 2. Changing the index while + // the input is still pending is allowed by the completion immutability + // trigger and models a malformed completed prefix without disabling it. + _, err = conn.Exec(ctx, `UPDATE input SET index = 2 + WHERE epoch_application_id = $1 AND index = 1`, app.ID) + require.NoError(t, err) + machineHash := repotest.UniqueHash() + txBufferDataBlock := repotest.UniqueHash() + _, err = conn.Exec(ctx, `UPDATE input + SET status = 'ACCEPTED', machine_hash = $2, tx_buffer_data_block = $3 + WHERE epoch_application_id = $1`, app.ID, machineHash.Bytes(), txBufferDataBlock.Bytes()) + require.NoError(t, err) + _, err = conn.Exec(ctx, `UPDATE application SET processed_inputs = 2 WHERE id = $1`, app.ID) + require.NoError(t, err) + + _, err = repo.ReplaySummary( + ctx, app.IApplicationAddress, repository.ReplayVerificationCanonical, + ) + violation := requireReplayViolationKind( + t, err, repository.ReplayStructureCompletedInputSequence, + ) + require.Equal(t, uint64(2), violation.InputIndex) + require.Equal(t, uint64(1), violation.ExpectedIndex) +} + +func TestPostgresReplayRejectsInvalidStateHashOrdering(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + + ctx := context.Background() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(ctx)) }) + + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(ctx, t, repo) + epoch := repotest.NewEpochBuilder(app.ID). + WithStatus(model.EpochStatus_Closed). + WithInputBounds(0, 1). + Build() + inputs := []*model.Input{ + repotest.NewInputBuilder().WithIndex(0).Build(), + repotest.NewInputBuilder().WithIndex(1).Build(), + } + require.NoError(t, repo.CreateEpochsAndInputs( + ctx, + app.IApplicationAddress.String(), + map[*model.Epoch][]*model.Input{epoch: inputs}, + 10, + )) + for inputIndex := range uint64(2) { + require.NoError(t, repo.StoreAdvanceResult(ctx, app.ID, &model.AdvanceResult{ + EpochIndex: 0, + InputIndex: inputIndex, + Status: model.InputCompletionStatus_Accepted, + IsDaveConsensus: true, + PaddingRepetitions: 1 << 24, + StateProof: *repotest.DummyStateProof(), + })) + } + _, err = repo.ReplaySummary(ctx, app.IApplicationAddress, repository.ReplayVerificationFull) + require.NoError(t, err) + + _, err = conn.Exec(ctx, `WITH d AS (DELETE FROM state_hashes WHERE index = 1 RETURNING *) + INSERT INTO state_hashes SELECT input_epoch_application_id, epoch_index, input_index, + 2, machine_hash, repetitions, created_at, updated_at FROM d`) + require.NoError(t, err) + _, err = repo.ReplaySummary(ctx, app.IApplicationAddress, repository.ReplayVerificationFull) + _ = requireReplayViolationKind(t, err, repository.ReplayStructureStateHashIndexSequence) + _, err = conn.Exec(ctx, `WITH d AS (DELETE FROM state_hashes WHERE index = 2 RETURNING *) + INSERT INTO state_hashes SELECT input_epoch_application_id, epoch_index, input_index, + 1, machine_hash, repetitions, created_at, updated_at FROM d`) + require.NoError(t, err) + + _, err = conn.Exec(ctx, `WITH d AS (DELETE FROM state_hashes RETURNING *) + INSERT INTO state_hashes SELECT input_epoch_application_id, epoch_index, + CASE index WHEN 0 THEN 1 ELSE 0 END, index, machine_hash, repetitions, created_at, updated_at FROM d`) + require.NoError(t, err) + _, err = repo.ReplaySummary(ctx, app.IApplicationAddress, repository.ReplayVerificationFull) + _ = requireReplayViolationKind(t, err, repository.ReplayStructureStateHashInputOrder) +} + +func TestPostgresStateHashesRefuseUpdate(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx := t.Context() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + conn, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close(ctx)) }) + seed := repotest.Seed(ctx, t, repo) + require.NoError(t, repo.StoreAdvanceResult(ctx, seed.App.ID, &model.AdvanceResult{ + EpochIndex: 0, InputIndex: 0, Status: model.InputCompletionStatus_Accepted, + StateProof: *repotest.DummyStateProof(), IsDaveConsensus: true, + PaddingRepetitions: model.InputHashCollectionCapacity, + })) + for _, column := range []string{"index", "input_index", "epoch_index", "input_epoch_application_id", + "machine_hash", "repetitions", "created_at", "updated_at"} { + identifier := pgx.Identifier{column}.Sanitize() + _, err := conn.Exec(ctx, "UPDATE state_hashes SET "+identifier+" = "+identifier+ + " WHERE input_epoch_application_id = $1", seed.App.ID) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "23001", pgErr.Code) + require.Equal(t, "state_hashes row is immutable", pgErr.Message) + } + command, err := conn.Exec(ctx, `UPDATE state_hashes SET repetitions = 2 + WHERE input_epoch_application_id = $1 AND index = 99`, seed.App.ID) + require.NoError(t, err) + require.Zero(t, command.RowsAffected()) +} + +func requireReplayViolationKind( + t *testing.T, + err error, + want repository.ReplayStructureViolationKind, +) *repository.ReplayStructureViolationError { + t.Helper() + require.ErrorIs(t, err, repository.ErrReplayInvalidStructure) + var violation *repository.ReplayStructureViolationError + require.ErrorAs(t, err, &violation) + require.Equal(t, want, violation.Kind) + return violation +} + +func TestPostgresReplayRejectsInvalidRequests(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + + ctx := context.Background() + repo, err := factory.NewRepositoryFromConnectionString(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(repo.Close) + app := repotest.NewApplicationBuilder().Create(ctx, t, repo) + + _, err = repo.ReplaySummary(ctx, app.IApplicationAddress, repository.ReplayVerificationLevel(255)) + require.ErrorContains(t, err, "unsupported replay verification level") + _, err = repo.ReplaySummary( + ctx, + common.HexToAddress("0xdead"), + repository.ReplayVerificationCanonical, + ) + require.ErrorIs(t, err, repository.ErrNotFound) + _, err = repo.ReplayPage(ctx, repository.ReplayPageRequest{ + ApplicationID: app.ID, + FromInput: 1, + ToInputExclusive: 0, + Limit: 1, + Verification: repository.ReplayVerificationCanonical, + }) + require.ErrorContains(t, err, "replay input range is invalid") +} diff --git a/internal/repository/postgres/replay_test.go b/internal/repository/postgres/replay_test.go new file mode 100644 index 000000000..88db0fba5 --- /dev/null +++ b/internal/repository/postgres/replay_test.go @@ -0,0 +1,248 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestStateHashInsertShape(t *testing.T) { + t.Parallel() + + t.Run("positive padding appends final row", func(t *testing.T) { + rows, err := stateHashInsertShape( + 1, model.InputHashCollectionCapacity-1, + ) + require.NoError(t, err) + require.Equal(t, uint64(2), rows) + }) + + t.Run("empty collection uses one positive padding row", func(t *testing.T) { + rows, err := stateHashInsertShape(0, model.InputHashCollectionCapacity) + require.NoError(t, err) + require.Equal(t, uint64(1), rows) + }) + + t.Run("full span has no padding row", func(t *testing.T) { + rows, err := stateHashInsertShape(model.InputHashCollectionCapacity, 0) + require.NoError(t, err) + require.Equal(t, model.InputHashCollectionCapacity, rows) + }) + + t.Run("rejects incomplete span", func(t *testing.T) { + _, err := stateHashInsertShape(1, 7) + require.ErrorContains(t, err, "does not cover input hash collection capacity") + }) + +} + +func TestStateHashCopySource(t *testing.T) { + t.Parallel() + + hashes := [][32]byte{common.HexToHash("0x11"), common.HexToHash("0x12")} + finalEntry := common.HexToHash("0x13") + for _, withPadding := range []bool{false, true} { + name := "without padding" + padding := uint64(0) + expected := hashes + if withPadding { + name = "with padding" + padding = model.InputHashCollectionCapacity - uint64(len(hashes)) + expected = append(expected, finalEntry) + } + t.Run(name, func(t *testing.T) { + source := &stateHashCopySource{ + appID: 7, + epochIndex: 8, + inputIndex: 9, + nextIndex: 10, + hashes: hashes, + finalEntry: finalEntry, + paddingRepetitions: padding, + rowCount: uint64(len(expected)), + } + for index, expectedHash := range expected { + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Equal(t, int64(7), values[0]) + require.Equal(t, uint64(8), values[1]) + require.Equal(t, uint64(9), values[2]) + require.Equal(t, uint64(10+index), values[3]) + require.Equal(t, expectedHash[:], values[4]) + if index < len(hashes) { + require.Equal(t, int64(1), values[5]) + } else { + require.Equal(t, padding, values[5]) + } + } + require.False(t, source.Next()) + require.NoError(t, source.Err()) + }) + } +} + +func TestPostgresStateHashesRequirePositiveRepetitions(t *testing.T) { + repo := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch := seedEpochPublication(t, repo) + _, err := repo.db.Exec(t.Context(), ` + INSERT INTO state_hashes + (input_epoch_application_id, epoch_index, input_index, index, machine_hash, repetitions) + VALUES ($1, $2, 0, 0, $3, 0)`, app.ID, epoch.Index, repotest.UniqueHash().Bytes()) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "23514", pgErr.Code) + require.Equal(t, "state_hashes_repetitions_check", pgErr.ConstraintName) +} + +func TestInsertStateHashesQualifiesPublicSchema(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + ctx := context.Background() + + // Remove a shadow schema left by an interrupted prior run before schema + // migrations try to drop the uint64/hash domains it may reference. + bootstrap, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + _, err = bootstrap.Exec(ctx, `DROP SCHEMA IF EXISTS state_hash_copy_shadow CASCADE`) + require.NoError(t, err) + require.NoError(t, bootstrap.Close(ctx)) + require.NoError(t, db.SetupTestPostgres(endpoint)) + + repositoryInterface, err := NewPostgresRepository(ctx, endpoint, 1, 0) + require.NoError(t, err) + repo := repositoryInterface.(*PostgresRepository) + t.Cleanup(func() { + _, _ = repo.db.Exec(ctx, `DROP SCHEMA IF EXISTS state_hash_copy_shadow CASCADE`) + repo.Close() + }) + + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create( + ctx, t, repo, + ) + epoch := repotest.NewEpochBuilder(app.ID). + WithStatus(model.EpochStatus_Closed). + WithInputBounds(0, 0). + Build() + input := repotest.NewInputBuilder().WithIndex(0).Build() + require.NoError(t, repo.CreateEpochsAndInputs( + ctx, + app.IApplicationAddress.String(), + map[*model.Epoch][]*model.Input{epoch: {input}}, + 1, + )) + + _, err = repo.db.Exec(ctx, ` + CREATE SCHEMA state_hash_copy_shadow; + CREATE TABLE state_hash_copy_shadow.state_hashes + (LIKE public.state_hashes INCLUDING ALL)`) + require.NoError(t, err) + tx, err := repo.db.Begin(ctx) + require.NoError(t, err) + defer tx.Rollback(ctx) //nolint:errcheck + _, err = tx.Exec(ctx, `SET LOCAL search_path TO state_hash_copy_shadow, public`) + require.NoError(t, err) + machineHash := repotest.UniqueHash() + require.NoError(t, insertStateHashes( + ctx, + tx, + app.ID, + 0, + 0, + nil, + machineHash, + model.InputHashCollectionCapacity, + )) + require.NoError(t, tx.Commit(ctx)) + + var publicCount, shadowCount uint64 + require.NoError(t, repo.db.QueryRow(ctx, `SELECT count(*) FROM public.state_hashes`).Scan(&publicCount)) + require.NoError(t, repo.db.QueryRow( + ctx, + `SELECT count(*) FROM state_hash_copy_shadow.state_hashes`, + ).Scan(&shadowCount)) + require.Equal(t, uint64(1), publicCount) + require.Zero(t, shadowCount) +} + +func TestReplayRecordForChild(t *testing.T) { + t.Parallel() + + record := new(model.ReplayRecord) + got, err := replayRecordForChild( + map[uint64]*model.ReplayRecord{4: record}, + repository.ReplayEvidenceOutput, + 4, + ) + require.NoError(t, err) + require.Same(t, record, got) + + for _, childKind := range []repository.ReplayEvidenceKind{ + repository.ReplayEvidenceOutput, + repository.ReplayEvidenceReport, + repository.ReplayEvidenceStateHash, + } { + t.Run(childKind.String(), func(t *testing.T) { + const inputWithoutCompletedRecord = uint64(7) + _, err := replayRecordForChild(nil, childKind, inputWithoutCompletedRecord) + + require.ErrorIs(t, err, repository.ErrReplayInconsistentEvidence) + var detail *repository.ReplayInconsistentEvidenceError + require.ErrorAs(t, err, &detail) + require.Equal(t, childKind, detail.Kind) + require.Equal(t, inputWithoutCompletedRecord, detail.InputIndex) + require.Contains(t, err.Error(), childKind.String()) + require.Contains(t, err.Error(), "input 7") + require.NotContains(t, err.Error(), "payload-must-stay-private") + }) + } +} + +func TestReplayInconsistencyErrorIdentity(t *testing.T) { + t.Parallel() + err := &repository.ReplayInconsistentEvidenceError{ + Kind: repository.ReplayEvidenceReport, + InputIndex: 11, + } + require.True(t, errors.Is(err, repository.ErrReplayInconsistentEvidence)) +} + +func TestReplayStructureViolationErrorIdentityAndPayloadHygiene(t *testing.T) { + t.Parallel() + epochIndex := uint64(3) + for _, kind := range []repository.ReplayStructureViolationKind{ + repository.ReplayStructureCompletedInputSequence, + repository.ReplayStructureStateHashIndexSequence, + repository.ReplayStructureStateHashInputOrder, + repository.ReplayStructureProcessedInputCount, + repository.ReplayStructureUnexpectedStateHash, + } { + t.Run(kind.String(), func(t *testing.T) { + err := &repository.ReplayStructureViolationError{ + Kind: kind, + EpochIndex: &epochIndex, + InputIndex: 4, + EvidenceIndex: 7, + ExpectedIndex: 6, + PreviousInputIndex: 5, + } + require.ErrorIs(t, err, repository.ErrReplayInvalidStructure) + require.Contains(t, err.Error(), "kind="+kind.String()) + require.NotContains(t, err.Error(), "payload-must-stay-private") + }) + } +} diff --git a/internal/repository/postgres/report.go b/internal/repository/postgres/report.go new file mode 100644 index 000000000..cf509f7d6 --- /dev/null +++ b/internal/repository/postgres/report.go @@ -0,0 +1,177 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "database/sql" + "errors" + + "github.com/go-jet/jet/v2/postgres" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +func (r *PostgresRepository) GetReport( + ctx context.Context, + nameOrAddress string, + reportIndex uint64, +) (*model.Report, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Report. + SELECT( + table.Report.InputEpochApplicationID, + table.Report.InputIndex, + table.Report.Index, + table.Report.RawData, + table.Report.CreatedAt, + table.Report.UpdatedAt, + table.Input.EpochIndex, + ). + FROM( + table.Report.INNER_JOIN( + table.Application, + table.Report.InputEpochApplicationID.EQ(table.Application.ID), + ).INNER_JOIN( + table.Input, + table.Report.InputIndex.EQ(table.Input.Index). + AND(table.Report.InputEpochApplicationID.EQ(table.Input.EpochApplicationID)), + ), + ). + WHERE( + whereClause. + AND(table.Report.Index.EQ(uint64Expr(reportIndex))), + ) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var rp model.Report + err := row.Scan( + &rp.InputEpochApplicationID, + &rp.InputIndex, + &rp.Index, + &rp.RawData, + &rp.CreatedAt, + &rp.UpdatedAt, + &rp.EpochIndex, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &rp, nil +} + +func (r *PostgresRepository) ListReports( + ctx context.Context, + nameOrAddress string, + f repository.ReportFilter, + p repository.Pagination, + descending bool, +) ([]*model.Report, uint64, error) { + + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + fromClause := table.Report.INNER_JOIN( + table.Application, + table.Report.InputEpochApplicationID.EQ(table.Application.ID), + ).INNER_JOIN( + table.Input, + table.Report.InputIndex.EQ(table.Input.Index). + AND(table.Report.InputEpochApplicationID.EQ(table.Input.EpochApplicationID)), + ) + + conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Report.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Report.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } + if f.InputIndex != nil { + conditions = append(conditions, table.Report.InputIndex.EQ(uint64Expr(*f.InputIndex))) + } + + if f.EpochIndex != nil { + conditions = append(conditions, table.Input.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + countStmt := table.Report.SELECT(postgres.COUNT(postgres.STAR)). + FROM(fromClause).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil { + return nil, 0, err + } + if total == 0 { + return nil, 0, nil + } + + sel := table.Report. + SELECT( + table.Report.InputEpochApplicationID, + table.Report.InputIndex, + table.Report.Index, + table.Report.RawData, + table.Report.CreatedAt, + table.Report.UpdatedAt, + table.Input.EpochIndex, + ). + FROM(fromClause). + WHERE(postgres.AND(conditions...)) + + if descending { + sel = sel.ORDER_BY(table.Report.Index.DESC()) + } else { + sel = sel.ORDER_BY(table.Report.Index.ASC()) + } + + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var reports []*model.Report + for rows.Next() { + var rp model.Report + err := rows.Scan( + &rp.InputEpochApplicationID, + &rp.InputIndex, + &rp.Index, + &rp.RawData, + &rp.CreatedAt, + &rp.UpdatedAt, + &rp.EpochIndex, + ) + if err != nil { + return nil, 0, err + } + reports = append(reports, &rp) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return reports, total, nil +} diff --git a/internal/repository/postgres/repository.go b/internal/repository/postgres/repository.go new file mode 100644 index 000000000..3c1cdaef3 --- /dev/null +++ b/internal/repository/postgres/repository.go @@ -0,0 +1,130 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" +) + +// postgresRepository is the concrete type that implements the repository.Repository interface. +type PostgresRepository struct { + db *pgxpool.Pool +} + +func (r *PostgresRepository) Close() { + if r.db != nil { + r.db.Close() + } +} + +func validateSchema(pool *pgxpool.Pool) error { + + s, err := schema.NewWithPool(pool) + if err != nil { + return err + } + defer s.Close() + + _, err = s.ValidateVersion() + return err +} + +func NewPostgresRepository(ctx context.Context, conn string, maxRetries int, delay time.Duration) (repository.Repository, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + config, err := pgxpool.ParseConfig(conn) + if err != nil { + // pgx masks passwords, but parser errors still contain other DSN values. + // Do not retain its raw connection string in the public error chain. + return nil, errors.New("failed to parse Postgres connection string") + } + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + return nil, errors.New("failed to create Postgres pool") + } + + // Wait for database to be available + for i := range maxRetries { + pingErr := pool.Ping(ctx) + if pingErr == nil { + break + } + if err := ctx.Err(); err != nil { + pool.Close() + return nil, err + } + if i == maxRetries-1 { + pool.Close() + return nil, fmt.Errorf("failed to ping Postgres after %d retries: %s", maxRetries, safePostgresConnectCause(pingErr)) + } + select { + case <-time.After(delay): + case <-ctx.Done(): + pool.Close() + return nil, ctx.Err() + } + } + + // Wait for schema validation (migrations) to complete. Workaround to facilitate container startup order. + for i := range maxRetries { + err = validateSchema(pool) + if err == nil { + return &PostgresRepository{db: pool}, nil + } + if err := ctx.Err(); err != nil { + pool.Close() + return nil, err + } + if i == maxRetries-1 { + pool.Close() + return nil, safeSchemaValidationError(err) + } + select { + case <-time.After(delay): + case <-ctx.Done(): + pool.Close() + return nil, ctx.Err() + } + } + + // This should never be reached due to the returns in the loops above + pool.Close() + return nil, fmt.Errorf("unexpected error initializing Postgres repository") +} + +// Only local schema diagnostics and context sentinels may escape construction. +// Driver errors can contain connection settings even after parsing succeeds. +func safeSchemaValidationError(err error) error { + var safe error + for _, known := range []error{ + context.Canceled, context.DeadlineExceeded, + schema.ErrMigrationNotCompleted, schema.ErrNoValidDatabaseSchema, + } { + if errors.Is(err, known) { + safe = known + break + } + } + var mismatch *schema.VersionMismatchError + if safe == nil && errors.As(err, &mismatch) { + safe = mismatch + } + if safe != nil { + return fmt.Errorf("failed to validate Postgres schema version: %w", safe) + } + return errors.New("failed to validate Postgres schema version") +} diff --git a/internal/repository/postgres/repository_error_test.go b/internal/repository/postgres/repository_error_test.go new file mode 100644 index 000000000..2d78c8f86 --- /dev/null +++ b/internal/repository/postgres/repository_error_test.go @@ -0,0 +1,127 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres_test + +import ( + "context" + "errors" + "fmt" + "net" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/repository/postgres" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func TestNewPostgresRepository_InvalidConnectionString(t *testing.T) { + ctx := context.Background() + _, err := postgres.NewPostgresRepository( + ctx, "not-a-valid-connection-string", 1, time.Millisecond) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse Postgres connection string") +} + +func TestNewPostgresRepository_ConnectionSettingsNotExposed(t *testing.T) { + t.Setenv("PGSERVICE", "") + const marker = "DATABASE_SETTINGS_SECRET" + for _, option := range []string{"connect_timeout", "pool_max_conns", "pool_max_conn_lifetime"} { + t.Run(option, func(t *testing.T) { + dsn := "postgres://" + marker + ":" + marker + "@127.0.0.1:1/" + marker + + "?sslmode=disable&" + option + "=" + marker + // Prove this input reaches the driver conversion that used to echo it. + _, driverErr := pgxpool.ParseConfig(dsn) + require.Error(t, driverErr) + require.Contains(t, driverErr.Error(), option) + require.Contains(t, driverErr.Error(), marker) + + _, err := postgres.NewPostgresRepository(t.Context(), dsn, 1, 0) + require.EqualError(t, err, "failed to parse Postgres connection string") + for _, verb := range []string{"%v", "%+v", "%#v"} { + require.NotContains(t, fmt.Sprintf(verb, err), marker) + } + var raw *pgconn.ParseConfigError + require.False(t, errors.As(err, &raw), "the raw DSN must not remain in the returned error chain") + }) + } +} + +func TestNewPostgresRepository_ContextAlreadyInterrupted(t *testing.T) { + t.Setenv("PGSERVICE", "") + for _, deadline := range []bool{false, true} { + name, want := "canceled", context.Canceled + if deadline { + name, want = "deadline", context.DeadlineExceeded + } + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + if deadline { + cancel() + ctx, cancel = context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + } else { + cancel() + } + defer cancel() + _, err := postgres.NewPostgresRepository(ctx, + "postgres://user:pass@127.0.0.1:1/testdb?sslmode=disable", 1, 0) + require.ErrorIs(t, err, want) + }) + } +} + +func TestNewPostgresRepository_ContextInterruptedOnLastAttempt(t *testing.T) { + t.Setenv("PGSERVICE", "") + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + require.NoError(t, listener.(*net.TCPListener).SetDeadline(time.Now().Add(5*time.Second))) + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := postgres.NewPostgresRepository(ctx, + "postgres://user:pass@"+listener.Addr().String()+"/testdb?sslmode=disable", 1, 0) + result <- err + }() + // Accept the connection but do not answer the PostgreSQL startup message. + // This proves the deadline interrupts the only attempt while it is in flight. + conn, err := listener.Accept() + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + require.ErrorIs(t, <-result, context.DeadlineExceeded) +} + +func TestNewPostgresRepository_UnreachableHostRetriesExhausted(t *testing.T) { + ctx := context.Background() + // Port 1 on localhost is almost certainly not running PostgreSQL. + // With maxRetries=2 and minimal delay the retries exhaust quickly. + _, err := postgres.NewPostgresRepository( + ctx, "postgres://DATABASE_SETTINGS_SECRET:DATABASE_SETTINGS_SECRET@127.0.0.1:1/DATABASE_SETTINGS_SECRET?connect_timeout=1", + 2, time.Millisecond) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to ping Postgres after 2 retries") + require.Contains(t, err.Error(), "connection refused") + for _, verb := range []string{"%v", "%+v", "%#v"} { + require.NotContains(t, fmt.Sprintf(verb, err), "DATABASE_SETTINGS_SECRET") + } +} + +func TestNewPostgresRepository_ContextCancelledDuringRetry(t *testing.T) { + // Use a short-lived context so that it expires while the function is + // waiting between retry attempts (delay is deliberately long). + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + _, err := postgres.NewPostgresRepository( + ctx, + "postgres://user:pass@localhost:1/testdb?connect_timeout=1", + 100, // many retries — we won't exhaust them + 10*time.Second, // long delay — context expires before this elapses + ) + require.Error(t, err) + require.ErrorIs(t, err, context.DeadlineExceeded) +} diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql new file mode 100644 index 000000000..aee75c1f9 --- /dev/null +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql @@ -0,0 +1,113 @@ +-- (c) Cartesi and individual authors (see AUTHORS) +-- SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +BEGIN; + +DROP TRIGGER IF EXISTS "state_hashes_immutability_check" ON "state_hashes"; +DROP INDEX IF EXISTS "state_hashes_input_index_idx"; +DROP TABLE IF EXISTS "state_hashes"; +DROP FUNCTION IF EXISTS "enforce_state_hash_immutability"; + +DROP TABLE IF EXISTS "bond_events"; + +DROP TRIGGER IF EXISTS "standing_changes_set_updated_at" ON "standing_changes"; +DROP INDEX IF EXISTS "standing_changes_tournament_order_idx"; +DROP TABLE IF EXISTS "standing_changes"; + +DROP TRIGGER IF EXISTS "match_advances_set_updated_at" ON "match_advances"; +DROP INDEX IF EXISTS "match_advances_block_number_idx"; +DROP TABLE IF EXISTS "match_advances"; + +ALTER TABLE "tournaments" DROP CONSTRAINT "tournaments_parent_match_fkey"; + +DROP TRIGGER IF EXISTS "matches_set_updated_at" ON "matches"; +DROP INDEX IF EXISTS "matches_unique_pair_idx"; +DROP TABLE IF EXISTS "matches"; + +DROP TRIGGER IF EXISTS "commitments_set_updated_at" ON "commitments"; +DROP INDEX IF EXISTS "commitments_final_state_idx"; +DROP TABLE IF EXISTS "commitments"; + +DROP TRIGGER IF EXISTS "tournaments_set_updated_at" ON "tournaments"; +DROP INDEX IF EXISTS "tournaments_parent_match_nonroot_idx"; +DROP INDEX IF EXISTS "tournaments_address_idx"; +DROP INDEX IF EXISTS "unique_root_per_epoch_idx"; +DROP TABLE IF EXISTS "tournaments"; + +DROP TRIGGER IF EXISTS "config_set_updated_at" ON "node_config"; +DROP TABLE IF EXISTS "node_config"; + +DROP TRIGGER IF EXISTS "report_set_updated_at" ON "report"; +DROP INDEX IF EXISTS "report_input_index_idx"; +DROP TABLE IF EXISTS "report"; + +DROP TRIGGER IF EXISTS "withdrawal_set_updated_at" ON "withdrawal"; +DROP INDEX IF EXISTS "withdrawal_block_number_idx"; +DROP TABLE IF EXISTS "withdrawal"; + +DROP TRIGGER IF EXISTS "output_set_updated_at" ON "output"; +DROP TRIGGER IF EXISTS "output_immutability_check" ON "output"; +DROP INDEX IF EXISTS "output_input_index_idx"; +DROP INDEX IF EXISTS "output_pending_voucher_idx"; +DROP INDEX IF EXISTS "output_executed_idx"; +DROP INDEX IF EXISTS "output_raw_data_address_idx"; +DROP INDEX IF EXISTS "output_raw_data_type_idx"; +DROP TABLE IF EXISTS "output"; +DROP FUNCTION IF EXISTS "enforce_output_immutability"; + +DROP TRIGGER IF EXISTS "input_set_updated_at" ON "input"; +DROP TRIGGER IF EXISTS "input_completion_immutability_check" ON "input"; +DROP INDEX IF EXISTS "input_sender_idx"; +DROP INDEX IF EXISTS "input_unprocessed_idx"; +DROP INDEX IF EXISTS "input_status_idx"; +DROP INDEX IF EXISTS "input_block_number_idx"; +DROP TABLE IF EXISTS "input"; +DROP FUNCTION IF EXISTS "enforce_input_completion_immutability"; + +DROP TRIGGER IF EXISTS "epoch_status_transition_check" ON "epoch"; +DROP TRIGGER IF EXISTS "epoch_set_updated_at" ON "epoch"; +DROP INDEX IF EXISTS "epoch_unvalidated_idx"; +DROP INDEX IF EXISTS "epoch_status_idx"; +DROP INDEX IF EXISTS "epoch_last_block_idx"; +DROP INDEX IF EXISTS "epoch_unreconciled_idx"; +DROP INDEX IF EXISTS "epoch_unpublished_idx"; +DROP TABLE IF EXISTS "epoch"; + +DROP FUNCTION IF EXISTS "enforce_epoch_status_transition"; + +DROP TRIGGER IF EXISTS "execution_parameters_set_updated_at" ON "execution_parameters"; +DROP TABLE IF EXISTS "execution_parameters"; + +DROP TABLE IF EXISTS "deployment_admission_state"; + +DROP TRIGGER IF EXISTS "application_validate_status_transition" ON "application"; +DROP TRIGGER IF EXISTS "application_set_updated_at" ON "application"; +DROP INDEX IF EXISTS "application_foreclosed_idx"; +DROP TABLE IF EXISTS "application"; + +DROP FUNCTION IF EXISTS "validate_application_status_transition"; +DROP FUNCTION IF EXISTS "update_updated_at_column"; +DROP FUNCTION IF EXISTS "check_hash_siblings"; + +DROP TYPE IF EXISTS "WinnerCommitment"; +DROP TYPE IF EXISTS "TournamentKind"; +DROP TYPE IF EXISTS "TournamentStandingState"; +DROP TYPE IF EXISTS "MatchPhase"; +DROP TYPE IF EXISTS "CommitmentSide"; +DROP TYPE IF EXISTS "MatchTimeoutOutcome"; +DROP TYPE IF EXISTS "InnerTournamentDisposition"; +DROP TYPE IF EXISTS "BondDisposition"; +DROP TYPE IF EXISTS "BondEventType"; +DROP TYPE IF EXISTS "MatchDeletionReason"; +DROP TYPE IF EXISTS "Consensus"; +DROP TYPE IF EXISTS "SnapshotPolicy"; +DROP TYPE IF EXISTS "EpochStatus"; +DROP TYPE IF EXISTS "DefaultBlock"; +DROP TYPE IF EXISTS "InputCompletionStatus"; +DROP TYPE IF EXISTS "ApplicationStatus"; +DROP DOMAIN IF EXISTS "hash"; +DROP DOMAIN IF EXISTS "uint64"; +DROP DOMAIN IF EXISTS "uint256"; +DROP DOMAIN IF EXISTS "ethereum_address"; + +COMMIT; diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql new file mode 100644 index 000000000..366f40916 --- /dev/null +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql @@ -0,0 +1,1194 @@ +-- (c) Cartesi and individual authors (see AUTHORS) +-- SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +BEGIN; + +CREATE DOMAIN "ethereum_address" AS BYTEA CHECK (octet_length(VALUE) = 20); +CREATE DOMAIN "uint64" AS NUMERIC(20, 0) CHECK (VALUE >= 0 AND VALUE <= 18446744073709551615); +CREATE DOMAIN "hash" AS BYTEA CHECK (octet_length(VALUE) = 32); + +CREATE TYPE "ApplicationStatus" AS ENUM ( + 'OK', + 'FAILED', + 'DIVERGED', + 'CORRUPTED', + 'GUEST_EXCEPTION', + 'MACHINE_HALTED', + 'MCYCLE_OVERFLOW', + 'UNEXPECTED_YIELD', + 'INVALID_OUTPUTS_ROOT'); + +CREATE TYPE "InputCompletionStatus" AS ENUM ( + 'NONE', + 'ACCEPTED', + 'REJECTED', + 'EXCEPTION', + 'MACHINE_HALTED', + 'OVERFLOW', + 'UNEXPECTED_YIELD', + 'INVALID_OUTPUTS_ROOT'); + +CREATE TYPE "DefaultBlock" AS ENUM ('FINALIZED', 'LATEST', 'PENDING', 'SAFE'); + +CREATE TYPE "EpochStatus" AS ENUM ( + 'OPEN', + 'CLOSED', + 'INPUTS_PROCESSED', + 'CLAIM_COMPUTED', + 'CLAIM_SUBMITTED', + 'CLAIM_STAGED', + 'CLAIM_ACCEPTED', + 'CLAIM_REJECTED', + 'CLAIM_FORECLOSED'); + +CREATE TYPE "SnapshotPolicy" AS ENUM ('NONE', 'EVERY_INPUT', 'EVERY_EPOCH'); + +CREATE TYPE "Consensus" AS ENUM ('AUTHORITY', 'QUORUM', 'PRT'); + +CREATE TYPE "MatchDeletionReason" AS ENUM ('STEP', 'TIMEOUT', 'CHILD_TOURNAMENT', 'NOT_DELETED'); + +CREATE TYPE "WinnerCommitment" AS ENUM ('NONE', 'ONE', 'TWO'); + +CREATE FUNCTION "update_updated_at_column"() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION check_hash_siblings(arr BYTEA[]) +RETURNS BOOLEAN AS $$ +DECLARE + elem BYTEA; +BEGIN + IF arr IS NULL THEN + RETURN TRUE; -- NULL array is allowed + END IF; + + FOREACH elem IN ARRAY arr + LOOP + IF elem IS NULL OR octet_length(elem) <> 32 THEN + RETURN FALSE; -- any element not 32 bytes => fail + END IF; + END LOOP; + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE TABLE "application" +( + "id" INT GENERATED ALWAYS AS IDENTITY, + "name" VARCHAR(4096) UNIQUE NOT NULL CHECK ("name" ~ '^[a-z0-9_-]+$'), + "iapplication_address" ethereum_address UNIQUE NOT NULL, + "iconsensus_address" ethereum_address NOT NULL, + "iinputbox_address" ethereum_address NOT NULL, + "iinputbox_block" uint64 NOT NULL, + "template_hash" hash NOT NULL, + "template_uri" VARCHAR(4096) NOT NULL, + "epoch_length" uint64 NOT NULL, + -- claim_staging_period is a cache of the on-chain immutable returned by + -- IConsensus.getClaimStagingPeriod(). On-chain, submitClaim() always + -- both submits and stages in the same tx (Authority._submitClaim + + -- _stageClaim, atomic). acceptClaim() is a separate tx that requires + -- claim.status == STAGED AND block.number - stagingBlockNumber >= + -- claim_staging_period. With DEFAULT 0, acceptClaim can fire as early + -- as the block immediately after staging; with N > 0, accept must wait + -- N blocks past the staging block. A cached value lower than the chain + -- value causes ClaimStagingPeriodNotOverYet reverts that the claimer + -- reclassifies as retry-later (see handleAcceptClaimRevert) — one + -- wasted broadcast per tick until reality catches up; bounded. + -- The chain is the source of truth; this column is populated once at + -- register time from getClaimStagingPeriod(). + "claim_staging_period" uint64 NOT NULL DEFAULT 0, + "withdrawal_guardian" ethereum_address NOT NULL DEFAULT '\x0000000000000000000000000000000000000000', + "withdrawal_log2_leaves_per_account" SMALLINT NOT NULL DEFAULT 0 CHECK ("withdrawal_log2_leaves_per_account" BETWEEN 0 AND 255), + "withdrawal_log2_max_num_of_accounts" SMALLINT NOT NULL DEFAULT 0 CHECK ("withdrawal_log2_max_num_of_accounts" BETWEEN 0 AND 255), + "withdrawal_accounts_drive_start_index" uint64 NOT NULL DEFAULT 0, + "withdrawal_output_builder" ethereum_address NOT NULL DEFAULT '\x0000000000000000000000000000000000000000', + "consensus_type" "Consensus" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "status" "ApplicationStatus" NOT NULL DEFAULT 'OK', + "reason" VARCHAR(4096), + -- Earliest known epoch that cannot be published. A later validator finding + -- may lower this boundary without changing the first execution diagnosis. + "problem_epoch_index" uint64, + "problem_kind" "ApplicationStatus", + "problem_input_index" uint64, + CONSTRAINT "application_problem_shape" CHECK ( + ("problem_epoch_index" IS NULL AND "problem_kind" IS NULL AND "problem_input_index" IS NULL) + OR ("problem_epoch_index" IS NOT NULL AND "problem_kind" IS NOT NULL + AND ("problem_kind" = 'INVALID_OUTPUTS_ROOT' + OR ("problem_kind" IN ('GUEST_EXCEPTION', 'MACHINE_HALTED', + 'MCYCLE_OVERFLOW', 'UNEXPECTED_YIELD') AND "problem_input_index" IS NOT NULL))) + ), + "last_epoch_check_block" uint64 NOT NULL, + "last_input_check_block" uint64 NOT NULL, + -- Safe prefix for independent ClaimStaged/ClaimAccepted processing. + "last_claim_check_block" uint64 NOT NULL DEFAULT 0, + "last_output_check_block" uint64 NOT NULL, + "last_tournament_check_block" uint64 NOT NULL, + "last_foreclose_check_block" uint64 NOT NULL DEFAULT 0, + "last_accounts_drive_proved_check_block" uint64 NOT NULL DEFAULT 0, + "last_withdrawal_check_block" uint64 NOT NULL DEFAULT 0, + "processed_inputs" uint64 NOT NULL, + -- foreclose_block / accounts_drive_proved_block use 0 as the "not yet + -- observed" sentinel — block 0 is structurally unreachable for the + -- corresponding events. The companion hash columns are nullable: a Hash + -- has no natural unreachable value, so NULL is the canonical "not set" + -- indicator rather than a manufactured zero-hash literal. + "foreclose_block" uint64 NOT NULL DEFAULT 0, + "foreclose_transaction" hash, + "accounts_drive_proved_block" uint64 NOT NULL DEFAULT 0, + "accounts_drive_proved_transaction" hash, + "accounts_drive_merkle_root" hash, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "reason_required_for_failure_statuses" CHECK (NOT ( + "status" IN ( + 'FAILED', + 'DIVERGED', + 'CORRUPTED', + 'GUEST_EXCEPTION', + 'MACHINE_HALTED', + 'MCYCLE_OVERFLOW', + 'UNEXPECTED_YIELD', + 'INVALID_OUTPUTS_ROOT') + AND ("reason" IS NULL OR LENGTH("reason") = 0))), + -- The foreclose pair is populated together by the atomic foreclosure + -- marker+cursor repository write (set-once, first-writer-wins via WHERE + -- foreclose_block = 0). This CHECK enforces the same invariant at the + -- schema level: either both unset (block = 0, tx IS NULL) or both set + -- together. A future code path that wrote one without the other is + -- rejected at the DB boundary. + CONSTRAINT "foreclose_block_and_tx_set_together" + CHECK (("foreclose_block" = 0) = ("foreclose_transaction" IS NULL)), + -- Same invariant for the drive-proved pair (set together by the atomic + -- drive-proved marker+cursor repository write); the merkle root is + -- recorded only when a proved-block is observed, so all three + -- drive-proved columns are co-set. + CONSTRAINT "accounts_drive_proved_columns_set_together" + CHECK (("accounts_drive_proved_block" = 0) + = ("accounts_drive_proved_transaction" IS NULL) + AND ("accounts_drive_proved_block" = 0) + = ("accounts_drive_merkle_root" IS NULL)), + CONSTRAINT "application_pkey" PRIMARY KEY ("id") +); + +-- Supports ListApplications(ForeclosureRecorded = true), used by the claimer's +-- listEnabledForeclosedNonPRTApps once per tick. The filtered set is small +-- (foreclosed apps), so a partial index keyed on foreclose_block > 0 keeps +-- the scan an index-only seek even as the application table grows. +CREATE INDEX "application_foreclosed_idx" ON "application"("id") + WHERE "foreclose_block" > 0; + +CREATE TRIGGER "application_set_updated_at" BEFORE UPDATE ON "application" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE OR REPLACE FUNCTION validate_application_status_transition() +RETURNS TRIGGER AS $$ +BEGIN + -- Execution terminals are outcomes of a run from a healthy machine. They + -- cannot be entered from FAILED or another terminal status by an unrelated + -- status writer. + IF NEW.status IN ( + 'GUEST_EXCEPTION'::"ApplicationStatus", + 'MACHINE_HALTED'::"ApplicationStatus", + 'MCYCLE_OVERFLOW'::"ApplicationStatus", + 'UNEXPECTED_YIELD'::"ApplicationStatus") + AND NEW.status <> OLD.status + AND OLD.status <> 'OK'::"ApplicationStatus" + THEN + RAISE EXCEPTION 'cannot enter execution-terminal status % from application status %', NEW.status, OLD.status; + END IF; + + -- IOR now permits valid earlier claims. It cannot erase an operational + -- failure whose ownership has not been repaired by the operator. + IF OLD.status = 'FAILED'::"ApplicationStatus" + AND NEW.status = 'INVALID_OUTPUTS_ROOT'::"ApplicationStatus" + THEN + RAISE EXCEPTION 'cannot replace FAILED with INVALID_OUTPUTS_ROOT'; + END IF; + + -- The first integrity failure is final. Execution/root stops can escalate + -- to a genuine integrity finding while retaining their typed epoch boundary. + IF OLD.status IN ('DIVERGED'::"ApplicationStatus", 'CORRUPTED'::"ApplicationStatus") + AND (NEW.status <> OLD.status OR NEW.reason IS DISTINCT FROM OLD.reason) + THEN + RAISE EXCEPTION 'cannot change status or reason of a terminal (%) application', OLD.status; + END IF; + + IF OLD.status IN ( + 'GUEST_EXCEPTION'::"ApplicationStatus", 'MACHINE_HALTED'::"ApplicationStatus", + 'MCYCLE_OVERFLOW'::"ApplicationStatus", 'UNEXPECTED_YIELD'::"ApplicationStatus", + 'INVALID_OUTPUTS_ROOT'::"ApplicationStatus") + AND NOT ( + NEW.status IN ('DIVERGED'::"ApplicationStatus", 'CORRUPTED'::"ApplicationStatus") + OR (NEW.status = OLD.status AND NEW.reason IS NOT DISTINCT FROM OLD.reason) + ) + THEN + RAISE EXCEPTION 'cannot change status or reason of a terminal (%) application', OLD.status; + END IF; + + -- Foreclosure is one-way and lives in foreclose_block, not in status: a + -- status write must never clear the foreclosure marker. + IF OLD.foreclose_block <> 0 AND NEW.foreclose_block = 0 THEN + RAISE EXCEPTION 'cannot clear foreclose_block of a foreclosed application'; + END IF; + + -- Clear a stale failure reason when health returns to OK. + IF NEW.status = 'OK'::"ApplicationStatus" THEN + NEW.reason := NULL; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "application_validate_status_transition" BEFORE UPDATE ON "application" +FOR EACH ROW EXECUTE FUNCTION validate_application_status_transition(); + +-- Private deployment restrictions are independent of application health. +-- Clearing retains the row. This counter fences explicit enable and startup +-- assessment episodes; an empty restriction never establishes compatibility. +CREATE TABLE "deployment_admission_state" ( + "application_id" INT NOT NULL, + "reset_generation" BIGINT NOT NULL DEFAULT 0, + "confirmed_tier" TEXT, + "deployment_reason" TEXT, + CONSTRAINT "deployment_admission_state_pkey" PRIMARY KEY ("application_id"), + CONSTRAINT "deployment_admission_state_application_fkey" + FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE CASCADE, + CONSTRAINT "deployment_admission_state_reset_generation_check" + CHECK ("reset_generation" >= 0), + CONSTRAINT "deployment_admission_state_confirmed_tier_check" + CHECK ("confirmed_tier" IN ('generation', 'table')), + CONSTRAINT "deployment_admission_state_restriction_pair_check" + CHECK (("confirmed_tier" IS NULL) = ("deployment_reason" IS NULL)) +); + +CREATE TABLE "execution_parameters" ( + "application_id" INT PRIMARY KEY, + "snapshot_policy" "SnapshotPolicy" NOT NULL DEFAULT 'NONE', + "advance_inc_cycles" BIGINT NOT NULL CHECK ("advance_inc_cycles" > 0) DEFAULT 4194304, -- 1 << 22 + "advance_max_cycles" BIGINT NOT NULL CHECK ("advance_max_cycles" >= 0 AND "advance_max_cycles" <= 281474976710655) DEFAULT 0, -- 0 uses the machine's fixed (1 << 48) - 1 cycle span + "inspect_inc_cycles" BIGINT NOT NULL CHECK ("inspect_inc_cycles" > 0) DEFAULT 4194304, -- 1 << 22 + "inspect_max_cycles" BIGINT NOT NULL CHECK ("inspect_max_cycles" >= 0 AND "inspect_max_cycles" <= 281474976710655) DEFAULT 0, -- 0 uses the machine's fixed (1 << 48) - 1 cycle span + "advance_inc_deadline" BIGINT NOT NULL CHECK ("advance_inc_deadline" > 0) DEFAULT 10000000000, -- 10s + "advance_max_deadline" BIGINT NOT NULL CHECK ("advance_max_deadline" > 0) DEFAULT 180000000000, -- 180s + "inspect_inc_deadline" BIGINT NOT NULL CHECK ("inspect_inc_deadline" > 0) DEFAULT 10000000000, --10s + "inspect_max_deadline" BIGINT NOT NULL CHECK ("inspect_max_deadline" > 0) DEFAULT 180000000000, -- 180s + "load_deadline" BIGINT NOT NULL CHECK ("load_deadline" > 0) DEFAULT 300000000000, -- 300s + "store_deadline" BIGINT NOT NULL CHECK ("store_deadline" > 0) DEFAULT 180000000000, -- 180s + "fast_deadline" BIGINT NOT NULL CHECK ("fast_deadline" > 0) DEFAULT 5000000000, -- 5s + "max_concurrent_inspects" INT NOT NULL CHECK ("max_concurrent_inspects" > 0) DEFAULT 10, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "execution_parameters_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE CASCADE +); + +CREATE TRIGGER "execution_parameters_set_updated_at" BEFORE UPDATE ON "execution_parameters" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "epoch" +( + "application_id" int4 NOT NULL, + "index" uint64 NOT NULL, + "first_block" uint64 NOT NULL, + "last_block" uint64 NOT NULL, + "input_index_lower_bound" uint64 NOT NULL, + "input_index_upper_bound" uint64 NOT NULL, + "machine_hash" hash, + "tx_buffer_data_block" hash, + "tx_buffer_proof" BYTEA[], + "iflags_y_data_block" hash, + "iflags_y_proof" BYTEA[], + "htif_tohost_data_block" hash, + "htif_tohost_proof" BYTEA[], + "commitment" hash, + "commitment_proof" BYTEA[], + "tournament_address" ethereum_address, + "claim_transaction_hash" hash, + "status" "EpochStatus" NOT NULL, + -- Validation survives lifecycle completion through CLAIM_FORECLOSED. + "claim_validated" BOOLEAN NOT NULL DEFAULT false, + "staged_at_block" uint64, + "virtual_index" uint64 NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "epoch_pkey" PRIMARY KEY ("application_id", "index"), + CONSTRAINT "epoch_application_id_virtual_index_unique" UNIQUE ("application_id", "virtual_index"), + CONSTRAINT "epoch_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE CASCADE, + CONSTRAINT "epoch_block_bounds_check" CHECK ("first_block" <= "last_block"), + CONSTRAINT "epoch_input_bounds_check" CHECK ("input_index_lower_bound" <= "input_index_upper_bound"), + CONSTRAINT "epoch_tx_buffer_proof_elements_check" CHECK (check_hash_siblings("tx_buffer_proof")), + CONSTRAINT "epoch_iflags_y_proof_elements_check" CHECK (check_hash_siblings("iflags_y_proof")), + CONSTRAINT "epoch_htif_tohost_proof_elements_check" CHECK (check_hash_siblings("htif_tohost_proof")), + -- A proof is either absent or complete, even while the epoch is CLOSED. + -- This prevents a crash or ad-hoc write from leaving a mixed tuple that a + -- later reader could mistake for a usable terminal-state commitment. + CONSTRAINT "epoch_state_proof_tuple_check" CHECK ( + ( + "machine_hash" IS NULL + AND "tx_buffer_data_block" IS NULL + AND "tx_buffer_proof" IS NULL + AND "iflags_y_data_block" IS NULL + AND "iflags_y_proof" IS NULL + AND "htif_tohost_data_block" IS NULL + AND "htif_tohost_proof" IS NULL + ) + OR ( + "machine_hash" IS NOT NULL + AND "tx_buffer_data_block" IS NOT NULL + AND "tx_buffer_proof" IS NOT NULL + AND cardinality("tx_buffer_proof") = 59 + AND "iflags_y_data_block" IS NOT NULL + AND "iflags_y_proof" IS NOT NULL + AND cardinality("iflags_y_proof") = 59 + AND "htif_tohost_data_block" IS NOT NULL + AND "htif_tohost_proof" IS NOT NULL + AND cardinality("htif_tohost_proof") = 59 + ) + ), + -- Every published claim candidate carries the complete accepted-state + -- machine proof. CLAIM_FORECLOSED is excluded because foreclosure + -- may terminalize an epoch before the machine can publish a proof. A + -- previously validated epoch must retain its complete proof after that. + CONSTRAINT "epoch_published_validity_proof_check" CHECK ( + "status" NOT IN ('INPUTS_PROCESSED', 'CLAIM_COMPUTED', + 'CLAIM_SUBMITTED', 'CLAIM_STAGED', + 'CLAIM_ACCEPTED', 'CLAIM_REJECTED') + AND NOT "claim_validated" + OR ( + "machine_hash" IS NOT NULL + AND "tx_buffer_data_block" IS NOT NULL + AND "tx_buffer_proof" IS NOT NULL + AND cardinality("tx_buffer_proof") = 59 + AND "iflags_y_data_block" IS NOT NULL + AND "iflags_y_proof" IS NOT NULL + AND cardinality("iflags_y_proof") = 59 + AND "htif_tohost_data_block" IS NOT NULL + AND "htif_tohost_proof" IS NOT NULL + AND cardinality("htif_tohost_proof") = 59 + ) + ), + -- staged_at_block is set when an epoch is staged on chain and is then + -- kept historically — same lifetime convention as claim_transaction_hash. + -- We only enforce the forward direction: if you're in CLAIM_STAGED you + -- must have a staging block. After transitioning out to CLAIM_ACCEPTED, + -- the column is retained as audit info. + CONSTRAINT "epoch_staged_requires_block" CHECK ("status" <> 'CLAIM_STAGED' OR "staged_at_block" IS NOT NULL) +); + +CREATE INDEX "epoch_last_block_idx" ON "epoch"("application_id", "last_block"); +CREATE INDEX "epoch_status_idx" ON "epoch"("application_id", "status"); +-- Seek an unvalidated predecessor without scanning a future suffix. +CREATE INDEX "epoch_unvalidated_idx" ON "epoch"("application_id", "first_block") + WHERE NOT "claim_validated"; +-- Seek unpublished epochs without walking an INPUTS_PROCESSED prefix. At the +-- caught-up A/Q foreclosure frontier there is at most one OPEN boundary epoch. +CREATE INDEX "epoch_unpublished_idx" ON "epoch"("application_id", "first_block") + WHERE "status" IN ('OPEN','CLOSED'); +-- Supports HasUnreconciledClaimsBeforeBlock: the broad Authority/Quorum drain +-- gate scans epoch rows for (application_id, first_block <= $foreclose_block) +-- restricted to the set of actionable pre-terminal statuses. A partial index keyed on +-- (application_id, first_block) with the same status predicate keeps the +-- scan an index-only lookup; the bare epoch_status_idx covers the status +-- filter but adds a per-row comparison on first_block. +-- +-- The status list below MUST match NonTerminalEpochStatuses in +-- internal/model/models.go. If they drift, the query stops +-- matching this partial index and silently degrades to a full table scan. +CREATE INDEX "epoch_unreconciled_idx" ON "epoch"("application_id", "first_block") + WHERE "status" IN ('OPEN','CLOSED','INPUTS_PROCESSED', + 'CLAIM_COMPUTED','CLAIM_SUBMITTED','CLAIM_STAGED'); + +CREATE TRIGGER "epoch_set_updated_at" BEFORE UPDATE ON "epoch" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Enforce valid epoch status transitions. +-- The state machine is: +-- OPEN → CLOSED → INPUTS_PROCESSED → CLAIM_COMPUTED +-- CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_STAGED → CLAIM_ACCEPTED (v3 normal) +-- CLAIM_COMPUTED → CLAIM_STAGED (chain already staged, including Dave/PRT) +-- CLAIM_COMPUTED → CLAIM_ACCEPTED (deep reader-mode catch-up, including PRT) +-- CLAIM_COMPUTED → CLAIM_REJECTED (conflicting Quorum claim staged/accepted before we submitted) +-- CLAIM_SUBMITTED → CLAIM_REJECTED (we submitted, then a different Quorum claim was staged/accepted) +-- OPEN → CLAIM_FORECLOSED (guardian foreclosed before epoch could finish) +-- CLOSED → CLAIM_FORECLOSED (guardian foreclosed before inputs/proofs could finish) +-- INPUTS_PROCESSED → CLAIM_FORECLOSED (guardian foreclosed before claim could be computed) +-- CLAIM_COMPUTED → CLAIM_FORECLOSED (guardian foreclosed before claim could progress) +-- CLAIM_SUBMITTED → CLAIM_FORECLOSED (guardian foreclosed before claim could stage) +-- CLAIM_STAGED → CLAIM_FORECLOSED (guardian foreclosed before claim could be accepted) +-- CLAIM_STAGED → CLAIM_ACCEPTED (normal acceptance path) +-- Any other transition (including backwards) is rejected. +-- Same-status updates are allowed (idempotent no-ops). +-- +-- The epoch_published_validity_proof_check table constraint requires the +-- complete three-leaf machine proof from INPUTS_PROCESSED onward. When +-- transitioning to CLAIM_COMPUTED, this trigger additionally requires the +-- PRT commitment and commitment proof. +-- +-- Dave/PRT also uses CLAIM_STAGED while its tournament result waits for +-- sentry agreement or the claim-staging period. Like Authority/Quorum, every +-- CLAIM_STAGED row must record the corresponding on-chain staging block. +CREATE FUNCTION enforce_epoch_status_transition() RETURNS trigger AS $$ +DECLARE + valid_transitions text[][] := ARRAY[ + ARRAY['OPEN', 'CLOSED'], + ARRAY['OPEN', 'CLAIM_FORECLOSED'], + ARRAY['CLOSED', 'INPUTS_PROCESSED'], + ARRAY['CLOSED', 'CLAIM_FORECLOSED'], + ARRAY['INPUTS_PROCESSED', 'CLAIM_COMPUTED'], + ARRAY['INPUTS_PROCESSED', 'CLAIM_FORECLOSED'], + ARRAY['CLAIM_COMPUTED', 'CLAIM_SUBMITTED'], + ARRAY['CLAIM_COMPUTED', 'CLAIM_STAGED'], + ARRAY['CLAIM_COMPUTED', 'CLAIM_ACCEPTED'], + ARRAY['CLAIM_COMPUTED', 'CLAIM_REJECTED'], + ARRAY['CLAIM_COMPUTED', 'CLAIM_FORECLOSED'], + ARRAY['CLAIM_SUBMITTED', 'CLAIM_STAGED'], + ARRAY['CLAIM_SUBMITTED', 'CLAIM_ACCEPTED'], + ARRAY['CLAIM_SUBMITTED', 'CLAIM_REJECTED'], + ARRAY['CLAIM_SUBMITTED', 'CLAIM_FORECLOSED'], + ARRAY['CLAIM_STAGED', 'CLAIM_FORECLOSED'], + ARRAY['CLAIM_STAGED', 'CLAIM_ACCEPTED'] + ]; + is_valid boolean := false; + app_consensus text; +BEGIN + NEW.claim_validated := OLD.claim_validated; + IF OLD.status = NEW.status THEN + RETURN NEW; + END IF; + FOR i IN 1..array_length(valid_transitions, 1) LOOP + IF OLD.status::text = valid_transitions[i][1] + AND NEW.status::text = valid_transitions[i][2] THEN + is_valid := true; + EXIT; + END IF; + END LOOP; + IF NOT is_valid THEN + RAISE EXCEPTION 'invalid epoch status transition: % -> %', + OLD.status, NEW.status; + END IF; + + IF OLD.status = 'INPUTS_PROCESSED' AND NEW.status = 'CLAIM_COMPUTED' THEN + NEW.claim_validated := true; + END IF; + + -- Enforce PRT-specific claim fields when entering CLAIM_COMPUTED. + IF NEW.status::text = 'CLAIM_COMPUTED' THEN + SELECT a.consensus_type::text INTO app_consensus + FROM application a + WHERE a.id = NEW.application_id; + + IF app_consensus = 'PRT' THEN + IF NEW.commitment IS NULL + OR NEW.commitment_proof IS NULL THEN + RAISE EXCEPTION + 'CLAIM_COMPUTED for PRT apps requires commitment ' + 'and commitment_proof to be non-null'; + END IF; + END IF; + END IF; + + -- staged_at_block is required when entering CLAIM_STAGED. The + -- staged_requires_block table CHECK constraint also enforces this; the + -- trigger gives a clearer error message on the state-machine path. + IF NEW.status::text = 'CLAIM_STAGED' THEN + IF NEW.staged_at_block IS NULL THEN + RAISE EXCEPTION + 'CLAIM_STAGED requires staged_at_block to be non-null'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "epoch_status_transition_check" + BEFORE UPDATE OF "status" ON "epoch" + FOR EACH ROW + EXECUTE FUNCTION enforce_epoch_status_transition(); + +CREATE TABLE "input" +( + "epoch_application_id" int4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "index" uint64 NOT NULL, + "block_number" uint64 NOT NULL, + "raw_data" BYTEA NOT NULL, + "status" "InputCompletionStatus" NOT NULL, + "exception_data" BYTEA, + "machine_hash" hash, + "tx_buffer_data_block" hash, + "transaction_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "snapshot_uri" VARCHAR(4096), + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "input_exception_data_check" CHECK ( + ("status" = 'EXCEPTION' AND "exception_data" IS NOT NULL) + OR + ("status" <> 'EXCEPTION' AND "exception_data" IS NULL) + ), + CONSTRAINT "input_pkey" PRIMARY KEY ("epoch_application_id", "index"), + -- The state_hashes foreign key references this exact three-column key + -- to verify that the input belongs to the stated epoch. A plain index + -- cannot replace the unique key required by that foreign key. + CONSTRAINT "input_epoch_index_unique" UNIQUE ("epoch_application_id", "epoch_index", "index"), + CONSTRAINT "input_application_id_tx_hash_log_index_unique" UNIQUE ("epoch_application_id", "transaction_hash", "log_index"), + CONSTRAINT "input_completed_hashes_check" CHECK ( + ("status" = 'NONE' AND "machine_hash" IS NULL AND "tx_buffer_data_block" IS NULL) + OR + ("status" <> 'NONE' AND "machine_hash" IS NOT NULL AND "tx_buffer_data_block" IS NOT NULL) + ), + CONSTRAINT "input_epoch_id_fkey" FOREIGN KEY ("epoch_application_id", "epoch_index") REFERENCES "epoch"("application_id", "index") ON DELETE CASCADE +); + +CREATE INDEX "input_block_number_idx" ON "input"("epoch_application_id", "block_number"); +CREATE INDEX "input_status_idx" ON "input"("epoch_application_id", "status"); +CREATE INDEX "input_unprocessed_idx" ON "input"("epoch_application_id", "epoch_index", "index") + WHERE "status" = 'NONE'; + +CREATE INDEX "input_sender_idx" ON "input" ("epoch_application_id", substring("raw_data" FROM 81 FOR 20)); + +-- The input table has a hot update pattern: status changes from NONE to a +-- terminal value on every processed input. Lower the autovacuum threshold so +-- dead tuples (and the shrinking partial index) are reclaimed promptly. +ALTER TABLE "input" SET (autovacuum_vacuum_scale_factor = 0.02); + +CREATE FUNCTION enforce_input_completion_immutability() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.status <> 'NONE' AND ( + NEW.epoch_application_id IS DISTINCT FROM OLD.epoch_application_id + OR NEW.epoch_index IS DISTINCT FROM OLD.epoch_index + OR NEW.index IS DISTINCT FROM OLD.index + OR NEW.raw_data IS DISTINCT FROM OLD.raw_data + OR NEW.status IS DISTINCT FROM OLD.status + OR NEW.exception_data IS DISTINCT FROM OLD.exception_data + OR NEW.machine_hash IS DISTINCT FROM OLD.machine_hash + OR NEW.tx_buffer_data_block IS DISTINCT FROM OLD.tx_buffer_data_block + ) THEN + RAISE EXCEPTION + 'completed input result is immutable'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "input_completion_immutability_check" + BEFORE UPDATE OF "epoch_application_id", "epoch_index", "index", "raw_data", + "status", "exception_data", "machine_hash", "tx_buffer_data_block" ON "input" + FOR EACH ROW + EXECUTE FUNCTION enforce_input_completion_immutability(); + +CREATE TRIGGER "input_set_updated_at" BEFORE UPDATE ON "input" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "output" +( + "input_epoch_application_id" int4 NOT NULL, + "input_index" uint64 NOT NULL, + "index" uint64 NOT NULL, + "raw_data" BYTEA NOT NULL, + "hash" hash, + "output_hashes_siblings" BYTEA[], + "execution_transaction_hash" hash, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "output_pkey" PRIMARY KEY ("input_epoch_application_id", "index"), + CONSTRAINT "output_input_id_fkey" FOREIGN KEY ("input_epoch_application_id", "input_index") REFERENCES "input"("epoch_application_id", "index") ON DELETE CASCADE, + CONSTRAINT "output_hashes_siblings_length_check" CHECK (check_hash_siblings("output_hashes_siblings")) +); + +CREATE INDEX "output_raw_data_type_idx" ON "output" ("input_epoch_application_id", substring("raw_data" FROM 1 FOR 4)); + +CREATE INDEX "output_input_index_idx" ON "output" ("input_epoch_application_id", "input_index", "index"); + +CREATE INDEX "output_raw_data_address_idx" ON "output" ("input_epoch_application_id", substring("raw_data" FROM 17 FOR 20)) +WHERE SUBSTRING("raw_data" FROM 1 FOR 4) IN ( + E'\\x10321e8b', -- DelegateCallVoucher + E'\\x237a816f' -- Voucher +); + +-- Serves GetNumberOfPendingExecutableOutputs and pending-voucher list queries +-- without scanning the application's full output history. +CREATE INDEX "output_pending_voucher_idx" ON "output" ("input_epoch_application_id") +WHERE "execution_transaction_hash" IS NULL AND SUBSTRING("raw_data" FROM 1 FOR 4) IN ( + E'\\x10321e8b', -- DelegateCallVoucher + E'\\x237a816f' -- Voucher +); + +-- Serves GetNumberOfExecutedOutputs without scanning the application's full +-- output history. Outputs enter this index only after execution is observed. +CREATE INDEX "output_executed_idx" ON "output" ("input_epoch_application_id") +WHERE "execution_transaction_hash" IS NOT NULL; + +CREATE TRIGGER "output_set_updated_at" BEFORE UPDATE ON "output" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- A later data migration that must update these columns disables this trigger in its own transaction. +CREATE FUNCTION enforce_output_immutability() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'output identity or raw data is immutable' USING ERRCODE = 'restrict_violation'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "output_immutability_check" +BEFORE UPDATE OF "input_epoch_application_id", "input_index", "index", "raw_data" ON "output" +FOR EACH ROW EXECUTE FUNCTION enforce_output_immutability(); + +-- Withdrawal rows are append-only for the lifetime of an application: they are +-- inserted as the evmreader scans Withdrawal events and are never updated or +-- deleted (except by ON DELETE CASCADE when the whole application is removed). +-- The post-foreclosure withdrawal scanner relies on this — it uses the row +-- COUNT(*) (GetNumberOfWithdrawals) as the previous on-chain withdrawal counter +-- to seed its monotonic search. Deleting a row would make that count disagree +-- with the chain counter and trip the corruption guard. Do not add an UPDATE or +-- DELETE path for individual withdrawal rows. +CREATE TABLE "withdrawal" +( + "application_id" INT NOT NULL, + "account_index" uint64 NOT NULL, + "account" BYTEA NOT NULL, + "output" BYTEA NOT NULL, + "block_number" uint64 NOT NULL, + "transaction_hash" hash NOT NULL, + "log_index" INT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "withdrawal_pkey" PRIMARY KEY ("application_id", "account_index"), + CONSTRAINT "withdrawal_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "application"("id") ON DELETE CASCADE +); + +CREATE INDEX "withdrawal_block_number_idx" ON "withdrawal" ("application_id", "block_number"); + +CREATE TRIGGER "withdrawal_set_updated_at" BEFORE UPDATE ON "withdrawal" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "report" +( + "input_epoch_application_id" int4 NOT NULL, + "input_index" uint64 NOT NULL, + "index" uint64 NOT NULL, + "raw_data" BYTEA NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "report_pkey" PRIMARY KEY ("input_epoch_application_id", "index"), + CONSTRAINT "report_input_id_fkey" FOREIGN KEY ("input_epoch_application_id", "input_index") REFERENCES "input"("epoch_application_id", "index") ON DELETE CASCADE +); + +CREATE INDEX "report_input_index_idx" ON "report" ("input_epoch_application_id", "input_index", "index"); + +CREATE TRIGGER "report_set_updated_at" BEFORE UPDATE ON "report" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "node_config" +( + "key" VARCHAR(255) PRIMARY KEY, + "value" jsonb NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TRIGGER "config_set_updated_at" BEFORE UPDATE ON "node_config" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE DOMAIN uint256 AS NUMERIC CHECK ( + VALUE >= 0 AND VALUE = trunc(VALUE) AND VALUE <= + 115792089237316195423570985008687907853269984665640564039457584007913129639935 +); + +CREATE TYPE "TournamentKind" AS ENUM ( + 'LEAF', + 'NON_LEAF' +); + +CREATE TYPE "TournamentStandingState" AS ENUM ( + 'MATCHES_ACTIVE', + 'AWAITING_CLOSURE', + 'ROOT_WINNER', + 'ROOT_FAILED', + 'INNER_WINNER', + 'INNER_ELIMINABLE_NO_WINNER', + 'INNER_ELIMINABLE_WINNER_EXPIRED' +); + +CREATE TYPE "MatchPhase" AS ENUM ( + 'UNINITIALIZED', + 'BISECTING', + 'READY_TO_SEAL', + 'SEALED' +); + +CREATE TYPE "CommitmentSide" AS ENUM ( + 'ONE', + 'TWO' +); + +CREATE TYPE "MatchTimeoutOutcome" AS ENUM ( + 'NONE', + 'ONE_WINS', + 'TWO_WINS', + 'ELIMINATE_BOTH' +); + +CREATE TYPE "InnerTournamentDisposition" AS ENUM ( + 'UNSETTLED', + 'WINNER', + 'ELIMINABLE' +); + +CREATE TYPE "BondDisposition" AS ENUM ( + 'TOURNAMENT_RUNNING', + 'NO_WINNER', + 'RECOVERABLE', + 'RECOVERED' +); + +CREATE TYPE "BondEventType" AS ENUM ( + 'PARTIAL_BOND_REFUND', + 'BOND_RECOVERED' +); + +-- Tournament, commitment, and match views describe current state at their +-- as_of_block. These rows are not a historical snapshot API. Event facts are +-- immutable and are published atomically with each current-state window. +CREATE TABLE "tournaments" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "address" ethereum_address NOT NULL, + "parent_tournament_address" ethereum_address, + "parent_match_id_hash" hash, + "max_level" INT NOT NULL CHECK("max_level" > 0), + "level" INT NOT NULL CHECK("level" >= 0), + "log2step" INT NOT NULL CHECK("log2step" >= 0), + "height" INT NOT NULL CHECK("height" >= 0), + "initial_hash" hash NOT NULL, + "base_cycle" uint256 NOT NULL, + "kind" "TournamentKind" NOT NULL, + "start_instant" uint64 NOT NULL, + "allowance" uint64 NOT NULL, + "bond_value" uint256 NOT NULL CHECK ("bond_value" > 0), + "contested_final_state_one" hash, + "contested_final_state_two" hash, + "creation_block_number" uint64, + "creation_tx_hash" hash, + "creation_log_index" uint64, + "as_of_block" uint64 NOT NULL, + "standing" "TournamentStandingState" NOT NULL, + "accepts_joins" BOOLEAN NOT NULL, + "candidate" hash, + "winner_commitment" hash, + "final_state_hash" hash, + "finished_at_block" uint64 NOT NULL DEFAULT 0, + "parent_commitment" hash, + "winner_expires_at" uint64 NOT NULL, + "inner_disposition" "InnerTournamentDisposition", + "inner_parent_commitment" hash, + "inner_paused_allowance" uint64, + "bond_disposition" "BondDisposition" NOT NULL, + "bond_claimer" ethereum_address, + "bond_payment" uint256, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "tournaments_pkey" PRIMARY KEY ("application_id","epoch_index","address"), + CONSTRAINT "tournaments_epoch_fkey" FOREIGN KEY ("application_id","epoch_index") + REFERENCES "epoch"("application_id","index") + ON DELETE CASCADE, + CONSTRAINT "chk_tournament_root_parent" + CHECK ( + ("level" = 0 AND "parent_tournament_address" IS NULL AND "parent_match_id_hash" IS NULL) + OR + ("level" > 0 AND "parent_tournament_address" IS NOT NULL AND "parent_match_id_hash" IS NOT NULL) + ), + CONSTRAINT "tournaments_max_level_gt_level_check" CHECK ("max_level" > "level"), + CONSTRAINT "tournaments_creation_block_check" CHECK ("creation_block_number" = "start_instant"), + CONSTRAINT "tournaments_creation_event_check" CHECK ( + ("level" = 0 AND num_nonnulls("creation_block_number", "creation_tx_hash", "creation_log_index") = 0) + OR + ("level" > 0 AND num_nonnulls("creation_block_number", "creation_tx_hash", "creation_log_index") = 3) + ), + CONSTRAINT "tournaments_nested_dispute_check" CHECK ( + ("level" = 0 AND num_nonnulls("contested_final_state_one", "contested_final_state_two") = 0) + OR + ("level" > 0 AND num_nonnulls("contested_final_state_one", "contested_final_state_two") = 2) + ), + CONSTRAINT "tournaments_current_winner_check" CHECK ( + ("standing" IN ('ROOT_WINNER', 'INNER_WINNER') AND "candidate" IS NOT NULL + AND "winner_commitment" IS NOT NULL AND "winner_commitment" = "candidate" AND "final_state_hash" IS NOT NULL) + OR + ("standing" NOT IN ('ROOT_WINNER', 'INNER_WINNER') AND "winner_commitment" IS NULL AND "final_state_hash" IS NULL) + ), + CONSTRAINT "tournaments_current_result_check" CHECK ( + ("standing" IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE') AND "finished_at_block" = 0) + OR + ("standing" NOT IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE') AND "finished_at_block" > 0 + AND "finished_at_block" <= "as_of_block") + ), + CONSTRAINT "tournaments_inner_winner_check" CHECK ( + ("standing" = 'INNER_WINNER' AND "level" > 0 AND "parent_commitment" IS NOT NULL + AND "winner_expires_at" > "as_of_block") + OR + ("standing" <> 'INNER_WINNER' AND "parent_commitment" IS NULL AND "winner_expires_at" = 0) + ), + CONSTRAINT "tournaments_standing_level_check" CHECK ( + ("level" = 0 AND "standing" IN ('MATCHES_ACTIVE', 'AWAITING_CLOSURE', 'ROOT_WINNER', 'ROOT_FAILED')) + OR + ("level" > 0 AND "standing" NOT IN ('ROOT_WINNER', 'ROOT_FAILED')) + ), + CONSTRAINT "tournaments_inner_result_check" CHECK ( + ("level" = 0 AND num_nonnulls("inner_disposition", "inner_parent_commitment", "inner_paused_allowance") = 0) + OR + ("level" > 0 AND "inner_disposition" IS NOT NULL AND "inner_paused_allowance" IS NOT NULL AND ( + ("inner_disposition" = 'WINNER' AND "inner_parent_commitment" IS NOT NULL AND "inner_paused_allowance" > 0) + OR + ("inner_disposition" <> 'WINNER' AND "inner_parent_commitment" IS NULL AND "inner_paused_allowance" = 0) + )) + ), + CONSTRAINT "tournaments_bond_recovery_check" CHECK ( + ("bond_disposition" = 'RECOVERABLE' AND "bond_claimer" IS NOT NULL AND "bond_payment" IS NOT NULL) + OR + ("bond_disposition" <> 'RECOVERABLE' AND "bond_claimer" IS NULL AND "bond_payment" IS NULL) + ), + CONSTRAINT "tournaments_creation_event_key" UNIQUE ("application_id", "creation_tx_hash", "creation_log_index") +); + +CREATE UNIQUE INDEX "unique_root_per_epoch_idx" + ON "tournaments"("application_id","epoch_index") + WHERE "level" = 0; + +-- Recovery scans need only this signer's unpaid roots, not all accepted history. +CREATE INDEX "tournaments_recoverable_root_idx" + ON "tournaments"("application_id","bond_claimer","epoch_index") + WHERE "level" = 0 AND "bond_disposition" = 'RECOVERABLE'; + +-- A tournament address identifies one epoch within an application. This key +-- also supports tournament lookups that do not specify an epoch. +CREATE UNIQUE INDEX "tournaments_address_idx" + ON "tournaments"("application_id","address"); + +CREATE INDEX "tournaments_parent_match_nonroot_idx" + ON "tournaments"("application_id","epoch_index","parent_tournament_address","parent_match_id_hash") + WHERE "level" > 0; + +CREATE TRIGGER "tournaments_set_updated_at" +BEFORE UPDATE ON "tournaments" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "commitments" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "tournament_address" ethereum_address NOT NULL, + "commitment" hash NOT NULL, + "final_state_hash" hash NOT NULL, + "submitter_address" ethereum_address NOT NULL, + "block_number" uint64 NOT NULL, + "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "as_of_block" uint64 NOT NULL, + "claimer" ethereum_address NOT NULL, + "clock_running" BOOLEAN NOT NULL, + "clock_deadline" uint64 NOT NULL, + "clock_allowance" uint64 NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "commitments_pkey" + PRIMARY KEY ("application_id","epoch_index","tournament_address","commitment"), + CONSTRAINT "commitments_event_key" UNIQUE ("application_id", "tx_hash", "log_index"), + CONSTRAINT "commitments_paused_clock_check" CHECK ("clock_running" OR "clock_deadline" = 0), + CONSTRAINT "commitments_tournament_fkey" + FOREIGN KEY ("application_id","epoch_index","tournament_address") + REFERENCES "tournaments"("application_id","epoch_index","address") + ON DELETE CASCADE +); + +CREATE INDEX "commitments_final_state_idx" + ON "commitments"("final_state_hash"); + +CREATE TRIGGER "commitments_set_updated_at" +BEFORE UPDATE ON "commitments" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "matches" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "tournament_address" ethereum_address NOT NULL, + "id_hash" hash NOT NULL, + "commitment_one" hash NOT NULL, + "commitment_two" hash NOT NULL, + "left_of_two" hash NOT NULL, + "block_number" uint64 NOT NULL, + "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "eliminable_at" uint64 NOT NULL, + "responder_deadline" uint64 NOT NULL, + "seal_eliminable_at" uint64, + "seal_agree_state" hash, + "seal_divergence_position" uint256, + "seal_final_state_one" hash, + "seal_final_state_two" hash, + "seal_deadline_one" uint64, + "seal_deadline_two" uint64, + "seal_block_number" uint64, + "seal_tx_hash" hash, + "seal_log_index" uint64, + "as_of_block" uint64 NOT NULL, + "phase" "MatchPhase" NOT NULL, + "timeout_outcome" "MatchTimeoutOutcome" NOT NULL, + "deferred_charge" uint64 NOT NULL, + "revealing_parent" hash, + "waiting_left" hash, + "waiting_right" hash, + "segment_start_position" uint256, + "segment_start_cycle" uint256, + "current_height" uint64, + "responder" "CommitmentSide", + "agree_state" hash, + "divergence_position" uint256, + "divergence_cycle" uint256, + "final_state_one" hash, + "final_state_two" hash, + "winner" "WinnerCommitment" NOT NULL, + "deletion_reason" "MatchDeletionReason" NOT NULL, + "deletion_block_number" uint64 NOT NULL DEFAULT 0, + "deletion_tx_hash" hash, + "deletion_log_index" uint64, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "matches_pkey" + PRIMARY KEY ("application_id","epoch_index","tournament_address","id_hash"), + + CONSTRAINT "matches_deletion_columns_set_together" CHECK ( + ("deletion_reason" = 'NOT_DELETED' AND "deletion_block_number" = 0 + AND "deletion_tx_hash" IS NULL AND "deletion_log_index" IS NULL) + OR + ("deletion_reason" <> 'NOT_DELETED' AND "deletion_block_number" > 0 + AND "deletion_tx_hash" IS NOT NULL AND "deletion_log_index" IS NOT NULL) + ), + + CONSTRAINT "matches_creation_schedule_check" CHECK ("responder_deadline" <= "eliminable_at"), + CONSTRAINT "matches_seal_tuple_check" CHECK ( + num_nonnulls("seal_eliminable_at", "seal_agree_state", "seal_divergence_position", + "seal_final_state_one", "seal_final_state_two", "seal_deadline_one", "seal_deadline_two", + "seal_block_number", "seal_tx_hash", "seal_log_index") IN (0, 10) + ), + CONSTRAINT "matches_seal_schedule_check" CHECK ("seal_tx_hash" IS NULL OR + "seal_eliminable_at" = GREATEST("seal_deadline_one", "seal_deadline_two")), + CONSTRAINT "matches_sealed_view_check" CHECK ("seal_tx_hash" IS NULL OR "phase" <> 'SEALED' OR ( + "agree_state" IS NOT DISTINCT FROM "seal_agree_state" + AND "divergence_position" IS NOT DISTINCT FROM "seal_divergence_position" + AND "final_state_one" IS NOT DISTINCT FROM "seal_final_state_one" + AND "final_state_two" IS NOT DISTINCT FROM "seal_final_state_two" + )), + CONSTRAINT "matches_event_key" UNIQUE ("application_id", "tx_hash", "log_index"), + CONSTRAINT "matches_seal_event_key" UNIQUE ("application_id", "seal_tx_hash", "seal_log_index"), + CONSTRAINT "matches_deletion_event_key" UNIQUE ("application_id", "deletion_tx_hash", "deletion_log_index"), + CONSTRAINT "matches_current_phase_check" CHECK ( + ("phase" IN ('BISECTING', 'READY_TO_SEAL') AND "deletion_reason" = 'NOT_DELETED' + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder") = 6 + AND num_nonnulls("agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 0 + AND (("phase" = 'BISECTING' AND "current_height" IS NOT NULL) + OR ("phase" = 'READY_TO_SEAL' AND "current_height" IS NULL))) + OR + ("phase" = 'SEALED' AND "deletion_reason" = 'NOT_DELETED' + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder", "current_height") = 0 + AND num_nonnulls("agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 5) + OR + ("phase" = 'UNINITIALIZED' AND "deletion_reason" <> 'NOT_DELETED' AND "timeout_outcome" = 'NONE' AND "deferred_charge" = 0 + AND num_nonnulls("revealing_parent", "waiting_left", "waiting_right", "segment_start_position", "segment_start_cycle", "responder", "current_height", + "agree_state", "divergence_position", "divergence_cycle", "final_state_one", "final_state_two") = 0) + ), + + CONSTRAINT "matches_tournament_fkey" + FOREIGN KEY ("application_id","epoch_index","tournament_address") + REFERENCES "tournaments"("application_id","epoch_index","address") + ON DELETE CASCADE, + + CONSTRAINT "matches_one_commitment_fkey" + FOREIGN KEY ("application_id","epoch_index","tournament_address","commitment_one") + REFERENCES "commitments"("application_id","epoch_index","tournament_address","commitment") + ON DELETE CASCADE, + + CONSTRAINT "matches_two_commitment_fkey" + FOREIGN KEY ("application_id","epoch_index","tournament_address","commitment_two") + REFERENCES "commitments"("application_id","epoch_index","tournament_address","commitment") + ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "matches_unique_pair_idx" + ON "matches"("application_id","epoch_index","tournament_address","commitment_one","commitment_two"); + +CREATE TRIGGER "matches_set_updated_at" +BEFORE UPDATE ON "matches" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Add foreign key from tournaments to matches (parent match) +ALTER TABLE "tournaments" + ADD CONSTRAINT "tournaments_parent_match_fkey" + FOREIGN KEY ("application_id","epoch_index","parent_tournament_address","parent_match_id_hash") + REFERENCES "matches"("application_id","epoch_index","tournament_address","id_hash") + ON DELETE CASCADE; + +CREATE TABLE "match_advances" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "tournament_address" ethereum_address NOT NULL, + "id_hash" hash NOT NULL, -- keccak256(abi.encode(one,two)) + "other_parent" hash NOT NULL, + "left_node" hash NOT NULL, + "segment_start_position" uint256 NOT NULL, + "eliminable_at" uint64 NOT NULL, + "current_height" uint64 NOT NULL, + "responder_deadline" uint64 NOT NULL, + "block_number" uint64 NOT NULL, + "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "match_advances_pkey" + PRIMARY KEY ("application_id","tx_hash","log_index"), + + CONSTRAINT "match_advances_height_check" CHECK ("current_height" >= 1), + CONSTRAINT "match_advances_schedule_check" CHECK ("responder_deadline" <= "eliminable_at"), + CONSTRAINT "match_advances_height_key" + UNIQUE ("application_id", "epoch_index", "tournament_address", "id_hash", "current_height"), + + CONSTRAINT "match_advances_matches_fkey" + FOREIGN KEY ("application_id","epoch_index","tournament_address","id_hash") + REFERENCES "matches"("application_id","epoch_index","tournament_address","id_hash") + ON DELETE CASCADE +); + +CREATE INDEX "match_advances_block_number_idx" + ON "match_advances"("application_id","epoch_index","tournament_address","id_hash","block_number","log_index","tx_hash"); + +CREATE TRIGGER "match_advances_set_updated_at" +BEFORE UPDATE ON "match_advances" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "bond_events" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "tournament_address" ethereum_address NOT NULL, + "type" "BondEventType" NOT NULL, + "block_number" uint64 NOT NULL, + "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "recipient" ethereum_address, + "value" uint256, + "success" BOOLEAN, + "commitment" hash, + "claimer" ethereum_address, + "payment" uint256, + "burned" uint256, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "bond_events_pkey" PRIMARY KEY ("application_id", "tx_hash", "log_index"), + CONSTRAINT "bond_events_tournament_fkey" FOREIGN KEY ("application_id", "epoch_index", "tournament_address") + REFERENCES "tournaments"("application_id", "epoch_index", "address") ON DELETE CASCADE, + CONSTRAINT "bond_events_variant_check" CHECK ( + ("type" = 'PARTIAL_BOND_REFUND' AND num_nonnulls("recipient", "value", "success") = 3 + AND num_nonnulls("commitment", "claimer", "payment", "burned") = 0) + OR + ("type" = 'BOND_RECOVERED' AND num_nonnulls("recipient", "value", "success") = 0 + AND num_nonnulls("commitment", "claimer", "payment", "burned") = 4) + ) +); + +CREATE INDEX "bond_events_application_order_idx" + ON "bond_events"("application_id", "block_number", "log_index", "tx_hash"); + +CREATE INDEX "bond_events_tournament_order_idx" + ON "bond_events"("application_id", "epoch_index", "tournament_address", "block_number", "log_index", "tx_hash"); + +CREATE TRIGGER "bond_events_set_updated_at" BEFORE UPDATE ON "bond_events" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE "standing_changes" +( + "application_id" INT4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "tournament_address" ethereum_address NOT NULL, + "match_count" uint256 NOT NULL, + "dangling" hash, + "parent_commitment" hash, + "result_at" uint64 NOT NULL, + "winner_expires_at" uint64 NOT NULL, + "block_number" uint64 NOT NULL, + "tx_hash" hash NOT NULL, + "log_index" uint64 NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "standing_changes_pkey" PRIMARY KEY ("application_id", "tx_hash", "log_index"), + CONSTRAINT "standing_changes_tournament_fkey" + FOREIGN KEY ("application_id", "epoch_index", "tournament_address") + REFERENCES "tournaments"("application_id", "epoch_index", "address") ON DELETE CASCADE, + CONSTRAINT "standing_changes_dangling_fkey" + FOREIGN KEY ("application_id", "epoch_index", "tournament_address", "dangling") + REFERENCES "commitments"("application_id", "epoch_index", "tournament_address", "commitment") ON DELETE CASCADE, + CONSTRAINT "standing_changes_live_matches_check" CHECK ("match_count" = 0 OR + ("result_at" = 0 AND "parent_commitment" IS NULL AND "winner_expires_at" = 0)), + CONSTRAINT "standing_changes_result_check" CHECK ("match_count" > 0 OR "result_at" > 0), + CONSTRAINT "standing_changes_winner_check" CHECK ( + ("parent_commitment" IS NULL AND "winner_expires_at" = 0) + OR ("parent_commitment" IS NOT NULL AND "dangling" IS NOT NULL AND "winner_expires_at" >= "result_at")) +); + +CREATE INDEX "standing_changes_tournament_order_idx" + ON "standing_changes"("application_id", "epoch_index", "tournament_address", "block_number", "log_index", "tx_hash"); + +CREATE TRIGGER "standing_changes_set_updated_at" BEFORE UPDATE ON "standing_changes" +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- One row of an input's PRT computation-hash collection, in entries. An +-- entry is the Merkle root of 2^k consecutive state-hash samples +-- (k = model.Log2StateHashesPerEntry), so machine_hash holds an entry root, +-- not a machine state hash; the two coincide only when k = 0. +-- "repetitions" counts entries: the rows of one input sum to +-- model.InputHashCollectionCapacity. "index" is the row ordinal within the +-- epoch, not an entry or leaf position; the rows of one input are +-- consecutive and in input order. +-- An input's rows are its sampled entries, each with repetitions 1 (the +-- last one may end with copies of the final state), then a padding row: +-- model.FinalStateEntry(input.machine_hash), the root of 2^k copies of the +-- input's final machine hash, repeated over the rest of the input. An input +-- whose sampled entries fill its span has no padding row. +CREATE TABLE "state_hashes" +( + "input_epoch_application_id" int4 NOT NULL, + "epoch_index" uint64 NOT NULL, + "input_index" uint64 NOT NULL, + "index" uint64 NOT NULL, + "machine_hash" hash NOT NULL, + "repetitions" INT8 NOT NULL CHECK ("repetitions" > 0), + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "state_hashes_pkey" PRIMARY KEY ("input_epoch_application_id", "epoch_index", "index"), + CONSTRAINT "state_hashes_input_id_fkey" FOREIGN KEY ("input_epoch_application_id", "epoch_index", "input_index") REFERENCES "input"("epoch_application_id", "epoch_index", "index") ON DELETE CASCADE +); + +CREATE INDEX "state_hashes_input_index_idx" ON "state_hashes" ("input_epoch_application_id", "input_index", "index"); + +-- A later data migration that must update rows disables this trigger in its own transaction. +CREATE FUNCTION enforce_state_hash_immutability() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'state_hashes row is immutable' USING ERRCODE = 'restrict_violation'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "state_hashes_immutability_check" BEFORE UPDATE ON "state_hashes" +FOR EACH ROW EXECUTE FUNCTION enforce_state_hash_immutability(); + +COMMIT; diff --git a/internal/repository/postgres/schema/schema.go b/internal/repository/postgres/schema/schema.go new file mode 100644 index 000000000..376da48b4 --- /dev/null +++ b/internal/repository/postgres/schema/schema.go @@ -0,0 +1,125 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package schema + +import ( + "embed" + "errors" + "fmt" + "log/slog" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/pgx" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" +) + +//go:embed migrations/* +var content embed.FS + +const ExpectedVersion uint = 1 + +type Schema struct { + migrate *migrate.Migrate +} + +var ( + ErrMigrationNotCompleted = errors.New("Schema version is dirty") + ErrNoValidDatabaseSchema = errors.New("No valid database schema found") +) + +// VersionMismatchError contains only the schema versions needed for diagnosis. +type VersionMismatchError struct { + Expected uint + Actual uint +} + +func (e *VersionMismatchError) Error() string { + return fmt.Sprintf("database schema version mismatch. Expected %d but it is %d", e.Expected, e.Actual) +} + +func New(postgresEndpoint string) (*Schema, error) { + driver, err := iofs.New(content, "migrations") + if err != nil { + return nil, err + } + + migrate, err := migrate.NewWithSourceInstance("iofs", driver, postgresEndpoint) + if err != nil { + return nil, err + } + + return &Schema{migrate: migrate}, nil +} + +func NewWithPool(pool *pgxpool.Pool) (*Schema, error) { + source, err := iofs.New(content, "migrations") + if err != nil { + return nil, err + } + + db := stdlib.OpenDBFromPool(pool) + driver, err := pgx.WithInstance(db, &pgx.Config{}) + if err != nil { + return nil, fmt.Errorf("could not instantiate pgx migrate driver: %v", err) + } + + migrate, err := migrate.NewWithInstance("iofs", source, "postgres", driver) + if err != nil { + return nil, err + } + + return &Schema{migrate: migrate}, nil +} + +func (s *Schema) Version() (uint, bool, error) { + version, dirty, err := s.migrate.Version() + if err != nil && errors.Is(err, migrate.ErrNilVersion) { + return version, dirty, ErrNoValidDatabaseSchema + } + return version, dirty, err +} + +func (s *Schema) Upgrade() error { + if err := s.migrate.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + return nil +} + +func (s *Schema) Downgrade() error { + if err := s.migrate.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + return nil +} + +func (s *Schema) Close() { + source, db := s.migrate.Close() + if source != nil { + slog.Error("Error releasing migration sources", "error", source) + } + if db != nil { + slog.Error("Error closing db connection", "error", db) + } +} + +func (s *Schema) ValidateVersion() (uint, error) { + version, dirty, err := s.Version() + if err != nil { + return 0, err + } + + if dirty { + return 0, ErrMigrationNotCompleted + } + + if version != ExpectedVersion { + return 0, &VersionMismatchError{Expected: ExpectedVersion, Actual: version} + } + return version, nil +} diff --git a/internal/repository/postgres/standing_changed.go b/internal/repository/postgres/standing_changed.go new file mode 100644 index 000000000..82d2f39e2 --- /dev/null +++ b/internal/repository/postgres/standing_changed.go @@ -0,0 +1,97 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func (r *PostgresRepository) GetStandingChange( + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + txHash common.Hash, logIndex uint64, +) (*model.StandingChanged, error) { + sel := table.StandingChanges.SELECT(standingChangeColumns, table.StandingChanges.CreatedAt, table.StandingChanges.UpdatedAt). + FROM(table.StandingChanges.INNER_JOIN(table.Application, table.StandingChanges.ApplicationID.EQ(table.Application.ID))). + WHERE(postgres.AND( + getWhereClauseFromNameOrAddress(nameOrAddress), + table.StandingChanges.EpochIndex.EQ(uint64Expr(epochIndex)), + table.StandingChanges.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + table.StandingChanges.TxHash.EQ(postgres.Bytea(txHash.Bytes())), + table.StandingChanges.LogIndex.EQ(uint64Expr(logIndex)), + )) + sqlStr, args := sel.Sql() + value, err := scanStandingChanged(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListStandingChanges( + ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + p repository.Pagination, descending bool, +) ([]*model.StandingChanged, uint64, error) { + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.StandingChanges.INNER_JOIN(table.Application, table.StandingChanges.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{ + getWhereClauseFromNameOrAddress(nameOrAddress), + table.StandingChanges.EpochIndex.EQ(uint64Expr(epochIndex)), + table.StandingChanges.TournamentAddress.EQ(postgres.Bytea(common.HexToAddress(tournamentAddress).Bytes())), + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.StandingChanges.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.StandingChanges.SELECT(standingChangeColumns, table.StandingChanges.CreatedAt, table.StandingChanges.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY(table.StandingChanges.BlockNumber.DESC(), + table.StandingChanges.LogIndex.DESC(), table.StandingChanges.TxHash.DESC()) + } else { + sel = sel.ORDER_BY(table.StandingChanges.BlockNumber.ASC(), + table.StandingChanges.LogIndex.ASC(), table.StandingChanges.TxHash.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*model.StandingChanged + for rows.Next() { + value, err := scanStandingChanged(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} diff --git a/internal/repository/postgres/standing_changed_events.go b/internal/repository/postgres/standing_changed_events.go new file mode 100644 index 000000000..375051c97 --- /dev/null +++ b/internal/repository/postgres/standing_changed_events.go @@ -0,0 +1,77 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func insertStandingChangedChunk(ctx context.Context, tx pgx.Tx, appID int64, values []*model.StandingChanged) error { + if len(values) == 0 { + return nil + } + stmt := table.StandingChanges.INSERT(standingChangeColumns) + for _, value := range values { + if value == nil || value.ApplicationID != appID { + return fmt.Errorf("invalid standing change application identity") + } + row := standingChangeValues(value) + stmt = stmt.VALUES(row[0], row[1:]...) + } + query, args := stmt.ON_CONFLICT( + table.StandingChanges.ApplicationID, table.StandingChanges.TxHash, table.StandingChanges.LogIndex, + ).DO_NOTHING().Sql() + command, err := tx.Exec(ctx, query, args...) + if err != nil { + return err + } + if command.RowsAffected() == int64(len(values)) { + return nil + } + // Exact retries retain the original timestamps. A contradictory payload + // must fail the owning interval before any projection or cursor commits. + for _, value := range values { + stmt := table.StandingChanges.SELECT(standingChangeColumns, + table.StandingChanges.CreatedAt, table.StandingChanges.UpdatedAt).WHERE(postgres.AND( + table.StandingChanges.ApplicationID.EQ(postgres.Int64(appID)), + table.StandingChanges.TxHash.EQ(postgres.Bytea(value.TxHash.Bytes())), + table.StandingChanges.LogIndex.EQ(uint64Expr(value.LogIndex)), + )) + query, args := stmt.Sql() + stored, err := scanStandingChanged(tx.QueryRow(ctx, query, args...)) + if err != nil { + return err + } + if !sameStandingChangedEvent(stored, value) { + return fmt.Errorf("%w: transaction %s log %d", repository.ErrTournamentEventConflict, value.TxHash, value.LogIndex) + } + } + return nil +} + +func sameStandingChangedEvent(a, b *model.StandingChanged) bool { + return a.ApplicationID == b.ApplicationID && + a.EpochIndex == b.EpochIndex && + a.TournamentAddress == b.TournamentAddress && + a.MatchCount == b.MatchCount && + sameOptionalHash(a.Dangling, b.Dangling) && + sameOptionalHash(a.ParentCommitment, b.ParentCommitment) && + a.ResultAt == b.ResultAt && + a.WinnerExpiresAt == b.WinnerExpiresAt && + a.BlockNumber == b.BlockNumber && + a.TxHash == b.TxHash && + a.LogIndex == b.LogIndex +} + +func sameOptionalHash(a, b *common.Hash) bool { + return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) +} diff --git a/internal/repository/postgres/startup_errors_test.go b/internal/repository/postgres/startup_errors_test.go new file mode 100644 index 000000000..3b9726b06 --- /dev/null +++ b/internal/repository/postgres/startup_errors_test.go @@ -0,0 +1,48 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/internal/repository/postgres/schema" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +func TestSchemaStartupErrorsKeepOnlySafeDetails(t *testing.T) { + const marker = "SCHEMA_STARTUP_SECRET" + mismatch := &schema.VersionMismatchError{Expected: 1, Actual: 2} + for _, test := range []struct { + name string + err error + safe error + }{ + {"server message", &pgconn.PgError{Severity: "ERROR", Code: "08006", Message: marker}, nil}, + {"dirty schema", fmt.Errorf("%s: %w", marker, schema.ErrMigrationNotCompleted), schema.ErrMigrationNotCompleted}, + {"missing schema", fmt.Errorf("%s: %w", marker, schema.ErrNoValidDatabaseSchema), schema.ErrNoValidDatabaseSchema}, + {"cancellation", fmt.Errorf("%s: %w", marker, context.Canceled), context.Canceled}, + {"deadline", fmt.Errorf("%s: %w", marker, context.DeadlineExceeded), context.DeadlineExceeded}, + {"version mismatch", fmt.Errorf("%s: %w", marker, mismatch), mismatch}, + } { + t.Run(test.name, func(t *testing.T) { + err := safeSchemaValidationError(test.err) + require.Contains(t, err.Error(), "failed to validate Postgres schema version") + for _, verb := range []string{"%v", "%+v", "%#v"} { + require.NotContains(t, fmt.Sprintf(verb, err), marker) + } + if test.safe != nil { + require.ErrorIs(t, err, test.safe) + require.Contains(t, err.Error(), test.safe.Error()) + } else { + require.EqualError(t, err, "failed to validate Postgres schema version") + } + var driver *pgconn.PgError + require.False(t, errors.As(err, &driver), "raw server diagnostics must not remain reachable") + }) + } +} diff --git a/internal/repository/postgres/state_hash.go b/internal/repository/postgres/state_hash.go new file mode 100644 index 000000000..d7ab11626 --- /dev/null +++ b/internal/repository/postgres/state_hash.go @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + + "github.com/go-jet/jet/v2/postgres" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +func (r *PostgresRepository) CountEpochStateHashes(ctx context.Context, appID int64, epochIndex uint64) (uint64, error) { + stmt := table.StateHashes.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + postgres.AND( + table.StateHashes.InputEpochApplicationID.EQ(postgres.Int64(appID)), + table.StateHashes.EpochIndex.EQ(uint64Expr(epochIndex)), + ), + ) + query, args := stmt.Sql() + var count uint64 + err := r.db.QueryRow(ctx, query, args...).Scan(&count) + return count, err +} + +func (r *PostgresRepository) ListEpochStateHashes( + ctx context.Context, appID int64, epochIndex uint64, afterIndex *uint64, limit uint64, +) ([]model.EpochStateHash, error) { + if limit == 0 || limit > repository.MaxStateHashPageSize { + return nil, fmt.Errorf("state hash page limit must be between 1 and %d", repository.MaxStateHashPageSize) + } + conditions := []postgres.BoolExpression{ + table.StateHashes.InputEpochApplicationID.EQ(postgres.Int64(appID)), + table.StateHashes.EpochIndex.EQ(uint64Expr(epochIndex)), + } + if afterIndex != nil { + conditions = append(conditions, table.StateHashes.Index.GT(uint64Expr(*afterIndex))) + } + stmt := table.StateHashes.SELECT( + table.StateHashes.Index, + table.StateHashes.InputIndex, + table.StateHashes.MachineHash, + table.StateHashes.Repetitions, + ).WHERE(postgres.AND(conditions...)).ORDER_BY(table.StateHashes.Index.ASC()).LIMIT(int64(limit)) + query, args := stmt.Sql() + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []model.EpochStateHash + for rows.Next() { + var row model.EpochStateHash + if err := rows.Scan(&row.Index, &row.InputIndex, &row.MachineHash, &row.Repetitions); err != nil { + return nil, err + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/repository/postgres/state_hash_test.go b/internal/repository/postgres/state_hash_test.go new file mode 100644 index 000000000..6d3d553cb --- /dev/null +++ b/internal/repository/postgres/state_hash_test.go @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math" + "strconv" + "testing" + + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func TestUint64ExprBindsUint64(t *testing.T) { + t.Parallel() + var expectedSQL string + for _, value := range []uint64{0, 1, math.MaxInt64 + 1, math.MaxUint64} { + query, args := postgres.SELECT(uint64Expr(value)).Sql() + if expectedSQL == "" { + expectedSQL = query + } + require.Equal(t, expectedSQL, query) + require.Equal(t, []any{strconv.FormatUint(value, 10)}, args) + } +} + +func TestPostgresEpochStateHashLargeOrdinals(t *testing.T) { + repo := newEpochPublicationRepository(t).(*PostgresRepository) + app, epoch := seedEpochPublication(t, repo) + hash := repotest.UniqueHash() + indexes := []uint64{0, math.MaxInt64 + 17, math.MaxUint64} + for _, index := range indexes { + _, err := repo.db.Exec(t.Context(), `INSERT INTO state_hashes + (input_epoch_application_id, epoch_index, input_index, index, machine_hash, repetitions) + VALUES ($1, $2, 0, CAST($3 AS NUMERIC), $4, 1)`, + app.ID, epoch.Index, strconv.FormatUint(index, 10), hash.Bytes()) + require.NoError(t, err) + } + count, err := repo.CountEpochStateHashes(t.Context(), app.ID, epoch.Index) + require.NoError(t, err) + require.Equal(t, uint64(len(indexes)), count) + page, err := repo.ListEpochStateHashes(t.Context(), app.ID, epoch.Index, nil, 3) + require.NoError(t, err) + require.Len(t, page, len(indexes)) + for position, index := range indexes { + require.Equal(t, index, page[position].Index) + require.Equal(t, hash, page[position].MachineHash) + } + page, err = repo.ListEpochStateHashes(t.Context(), app.ID, epoch.Index, &indexes[1], 3) + require.NoError(t, err) + require.Len(t, page, 1) + require.Equal(t, uint64(math.MaxUint64), page[0].Index) + page, err = repo.ListEpochStateHashes(t.Context(), app.ID, epoch.Index, &indexes[2], 3) + require.NoError(t, err) + require.Empty(t, page) + for _, absentEpoch := range []uint64{math.MaxInt64 + 1, math.MaxUint64} { + count, err := repo.CountEpochStateHashes(t.Context(), app.ID, absentEpoch) + require.NoError(t, err) + require.Zero(t, count) + page, err := repo.ListEpochStateHashes(t.Context(), app.ID, absentEpoch, nil, 3) + require.NoError(t, err) + require.Empty(t, page) + } +} diff --git a/internal/repository/postgres/terminal_drain_test.go b/internal/repository/postgres/terminal_drain_test.go new file mode 100644 index 000000000..dd949ebe9 --- /dev/null +++ b/internal/repository/postgres/terminal_drain_test.go @@ -0,0 +1,204 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +const ( + terminalDrainDisabledCase = "disabled" + terminalDrainDivergedCase = "diverged" + terminalDrainCorruptedCase = "corrupted" + terminalDrainFailedCase = "failed" +) + +// The advancer reaches the terminal input in P=2 before E=0/1 are validated. +// A later NONE input lies in the foreclosure block itself. +func terminalDrainFixture(t *testing.T, consensus model.Consensus) (*PostgresRepository, *model.Application) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(consensus).Create(t.Context(), t, r) + rows := make(map[*model.Epoch][]*model.Input) + for i := range uint64(4) { + epoch := repotest.NewEpochBuilder(app.ID).WithIndex(i).WithStatus(model.EpochStatus_Closed). + WithBlocks(i*10, i*10+9).WithInputBounds(i, i+1).Build() + rows[epoch] = []*model.Input{repotest.NewInputBuilder().WithIndex(i).WithBlockNumber(i*10 + 5).Build()} + } + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, rows, 40)) + for i := range uint64(2) { + proof := repotest.DummyStateProof() + require.NoError(t, r.StoreAdvanceResult(t.Context(), app.ID, &model.AdvanceResult{ + EpochIndex: i, InputIndex: i, Status: model.InputCompletionStatus_Accepted, StateProof: *proof, + })) + require.NoError(t, r.UpdateEpochInputsProcessed(t.Context(), app.Name, i, proof)) + } + require.NoError(t, r.StoreAdvanceResult(t.Context(), app.ID, &model.AdvanceResult{ + EpochIndex: 2, InputIndex: 2, Status: model.InputCompletionStatus_MachineHalted, StateProof: *repotest.DummyStateProof(), + })) + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 35, repotest.UniqueHash(), 40)) + return r, app +} + +func TestTerminalInputDrainUsesExecutionCursorAfterEarlierIOR(t *testing.T) { + for _, consensus := range model.ConsensusAllValues { + t.Run(consensus.String(), func(t *testing.T) { + r, app := terminalDrainFixture(t, consensus) + pending, err := r.HasUndrainedEpochsBeforeBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.False(t, pending, "the terminal machine cannot execute its later same-block input") + require.NoError(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, 1, "earlier root mismatch")) + state, err := r.GetEpochCompletionState(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, uint64(1), state.Problem.EpochIndex) + require.Equal(t, uint64(3), state.Application.ProcessedInputs) + pending, err = r.HasUndrainedEpochsBeforeBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.False(t, pending) + // A cut is not a substitute for execution progress. Detect an + // inconsistent NONE below the actual cursor, even outside the + // now-smaller publication prefix. + _, err = r.db.Exec(t.Context(), `UPDATE application SET processed_inputs=4 WHERE id=$1`, app.ID) + require.NoError(t, err) + pending, err = r.HasUndrainedEpochsBeforeBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.True(t, pending) + _, err = r.db.Exec(t.Context(), `UPDATE application SET processed_inputs=3 WHERE id=$1`, app.ID) + require.NoError(t, err) + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, model.ApplicationStatus_Corrupted, new("integrity failure"))) + pending, err = r.HasUndrainedEpochsBeforeBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.True(t, pending, "DC does not grant terminal-suffix drain permission") + }) + } +} + +func TestTerminalForeclosureDrainsSuffixPreservingValidationAndAcceptance(t *testing.T) { + for _, consensus := range []model.Consensus{model.Consensus_Authority, model.Consensus_Quorum} { + t.Run(consensus.String(), func(t *testing.T) { + r, app := terminalDrainFixture(t, consensus) + count, err := r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.Equal(t, int64(2), count, "P and its unreachable suffix are terminalized") + for i := range uint64(2) { + epoch, err := r.GetEpoch(t.Context(), app.Name, i) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_InputsProcessed, epoch.Status, "earlier validation remains required") + } + require.NoError(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, 1, "earlier root mismatch")) + count, err = r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.Equal(t, int64(1), count, "the newly invalid earlier epoch can drain") + epoch, err := r.GetEpoch(t.Context(), app.Name, 0) + require.NoError(t, err) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epoch, nil)) + accepted := repotest.UniqueHash() + require.NoError(t, r.UpdateEpochWithAcceptedClaim(t.Context(), app.ID, 0, &accepted)) + count, err = r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.Zero(t, count) + epoch, err = r.GetEpoch(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimAccepted, epoch.Status) + require.Equal(t, &accepted, epoch.ClaimTransactionHash) + pending, err := r.HasUnreconciledClaimsBeforeBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.False(t, pending) + }) + } +} + +func TestTerminalForeclosureDrainRetainsGlobalStopGuards(t *testing.T) { + for _, stop := range []string{terminalDrainDisabledCase, terminalDrainDivergedCase, terminalDrainCorruptedCase} { + t.Run(stop, func(t *testing.T) { + r, app := terminalDrainFixture(t, model.Consensus_Authority) + if stop == terminalDrainDisabledCase { + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + } else { + status := model.ApplicationStatus_Diverged + if stop == terminalDrainCorruptedCase { + status = model.ApplicationStatus_Corrupted + } + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, status, new("integrity failure"))) + } + count, err := r.ForecloseUnacceptedEpochsAtOrAfterBlock(t.Context(), app.ID, 35) + require.NoError(t, err) + require.Zero(t, count) + require.ErrorIs(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 0), repository.ErrNoUpdate) + require.ErrorIs(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 2), repository.ErrNoUpdate) + epoch, err := r.GetEpoch(t.Context(), app.Name, 2) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_Closed, epoch.Status) + }) + } +} + +func TestTerminalIndividualDrainPreservesPrefixAwaitingValidator(t *testing.T) { + r, app := terminalDrainFixture(t, model.Consensus_PRT) + require.ErrorIs(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 0), repository.ErrNoUpdate) + require.NoError(t, r.StoreInvalidOutputsRoot(t.Context(), app.ID, 1, "earlier root mismatch")) + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 1)) + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 2)) + epoch, err := r.GetEpoch(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_InputsProcessed, epoch.Status) +} + +// CLAIM_* reconciliation existed before terminal-prefix draining. Unhealthy or +// disabled applications must not leave these already-computed rows retrying forever. +func TestForeclosedClaimReconciliationPreservesHealthAndEnableState(t *testing.T) { + for _, stop := range []string{ + terminalDrainFailedCase, terminalDrainDivergedCase, terminalDrainCorruptedCase, terminalDrainDisabledCase, + } { + for _, claimStatus := range []model.EpochStatus{ + model.EpochStatus_ClaimComputed, model.EpochStatus_ClaimSubmitted, model.EpochStatus_ClaimStaged, + } { + t.Run(stop+"/"+claimStatus.String(), func(t *testing.T) { + r := newEpochPublicationRepository(t) + app, epochs := seedProblemEpochs(t, r) + require.NoError(t, r.StoreClaimAndProofs(t.Context(), epochs[0], nil)) + if claimStatus != model.EpochStatus_ClaimComputed { + require.NoError(t, r.UpdateEpochWithSubmittedClaim(t.Context(), app.ID, 0, repotest.UniqueHash())) + } + if claimStatus == model.EpochStatus_ClaimStaged { + require.NoError(t, r.UpdateEpochToStaged(t.Context(), app.ID, 0, 20)) + } + require.NoError(t, r.UpdateApplicationForeclosure(t.Context(), app.ID, 35, repotest.UniqueHash(), 40)) + if stop == terminalDrainDisabledCase { + require.NoError(t, r.UpdateApplicationEnabled(t.Context(), app.ID, false)) + } else { + status := model.ApplicationStatus_Failed + switch stop { + case terminalDrainDivergedCase: + status = model.ApplicationStatus_Diverged + case terminalDrainCorruptedCase: + status = model.ApplicationStatus_Corrupted + } + require.NoError(t, r.UpdateApplicationStatus(t.Context(), app.ID, status, new("independent stop"))) + } + before, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.NoError(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 0)) + epoch, err := r.GetEpoch(t.Context(), app.Name, 0) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimForeclosed, epoch.Status) + require.ErrorIs(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 0), repository.ErrNoUpdate) + after, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, before.Status, after.Status) + require.Equal(t, before.Reason, after.Reason) + require.Equal(t, before.Enabled, after.Enabled) + // Reopening reconciliation does not grant permission to drain + // unvalidated prefix or suffix rows under the same stop. + require.ErrorIs(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 1), repository.ErrNoUpdate) + require.ErrorIs(t, r.UpdateEpochWithForeclosedClaim(t.Context(), app.ID, 2), repository.ErrNoUpdate) + }) + } + } +} diff --git a/internal/repository/postgres/tournament.go b/internal/repository/postgres/tournament.go new file mode 100644 index 000000000..582fbeee6 --- /dev/null +++ b/internal/repository/postgres/tournament.go @@ -0,0 +1,168 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/enum" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +func (r *PostgresRepository) CreateTournament(ctx context.Context, nameOrAddress string, value *Tournament) error { + if value == nil { + return fmt.Errorf("cannot create a nil tournament") + } + applicationID := table.Application.SELECT(table.Application.ID).WHERE(getWhereClauseFromNameOrAddress(nameOrAddress)) + stmt := tournamentUpsert(value, applicationID) + sqlStr, args := stmt.Sql() + command, err := r.db.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() == 0 { + return fmt.Errorf("%w: tournament %s", repository.ErrTournamentEventConflict, value.Address) + } + return nil +} + +func (r *PostgresRepository) GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + sel := table.Tournaments.SELECT(tournamentColumns, table.Tournaments.CreatedAt, table.Tournaments.UpdatedAt). + FROM(table.Tournaments.INNER_JOIN(table.Application, table.Tournaments.ApplicationID.EQ(table.Application.ID))). + WHERE(getWhereClauseFromNameOrAddress(nameOrAddress).AND( + table.Tournaments.Address.EQ(postgres.Bytea(common.HexToAddress(address).Bytes())), + )) + sqlStr, args := sel.Sql() + value, err := scanTournament(r.db.QueryRow(ctx, sqlStr, args...)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return value, err +} + +func (r *PostgresRepository) ListTournaments( + ctx context.Context, nameOrAddress string, f repository.TournamentFilter, p repository.Pagination, descending bool, +) ([]*Tournament, uint64, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if p.Limit > math.MaxInt64 || p.Offset > math.MaxInt64 { + return nil, 0, fmt.Errorf("pagination exceeds PostgreSQL integer range") + } + from := table.Tournaments.INNER_JOIN(table.Application, table.Tournaments.ApplicationID.EQ(table.Application.ID)) + conditions := []postgres.BoolExpression{getWhereClauseFromNameOrAddress(nameOrAddress)} + if f.EpochIndex != nil { + conditions = append(conditions, table.Tournaments.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) + } + if f.Level != nil { + conditions = append(conditions, table.Tournaments.Level.EQ(postgres.RawInt(fmt.Sprintf("%d", *f.Level)))) + } + if f.ParentTournamentAddress != nil { + conditions = append(conditions, table.Tournaments.ParentTournamentAddress.EQ(postgres.Bytea(f.ParentTournamentAddress.Bytes()))) + } + if f.ParentMatchIDHash != nil { + conditions = append(conditions, table.Tournaments.ParentMatchIDHash.EQ(postgres.Bytea(f.ParentMatchIDHash.Bytes()))) + } + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + countStmt := table.Tournaments.SELECT(postgres.COUNT(postgres.STAR)).FROM(from).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil || total == 0 { + return nil, total, err + } + sel := table.Tournaments.SELECT(tournamentColumns, table.Tournaments.CreatedAt, table.Tournaments.UpdatedAt). + FROM(from).WHERE(postgres.AND(conditions...)) + if descending { + sel = sel.ORDER_BY(table.Tournaments.EpochIndex.DESC(), table.Tournaments.Level.DESC(), table.Tournaments.Address.DESC()) + } else { + sel = sel.ORDER_BY(table.Tournaments.EpochIndex.ASC(), table.Tournaments.Level.ASC(), table.Tournaments.Address.ASC()) + } + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var values []*Tournament + for rows.Next() { + value, err := scanTournament(rows) + if err != nil { + return nil, 0, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return values, total, nil +} + +// ListRecoverableRootBonds searches the configured recipient +// within a finite epoch sweep and the published observation cursor. +func (r *PostgresRepository) ListRecoverableRootBonds( + ctx context.Context, appID int64, claimer common.Address, observedBlock uint64, + afterEpoch *uint64, throughEpoch, limit uint64, +) ([]repository.RootBondRecoveryCandidate, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if limit == 0 || limit > math.MaxInt64 { + return nil, fmt.Errorf("root bond recovery limit must be between 1 and %d", int64(math.MaxInt64)) + } + if claimer == (common.Address{}) { + return nil, fmt.Errorf("bond recovery recipient cannot be zero") + } + if afterEpoch != nil && *afterEpoch >= throughEpoch { + return nil, nil + } + conditions := []postgres.BoolExpression{ + table.Tournaments.ApplicationID.EQ(postgres.Int64(appID)), + table.Tournaments.Level.EQ(postgres.Int(0)), + table.Tournaments.BondDisposition.EQ(enum.BondDisposition.Recoverable), + table.Tournaments.BondClaimer.EQ(postgres.Bytea(claimer.Bytes())), + table.Tournaments.AsOfBlock.LT_EQ(uint64Expr(observedBlock)), + table.Tournaments.EpochIndex.LT_EQ(uint64Expr(throughEpoch)), + } + if afterEpoch != nil { + conditions = append(conditions, table.Tournaments.EpochIndex.GT(uint64Expr(*afterEpoch))) + } + stmt := table.Tournaments.SELECT(table.Tournaments.EpochIndex, table.Tournaments.Address). + FROM(table.Tournaments.INNER_JOIN(table.Application, table.Application.ID.EQ(table.Tournaments.ApplicationID))). + WHERE(postgres.AND(conditions...)).ORDER_BY(table.Tournaments.EpochIndex.ASC()).LIMIT(int64(limit)) + query, args := stmt.Sql() + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var candidates []repository.RootBondRecoveryCandidate + for rows.Next() { + var candidate repository.RootBondRecoveryCandidate + if err = rows.Scan(&candidate.EpochIndex, &candidate.Tournament); err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} diff --git a/internal/repository/postgres/tournament_counts.go b/internal/repository/postgres/tournament_counts.go new file mode 100644 index 000000000..387ec964b --- /dev/null +++ b/internal/repository/postgres/tournament_counts.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" +) + +// GetTournamentEventCounts reads immutable event facts in one statement snapshot. +// The event's own block, rather than the projection's later AsOfBlock, bounds +// each count. A refreshed projection can therefore reconstruct an older cursor. +func (r *PostgresRepository) GetTournamentEventCounts( + ctx context.Context, applicationID int64, epochIndex uint64, tournament common.Address, throughBlock uint64, +) (repository.TournamentEventCounts, error) { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + commitments := table.Commitments.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + table.Commitments.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Commitments.EpochIndex.EQ(uint64Expr(epochIndex))). + AND(table.Commitments.TournamentAddress.EQ(postgres.Bytea(tournament.Bytes()))). + AND(table.Commitments.BlockNumber.LT_EQ(uint64Expr(throughBlock)))) + matchScope := table.Matches.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Matches.EpochIndex.EQ(uint64Expr(epochIndex))). + AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(tournament.Bytes()))) + matches := table.Matches.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + matchScope.AND(table.Matches.BlockNumber.LT_EQ(uint64Expr(throughBlock)))) + advances := table.MatchAdvances.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + table.MatchAdvances.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.MatchAdvances.EpochIndex.EQ(uint64Expr(epochIndex))). + AND(table.MatchAdvances.TournamentAddress.EQ(postgres.Bytea(tournament.Bytes()))). + AND(table.MatchAdvances.BlockNumber.LT_EQ(uint64Expr(throughBlock)))) + seals := table.Matches.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + matchScope.AND(table.Matches.SealBlockNumber.LT_EQ(uint64Expr(throughBlock)))) + deletions := table.Matches.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + matchScope.AND(table.Matches.DeletionBlockNumber.GT(uint64Expr(0))). + AND(table.Matches.DeletionBlockNumber.LT_EQ(uint64Expr(throughBlock)))) + children := table.Tournaments.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + table.Tournaments.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.Tournaments.EpochIndex.EQ(uint64Expr(epochIndex))). + AND(table.Tournaments.ParentTournamentAddress.EQ(postgres.Bytea(tournament.Bytes()))). + AND(table.Tournaments.CreationBlockNumber.LT_EQ(uint64Expr(throughBlock)))) + standing := table.StandingChanges.SELECT(postgres.COUNT(postgres.STAR)).WHERE( + table.StandingChanges.ApplicationID.EQ(postgres.Int64(applicationID)). + AND(table.StandingChanges.EpochIndex.EQ(uint64Expr(epochIndex))). + AND(table.StandingChanges.TournamentAddress.EQ(postgres.Bytea(tournament.Bytes()))). + AND(table.StandingChanges.BlockNumber.LT_EQ(uint64Expr(throughBlock)))) + stmt := postgres.SELECT(commitments, matches, advances, seals, deletions, children, standing) + query, args := stmt.Sql() + var counts repository.TournamentEventCounts + err := r.db.QueryRow(ctx, query, args...).Scan(&counts.CommitmentJoined, &counts.MatchCreated, + &counts.MatchAdvanced, &counts.LeafMatchSealed, &counts.MatchDeleted, &counts.NewInnerTournament, &counts.StandingChanged) + if err != nil { + return counts, fmt.Errorf("counting tournament %s events through block %d: %w", tournament, throughBlock, err) + } + return counts, nil +} diff --git a/internal/repository/postgres/tournament_counts_test.go b/internal/repository/postgres/tournament_counts_test.go new file mode 100644 index 000000000..f4e0e3d03 --- /dev/null +++ b/internal/repository/postgres/tournament_counts_test.go @@ -0,0 +1,80 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +func TestTournamentEventCountsUseEventBlocksAndExactScope(t *testing.T) { + r, app, batches := newTournamentInterval(t) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + root, child := batches[0].Tournament, batches[1].Tournament + for _, test := range []struct { + name string + child bool + block uint64 + want repository.TournamentEventCounts + }{ + {"before joins", false, 9, repository.TournamentEventCounts{}}, + {"first join", false, 10, repository.TournamentEventCounts{CommitmentJoined: 1}}, + {"root match", false, 11, repository.TournamentEventCounts{CommitmentJoined: 2, MatchCreated: 1}}, + {"before child", false, 49, repository.TournamentEventCounts{CommitmentJoined: 2, MatchCreated: 1}}, + {"child creation", false, 50, repository.TournamentEventCounts{CommitmentJoined: 2, MatchCreated: 1, NewInnerTournament: 1}}, + {"root deletion", false, 80, repository.TournamentEventCounts{ + CommitmentJoined: 2, MatchCreated: 1, MatchDeleted: 1, NewInnerTournament: 1, + }}, + {"before child joins", true, 50, repository.TournamentEventCounts{}}, + {"child match", true, 52, repository.TournamentEventCounts{CommitmentJoined: 2, MatchCreated: 1}}, + {"two advances", true, 55, repository.TournamentEventCounts{CommitmentJoined: 2, MatchCreated: 1, MatchAdvanced: 2}}, + {"child seal", true, 60, repository.TournamentEventCounts{ + CommitmentJoined: 2, MatchCreated: 1, MatchAdvanced: 2, LeafMatchSealed: 1, + }}, + {"child deletion", true, 70, repository.TournamentEventCounts{ + CommitmentJoined: 2, MatchCreated: 1, MatchAdvanced: 2, LeafMatchSealed: 1, MatchDeleted: 1, + }}, + } { + t.Run(test.name, func(t *testing.T) { + tournament := root + if test.child { + tournament = child + } + counts, err := r.GetTournamentEventCounts(t.Context(), app.ID, tournament.EpochIndex, tournament.Address, test.block) + require.NoError(t, err) + require.Equal(t, test.want, counts, "all projections were saved at block 100") + }) + } + for _, test := range []struct { + name string + appID int64 + epoch uint64 + }{ + {"other application", app.ID + 1, root.EpochIndex}, + {"other epoch", app.ID, root.EpochIndex + 1}, + } { + t.Run(test.name, func(t *testing.T) { + for _, batch := range batches { + counts, err := r.GetTournamentEventCounts(t.Context(), test.appID, test.epoch, batch.Tournament.Address, 100) + require.NoError(t, err) + require.Equal(t, repository.TournamentEventCounts{}, counts) + } + }) + } + counts, err := r.GetTournamentEventCounts(t.Context(), app.ID, root.EpochIndex, repotest.UniqueAddress(), 100) + require.NoError(t, err) + require.Equal(t, repository.TournamentEventCounts{}, counts) +} + +func TestTournamentEventCountsExcludeUnsetOptionalEvents(t *testing.T) { + r, app, batch := newRootEventInterval(t) + addIntervalMatch(batch) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + counts, err := r.GetTournamentEventCounts(t.Context(), app.ID, 0, batch.Tournament.Address, 100) + require.NoError(t, err) + require.Equal(t, repository.TournamentEventCounts{CommitmentJoined: 2, MatchCreated: 1}, counts) +} diff --git a/internal/repository/postgres/tournament_events.go b/internal/repository/postgres/tournament_events.go new file mode 100644 index 000000000..55ed65d52 --- /dev/null +++ b/internal/repository/postgres/tournament_events.go @@ -0,0 +1,381 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + "slices" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +// Keep every statement below PostgreSQL's 65,535 bind-parameter limit, even +// for the widest event row. All chunks use the interval's existing transaction. +const tournamentRowsPerStatement = 1000 + +func validateTournamentBatch(appID int64, batch *repository.TournamentEventBatch, lastBlock uint64) error { + if batch == nil || batch.Tournament == nil { + return fmt.Errorf("nil tournament batch for application %d", appID) + } + t := batch.Tournament + if t.ApplicationID != appID || t.Snapshot.AsOfBlock != lastBlock { + return fmt.Errorf("tournament projection does not match application %d at block %d", appID, lastBlock) + } + commitments := make(map[common.Hash]struct{}, len(batch.Commitments)) + for _, v := range batch.Commitments { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.Snapshot.AsOfBlock != lastBlock || v.BlockNumber > lastBlock { + return fmt.Errorf("commitment does not belong to tournament %s at block %d", t.Address, lastBlock) + } + if _, duplicate := commitments[v.Commitment]; duplicate { + return fmt.Errorf("duplicate commitment %s in tournament interval", v.Commitment) + } + commitments[v.Commitment] = struct{}{} + } + matches := make(map[common.Hash]struct{}, len(batch.Matches)) + for _, v := range batch.Matches { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.Snapshot.AsOfBlock != lastBlock || v.BlockNumber > lastBlock || + v.DeletionBlockNumber > lastBlock || (v.LeafSeal != nil && v.LeafSeal.BlockNumber > lastBlock) { + return fmt.Errorf("match does not belong to tournament %s at block %d", t.Address, lastBlock) + } + if _, duplicate := matches[v.IDHash]; duplicate { + return fmt.Errorf("duplicate match %s in tournament interval", v.IDHash) + } + matches[v.IDHash] = struct{}{} + } + for _, v := range batch.MatchAdvances { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.BlockNumber > lastBlock { + return fmt.Errorf("advance does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.StandingChanges { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.BlockNumber > lastBlock { + return fmt.Errorf("standing change does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + for _, v := range batch.BondEvents { + if v == nil || v.ApplicationID != appID || v.EpochIndex != t.EpochIndex || + v.TournamentAddress != t.Address || v.BlockNumber > lastBlock { + return fmt.Errorf("bond event does not belong to tournament %s at block %d", t.Address, lastBlock) + } + } + return nil +} + +func tournamentUpsert(v *Tournament, applicationID any) postgres.InsertStatement { + values := tournamentValues(v) + values[0] = applicationID + return table.Tournaments.INSERT(tournamentColumns).ON_CONFLICT( + table.Tournaments.ApplicationID, table.Tournaments.EpochIndex, table.Tournaments.Address, + ).DO_UPDATE(postgres.SET( + table.Tournaments.AsOfBlock.SET(table.Tournaments.EXCLUDED.AsOfBlock), + table.Tournaments.Standing.SET(table.Tournaments.EXCLUDED.Standing), + table.Tournaments.AcceptsJoins.SET(table.Tournaments.EXCLUDED.AcceptsJoins), + table.Tournaments.Candidate.SET(table.Tournaments.EXCLUDED.Candidate), + table.Tournaments.WinnerCommitment.SET(table.Tournaments.EXCLUDED.WinnerCommitment), + table.Tournaments.FinalStateHash.SET(table.Tournaments.EXCLUDED.FinalStateHash), + table.Tournaments.FinishedAtBlock.SET(table.Tournaments.EXCLUDED.FinishedAtBlock), + table.Tournaments.ParentCommitment.SET(table.Tournaments.EXCLUDED.ParentCommitment), + table.Tournaments.WinnerExpiresAt.SET(table.Tournaments.EXCLUDED.WinnerExpiresAt), + table.Tournaments.InnerDisposition.SET(table.Tournaments.EXCLUDED.InnerDisposition), + table.Tournaments.InnerParentCommitment.SET(table.Tournaments.EXCLUDED.InnerParentCommitment), + table.Tournaments.InnerPausedAllowance.SET(table.Tournaments.EXCLUDED.InnerPausedAllowance), + table.Tournaments.BondDisposition.SET(table.Tournaments.EXCLUDED.BondDisposition), + table.Tournaments.BondClaimer.SET(table.Tournaments.EXCLUDED.BondClaimer), + table.Tournaments.BondPayment.SET(table.Tournaments.EXCLUDED.BondPayment), + ).WHERE(postgres.AND( + table.Tournaments.EpochIndex.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.EpochIndex), + table.Tournaments.Address.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Address), + table.Tournaments.ParentTournamentAddress.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ParentTournamentAddress), + table.Tournaments.ParentMatchIDHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ParentMatchIDHash), + table.Tournaments.MaxLevel.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.MaxLevel), + table.Tournaments.Level.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Level), + table.Tournaments.Log2step.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Log2step), + table.Tournaments.Height.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Height), + table.Tournaments.InitialHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.InitialHash), + table.Tournaments.BaseCycle.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.BaseCycle), + table.Tournaments.Kind.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Kind), + table.Tournaments.StartInstant.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.StartInstant), + table.Tournaments.Allowance.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.Allowance), + table.Tournaments.BondValue.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.BondValue), + table.Tournaments.ContestedFinalStateOne.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ContestedFinalStateOne), + table.Tournaments.ContestedFinalStateTwo.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.ContestedFinalStateTwo), + table.Tournaments.CreationBlockNumber.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationBlockNumber), + table.Tournaments.CreationTxHash.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationTxHash), + table.Tournaments.CreationLogIndex.IS_NOT_DISTINCT_FROM(table.Tournaments.EXCLUDED.CreationLogIndex), + table.Tournaments.AsOfBlock.LT_EQ(table.Tournaments.EXCLUDED.AsOfBlock), + ))).VALUES(values[0], values[1:]...) +} + +func storeTournamentProjection(ctx context.Context, tx pgx.Tx, appID int64, v *Tournament) error { + stmt := tournamentUpsert(v, appID) + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("storing tournament %s: %w", v.Address, err) + } + if command.RowsAffected() != 1 { + return fmt.Errorf("%w: tournament %s", repository.ErrTournamentEventConflict, v.Address) + } + return nil +} + +// insertChunked stores values with insert, one statement per chunk of at most +// tournamentRowsPerStatement rows. +func insertChunked[T any]( + ctx context.Context, tx pgx.Tx, appID int64, values []T, insert func(context.Context, pgx.Tx, int64, []T) error, +) error { + for chunk := range slices.Chunk(values, tournamentRowsPerStatement) { + if err := insert(ctx, tx, appID, chunk); err != nil { + return err + } + } + return nil +} + +func insertCommitmentChunk(ctx context.Context, tx pgx.Tx, appID int64, values []*Commitment) error { + if len(values) == 0 { + return nil + } + stmt := table.Commitments.INSERT(commitmentColumns).ON_CONFLICT( + table.Commitments.ApplicationID, table.Commitments.EpochIndex, table.Commitments.TournamentAddress, table.Commitments.Commitment, + ).DO_UPDATE(postgres.SET( + table.Commitments.AsOfBlock.SET(table.Commitments.EXCLUDED.AsOfBlock), + table.Commitments.Claimer.SET(table.Commitments.EXCLUDED.Claimer), + table.Commitments.ClockRunning.SET(table.Commitments.EXCLUDED.ClockRunning), + table.Commitments.ClockDeadline.SET(table.Commitments.EXCLUDED.ClockDeadline), + table.Commitments.ClockAllowance.SET(table.Commitments.EXCLUDED.ClockAllowance), + ).WHERE(postgres.AND( + table.Commitments.EpochIndex.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.EpochIndex), + table.Commitments.TournamentAddress.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.TournamentAddress), + table.Commitments.Commitment.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.Commitment), + table.Commitments.FinalStateHash.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.FinalStateHash), + table.Commitments.SubmitterAddress.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.SubmitterAddress), + table.Commitments.BlockNumber.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.BlockNumber), + table.Commitments.TxHash.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.TxHash), + table.Commitments.LogIndex.IS_NOT_DISTINCT_FROM(table.Commitments.EXCLUDED.LogIndex), + table.Commitments.AsOfBlock.LT_EQ(table.Commitments.EXCLUDED.AsOfBlock), + ))) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid commitment application identity") + } + row := commitmentValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() != int64(len(values)) { + return fmt.Errorf("%w: commitment window", repository.ErrTournamentEventConflict) + } + return nil +} + +func insertMatchChunk(ctx context.Context, tx pgx.Tx, appID int64, values []*Match) error { + if len(values) == 0 { + return nil + } + stmt := table.Matches.INSERT(matchColumns).ON_CONFLICT( + table.Matches.ApplicationID, table.Matches.EpochIndex, table.Matches.TournamentAddress, table.Matches.IDHash, + ).DO_UPDATE(postgres.SET( + table.Matches.SealEliminableAt.SET(table.Matches.EXCLUDED.SealEliminableAt), + table.Matches.SealAgreeState.SET(table.Matches.EXCLUDED.SealAgreeState), + table.Matches.SealDivergencePosition.SET(table.Matches.EXCLUDED.SealDivergencePosition), + table.Matches.SealFinalStateOne.SET(table.Matches.EXCLUDED.SealFinalStateOne), + table.Matches.SealFinalStateTwo.SET(table.Matches.EXCLUDED.SealFinalStateTwo), + table.Matches.SealDeadlineOne.SET(table.Matches.EXCLUDED.SealDeadlineOne), + table.Matches.SealDeadlineTwo.SET(table.Matches.EXCLUDED.SealDeadlineTwo), + table.Matches.SealBlockNumber.SET(table.Matches.EXCLUDED.SealBlockNumber), + table.Matches.SealTxHash.SET(table.Matches.EXCLUDED.SealTxHash), + table.Matches.SealLogIndex.SET(table.Matches.EXCLUDED.SealLogIndex), + table.Matches.AsOfBlock.SET(table.Matches.EXCLUDED.AsOfBlock), + table.Matches.Phase.SET(table.Matches.EXCLUDED.Phase), + table.Matches.TimeoutOutcome.SET(table.Matches.EXCLUDED.TimeoutOutcome), + table.Matches.DeferredCharge.SET(table.Matches.EXCLUDED.DeferredCharge), + table.Matches.RevealingParent.SET(table.Matches.EXCLUDED.RevealingParent), + table.Matches.WaitingLeft.SET(table.Matches.EXCLUDED.WaitingLeft), + table.Matches.WaitingRight.SET(table.Matches.EXCLUDED.WaitingRight), + table.Matches.SegmentStartPosition.SET(table.Matches.EXCLUDED.SegmentStartPosition), + table.Matches.SegmentStartCycle.SET(table.Matches.EXCLUDED.SegmentStartCycle), + table.Matches.CurrentHeight.SET(table.Matches.EXCLUDED.CurrentHeight), + table.Matches.Responder.SET(table.Matches.EXCLUDED.Responder), + table.Matches.AgreeState.SET(table.Matches.EXCLUDED.AgreeState), + table.Matches.DivergencePosition.SET(table.Matches.EXCLUDED.DivergencePosition), + table.Matches.DivergenceCycle.SET(table.Matches.EXCLUDED.DivergenceCycle), + table.Matches.FinalStateOne.SET(table.Matches.EXCLUDED.FinalStateOne), + table.Matches.FinalStateTwo.SET(table.Matches.EXCLUDED.FinalStateTwo), + table.Matches.Winner.SET(table.Matches.EXCLUDED.Winner), + table.Matches.DeletionReason.SET(table.Matches.EXCLUDED.DeletionReason), + table.Matches.DeletionBlockNumber.SET(table.Matches.EXCLUDED.DeletionBlockNumber), + table.Matches.DeletionTxHash.SET(table.Matches.EXCLUDED.DeletionTxHash), + table.Matches.DeletionLogIndex.SET(table.Matches.EXCLUDED.DeletionLogIndex), + ).WHERE(postgres.AND( + table.Matches.EpochIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.EpochIndex), + table.Matches.TournamentAddress.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.TournamentAddress), + table.Matches.IDHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.IDHash), + table.Matches.CommitmentOne.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.CommitmentOne), + table.Matches.CommitmentTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.CommitmentTwo), + table.Matches.LeftOfTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.LeftOfTwo), + table.Matches.BlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.BlockNumber), + table.Matches.TxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.TxHash), + table.Matches.LogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.LogIndex), + table.Matches.EliminableAt.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.EliminableAt), + table.Matches.ResponderDeadline.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.ResponderDeadline), + table.Matches.AsOfBlock.LT_EQ(table.Matches.EXCLUDED.AsOfBlock), + table.Matches.SealTxHash.IS_NULL().OR(postgres.AND( + table.Matches.SealEliminableAt.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealEliminableAt), + table.Matches.SealAgreeState.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealAgreeState), + table.Matches.SealDivergencePosition.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealDivergencePosition), + table.Matches.SealFinalStateOne.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealFinalStateOne), + table.Matches.SealFinalStateTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealFinalStateTwo), + table.Matches.SealDeadlineOne.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealDeadlineOne), + table.Matches.SealDeadlineTwo.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealDeadlineTwo), + table.Matches.SealBlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealBlockNumber), + table.Matches.SealTxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealTxHash), + table.Matches.SealLogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.SealLogIndex), + )), + table.Matches.DeletionReason.EQ(postgres.NewEnumValue(MatchDeletionReason_NOT_DELETED.String())).OR(postgres.AND( + table.Matches.Winner.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.Winner), + table.Matches.DeletionReason.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionReason), + table.Matches.DeletionBlockNumber.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionBlockNumber), + table.Matches.DeletionTxHash.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionTxHash), + table.Matches.DeletionLogIndex.IS_NOT_DISTINCT_FROM(table.Matches.EXCLUDED.DeletionLogIndex), + )), + ))) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid match application identity") + } + row := matchValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() != int64(len(values)) { + return fmt.Errorf("%w: match window", repository.ErrTournamentEventConflict) + } + return nil +} + +func insertMatchAdvancedChunk(ctx context.Context, tx pgx.Tx, appID int64, values []*MatchAdvanced) error { + if len(values) == 0 { + return nil + } + stmt := table.MatchAdvances.INSERT(matchAdvanceColumns) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid matchAdvance application identity") + } + row := matchAdvanceValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.ON_CONFLICT( + table.MatchAdvances.ApplicationID, table.MatchAdvances.TxHash, table.MatchAdvances.LogIndex, + ).DO_NOTHING().Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() == int64(len(values)) { + return nil + } + // A duplicate event keeps its original row and timestamps. Check its payload + // after the insert; a same-key contradiction must roll back the window. + for _, v := range values { + sel := table.MatchAdvances.SELECT(matchAdvanceColumns, table.MatchAdvances.CreatedAt, table.MatchAdvances.UpdatedAt).WHERE( + table.MatchAdvances.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.MatchAdvances.TxHash.EQ(postgres.Bytea(v.TxHash.Bytes()))). + AND(table.MatchAdvances.LogIndex.EQ(uint64Expr(v.LogIndex)))) + query, params := sel.Sql() + stored, err := scanMatchAdvanced(tx.QueryRow(ctx, query, params...)) + if err != nil { + return err + } + if !sameMatchAdvancedEvent(stored, v) { + return fmt.Errorf("%w: transaction %s log %d", repository.ErrTournamentEventConflict, v.TxHash, v.LogIndex) + } + } + return nil +} + +func sameMatchAdvancedEvent(a, b *MatchAdvanced) bool { + return a.ApplicationID == b.ApplicationID && + a.EpochIndex == b.EpochIndex && + a.TournamentAddress == b.TournamentAddress && + a.IDHash == b.IDHash && + a.OtherParent == b.OtherParent && + a.LeftNode == b.LeftNode && + a.SegmentStartPosition == b.SegmentStartPosition && + a.EliminableAt == b.EliminableAt && + a.CurrentHeight == b.CurrentHeight && + a.ResponderDeadline == b.ResponderDeadline && + a.BlockNumber == b.BlockNumber && + a.TxHash == b.TxHash && + a.LogIndex == b.LogIndex +} + +func insertBondEventChunk(ctx context.Context, tx pgx.Tx, appID int64, values []*BondEvent) error { + if len(values) == 0 { + return nil + } + stmt := table.BondEvents.INSERT(bondEventColumns) + for _, v := range values { + if v == nil || v.ApplicationID != appID { + return fmt.Errorf("invalid bondEvent application identity") + } + row := bondEventValues(v) + stmt = stmt.VALUES(row[0], row[1:]...) + } + sqlStr, args := stmt.ON_CONFLICT(table.BondEvents.ApplicationID, table.BondEvents.TxHash, table.BondEvents.LogIndex).DO_NOTHING().Sql() + command, err := tx.Exec(ctx, sqlStr, args...) + if err != nil { + return err + } + if command.RowsAffected() == int64(len(values)) { + return nil + } + // A duplicate event keeps its original row and timestamps. Check its payload + // after the insert; a same-key contradiction must roll back the window. + for _, v := range values { + sel := table.BondEvents.SELECT(bondEventColumns, table.BondEvents.CreatedAt, table.BondEvents.UpdatedAt).WHERE( + table.BondEvents.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.BondEvents.TxHash.EQ(postgres.Bytea(v.TxHash.Bytes()))). + AND(table.BondEvents.LogIndex.EQ(uint64Expr(v.LogIndex)))) + query, params := sel.Sql() + stored, err := scanBondEvent(tx.QueryRow(ctx, query, params...)) + if err != nil { + return err + } + if !sameBondEvent(stored, v) { + return fmt.Errorf("%w: transaction %s log %d", repository.ErrTournamentEventConflict, v.TxHash, v.LogIndex) + } + } + return nil +} + +func sameBondEvent(a, b *BondEvent) bool { + if a.ApplicationID != b.ApplicationID || a.EpochIndex != b.EpochIndex || a.TournamentAddress != b.TournamentAddress || + a.Type != b.Type || a.BlockNumber != b.BlockNumber || a.TxHash != b.TxHash || a.LogIndex != b.LogIndex { + return false + } + if (a.Refund == nil) != (b.Refund == nil) || (a.Recovery == nil) != (b.Recovery == nil) { + return false + } + return (a.Refund == nil || *a.Refund == *b.Refund) && (a.Recovery == nil || *a.Recovery == *b.Recovery) +} diff --git a/internal/repository/postgres/tournament_events_test.go b/internal/repository/postgres/tournament_events_test.go new file mode 100644 index 000000000..c2ed29c03 --- /dev/null +++ b/internal/repository/postgres/tournament_events_test.go @@ -0,0 +1,205 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +func TestTournamentEventWindowLocksApplicationBeforeChildren(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + newRepository := func() *PostgresRepository { + cfg, err := pgxpool.ParseConfig(endpoint) + require.NoError(t, err) + cfg.MaxConns = 1 // Each operation has a stable PostgreSQL backend PID. + pool, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(t, err) + t.Cleanup(pool.Close) + return &PostgresRepository{db: pool} + } + writer, deleter := newRepository(), newRepository() + registerDeliveryTest(t, writer, repotest.UniqueAddress()) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).WithEnabled(false).Create(ctx, t, writer) + root := repotest.NewTournamentBuilder(app.ID).Build() + root.StartInstant = 9 + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).WithInputBounds(0, 0).Build() + epoch.TournamentAddress = &root.Address + require.NoError(t, writer.CreateEpochsAndInputs(ctx, app.Name, map[*Epoch][]*Input{epoch: {}}, 10)) + var writerPID, deleterPID int32 + require.NoError(t, writer.db.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&writerPID)) + require.NoError(t, deleter.db.QueryRow(ctx, "SELECT pg_backend_pid()").Scan(&deleterPID)) + + gate, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, gate.Close(context.Background())) }) + gateTx, err := gate.Begin(ctx) + require.NoError(t, err) + defer gateTx.Rollback(context.Background()) //nolint:errcheck + _, err = gateTx.Exec(ctx, `SELECT 1 FROM epoch WHERE application_id = $1 FOR UPDATE`, app.ID) + require.NoError(t, err) + gatePID := int32(gate.PgConn().PID()) //nolint:gosec // PostgreSQL backend PIDs fit in int32. + waitForBlocker := func(blocked, blocker int32) { + t.Helper() + require.Eventually(t, func() bool { + var blockedByExpected bool + err := gate.QueryRow(ctx, "SELECT $1 = ANY(pg_blocking_pids($2))", blocker, blocked).Scan(&blockedByExpected) + return err == nil && blockedByExpected + }, 5*time.Second, 10*time.Millisecond, "backend %d must wait for backend %d", blocked, blocker) + } + + stored := make(chan error, 1) + go func() { + batch := &repository.TournamentEventBatch{Tournament: root} + batch.Tournament.Snapshot.AsOfBlock = 100 + stored <- writer.StoreTournamentInterval(ctx, app.ID, nil, []*repository.TournamentEventBatch{batch}) + }() + // The epoch lock pauses the first projection's foreign-key check. The + // writer must already hold the application lock before it reaches here. + waitForBlocker(writerPID, gatePID) + deleted := make(chan error, 1) + go func() { deleted <- deleter.DeleteApplication(ctx, app.ID) }() + waitForBlocker(deleterPID, writerPID) + + require.NoError(t, gateTx.Commit(ctx)) + require.NoError(t, <-stored, "the event window must commit before deletion can take its application lock") + require.NoError(t, <-deleted) + for _, query := range []string{"SELECT count(*) FROM application", "SELECT count(*) FROM epoch", "SELECT count(*) FROM tournaments"} { + var count int + require.NoError(t, writer.db.QueryRow(ctx, query).Scan(&count)) + require.Zero(t, count, "deletion must cascade only after the complete observation commits") + } +} + +func TestTournamentIntervalExistingParentRollsBackLaterFailures(t *testing.T) { + for _, test := range []struct { + name string + fault func([]*repository.TournamentEventBatch) + }{ + {name: "child row", fault: func(batches []*repository.TournamentEventBatch) { + child := *batches[1].Tournament + child.ParentMatchIDHash = new(repotest.UniqueHash()) + batches[1].Tournament = &child + }}, + {name: "child event", fault: func(batches []*repository.TournamentEventBatch) { + commitment := *batches[1].Commitments[0] + commitment.TournamentAddress = repotest.UniqueAddress() + batches[1].Commitments = []*Commitment{&commitment} + }}, + {name: "last child event", fault: func(batches []*repository.TournamentEventBatch) { + last := batches[len(batches)-1] + invalid := repotest.NewMatchBuilder(last.Tournament.ApplicationID). + WithEpochIndex(last.Tournament.EpochIndex).WithTournamentAddress(last.Tournament.Address).Build() + invalid.Snapshot.AsOfBlock = 100 + last.Matches = append(last.Matches, invalid) + }}, + {name: "duplicate commitment", fault: func(batches []*repository.TournamentEventBatch) { + batches[1].Commitments = append(batches[1].Commitments, batches[1].Commitments[0]) + }}, + } { + t.Run(test.name, func(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skip(err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + raw, err := NewPostgresRepository(t.Context(), endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(raw.Close) + r := raw.(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, raw) + epoch0 := repotest.NewEpochBuilder(app.ID).WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(9, 19).WithInputBounds(0, 0).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*Epoch][]*Input{epoch0: {}, epoch1: {}}, 20)) + + root := repotest.NewTournamentBuilder(app.ID).Build() + root.StartInstant = 9 + root.Snapshot.AsOfBlock = 50 + require.NoError(t, r.CreateTournament(t.Context(), app.Name, root)) + updatedRoot := *root + updatedRoot.Snapshot.AsOfBlock = 100 + updatedRoot.Snapshot.AcceptsJoins = false + makeBatch := func(tournament *Tournament) *repository.TournamentEventBatch { + tournament.Snapshot.AsOfBlock = 100 + tournament.Snapshot.Standing = TournamentStandingMatchesActive + one := repotest.NewCommitmentBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithEpochIndex(tournament.EpochIndex). + WithTournamentAddress(tournament.Address).WithCommitmentOne(one.Commitment). + WithCommitmentTwo(two.Commitment).Build() + one.Snapshot.AsOfBlock = 100 + two.Snapshot.AsOfBlock = 100 + match.Snapshot.AsOfBlock = 100 + one.BlockNumber, two.BlockNumber, match.BlockNumber = 100, 100, 100 + return &repository.TournamentEventBatch{Tournament: tournament, + Commitments: []*Commitment{one, two}, Matches: []*Match{match}} + } + parent := makeBatch(&updatedRoot) + child := repotest.NewTournamentBuilder(app.ID).WithLevel(1). + WithParent(root.Address, parent.Matches[0].IDHash).Build() + child.StartInstant = child.CreationEvent.BlockNumber + good := []*repository.TournamentEventBatch{parent, makeBatch(child)} + bad := make([]*repository.TournamentEventBatch, len(good)) + for i, batch := range good { + batchCopy := *batch + bad[i] = &batchCopy + } + test.fault(bad) + require.Error(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(50)), bad)) + storedRoot, err := r.GetTournament(t.Context(), app.Name, root.Address.Hex()) + require.NoError(t, err) + require.Equal(t, root.Snapshot, storedRoot.Snapshot, "projection update must roll back with the last batch") + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, storedApp.LastTournamentCheckBlock) + for _, target := range []struct { + query string + count int + }{ + {"SELECT count(*) FROM tournaments", 1}, {"SELECT count(*) FROM commitments", 0}, + {"SELECT count(*) FROM matches", 0}, {"SELECT count(*) FROM match_advances", 0}, + } { + var count int + require.NoError(t, r.db.QueryRow(t.Context(), target.query).Scan(&count)) + require.Equal(t, target.count, count, target.query) + } + + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(50)), good)) + storedApp, err = r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(100), storedApp.LastTournamentCheckBlock) + for _, batch := range good { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.NotNil(t, stored) + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + commitments, total, err := r.ListCommitments(t.Context(), app.Name, + repository.CommitmentFilter{TournamentAddress: new(batch.Tournament.Address.Hex())}, repository.Pagination{}, false) + require.NoError(t, err) + require.Equal(t, uint64(2), total) + require.Len(t, commitments, 2, "retry must not duplicate the rolled-back event rows") + } + }) + } +} diff --git a/internal/repository/postgres/tournament_fixture_test.go b/internal/repository/postgres/tournament_fixture_test.go new file mode 100644 index 000000000..873d50dda --- /dev/null +++ b/internal/repository/postgres/tournament_fixture_test.go @@ -0,0 +1,63 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +// StoreTournamentEvents seeds legacy constraint/read fixtures only. It is built +// exclusively into this package's tests; production uses StoreTournamentInterval. +func (r *PostgresRepository) StoreTournamentEvents( + ctx context.Context, appID int64, batches []*repository.TournamentEventBatch, lastBlock uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning tournament event window: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + // Keep the application aggregate lock before every child-row lock. + lockStmt := table.Application.SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int64(appID))).FOR(postgres.NO_KEY_UPDATE()) + lockSQL, lockArgs := lockStmt.Sql() + var lockedID int64 + if err := tx.QueryRow(ctx, lockSQL, lockArgs...).Scan(&lockedID); errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } else if err != nil { + return fmt.Errorf("locking tournament application %d: %w", appID, err) + } + for _, batch := range batches { + if err := validateTournamentBatch(appID, batch, lastBlock); err != nil { + return err + } + if err := storeTournamentProjection(ctx, tx, appID, batch.Tournament); err != nil { + return err + } + if err := insertChunked(ctx, tx, appID, batch.Commitments, insertCommitmentChunk); err != nil { + return fmt.Errorf("storing tournament commitments: %w", err) + } + if err := insertChunked(ctx, tx, appID, batch.Matches, insertMatchChunk); err != nil { + return fmt.Errorf("storing tournament matches: %w", err) + } + if err := insertChunked(ctx, tx, appID, batch.MatchAdvances, insertMatchAdvancedChunk); err != nil { + return fmt.Errorf("storing tournament match advances: %w", err) + } + if err := insertChunked(ctx, tx, appID, batch.StandingChanges, insertStandingChangedChunk); err != nil { + return fmt.Errorf("storing tournament standing changes: %w", err) + } + if err := insertChunked(ctx, tx, appID, batch.BondEvents, insertBondEventChunk); err != nil { + return fmt.Errorf("storing tournament bonds: %w", err) + } + } + if err := updateLastProcessedBlock(ctx, tx, appID, lastBlock); err != nil { + return fmt.Errorf("storing tournament checkpoint: %w", err) + } + return tx.Commit(ctx) +} diff --git a/internal/repository/postgres/tournament_integrity_test.go b/internal/repository/postgres/tournament_integrity_test.go new file mode 100644 index 000000000..dc4d47689 --- /dev/null +++ b/internal/repository/postgres/tournament_integrity_test.go @@ -0,0 +1,274 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/ethereum/go-ethereum/common" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +const ( + integrityFirstCheckpoint = uint64(100) + integrityNextCheckpoint = uint64(200) +) + +func newTournamentIntegrityFixture(t *testing.T) (*PostgresRepository, *Application, *Tournament, *Match) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{epoch: {}}, epoch.LastBlock)) + tournament := repotest.NewTournamentBuilder(app.ID).Build() + one := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + two := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(tournament.Address).Build() + match := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(tournament.Address). + WithCommitmentOne(one.Commitment).WithCommitmentTwo(two.Commitment).Build() + tournament.Snapshot.AsOfBlock = integrityFirstCheckpoint + one.Snapshot.AsOfBlock = integrityFirstCheckpoint + two.Snapshot.AsOfBlock = integrityFirstCheckpoint + match.Snapshot.AsOfBlock = integrityFirstCheckpoint + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{ + Tournament: tournament, Commitments: []*Commitment{one, two}, + }}, integrityFirstCheckpoint)) + return r, app, tournament, match +} + +func TestMatchDeletionMetadataRoundTrip(t *testing.T) { + for _, test := range []struct { + name string + hash *common.Hash + }{ + {name: "live"}, + {name: "deleted with zero hash", hash: new(common.Hash)}, + {name: "deleted with nonzero hash", hash: new(common.HexToHash("0x123"))}, + } { + for _, bulk := range []bool{false, true} { + writer := "create" + if bulk { + writer = "event window" + } + t.Run(test.name+"/"+writer, func(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + tournament.Snapshot.AsOfBlock = integrityNextCheckpoint + match.Snapshot.AsOfBlock = integrityNextCheckpoint + match.DeletionTxHash = test.hash + if test.hash != nil { + match.DeletionReason = MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = integrityNextCheckpoint + match.DeletionLogIndex = new(uint64(0)) + match.Snapshot.Phase = MatchPhaseUninitialized + match.Snapshot.Bisection = nil + } + if bulk { + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{{ + Tournament: tournament, Matches: []*Match{match}, + }}, integrityNextCheckpoint)) + } else { + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + } + stored, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, match.DeletionReason, stored.DeletionReason) + require.Equal(t, match.DeletionBlockNumber, stored.DeletionBlockNumber) + require.Equal(t, match.DeletionTxHash, stored.DeletionTxHash) + listed, _, err := r.ListMatches(t.Context(), app.Name, repository.MatchFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, match.DeletionTxHash, listed[0].DeletionTxHash) + var isNull bool + require.NoError(t, r.db.QueryRow(t.Context(), `SELECT deletion_tx_hash IS NULL FROM matches`).Scan(&isNull)) + require.Equal(t, test.hash == nil, isNull) + }) + } + } +} + +func TestMatchDeletionMetadataRejectsIncompleteTuples(t *testing.T) { + r, app, _, match := newTournamentIntegrityFixture(t) + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + for _, test := range []struct { + name string + reason MatchDeletionReason + block any + hash *common.Hash + code string + }{ + {"live with transaction", MatchDeletionReason_NOT_DELETED, 0, new(common.Hash), "23514"}, + {"live with block", MatchDeletionReason_NOT_DELETED, 1, nil, "23514"}, + {"deleted without transaction", MatchDeletionReason_TIMEOUT, 1, nil, "23514"}, + {"deleted without block", MatchDeletionReason_TIMEOUT, 0, new(common.Hash), "23514"}, + {"live with null block", MatchDeletionReason_NOT_DELETED, nil, nil, "23502"}, + {"deleted with null block", MatchDeletionReason_TIMEOUT, nil, new(common.Hash), "23502"}, + } { + t.Run(test.name, func(t *testing.T) { + // Keep the current phase coherent so this test isolates the event tuple. + _, err := r.db.Exec(t.Context(), `UPDATE matches SET + deletion_reason = $1::"MatchDeletionReason", deletion_block_number = $2, deletion_tx_hash = $3, + deletion_log_index = CASE WHEN $1 = 'NOT_DELETED' THEN NULL ELSE 0 END, + phase = CASE WHEN $1 = 'NOT_DELETED' THEN 'BISECTING'::"MatchPhase" ELSE 'UNINITIALIZED'::"MatchPhase" END, + revealing_parent = CASE WHEN $1 = 'NOT_DELETED' THEN revealing_parent ELSE NULL END, + waiting_left = CASE WHEN $1 = 'NOT_DELETED' THEN waiting_left ELSE NULL END, + waiting_right = CASE WHEN $1 = 'NOT_DELETED' THEN waiting_right ELSE NULL END, + segment_start_position = CASE WHEN $1 = 'NOT_DELETED' THEN segment_start_position ELSE NULL END, + segment_start_cycle = CASE WHEN $1 = 'NOT_DELETED' THEN segment_start_cycle ELSE NULL END, + current_height = CASE WHEN $1 = 'NOT_DELETED' THEN current_height ELSE NULL END, + responder = CASE WHEN $1 = 'NOT_DELETED' THEN responder ELSE NULL END`, + test.reason.String(), test.block, hashToBytes(test.hash)) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, test.code, pgErr.Code) + if test.block == nil { + require.Equal(t, "deletion_block_number", pgErr.ColumnName) + } else { + require.Equal(t, "matches_deletion_columns_set_together", pgErr.ConstraintName) + } + }) + } +} + +func TestTournamentWindowRollsBackInvalidDeletion(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + tournament.Snapshot.AsOfBlock = integrityNextCheckpoint + tournament.Snapshot.Standing = TournamentStandingRootWinner + tournament.Snapshot.FinishedAtBlock = integrityNextCheckpoint + tournament.Snapshot.Candidate = new(match.CommitmentOne) + tournament.Snapshot.WinnerCommitment = new(match.CommitmentOne) + tournament.Snapshot.FinalStateHash = new(common.Hash) + deletion := *match + deletion.Winner = WinnerCommitment_ONE + deletion.DeletionReason = MatchDeletionReason_TIMEOUT + deletion.DeletionBlockNumber = integrityNextCheckpoint + deletion.DeletionLogIndex = new(uint64(0)) + deletion.Snapshot = MatchSnapshot{AsOfBlock: integrityNextCheckpoint, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone} + batch := &repository.TournamentEventBatch{Tournament: tournament, Matches: []*Match{&deletion}} + err := r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, integrityNextCheckpoint) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "matches_deletion_columns_set_together", pgErr.ConstraintName) + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Zero(t, stored.Snapshot.FinishedAtBlock) + require.Nil(t, stored.Snapshot.WinnerCommitment) + require.Nil(t, stored.Snapshot.FinalStateHash) + storedMatch, err := r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, MatchDeletionReason_NOT_DELETED, storedMatch.DeletionReason) + require.Nil(t, storedMatch.DeletionTxHash) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityFirstCheckpoint, storedApp.LastTournamentCheckBlock) + + // The atomic path replaces the removed single-row update methods. Verify + // its successful update as well as the failed window above. + deletion.DeletionTxHash = new(common.Hash) + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{batch}, integrityNextCheckpoint)) + stored, err = r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.Snapshot, stored.Snapshot) + storedMatch, err = r.GetMatch(t.Context(), app.Name, match.EpochIndex, tournament.Address.Hex(), match.IDHash.Hex()) + require.NoError(t, err) + require.Equal(t, deletion.Winner, storedMatch.Winner) + require.Equal(t, deletion.DeletionReason, storedMatch.DeletionReason) + require.Equal(t, deletion.DeletionBlockNumber, storedMatch.DeletionBlockNumber) + require.Equal(t, deletion.DeletionTxHash, storedMatch.DeletionTxHash) + storedApp, err = r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityNextCheckpoint, storedApp.LastTournamentCheckBlock) +} + +func TestTournamentAddressIdentifiesOneEpochPerApplication(t *testing.T) { + r, app, tournament, _ := newTournamentIntegrityFixture(t) + // Re-observing the same identity remains idempotent. + require.NoError(t, r.CreateTournament(t.Context(), app.Name, tournament)) + nextEpoch := repotest.NewEpochBuilder(app.ID).WithIndex(1).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*Epoch][]*Input{nextEpoch: {}}, nextEpoch.LastBlock)) + duplicate := *tournament + duplicate.EpochIndex = nextEpoch.Index + err := r.CreateTournament(t.Context(), app.Name, &duplicate) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "tournaments_address_idx", pgErr.ConstraintName) + + updated := *tournament + updated.Snapshot.AsOfBlock = integrityNextCheckpoint + updated.Snapshot.Standing = TournamentStandingRootFailed + updated.Snapshot.FinishedAtBlock = integrityNextCheckpoint + duplicate.Snapshot.AsOfBlock = integrityNextCheckpoint + err = r.StoreTournamentEvents(t.Context(), app.ID, []*repository.TournamentEventBatch{ + {Tournament: &updated}, {Tournament: &duplicate}, + }, integrityNextCheckpoint) + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "tournaments_address_idx", pgErr.ConstraintName) + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.EpochIndex, stored.EpochIndex) + require.Zero(t, stored.Snapshot.FinishedAtBlock) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, integrityFirstCheckpoint, storedApp.LastTournamentCheckBlock) + + otherApp := repotest.NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(t.Context(), t, r) + otherEpoch := repotest.NewEpochBuilder(otherApp.ID).WithStatus(EpochStatus_Closed).Build() + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), otherApp.Name, map[*Epoch][]*Input{otherEpoch: {}}, otherEpoch.LastBlock)) + otherTournament := *tournament + otherTournament.ApplicationID = otherApp.ID + require.NoError(t, r.CreateTournament(t.Context(), otherApp.Name, &otherTournament), "the uniqueness key includes the application") +} + +func TestTournamentLevelConstraints(t *testing.T) { + for _, test := range []struct { + name string + maxLevel uint64 + level uint64 + constraint string + }{ + {name: "single level root", maxLevel: 1, level: 0}, + {name: "multi level root", maxLevel: 3, level: 0}, + {name: "inner nonleaf", maxLevel: 3, level: 1}, + {name: "inner leaf", maxLevel: 2, level: 1}, + {name: "zero levels", maxLevel: 0, level: 0, constraint: "tournaments_max_level_check"}, + {name: "level equals count", maxLevel: 1, level: 1, constraint: "tournaments_max_level_gt_level_check"}, + {name: "level exceeds count", maxLevel: 1, level: 2, constraint: "tournaments_max_level_gt_level_check"}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, tournament, match := newTournamentIntegrityFixture(t) + if test.level > 0 { + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + tournament = repotest.NewTournamentBuilder(app.ID).WithLevel(1). + WithParent(tournament.Address, match.IDHash).Build() + require.NoError(t, r.CreateTournament(t.Context(), app.Name, tournament)) + } + kind := TournamentKindNonLeaf + if test.maxLevel > 0 && test.level == test.maxLevel-1 { + kind = TournamentKindLeaf + } + + // Write directly to test the database constraint, not a Go-side guard. + _, err := r.db.Exec(t.Context(), `UPDATE tournaments SET max_level = $1, level = $2, kind = $3 + WHERE application_id = $4 AND epoch_index = $5 AND address = $6`, + test.maxLevel, test.level, kind.String(), app.ID, tournament.EpochIndex, tournament.Address.Bytes()) + if test.constraint != "" { + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "23514", pgErr.Code) + require.Equal(t, test.constraint, pgErr.ConstraintName) + } else { + require.NoError(t, err) + tournament.MaxLevel, tournament.Level, tournament.Kind = test.maxLevel, test.level, kind + } + stored, err := r.GetTournament(t.Context(), app.Name, tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, tournament.MaxLevel, stored.MaxLevel) + require.Equal(t, tournament.Level, stored.Level) + require.Equal(t, tournament.Kind, stored.Kind) + }) + } +} diff --git a/internal/repository/postgres/tournament_interval.go b/internal/repository/postgres/tournament_interval.go new file mode 100644 index 000000000..371576f74 --- /dev/null +++ b/internal/repository/postgres/tournament_interval.go @@ -0,0 +1,233 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" +) + +// StoreTournamentInterval commits one complete interval and the first complete +// observations of new descendants. Known children have independent cursors. +func (r *PostgresRepository) StoreTournamentInterval( + ctx context.Context, appID int64, expectedAsOf *uint64, batches []*repository.TournamentEventBatch, +) error { + ctx, cancel := beginDatabaseOperation(ctx) + defer cancel() + + if len(batches) == 0 || batches[0] == nil || batches[0].Tournament == nil { + return errors.New("tournament interval requires a primary projection") + } + through := batches[0].Tournament.Snapshot.AsOfBlock + for _, batch := range batches { + if err := validateTournamentBatch(appID, batch, through); err != nil { + return err + } + } + tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return fmt.Errorf("beginning tournament interval: %w", err) + } + defer rollbackDeliveryTx(tx) + // Match guarded deletion: application first, before tournament or event rows. + stmt := table.Application.SELECT(table.Application.ID). + WHERE(table.Application.ID.EQ(postgres.Int64(appID))).FOR(postgres.NO_KEY_UPDATE()) + query, args := stmt.Sql() + var lockedID int64 + if err = tx.QueryRow(ctx, query, args...).Scan(&lockedID); errors.Is(err, pgx.ErrNoRows) { + return repository.ErrNotFound + } else if err != nil { + return fmt.Errorf("locking tournament application %d: %w", appID, err) + } + if err = validateTournamentInterval(ctx, tx, appID, expectedAsOf, batches, through); err != nil { + return err + } + for _, batch := range batches { + if err = storeTournamentProjection(ctx, tx, appID, batch.Tournament); err != nil { + return err + } + if err = insertChunked(ctx, tx, appID, batch.Commitments, insertCommitmentChunk); err != nil { + return fmt.Errorf("storing tournament commitments: %w", err) + } + if err = insertChunked(ctx, tx, appID, batch.Matches, insertMatchChunk); err != nil { + return fmt.Errorf("storing tournament matches: %w", err) + } + if err = insertChunked(ctx, tx, appID, batch.MatchAdvances, insertMatchAdvancedChunk); err != nil { + return fmt.Errorf("storing tournament match advances: %w", err) + } + if err := insertChunked(ctx, tx, appID, batch.StandingChanges, insertStandingChangedChunk); err != nil { + return fmt.Errorf("storing tournament standing changes: %w", err) + } + if err = insertChunked(ctx, tx, appID, batch.BondEvents, insertBondEventChunk); err != nil { + return fmt.Errorf("storing tournament bonds: %w", err) + } + } + // This is a diagnostic high-water mark, not application-wide coverage. + if err = updateLastProcessedBlock(ctx, tx, appID, through); err != nil { + return fmt.Errorf("storing tournament high-water mark: %w", err) + } + return tx.Commit(ctx) +} + +func validateTournamentInterval( + ctx context.Context, tx pgx.Tx, appID int64, expectedAsOf *uint64, + batches []*repository.TournamentEventBatch, through uint64, +) error { + type includedTournament struct { + projection *model.Tournament + from uint64 + } + included := make(map[common.Address]includedTournament, len(batches)) + primary := batches[0].Tournament + for index, batch := range batches { + t := batch.Tournament + if _, duplicate := included[t.Address]; duplicate { + return errors.New("duplicate tournament in interval") + } + if t.EpochIndex != primary.EpochIndex || t.Address == (common.Address{}) || t.StartInstant > through { + return errors.New("invalid tournament interval identity or start") + } + stmt := table.Tournaments.SELECT(table.Tournaments.AsOfBlock).WHERE( + table.Tournaments.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.Tournaments.EpochIndex.EQ(uint64Expr(t.EpochIndex))). + AND(table.Tournaments.Address.EQ(postgres.Bytea(t.Address.Bytes())))) + query, args := stmt.Sql() + var cursor uint64 + err := tx.QueryRow(ctx, query, args...).Scan(&cursor) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("reading tournament cursor: %w", err) + } + exists := err == nil + from := t.StartInstant + if index == 0 { + if (expectedAsOf == nil && exists) || (expectedAsOf != nil && (!exists || cursor != *expectedAsOf)) { + return repository.ErrTournamentIntervalStale + } + if exists { + if cursor == math.MaxUint64 || through <= cursor { + return fmt.Errorf("%w: tournament interval must advance its cursor", repository.ErrTournamentIntervalStale) + } + from = max(from, cursor+1) + } else { + if t.Level != 0 || t.ParentTournamentAddress != nil || t.ParentMatchIDHash != nil || t.CreationEvent != nil { + return errors.New("a new primary tournament must be a root") + } + root := table.Epoch.SELECT(table.Epoch.TournamentAddress, table.Epoch.LastBlock).WHERE( + table.Epoch.ApplicationID.EQ(postgres.Int64(appID)).AND(table.Epoch.Index.EQ(uint64Expr(t.EpochIndex)))) + query, args = root.Sql() + var address *common.Address + var sealBlock uint64 + if err = tx.QueryRow(ctx, query, args...).Scan(&address, &sealBlock); err != nil { + return fmt.Errorf("reading new root provenance: %w", err) + } + if address == nil || *address != t.Address || sealBlock > through || + t.StartInstant != sealBlock { + return errors.New("new root does not match its observed epoch seal") + } + } + } else { + if exists { + return fmt.Errorf("%w: descendant %s already exists", repository.ErrTournamentIntervalStale, t.Address) + } + if t.ParentTournamentAddress == nil || t.ParentMatchIDHash == nil || t.CreationEvent == nil { + return errors.New("new descendant requires parent and creation provenance") + } + parent, ok := included[*t.ParentTournamentAddress] + if !ok || t.Level != parent.projection.Level+1 || t.MaxLevel != parent.projection.MaxLevel || + t.CreationEvent.BlockNumber != t.StartInstant || t.StartInstant < parent.from { + return errors.New("new descendant does not belong to its parent's interval") + } + } + for _, event := range batch.MatchAdvances { + if event.BlockNumber < from { + return errors.New("match advance precedes tournament interval") + } + } + for _, event := range batch.StandingChanges { + if event.BlockNumber < from { + return errors.New("standing change precedes tournament interval") + } + } + for _, event := range batch.BondEvents { + if event.BlockNumber < from { + return errors.New("bond event precedes tournament interval") + } + } + if err := validatePriorTournamentParticipants(ctx, tx, appID, batch, from); err != nil { + return err + } + included[t.Address] = includedTournament{projection: t, from: from} + } + return nil +} + +// Refreshed participants may carry older creation, seal and deletion facts. +// Every such fact must already exist; the upsert checks its full identity. +func validatePriorTournamentParticipants( + ctx context.Context, tx pgx.Tx, appID int64, batch *repository.TournamentEventBatch, from uint64, +) error { + t := batch.Tournament + var commitments, matches, seals, deletions [][]byte + for _, value := range batch.Commitments { + if value.BlockNumber < from { + commitments = append(commitments, value.Commitment.Bytes()) + } + } + for _, value := range batch.Matches { + if value.BlockNumber < from { + matches = append(matches, value.IDHash.Bytes()) + } + if value.LeafSeal != nil && value.LeafSeal.BlockNumber < from { + seals = append(seals, value.IDHash.Bytes()) + } + if value.DeletionBlockNumber > 0 && value.DeletionBlockNumber < from { + deletions = append(deletions, value.IDHash.Bytes()) + } + } + commitmentScope := table.Commitments.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.Commitments.EpochIndex.EQ(uint64Expr(t.EpochIndex))). + AND(table.Commitments.TournamentAddress.EQ(postgres.Bytea(t.Address.Bytes()))) + matchScope := table.Matches.ApplicationID.EQ(postgres.Int64(appID)). + AND(table.Matches.EpochIndex.EQ(uint64Expr(t.EpochIndex))). + AND(table.Matches.TournamentAddress.EQ(postgres.Bytea(t.Address.Bytes()))) + for _, check := range []struct { + source postgres.Table + column postgres.ColumnBytea + scope postgres.BoolExpression + keys [][]byte + kind string + }{ + {table.Commitments, table.Commitments.Commitment, commitmentScope, commitments, "commitment"}, + {table.Matches, table.Matches.IDHash, matchScope, matches, "match"}, + {table.Matches, table.Matches.IDHash, matchScope.AND(table.Matches.SealTxHash.IS_NOT_NULL()), seals, "leaf seal"}, + {table.Matches, table.Matches.IDHash, matchScope.AND(table.Matches.DeletionTxHash.IS_NOT_NULL()), deletions, "match deletion"}, + } { + if len(check.keys) == 0 { + continue + } + // One bound array avoids both PostgreSQL's parameter ceiling and the + // repeated full-prefix scans of generic plans for large IN lists. + keys := postgres.SELECT(postgres.Func("unnest", postgres.ByteaArray(check.keys...)).AS("identity")).AsTable("prior_keys") + key := postgres.ByteaColumn("identity").From(keys) + stmt := check.source.INNER_JOIN(keys, check.column.EQ(key)).SELECT(postgres.COUNT(postgres.STAR)).WHERE(check.scope) + query, args := stmt.Sql() + var count int + if err := tx.QueryRow(ctx, query, args...).Scan(&count); err != nil { + return fmt.Errorf("checking prior %s: %w", check.kind, err) + } + if count != len(check.keys) { + return fmt.Errorf("new %s precedes tournament interval", check.kind) + } + } + return nil +} diff --git a/internal/repository/postgres/tournament_interval_test.go b/internal/repository/postgres/tournament_interval_test.go new file mode 100644 index 000000000..296ce3e94 --- /dev/null +++ b/internal/repository/postgres/tournament_interval_test.go @@ -0,0 +1,264 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func newTournamentInterval(t *testing.T) (*PostgresRepository, *model.Application, []*repository.TournamentEventBatch) { + t.Helper() + r, app, batches := newPassiveObserverWindow(t) + root := batches[0].Tournament + stmt := table.Epoch.UPDATE(table.Epoch.TournamentAddress).SET(postgres.Bytea(root.Address.Bytes())). + WHERE(table.Epoch.ApplicationID.EQ(postgres.Int64(app.ID)).AND(table.Epoch.Index.EQ(uint64Expr(root.EpochIndex)))) + query, args := stmt.Sql() + _, err := r.db.Exec(t.Context(), query, args...) + require.NoError(t, err) + return r, app, batches +} + +func TestTournamentIntervalNewDescendantsAtomic(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "complete", true: "late foreign key failure"}[fail], func(t *testing.T) { + r, app, batches := newTournamentInterval(t) + root, child := batches[0].Tournament, batches[1].Tournament + root.MaxLevel, child.MaxLevel, child.Kind = 3, 3, model.TournamentKindNonLeaf + grandchild := repotest.NewTournamentBuilder(app.ID).WithLevel(2). + WithParent(child.Address, batches[1].Matches[0].IDHash).Build() + grandchild.MaxLevel, grandchild.Kind, grandchild.StartInstant = 3, model.TournamentKindLeaf, 60 + grandchild.CreationEvent.BlockNumber = 60 + if fail { + grandchild.ParentMatchIDHash = new(repotest.UniqueHash()) + } + batches = append(batches, &repository.TournamentEventBatch{Tournament: grandchild}) + err := r.StoreTournamentInterval(t.Context(), app.ID, nil, batches) + if fail { + require.Error(t, err) + } else { + require.NoError(t, err) + } + for _, batch := range batches { + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + if fail { + require.Nil(t, stored) + } else { + require.Equal(t, batch.Tournament.Snapshot, stored.Snapshot) + require.Equal(t, batch.Tournament.CreationEvent, stored.CreationEvent) + } + } + stored, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + if fail { + require.Zero(t, stored.LastTournamentCheckBlock) + } else { + require.Equal(t, observerCheckpoint, stored.LastTournamentCheckBlock) + } + }) + } +} + +func TestTournamentIntervalCursorCompareAndSet(t *testing.T) { + r, app, batches := newTournamentInterval(t) + require.ErrorIs(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(50)), batches), repository.ErrTournamentIntervalStale) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + require.ErrorIs(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches), repository.ErrTournamentIntervalStale) + root := *batches[0].Tournament + root.Snapshot.AsOfBlock++ + next := []*repository.TournamentEventBatch{{Tournament: &root}} + require.ErrorIs(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, next), repository.ErrTournamentIntervalStale, + "nil cannot replace an existing root even when its endpoint advances") + require.ErrorIs(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(99)), next), repository.ErrTournamentIntervalStale) + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { + <-start + results <- r.StoreTournamentInterval(t.Context(), app.ID, new(observerCheckpoint), next) + }() + } + close(start) + one, two := <-results, <-results + require.True(t, (one == nil) != (two == nil), "one cursor owner must win: %v / %v", one, two) + if one == nil { + require.ErrorIs(t, two, repository.ErrTournamentIntervalStale) + } else { + require.ErrorIs(t, one, repository.ErrTournamentIntervalStale) + } + require.ErrorIs(t, r.StoreTournamentInterval(t.Context(), app.ID, new(observerCheckpoint+1), next), + repository.ErrTournamentIntervalStale) +} + +func TestTournamentIntervalRejectsInvalidGroups(t *testing.T) { + for _, test := range []struct { + name string + change func([]*repository.TournamentEventBatch) []*repository.TournamentEventBatch + }{ + {"missing primary", func(_ []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { return nil }}, + {"duplicate", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { return append(b, b[1]) }}, + {"unrelated root", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { + b[1].Tournament = repotest.NewTournamentBuilder(b[0].Tournament.ApplicationID).Build() + return b + }}, + {"new primary child", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { return b[1:] }}, + {"wrong parent", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { + b[1].Tournament.ParentTournamentAddress = new(repotest.UniqueAddress()) + return b + }}, + {"wrong epoch", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { + b[1].Tournament.EpochIndex++ + return b + }}, + {"wrong level", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { + b[1].Tournament.Level++ + return b + }}, + {"creation before parent interval", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { + b[1].Tournament.StartInstant = 0 + b[1].Tournament.CreationEvent.BlockNumber = 0 + return b + }}, + {"creation after endpoint", func(b []*repository.TournamentEventBatch) []*repository.TournamentEventBatch { + b[1].Tournament.StartInstant = observerCheckpoint + 1 + b[1].Tournament.CreationEvent.BlockNumber = observerCheckpoint + 1 + return b + }}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batches := newTournamentInterval(t) + require.Error(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, test.change(batches))) + stored, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, stored.LastTournamentCheckBlock) + rows, total, err := r.ListTournaments(t.Context(), app.Name, repository.TournamentFilter{}, repository.Pagination{}, false) + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, rows) + }) + } +} + +func TestTournamentIntervalExistingChildrenAdvanceIndependently(t *testing.T) { + r, app, batches := newTournamentInterval(t) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + root := *batches[0].Tournament + root.Snapshot.AsOfBlock = 110 + root.Snapshot.BondRecovery = model.TournamentBondRecovery{Disposition: model.BondDispositionRecovered} + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 110 + require.ErrorIs(t, r.StoreTournamentInterval(t.Context(), app.ID, new(observerCheckpoint), + []*repository.TournamentEventBatch{{Tournament: &root}, {Tournament: &child}}), repository.ErrTournamentIntervalStale) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(observerCheckpoint), + []*repository.TournamentEventBatch{{Tournament: &root}})) + storedChild, err := r.GetTournament(t.Context(), app.Name, child.Address.Hex()) + require.NoError(t, err) + require.Equal(t, observerCheckpoint, storedChild.Snapshot.AsOfBlock) + // Child remains a current inner winner and can move beyond its retired parent. + child.Snapshot.AsOfBlock = 120 + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(observerCheckpoint), + []*repository.TournamentEventBatch{{Tournament: &child}})) + root.Snapshot.AsOfBlock = 115 + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(110)), + []*repository.TournamentEventBatch{{Tournament: &root}})) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(120), storedApp.LastTournamentCheckBlock) +} + +func TestTournamentIntervalLateRootIgnoresApplicationHighWater(t *testing.T) { + r, app, batches := newTournamentInterval(t) + stmt := table.Application.UPDATE(table.Application.LastTournamentCheckBlock).SET(uint64Expr(200)). + WHERE(table.Application.ID.EQ(postgres.Int64(app.ID))) + query, args := stmt.Sql() + _, err := r.db.Exec(t.Context(), query, args...) + require.NoError(t, err) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + stored, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(200), stored.LastTournamentCheckBlock) +} + +func TestTournamentIntervalRequiresRootSealProvenance(t *testing.T) { + r, app, batches := newTournamentInterval(t) + batches[0].Tournament.Address = repotest.UniqueAddress() + batches[0].Commitments, batches[0].Matches = nil, nil + require.ErrorContains(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches[:1]), "epoch seal") +} + +func TestTournamentIntervalRejectsBoundaryViolations(t *testing.T) { + t.Run("seal after interval", func(t *testing.T) { + r, app, batch := newRootEventInterval(t) + stmt := table.Epoch.UPDATE(table.Epoch.LastBlock).SET(uint64Expr(200)). + WHERE(table.Epoch.ApplicationID.EQ(postgres.Int64(app.ID))) + query, args := stmt.Sql() + _, err := r.db.Exec(t.Context(), query, args...) + require.NoError(t, err) + require.ErrorContains(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, + []*repository.TournamentEventBatch{batch}), "epoch seal") + }) + t.Run("event before primary cursor", func(t *testing.T) { + r, app, batches := newTournamentInterval(t) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + child := *batches[1].Tournament + child.Snapshot.AsOfBlock = 120 + advance := *batches[1].MatchAdvances[0] + advance.TxHash, advance.BlockNumber = repotest.UniqueHash(), 90 + err := r.StoreTournamentInterval(t.Context(), app.ID, new(observerCheckpoint), + []*repository.TournamentEventBatch{{Tournament: &child, MatchAdvances: []*model.MatchAdvanced{&advance}}}) + require.ErrorContains(t, err, "precedes tournament interval") + }) + t.Run("descendant skips level", func(t *testing.T) { + r, app, batches := newTournamentInterval(t) + // Both projections are individually valid; only their parent relation is wrong. + batches[0].Tournament.MaxLevel = 3 + batches[1].Tournament.MaxLevel, batches[1].Tournament.Level = 3, 2 + err := r.StoreTournamentInterval(t.Context(), app.ID, nil, batches) + require.ErrorContains(t, err, "does not belong to its parent's interval") + }) +} + +func TestTournamentIntervalRefreshPreservesParticipantHistoryBounds(t *testing.T) { + r, app, batches := newTournamentInterval(t) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, batches)) + refresh := batches[0] + refresh.Tournament.Snapshot.AsOfBlock = 110 + for _, value := range refresh.Commitments { + value.Snapshot.AsOfBlock = 110 + } + for _, value := range refresh.Matches { + value.Snapshot.AsOfBlock = 110 + } + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(observerCheckpoint), + []*repository.TournamentEventBatch{refresh})) + root := *refresh.Tournament + root.Snapshot.AsOfBlock = 111 + oldJoin := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(root.Address).Build() + oldJoin.Snapshot.AsOfBlock = 111 + oldMatch := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(root.Address). + WithCommitmentOne(refresh.Commitments[0].Commitment).WithCommitmentTwo(refresh.Commitments[1].Commitment).Build() + oldMatch.Snapshot.AsOfBlock = 111 + for _, test := range []struct { + name string + batch *repository.TournamentEventBatch + }{ + {"new historical commitment", &repository.TournamentEventBatch{Tournament: &root, Commitments: []*model.Commitment{oldJoin}}}, + {"new historical match", &repository.TournamentEventBatch{Tournament: &root, Matches: []*model.Match{oldMatch}}}, + } { + t.Run(test.name, func(t *testing.T) { + err := r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(110)), []*repository.TournamentEventBatch{test.batch}) + require.ErrorContains(t, err, "precedes tournament interval") + stored, err := r.GetTournament(t.Context(), app.Name, root.Address.Hex()) + require.NoError(t, err) + require.Equal(t, uint64(110), stored.Snapshot.AsOfBlock) + }) + } +} diff --git a/internal/repository/postgres/tournament_limits_test.go b/internal/repository/postgres/tournament_limits_test.go new file mode 100644 index 000000000..b6c802004 --- /dev/null +++ b/internal/repository/postgres/tournament_limits_test.go @@ -0,0 +1,175 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/go-jet/jet/v2/postgres" + "github.com/stretchr/testify/require" +) + +func newRootEventInterval(t *testing.T) (*PostgresRepository, *model.Application, *repository.TournamentEventBatch) { + t.Helper() + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(t.Context(), t, r) + root := repotest.NewTournamentBuilder(app.ID).Build() + root.StartInstant = 99 + epoch := repotest.NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed).WithBlocks(0, 99).Build() + epoch.TournamentAddress = &root.Address + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, map[*model.Epoch][]*model.Input{epoch: {}}, 100)) + return r, app, &repository.TournamentEventBatch{Tournament: root} +} + +func addIntervalCommitment(batch *repository.TournamentEventBatch) *model.Commitment { + value := repotest.NewCommitmentBuilder(batch.Tournament.ApplicationID). + WithTournamentAddress(batch.Tournament.Address).Build() + batch.Commitments = append(batch.Commitments, value) + return value +} + +func addIntervalMatch(batch *repository.TournamentEventBatch) *model.Match { + one, two := addIntervalCommitment(batch), addIntervalCommitment(batch) + value := repotest.NewMatchBuilder(batch.Tournament.ApplicationID).WithTournamentAddress(batch.Tournament.Address). + WithCommitmentOne(one.Commitment).WithCommitmentTwo(two.Commitment).Build() + batch.Matches = append(batch.Matches, value) + return value +} + +func TestTournamentIntervalStatementsAbovePreviousBindLimits(t *testing.T) { + for _, test := range []struct { + name string + rows int + fill func(*repository.TournamentEventBatch, int) + table postgres.Table + }{ + {"commitments", 4682, func(batch *repository.TournamentEventBatch, count int) { + for range count { + addIntervalCommitment(batch) + } + }, table.Commitments}, + {"matches", 1821, func(batch *repository.TournamentEventBatch, count int) { + for range count { + addIntervalMatch(batch) + } + }, table.Matches}, + {"advances", 5958, func(batch *repository.TournamentEventBatch, count int) { + for range count { + match := addIntervalMatch(batch) + batch.MatchAdvances = append(batch.MatchAdvances, repotest.NewMatchAdvancedBuilder(match.ApplicationID). + WithTournamentAddress(match.TournamentAddress).WithIDHash(match.IDHash).Build()) + } + }, table.MatchAdvances}, + {"bonds", 4682, func(batch *repository.TournamentEventBatch, count int) { + for range count { + batch.BondEvents = append(batch.BondEvents, &model.BondEvent{ + ApplicationID: batch.Tournament.ApplicationID, TournamentAddress: batch.Tournament.Address, + Type: model.BondEventPartialRefund, BlockNumber: 100, TxHash: repotest.UniqueHash(), + Refund: &model.PartialBondRefund{Recipient: repotest.UniqueAddress(), Success: true}, + }) + } + }, table.BondEvents}, + } { + t.Run(test.name, func(t *testing.T) { + r, app, batch := newRootEventInterval(t) + test.fill(batch, test.rows) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + query, args := test.table.SELECT(postgres.COUNT(postgres.STAR)).Sql() + var count int + require.NoError(t, r.db.QueryRow(t.Context(), query, args...).Scan(&count)) + require.Equal(t, test.rows, count) + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, uint64(100), stored.Snapshot.AsOfBlock) + // Refreshed participants keep their old creation blocks. Both kinds + // must pass membership checks before a later projection. + stored.Snapshot.AsOfBlock = 110 + for _, value := range batch.Commitments { + value.Snapshot.AsOfBlock = 110 + } + for _, value := range batch.Matches { + value.Snapshot.AsOfBlock = 110 + } + refresh := &repository.TournamentEventBatch{ + Tournament: stored, Commitments: batch.Commitments, Matches: batch.Matches, + } + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), + []*repository.TournamentEventBatch{refresh})) + }) + } +} + +func TestTournamentIntervalLateChunkConflictRollsBack(t *testing.T) { + r, app, batch := newRootEventInterval(t) + match := addIntervalMatch(batch) + for index := range tournamentRowsPerStatement + 1 { + batch.MatchAdvances = append(batch.MatchAdvances, repotest.NewMatchAdvancedBuilder(app.ID). + WithTournamentAddress(batch.Tournament.Address).WithIDHash(match.IDHash).Build()) + batch.MatchAdvances[index].CurrentHeight = uint64(index) + 1 + } + duplicate := *batch.MatchAdvances[0] + batch.MatchAdvances[len(batch.MatchAdvances)-1] = &duplicate + // An exact retry across chunk boundaries keeps one immutable event row. + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + query, args := table.MatchAdvances.SELECT(postgres.COUNT(postgres.STAR)).Sql() + var count int + require.NoError(t, r.db.QueryRow(t.Context(), query, args...).Scan(&count)) + require.Equal(t, tournamentRowsPerStatement, count) + + // Existing rows and the cursor must survive a later contradictory chunk. + root := *batch.Tournament + root.Snapshot.AsOfBlock = 110 + next := &repository.TournamentEventBatch{Tournament: &root} + for index := range tournamentRowsPerStatement { + advance := repotest.NewMatchAdvancedBuilder(app.ID).WithTournamentAddress(root.Address).WithIDHash(match.IDHash).Build() + advance.BlockNumber = 105 + advance.CurrentHeight = uint64(index) + uint64(tournamentRowsPerStatement) + 2 + next.MatchAdvances = append(next.MatchAdvances, advance) + } + conflict := *next.MatchAdvances[0] + conflict.LeftNode = repotest.UniqueHash() + next.MatchAdvances = append(next.MatchAdvances, &conflict) + err := r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), []*repository.TournamentEventBatch{next}) + require.ErrorIs(t, err, repository.ErrTournamentEventConflict) + require.NoError(t, r.db.QueryRow(t.Context(), query, args...).Scan(&count)) + require.Equal(t, tournamentRowsPerStatement, count, "the first new chunk must roll back") + stored, err := r.GetTournament(t.Context(), app.Name, root.Address.Hex()) + require.NoError(t, err) + require.Equal(t, uint64(100), stored.Snapshot.AsOfBlock) + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Equal(t, uint64(100), storedApp.LastTournamentCheckBlock) +} + +func TestTournamentIntervalHistoricalParticipantsUseBoundArray(t *testing.T) { + r, app, batch := newRootEventInterval(t) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + batch.Tournament.Snapshot.AsOfBlock = 110 + // These intentionally absent identities must produce the domain verdict, + // even when an unchunked membership statement would exceed 65,535 binds. + const participants = 65533 + commitment := repotest.NewCommitmentBuilder(app.ID).WithTournamentAddress(batch.Tournament.Address).Build() + commitment.Snapshot.AsOfBlock = 110 + for range participants { + value := *commitment + value.Commitment = repotest.UniqueHash() + batch.Commitments = append(batch.Commitments, &value) + } + err := r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), []*repository.TournamentEventBatch{batch}) + require.ErrorContains(t, err, "new commitment precedes tournament interval") + batch.Commitments = nil + match := repotest.NewMatchBuilder(app.ID).WithTournamentAddress(batch.Tournament.Address).Build() + match.Snapshot.AsOfBlock = 110 + for range participants { + value := *match + value.IDHash = repotest.UniqueHash() + batch.Matches = append(batch.Matches, &value) + } + err = r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), []*repository.TournamentEventBatch{batch}) + require.ErrorContains(t, err, "new match precedes tournament interval") +} diff --git a/internal/repository/postgres/tournament_membership_test.go b/internal/repository/postgres/tournament_membership_test.go new file mode 100644 index 000000000..9a10ef2e6 --- /dev/null +++ b/internal/repository/postgres/tournament_membership_test.go @@ -0,0 +1,152 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "strconv" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/stretchr/testify/require" +) + +const ( + intervalCommitmentKind = "commitment" + intervalLeafSealKind = "leaf seal" +) + +func TestTournamentIntervalRejectsDuplicateParticipantIdentities(t *testing.T) { + for _, kind := range []string{intervalCommitmentKind, "match"} { + for _, boundary := range []int{1, tournamentRowsPerStatement + 1} { + t.Run(kind+"/"+strconv.Itoa(boundary), func(t *testing.T) { + r, app, batch := newRootEventInterval(t) + for range boundary { + if kind == intervalCommitmentKind { + addIntervalCommitment(batch) + } else { + addIntervalMatch(batch) + } + } + if kind == intervalCommitmentKind { + duplicate := *batch.Commitments[0] + batch.Commitments = append(batch.Commitments, &duplicate) + } else { + duplicate := *batch.Matches[0] + batch.Matches = append(batch.Matches, &duplicate) + } + err := r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch}) + require.ErrorContains(t, err, "duplicate "+kind) + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Nil(t, stored, "duplicates are rejected independently of their insert chunk") + storedApp, err := r.GetApplication(t.Context(), app.Name) + require.NoError(t, err) + require.Zero(t, storedApp.LastTournamentCheckBlock) + }) + } + } +} + +func TestTournamentIntervalHistoricalMembershipRequiresExactScope(t *testing.T) { + r, app, batch := newRootEventInterval(t) + addIntervalMatch(batch) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + for _, kind := range []string{intervalCommitmentKind, "match"} { + for _, scope := range []string{"application", "epoch", "tournament"} { + t.Run(kind+"/"+scope, func(t *testing.T) { + root := *batch.Tournament + id := app.ID + switch scope { + case "application": + id++ + case "epoch": + root.EpochIndex++ + case "tournament": + root.Address = repotest.UniqueAddress() + } + check := &repository.TournamentEventBatch{Tournament: &root} + if kind == intervalCommitmentKind { + check.Commitments = batch.Commitments + } else { + check.Matches = batch.Matches + } + err := validatePriorTournamentParticipants(t.Context(), tx, id, check, 101) + require.ErrorContains(t, err, "new "+kind+" precedes tournament interval") + }) + } + } +} + +func TestTournamentIntervalHistoricalParticipantsDenseGenericPlan(t *testing.T) { + r, app, batch := newRootEventInterval(t) + // A complete, distinct prefix exercises the generic-plan path that used to + // scan all stored keys once for every scalar IN parameter. + const participants = 32000 + for range participants { + addIntervalCommitment(batch) + } + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + tx, err := r.db.Begin(t.Context()) + require.NoError(t, err) + defer rollbackDeliveryTx(tx) + // This PostgreSQL planner setting has no Jet statement form. It is local + // to this test transaction and changes no application data or saved config. + _, err = tx.Exec(t.Context(), "SET LOCAL plan_cache_mode = force_generic_plan") + require.NoError(t, err) + require.NoError(t, validatePriorTournamentParticipants(t.Context(), tx, app.ID, batch, 101)) + absent := *batch.Commitments[0] + absent.Commitment = repotest.UniqueHash() + batch.Commitments[len(batch.Commitments)-1] = &absent + err = validatePriorTournamentParticipants(t.Context(), tx, app.ID, batch, 101) + require.ErrorContains(t, err, "new commitment precedes tournament interval", "array membership must include every key") +} + +func setIntervalOptionalEvent(match *model.Match, kind string, block uint64) { + if kind == intervalLeafSealKind { + match.LeafSeal = &model.LeafMatchSeal{BlockNumber: block, TxHash: repotest.UniqueHash()} + return + } + match.DeletionReason = model.MatchDeletionReason_TIMEOUT + match.DeletionBlockNumber = block + match.DeletionTxHash, match.DeletionLogIndex = new(repotest.UniqueHash()), new(uint64(0)) + match.Snapshot = model.MatchSnapshot{AsOfBlock: match.Snapshot.AsOfBlock, Phase: model.MatchPhaseUninitialized, + TimeoutOutcome: model.MatchTimeoutNone} +} + +func TestTournamentIntervalRejectsFirstOptionalEventBelowCommittedCursor(t *testing.T) { + for _, kind := range []string{intervalLeafSealKind, "match deletion"} { + t.Run(kind, func(t *testing.T) { + r, app, batch := newRootEventInterval(t) + match := addIntervalMatch(batch) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, nil, []*repository.TournamentEventBatch{batch})) + before, err := r.GetTournamentEventCounts(t.Context(), app.ID, 0, batch.Tournament.Address, 100) + require.NoError(t, err) + batch.Tournament.Snapshot.AsOfBlock = 110 + match.Snapshot.AsOfBlock = 110 + batch.Commitments = nil + setIntervalOptionalEvent(match, kind, 95) + err = r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), []*repository.TournamentEventBatch{batch}) + require.ErrorContains(t, err, "new "+kind+" precedes tournament interval") + after, err := r.GetTournamentEventCounts(t.Context(), app.ID, 0, batch.Tournament.Address, 100) + require.NoError(t, err) + require.Equal(t, before, after, "a later interval cannot add evidence beneath a committed cursor") + stored, err := r.GetTournament(t.Context(), app.Name, batch.Tournament.Address.Hex()) + require.NoError(t, err) + require.Equal(t, uint64(100), stored.Snapshot.AsOfBlock) + // The first event at the next inclusive block is valid. + setIntervalOptionalEvent(match, kind, 101) + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(100)), + []*repository.TournamentEventBatch{batch})) + // A later participant refresh may repeat exactly that saved event. + batch.Tournament.Snapshot.AsOfBlock, match.Snapshot.AsOfBlock = 120, 120 + require.NoError(t, r.StoreTournamentInterval(t.Context(), app.ID, new(uint64(110)), + []*repository.TournamentEventBatch{batch})) + }) + } +} diff --git a/internal/repository/postgres/tournament_recovery_test.go b/internal/repository/postgres/tournament_recovery_test.go new file mode 100644 index 000000000..2756dd2aa --- /dev/null +++ b/internal/repository/postgres/tournament_recovery_test.go @@ -0,0 +1,174 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "math" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/repotest" +) + +const recoveryObservationBlock uint64 = 100 + +func storeRecoveryRoot( + t *testing.T, r *PostgresRepository, app *model.Application, index uint64, + claimer common.Address, disposition model.BondDisposition, observedBlock uint64, +) (*model.Epoch, repository.RootBondRecoveryCandidate) { + t.Helper() + root := repotest.NewTournamentBuilder(app.ID).WithEpochIndex(index).Build() + epoch := repotest.NewEpochBuilder(app.ID).WithIndex(index).WithStatus(model.EpochStatus_Closed).Build() + epoch.TournamentAddress = &root.Address + require.NoError(t, r.CreateEpochsAndInputs(t.Context(), app.Name, + map[*model.Epoch][]*model.Input{epoch: {}}, observedBlock)) + root.Snapshot.AsOfBlock = observedBlock + root.Snapshot.BondRecovery.Disposition = disposition + if disposition != model.BondDispositionTournamentRunning { + root.Snapshot.AcceptsJoins = false + root.Snapshot.FinishedAtBlock = observedBlock + root.Snapshot.Standing = model.TournamentStandingRootFailed + if disposition != model.BondDispositionNoWinner { + root.Snapshot.Standing = model.TournamentStandingRootWinner + root.Snapshot.Candidate = new(repotest.UniqueHash()) + root.Snapshot.WinnerCommitment = root.Snapshot.Candidate + root.Snapshot.FinalStateHash = new(repotest.UniqueHash()) + } + } + if disposition == model.BondDispositionRecoverable { + root.Snapshot.BondRecovery.Claimer = &claimer + root.Snapshot.BondRecovery.Payment = new(model.Uint256) + } + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, + []*repository.TournamentEventBatch{{Tournament: root}}, observedBlock)) + return epoch, repository.RootBondRecoveryCandidate{EpochIndex: index, Tournament: root.Address} +} + +func TestListRecoverableRootBondsFiltersPublishedOwnedRoots(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(t.Context(), t, r) + otherApp := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(t.Context(), t, r) + owner, otherOwner := repotest.UniqueAddress(), repotest.UniqueAddress() + want := make([]repository.RootBondRecoveryCandidate, 0, 3) + for index, status := range []model.EpochStatus{ + model.EpochStatus_ClaimAccepted, model.EpochStatus_Closed, model.EpochStatus_ClaimForeclosed, + } { + epoch, candidate := storeRecoveryRoot(t, r, app, uint64(index), owner, + model.BondDispositionRecoverable, recoveryObservationBlock) + if status != model.EpochStatus_Closed { + repotest.AdvanceEpochStatus(t.Context(), t, r, app.Name, epoch, status) + } + stored, err := r.GetEpoch(t.Context(), app.Name, epoch.Index) + require.NoError(t, err) + require.Equal(t, status, stored.Status) + if status != model.EpochStatus_ClaimAccepted { + require.Nil(t, stored.Commitment, "discovery must not require a local commitment") + } + want = append(want, candidate) + } + _, foreignOwned := storeRecoveryRoot(t, r, app, 3, otherOwner, + model.BondDispositionRecoverable, recoveryObservationBlock) + _, otherApplication := storeRecoveryRoot(t, r, otherApp, 0, owner, + model.BondDispositionRecoverable, recoveryObservationBlock) + for index, disposition := range []model.BondDisposition{ + model.BondDispositionTournamentRunning, model.BondDispositionNoWinner, model.BondDispositionRecovered, + } { + storeRecoveryRoot(t, r, app, uint64(index)+4, owner, disposition, recoveryObservationBlock) + } + _, newer := storeRecoveryRoot(t, r, app, 7, owner, model.BondDispositionRecoverable, recoveryObservationBlock+1) + for _, test := range []struct { + name string + appID int64 + claimer common.Address + observed uint64 + want []repository.RootBondRecoveryCandidate + }{ + {"owned published roots", app.ID, owner, recoveryObservationBlock, want}, + {"other signer", app.ID, otherOwner, recoveryObservationBlock, []repository.RootBondRecoveryCandidate{foreignOwned}}, + {"other application", otherApp.ID, owner, recoveryObservationBlock, []repository.RootBondRecoveryCandidate{otherApplication}}, + {"unknown application", otherApp.ID + 1, owner, recoveryObservationBlock, nil}, + {"zero publication cursor", app.ID, owner, 0, nil}, + {"older observation head", app.ID, owner, recoveryObservationBlock - 1, nil}, + {"publication catches up", app.ID, owner, recoveryObservationBlock + 1, + append(append([]repository.RootBondRecoveryCandidate{}, want...), newer)}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := r.ListRecoverableRootBonds(t.Context(), test.appID, test.claimer, + test.observed, nil, math.MaxUint64, 10) + require.NoError(t, err) + require.Equal(t, test.want, got) + }) + } + var joins int + require.NoError(t, r.db.QueryRow(t.Context(), "SELECT count(*) FROM commitments").Scan(&joins)) + require.Zero(t, joins, "candidates do not depend on recorded joins") +} + +func TestListRecoverableRootBondsExcludesInnerTournament(t *testing.T) { + r, app, root, match := newTournamentIntegrityFixture(t) + require.NoError(t, r.CreateMatch(t.Context(), app.Name, match)) + owner := repotest.UniqueAddress() + child := repotest.NewTournamentBuilder(app.ID).WithLevel(1).WithParent(root.Address, match.IDHash).Build() + child.Snapshot.Standing = model.TournamentStandingInnerEliminableWinnerExpired + child.Snapshot.AcceptsJoins = false + child.Snapshot.FinishedAtBlock = recoveryObservationBlock + child.Snapshot.InnerResult.Disposition = model.InnerTournamentEliminable + child.Snapshot.BondRecovery = model.TournamentBondRecovery{ + Disposition: model.BondDispositionRecoverable, Claimer: &owner, Payment: new(model.Uint256), + } + require.NoError(t, r.StoreTournamentEvents(t.Context(), app.ID, + []*repository.TournamentEventBatch{{Tournament: child}}, recoveryObservationBlock)) + got, err := r.ListRecoverableRootBonds(t.Context(), app.ID, owner, + recoveryObservationBlock, nil, math.MaxUint64, 10) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestListRecoverableRootBondsKeysetPagination(t *testing.T) { + r := newEpochPublicationRepository(t).(*PostgresRepository) + app := repotest.NewApplicationBuilder().WithConsensus(model.Consensus_PRT).Create(t.Context(), t, r) + owner := repotest.UniqueAddress() + var want []repository.RootBondRecoveryCandidate + // Reverse insertion order to prove that pagination uses epoch order. + for _, index := range []uint64{math.MaxUint64, 10, 5, 2, 0} { + _, candidate := storeRecoveryRoot(t, r, app, index, owner, model.BondDispositionRecoverable, recoveryObservationBlock) + want = append([]repository.RootBondRecoveryCandidate{candidate}, want...) + } + for _, test := range []struct { + name string + after *uint64 + through uint64 + limit uint64 + want []repository.RootBondRecoveryCandidate + }{ + {"first page includes zero", nil, 10, 2, want[:2]}, + {"next page excludes cursor", new(uint64(2)), 10, 2, want[2:4]}, + {"zero upper bound", nil, 0, 2, want[:1]}, + {"inclusive upper bound", nil, 5, 10, want[:3]}, + {"cursor need not be an existing epoch", new(uint64(3)), 5, 2, want[2:3]}, + {"exhausted sweep", new(uint64(10)), 10, 2, nil}, + {"cursor above sweep", new(uint64(11)), 10, 2, nil}, + {"full uint64 epoch range", new(uint64(math.MaxInt64)), math.MaxUint64, 2, want[4:]}, + {"max epoch cursor does not overflow", new(uint64(math.MaxUint64)), math.MaxUint64, 2, nil}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := r.ListRecoverableRootBonds(t.Context(), app.ID, owner, + recoveryObservationBlock, test.after, test.through, test.limit) + require.NoError(t, err) + require.Equal(t, test.want, got) + }) + } +} + +func TestListRecoverableRootBondsRejectsUnboundedLimit(t *testing.T) { + r := &PostgresRepository{} + for _, limit := range []uint64{0, uint64(math.MaxInt64) + 1, math.MaxUint64} { + _, err := r.ListRecoverableRootBonds(t.Context(), 1, common.Address{}, 100, nil, 0, limit) + require.ErrorContains(t, err, "root bond recovery limit must be between") + } +} diff --git a/internal/repository/postgres/util.go b/internal/repository/postgres/util.go new file mode 100644 index 000000000..66c767f02 --- /dev/null +++ b/internal/repository/postgres/util.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "fmt" + "regexp" + "strconv" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +var hexAddressRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) + +func isHexAddress(s string) bool { + return hexAddressRegex.MatchString(s) +} + +func getWhereClauseFromNameOrAddress(nameOrAddress string) postgres.BoolExpression { + if isHexAddress(nameOrAddress) { + address := common.HexToAddress(nameOrAddress) + return table.Application.IapplicationAddress.EQ(postgres.Bytea(address.Bytes())) + } + return table.Application.Name.EQ(postgres.String(nameOrAddress)) +} + +// uint64Expr converts a uint64 to a go-jet FloatExpression for use with +// PostgreSQL NUMERIC(20,0) "uint64" domain columns. +func uint64Expr(v uint64) postgres.FloatExpression { + return postgres.CAST(postgres.String(strconv.FormatUint(v, 10))).AS_NUMERIC() +} + +func hashToBytes(h *common.Hash) any { + if h == nil { + return nil + } + return (*h)[:] +} + +// beginReadTx starts a REPEATABLE READ, read-only transaction. +// This ensures that multiple queries within the transaction see the same snapshot. +func beginReadTx(ctx context.Context, pool *pgxpool.Pool) (pgx.Tx, error) { + return pool.BeginTx(ctx, pgx.TxOptions{ + IsoLevel: pgx.RepeatableRead, + AccessMode: pgx.ReadOnly, + }) +} + +// countFromTx executes a count query on the given transaction and returns the total. +func countFromTx(ctx context.Context, tx pgx.Tx, countStmt postgres.SelectStatement) (uint64, error) { + sqlStr, args := countStmt.Sql() + var total uint64 + err := tx.QueryRow(ctx, sqlStr, args...).Scan(&total) + return total, err +} + +// SubstrBytea returns a substring expression properly typed as ByteaExpression. +// It must render exactly as `substring(col FROM n FOR m)` with inline literals: +// PostgreSQL matches expression indexes structurally (by function OID and +// argument tree), so the schema's `substring(... FROM ... FOR ...)` indexes are +// only usable when the query emits the same function with constant arguments. +// `SUBSTR(col, $1, $2)` is a different catalog function and bypasses them. +func SubstrBytea(col postgres.ColumnBytea, from, count int64) postgres.ByteaExpression { + qualified := pgx.Identifier{col.TableName(), col.Name()}.Sanitize() + raw := fmt.Sprintf("substring(%s FROM %d FOR %d)", qualified, from, count) + return postgres.RawBytea(raw) +} + +// ByteaLiteral renders b as an inline bytea literal instead of a bind +// parameter. Inline literals are required where the planner must prove a +// partial-index predicate at plan time (e.g. output_raw_data_address_idx): +// a generic plan cannot prove implication from a parameterized IN list. +func ByteaLiteral(b []byte) postgres.ByteaExpression { + return postgres.RawBytea(fmt.Sprintf(`'\x%x'::bytea`, b)) +} + +func setLocalLockTimeout(ctx context.Context, tx pgx.Tx) error { + stmt := postgres.SELECT(postgres.Func("set_config", postgres.String("lock_timeout"), postgres.String("2s"), postgres.Bool(true))) + query, args := stmt.Sql() + _, err := tx.Exec(ctx, query, args...) + return err +} diff --git a/internal/repository/postgres/withdrawal.go b/internal/repository/postgres/withdrawal.go new file mode 100644 index 000000000..4f4123089 --- /dev/null +++ b/internal/repository/postgres/withdrawal.go @@ -0,0 +1,293 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/go-jet/jet/v2/postgres" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" +) + +type withdrawalExecutor interface { + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +// InsertWithdrawal records a Withdrawal event observed on chain. Idempotent on +// the (application_id, account_index) primary key via ON CONFLICT DO NOTHING, +// so re-processing the same block on restart cannot fail and cannot diverge +// from the first write. The contract marks each account index as withdrawn, +// so the event fires at most once per slot per app — duplicate inserts are +// restart artifacts. +func (r *PostgresRepository) InsertWithdrawal( + ctx context.Context, + w *model.Withdrawal, +) error { + return insertWithdrawal(ctx, r.db, w) +} + +func insertWithdrawal(ctx context.Context, exec withdrawalExecutor, w *model.Withdrawal) error { + insertStmt := table.Withdrawal. + INSERT( + table.Withdrawal.ApplicationID, + table.Withdrawal.AccountIndex, + table.Withdrawal.Account, + table.Withdrawal.Output, + table.Withdrawal.BlockNumber, + table.Withdrawal.TransactionHash, + table.Withdrawal.LogIndex, + ). + VALUES( + w.ApplicationID, + w.AccountIndex, + w.Account, + w.Output, + w.BlockNumber, + w.TransactionHash.Bytes(), + int64(w.LogIndex), + ). + ON_CONFLICT(table.Withdrawal.ApplicationID, table.Withdrawal.AccountIndex). + DO_NOTHING() + + sqlStr, args := insertStmt.Sql() + _, err := exec.Exec(ctx, sqlStr, args...) + if err != nil { + return fmt.Errorf("insert withdrawal (app=%d, index=%d): %w", w.ApplicationID, w.AccountIndex, err) + } + return nil +} + +// StoreWithdrawalEvents persists a completed withdrawal scan window atomically. +// Rows and cursor advancement are +// committed together so the DB withdrawal count remains the scanner's local +// previous counter for the next window. +func (r *PostgresRepository) StoreWithdrawalEvents( + ctx context.Context, + appID int64, + withdrawals []*model.Withdrawal, + blockNumber uint64, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + for _, w := range withdrawals { + if w.ApplicationID != appID { + return fmt.Errorf("insert withdrawal (app=%d, index=%d): application id mismatch %d", + w.ApplicationID, w.AccountIndex, appID) + } + if err := insertWithdrawal(ctx, tx, w); err != nil { + return err + } + } + + updateStmt := table.Application. + UPDATE(table.Application.LastWithdrawalCheckBlock). + SET(uint64Expr(blockNumber)). + WHERE( + table.Application.ID.EQ(postgres.Int(appID)). + AND(table.Application.LastWithdrawalCheckBlock.LT(uint64Expr(blockNumber))), + ) + + sqlStr, args := updateStmt.Sql() + if _, err := tx.Exec(ctx, sqlStr, args...); err != nil { + return err + } + return tx.Commit(ctx) +} + +// GetNumberOfWithdrawals returns COUNT(*) of withdrawal rows for the app. The +// post-foreclosure scanner uses this as the previous on-chain withdrawal +// counter, which is only correct because withdrawal rows are append-only (see +// the note on the "withdrawal" table in the schema migration). If rows could be +// deleted, this count would fall below the chain counter and the scanner's +// monotonic guard would (correctly) flag corruption. +func (r *PostgresRepository) GetNumberOfWithdrawals( + ctx context.Context, + appID int64, +) (uint64, error) { + sel := table.Withdrawal. + SELECT(postgres.COUNT(postgres.STAR)). + FROM(table.Withdrawal). + WHERE(table.Withdrawal.ApplicationID.EQ(postgres.Int(appID))) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var count uint64 + err := row.Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func (r *PostgresRepository) GetWithdrawal( + ctx context.Context, + nameOrAddress string, + accountIndex uint64, +) (*model.Withdrawal, error) { + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + sel := table.Withdrawal. + SELECT( + table.Withdrawal.ApplicationID, + table.Withdrawal.AccountIndex, + table.Withdrawal.Account, + table.Withdrawal.Output, + table.Withdrawal.BlockNumber, + table.Withdrawal.TransactionHash, + table.Withdrawal.LogIndex, + table.Withdrawal.CreatedAt, + table.Withdrawal.UpdatedAt, + ). + FROM( + table.Withdrawal.INNER_JOIN( + table.Application, + table.Withdrawal.ApplicationID.EQ(table.Application.ID), + ), + ). + WHERE( + whereClause. + AND(table.Withdrawal.AccountIndex.EQ(uint64Expr(accountIndex))), + ) + + sqlStr, args := sel.Sql() + row := r.db.QueryRow(ctx, sqlStr, args...) + + var w model.Withdrawal + var txHashBytes []byte + var logIndex int64 + err := row.Scan( + &w.ApplicationID, + &w.AccountIndex, + &w.Account, + &w.Output, + &w.BlockNumber, + &txHashBytes, + &logIndex, + &w.CreatedAt, + &w.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + copy(w.TransactionHash[:], txHashBytes) + w.LogIndex = uint(logIndex) + return &w, nil +} + +func (r *PostgresRepository) ListWithdrawals( + ctx context.Context, + nameOrAddress string, + f repository.WithdrawalFilter, + p repository.Pagination, + descending bool, +) ([]*model.Withdrawal, uint64, error) { + whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) + + fromClause := table.Withdrawal.INNER_JOIN( + table.Application, + table.Withdrawal.ApplicationID.EQ(table.Application.ID), + ) + + conditions := []postgres.BoolExpression{whereClause} + if f.AccountIndex != nil { + conditions = append(conditions, table.Withdrawal.AccountIndex.EQ(uint64Expr(*f.AccountIndex))) + } + + tx, err := beginReadTx(ctx, r.db) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + countStmt := table.Withdrawal.SELECT(postgres.COUNT(postgres.STAR)). + FROM(fromClause).WHERE(postgres.AND(conditions...)) + total, err := countFromTx(ctx, tx, countStmt) + if err != nil { + return nil, 0, err + } + if total == 0 { + return nil, 0, nil + } + + sel := table.Withdrawal. + SELECT( + table.Withdrawal.ApplicationID, + table.Withdrawal.AccountIndex, + table.Withdrawal.Account, + table.Withdrawal.Output, + table.Withdrawal.BlockNumber, + table.Withdrawal.TransactionHash, + table.Withdrawal.LogIndex, + table.Withdrawal.CreatedAt, + table.Withdrawal.UpdatedAt, + ). + FROM(fromClause). + WHERE(postgres.AND(conditions...)) + + if descending { + sel = sel.ORDER_BY(table.Withdrawal.AccountIndex.DESC()) + } else { + sel = sel.ORDER_BY(table.Withdrawal.AccountIndex.ASC()) + } + + if p.Limit > 0 { + sel = sel.LIMIT(int64(p.Limit)) + } + if p.Offset > 0 { + sel = sel.OFFSET(int64(p.Offset)) + } + + sqlStr, args := sel.Sql() + rows, err := tx.Query(ctx, sqlStr, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var withdrawals []*model.Withdrawal + for rows.Next() { + var w model.Withdrawal + var txHashBytes []byte + var logIndex int64 + err := rows.Scan( + &w.ApplicationID, + &w.AccountIndex, + &w.Account, + &w.Output, + &w.BlockNumber, + &txHashBytes, + &logIndex, + &w.CreatedAt, + &w.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + copy(w.TransactionHash[:], txHashBytes) + w.LogIndex = uint(logIndex) + withdrawals = append(withdrawals, &w) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + if err := tx.Commit(ctx); err != nil { + return nil, 0, err + } + return withdrawals, total, nil +} diff --git a/internal/repository/replay.go b/internal/repository/replay.go new file mode 100644 index 000000000..bb726cb69 --- /dev/null +++ b/internal/repository/replay.go @@ -0,0 +1,215 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +var ( + // ErrReplayInconsistentEvidence identifies persisted child evidence whose + // parent is not one of the completed inputs selected for the same page. + ErrReplayInconsistentEvidence = errors.New("persisted replay evidence is inconsistent") + + // ErrReplayInvalidStructure identifies malformed persisted replay + // coordinates or relationships. Typed details never contain payload bytes. + ErrReplayInvalidStructure = errors.New("persisted replay structure is invalid") +) + +const unknownReplayValue = "unknown" + +// ReplayVerificationLevel selects the persisted evidence read during replay. +type ReplayVerificationLevel uint8 + +const ( + // ReplayVerificationCanonical reads the completion outcome and canonical + // roots. It is the inexpensive default for normal machine reconstruction. + ReplayVerificationCanonical ReplayVerificationLevel = iota + + // ReplayVerificationFull additionally reads outputs of accepted inputs and + // reports of every completed input. Missing reports fail Full verification + // with a reports.count contradiction and are never backfilled. Canonical + // reconstruction does not compare reports and remains compatible. + // For PRT applications, it also reads the compressed per-input state-hash + // collections used to reconstruct and verify the epoch computation hash. + // Outputs and reports are audit evidence and do not participate in that hash. + // It is intended for explicit audits and the forthcoming + // machine-tool/libcartesi adapter, not normal reconstruction. + // ReplayPage evidence is scoped to the requested half-open input range; + // ReplaySummary's completed-prefix and structural checks are application-wide. + // A maximal PRT collection is large, so callers must provision memory for + // all evidence associated with at least one input. + ReplayVerificationFull +) + +// IsValid reports whether level is a supported verification policy. +func (level ReplayVerificationLevel) IsValid() bool { + return level == ReplayVerificationCanonical || level == ReplayVerificationFull +} + +func (level ReplayVerificationLevel) String() string { + switch level { + case ReplayVerificationCanonical: + return "canonical" + case ReplayVerificationFull: + return "full" + default: + return unknownReplayValue + } +} + +// ReplayPageRequest selects an absolute, application-local, half-open input +// range and the evidence that must accompany it. +type ReplayPageRequest struct { + ApplicationID int64 + FromInput uint64 + ToInputExclusive uint64 + Limit uint64 + Verification ReplayVerificationLevel +} + +// ReplayRepository provides a stable completed-input prefix and keyset- +// paginated replay evidence. Implementations validate persisted invariants for +// the requested verification level. +type ReplayRepository interface { + ReplaySummary( + ctx context.Context, + applicationAddress common.Address, + verification ReplayVerificationLevel, + ) (model.ReplaySummary, error) + ReplayPage(ctx context.Context, request ReplayPageRequest) ([]*model.ReplayRecord, error) +} + +// ReplayStructureViolationKind identifies one payload-free replay invariant. +type ReplayStructureViolationKind uint8 + +const ( + // Zero is reserved so an uninitialized kind cannot identify a real + // structural violation and instead formats as unknown. + ReplayStructureCompletedInputSequence ReplayStructureViolationKind = iota + 1 + ReplayStructureStateHashIndexSequence + ReplayStructureStateHashInputOrder + ReplayStructureProcessedInputCount + ReplayStructureUnexpectedStateHash +) + +func (kind ReplayStructureViolationKind) String() string { + switch kind { + case ReplayStructureCompletedInputSequence: + return "completed_input.index_sequence" + case ReplayStructureStateHashIndexSequence: + return "state_hash.index_sequence" + case ReplayStructureStateHashInputOrder: + return "state_hash.input_order" + case ReplayStructureProcessedInputCount: + return "application.processed_input_count" + case ReplayStructureUnexpectedStateHash: + return "state_hash.unexpected_for_consensus" + default: + return unknownReplayValue + } +} + +// ReplayStructureViolationError contains only persisted counts and coordinates. +type ReplayStructureViolationError struct { + Kind ReplayStructureViolationKind + EpochIndex *uint64 + InputIndex uint64 + EvidenceIndex uint64 + ExpectedIndex uint64 + PreviousInputIndex uint64 + ApplicationProcessedInputs uint64 + CompletedInputCount uint64 +} + +func (e *ReplayStructureViolationError) Error() string { + epoch := "" + if e.EpochIndex != nil { + epoch = fmt.Sprint(*e.EpochIndex) + } + switch e.Kind { + case ReplayStructureCompletedInputSequence: + return fmt.Sprintf( + "%v: kind=%s expected_last_input=%d actual_last_input=%d", + ErrReplayInvalidStructure, e.Kind, e.ExpectedIndex, e.InputIndex, + ) + case ReplayStructureStateHashIndexSequence: + return fmt.Sprintf( + "%v: kind=%s epoch=%s input=%d expected_index=%d actual_index=%d", + ErrReplayInvalidStructure, e.Kind, epoch, e.InputIndex, + e.ExpectedIndex, e.EvidenceIndex, + ) + case ReplayStructureStateHashInputOrder: + return fmt.Sprintf( + "%v: kind=%s epoch=%s state_hash_index=%d previous_input=%d input=%d", + ErrReplayInvalidStructure, e.Kind, epoch, e.EvidenceIndex, + e.PreviousInputIndex, e.InputIndex, + ) + case ReplayStructureProcessedInputCount: + return fmt.Sprintf( + "%v: kind=%s application_processed_inputs=%d completed_input_count=%d", + ErrReplayInvalidStructure, e.Kind, + e.ApplicationProcessedInputs, e.CompletedInputCount, + ) + case ReplayStructureUnexpectedStateHash: + return fmt.Sprintf( + "%v: kind=%s epoch=%s input=%d state_hash_index=%d", + ErrReplayInvalidStructure, e.Kind, epoch, e.InputIndex, e.EvidenceIndex, + ) + default: + return fmt.Sprintf("%v: kind=%s", ErrReplayInvalidStructure, unknownReplayValue) + } +} + +func (e *ReplayStructureViolationError) Unwrap() error { return ErrReplayInvalidStructure } + +// ReplayEvidenceKind identifies persisted child evidence without exposing +// payloads. +type ReplayEvidenceKind uint8 + +const ( + // Zero is reserved so an uninitialized kind cannot identify real evidence + // and instead formats as unknown. + ReplayEvidenceOutput ReplayEvidenceKind = iota + 1 + ReplayEvidenceReport + ReplayEvidenceStateHash +) + +func (kind ReplayEvidenceKind) String() string { + switch kind { + case ReplayEvidenceOutput: + return "output" + case ReplayEvidenceReport: + return "report" + case ReplayEvidenceStateHash: + return "state_hash" + default: + return unknownReplayValue + } +} + +// ReplayInconsistentEvidenceError reports evidence whose input is not among +// the completed inputs selected for the page. +type ReplayInconsistentEvidenceError struct { + Kind ReplayEvidenceKind + InputIndex uint64 +} + +func (e *ReplayInconsistentEvidenceError) Error() string { + return fmt.Sprintf( + "%v: %s row for input %d has no completed replay input in the selected page", + ErrReplayInconsistentEvidence, + e.Kind, + e.InputIndex, + ) +} + +func (e *ReplayInconsistentEvidenceError) Unwrap() error { + return ErrReplayInconsistentEvidence +} diff --git a/internal/repository/replay_test.go b/internal/repository/replay_test.go new file mode 100644 index 000000000..678905023 --- /dev/null +++ b/internal/repository/replay_test.go @@ -0,0 +1,39 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReplayVerificationLevel(t *testing.T) { + require.True(t, ReplayVerificationCanonical.IsValid()) + require.True(t, ReplayVerificationFull.IsValid()) + require.False(t, ReplayVerificationLevel(255).IsValid()) + require.Equal(t, "canonical", ReplayVerificationCanonical.String()) + require.Equal(t, "full", ReplayVerificationFull.String()) + require.Equal(t, "unknown", ReplayVerificationLevel(255).String()) +} + +func TestReplaySourceErrorsPreserveIdentityWithoutPayloads(t *testing.T) { + inconsistency := &ReplayInconsistentEvidenceError{ + Kind: ReplayEvidenceReport, InputIndex: 7, + } + require.True(t, errors.Is(inconsistency, ErrReplayInconsistentEvidence)) + require.NotContains(t, inconsistency.Error(), "payload") + + violation := &ReplayStructureViolationError{ + Kind: ReplayStructureProcessedInputCount, + InputIndex: 7, + ApplicationProcessedInputs: 8, + CompletedInputCount: 9, + } + require.True(t, errors.Is(violation, ErrReplayInvalidStructure)) + require.Contains(t, violation.Error(), "application_processed_inputs=8") + require.Contains(t, violation.Error(), "completed_input_count=9") + require.NotContains(t, violation.Error(), "payload") +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go new file mode 100644 index 000000000..1a0da5676 --- /dev/null +++ b/internal/repository/repository.go @@ -0,0 +1,568 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +var ( + // ErrLockTimeout means a database lock wait exceeded its transaction-local bound. + ErrLockTimeout = errors.New("database lock wait timed out") + ErrNotFound = errors.New("not found") + ErrNoUpdate = errors.New("update did not take effect") + ErrApplicationEnabled = errors.New("application must be disabled before removal") + // ErrEpochForeclosed means a stale epoch publication lost to foreclosure. + // The stored epoch is CLAIM_FORECLOSED; no new claim or proof was stored. + ErrEpochForeclosed = errors.New("epoch publication superseded by foreclosure") + // ErrApplicationNotRunnable means an advance result cannot be stored because + // the application's durable status does not allow machine execution. + ErrApplicationNotRunnable = errors.New("application is not runnable") + // ErrAdvanceCursorMismatch means an advance result does not match the next + // unprocessed input selected by the application's durable cursor. + ErrAdvanceCursorMismatch = errors.New("advance result does not match application cursor") + // ErrAdvanceAfterTerminal preserves the more specific classification for a + // store rejected because the application's durable status is terminal. + ErrAdvanceAfterTerminal = errors.New("cannot store an advance result after a terminal input") + // ErrInvalidStateProof means an advance result or epoch publication did not + // include the complete three-leaf machine state proof. + ErrInvalidStateProof = errors.New("invalid machine state proof") + // ErrTournamentEventConflict means an observation contradicts an already + // stored immutable tournament fact or would replace a newer snapshot. + ErrTournamentEventConflict = errors.New("tournament observation conflicts with stored data") + + // ErrInputLogIdentityConflict indicates an input insert conflicted with a + // stored row on the L1 log identity (transaction_hash, log_index) under a + // different input index. On a canonical chain this cannot happen; it means + // the stored inputs diverged from rescanned chain data (e.g. a reorg past + // the input cursor). Retrying cannot succeed — callers should treat the + // application state as corrupted. + ErrInputLogIdentityConflict = errors.New("input L1 log identity conflicts with stored data") +) + +type Pagination struct { + Limit uint64 + Offset uint64 +} + +type ApplicationFilter struct { + AfterID *int64 + ThroughID *int64 + Enabled *bool + Status *ApplicationStatus + Statuses []ApplicationStatus + ConsensusType *Consensus + ConsensusTypes []Consensus + // ForeclosureRecorded filters by the foreclose_block column: when non-nil + // and true, returns only apps whose foreclosure has been observed and + // recorded by the evmreader; when non-nil and false, returns only apps + // without a recorded foreclosure. + ForeclosureRecorded *bool +} + +// ExecutableApplicationsFilter selects apps that may run normal machine work. +// The manager uses this exact database predicate before creating machines. +func ExecutableApplicationsFilter() ApplicationFilter { + return ApplicationFilter{ + Enabled: new(true), + Status: new(ApplicationStatus_OK), + ForeclosureRecorded: new(false), + } +} + +type EpochFilter struct { + Status []EpochStatus + BeforeBlock *uint64 + IndexRange *Range + HasTournament *bool +} + +type InputFilter struct { + EpochIndex *uint64 + Status *InputCompletionStatus + NotStatus *InputCompletionStatus + Sender *common.Address + TransactionHash *common.Hash + IndexRange *Range +} + +// Range defines a closed interval: both Start and End are inclusive. +type Range struct { + Start uint64 + End uint64 +} + +type OutputFilter struct { + EpochIndex *uint64 + InputIndex *uint64 + BlockRange *Range + IndexRange *Range + OutputType *[][]byte + Executed *bool + VoucherAddress *common.Address +} + +type ReportFilter struct { + EpochIndex *uint64 + InputIndex *uint64 + IndexRange *Range +} + +type TournamentFilter struct { + EpochIndex *uint64 + Level *uint64 + ParentTournamentAddress *common.Address + ParentMatchIDHash *common.Hash +} + +// RootBondRecoveryCandidate identifies an owned root bond in a published snapshot. +// Its current disposition must be checked on chain before sending a recovery. +type RootBondRecoveryCandidate struct { + EpochIndex uint64 + Tournament common.Address +} + +type CommitmentFilter struct { + EpochIndex *uint64 + TournamentAddress *string +} + +type MatchFilter struct { + EpochIndex *uint64 + TournamentAddress *string +} + +type BondEventFilter struct { + EpochIndex *uint64 + TournamentAddress *common.Address +} + +type WithdrawalFilter struct { + AccountIndex *uint64 +} + +type ApplicationRepository interface { + CreateApplication(ctx context.Context, app *Application, withExecutionParameters bool) (int64, error) + GetApplication(ctx context.Context, nameOrAddress string) (*Application, error) + GetProcessedInputCount(ctx context.Context, nameOrAddress string) (uint64, error) + UpdateApplication(ctx context.Context, app *Application) error + UpdateApplicationEnabled(ctx context.Context, appID int64, enabled bool) error + // EnableApplicationAndResetDeployment atomically enables and resets deployment admission. + // clearFailed requires observed FAILED status and operator recovery consent. + EnableApplicationAndResetDeployment(ctx context.Context, appID int64, clearFailed bool) error + UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error + // UpdateApplicationForeclosure records the one-shot Foreclosure() event + // and advances the foreclosure scan cursor in + // one transaction. Used when the evmreader found the event in the scanned + // window; after this marker is recorded the scanner stops checking the app. + UpdateApplicationForeclosure( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + blockNumber uint64, + ) error + // UpdateApplicationLastForecloseCheckBlock advances the highest block + // the Foreclosure-event log search has scanned. The write is strictly + // monotonic: a lower or equal blockNumber is a no-op, so out-of-order + // ticks cannot rewind the value and re-cause a long [deployment, head] + // rescan. + UpdateApplicationLastForecloseCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error + // UpdateAccountsDriveProved records the one-shot + // AccountsDriveMerkleRootProved event and advances the scan cursor + // in one transaction. Used when the evmreader found the event in the + // scanned window; after this marker is recorded the scanner stops checking + // the app and moves on to withdrawals. + UpdateAccountsDriveProved( + ctx context.Context, + appID int64, + block uint64, + txHash common.Hash, + root common.Hash, + blockNumber uint64, + ) error + // UpdateApplicationLastAccountsDriveProvedCheckBlock advances the highest + // block the accounts-drive-proved scan has examined. + // Strictly monotonic — out-of-order or duplicate ticks are silent no-ops. + UpdateApplicationLastAccountsDriveProvedCheckBlock(ctx context.Context, appID int64, blockNumber uint64) error + DeleteApplication(ctx context.Context, id int64) error + ListApplications(ctx context.Context, f ApplicationFilter, p Pagination, descending bool) ([]*Application, uint64, error) + + GetExecutionParameters(ctx context.Context, applicationID int64) (*ExecutionParameters, error) + UpdateExecutionParameters(ctx context.Context, ep *ExecutionParameters) error + + GetEventLastCheckBlock(ctx context.Context, appID int64, event MonitoredEvent) (uint64, error) + // UpdateEventLastCheckBlock advances the event cursor monotonically. Lower + // or equal block numbers are no-ops. + UpdateEventLastCheckBlock(ctx context.Context, appIDs []int64, event MonitoredEvent, blockNumber uint64) error + + GetLastSnapshot(ctx context.Context, nameOrAddress string) (*Input, error) +} + +type EpochRepository interface { + // FIXME move to BulkOperationsRepository + CreateEpochsAndInputs(ctx context.Context, nameOrAddress string, epochInputMap map[*Epoch][]*Input, blockNumber uint64) error + + GetEpoch(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) + GetEpochCompletionState(ctx context.Context, nameOrAddress string, index uint64) (*EpochCompletionState, error) + StoreInvalidOutputsRoot(ctx context.Context, appID int64, epochIndex uint64, reason string) error + GetLastAcceptedEpochIndex(ctx context.Context, nameOrAddress string) (uint64, error) + GetLastNonOpenEpoch(ctx context.Context, nameOrAddress string) (*Epoch, error) + GetLastNonOpenEpochIndex(ctx context.Context, nameOrAddress string) (*uint64, error) + GetEpochByVirtualIndex(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) + + UpdateEpochClaimTransactionHash(ctx context.Context, nameOrAddress string, e *Epoch) error + UpdateEpochStatus(ctx context.Context, nameOrAddress string, e *Epoch) error + UpdateEpochInputsProcessed(ctx context.Context, nameOrAddress string, epochIndex uint64, proof *StateProof) error + + ListEpochs(ctx context.Context, nameOrAddress string, f EpochFilter, p Pagination, descending bool) ([]*Epoch, uint64, error) + + // HasUndrainedEpochsBeforeBlock reports whether any input for the given + // application has block_number <= blockBound and is still unprocessed + // in a non-terminal epoch and is still executable. ET/IOR excludes NONE + // inputs at or beyond processed_inputs, independently of the epoch cutoff. + // The bound is inclusive because a valid InputAdded event in the same + // block as Foreclosure must have executed before the foreclosure + // transaction; post-foreclosure addInput calls revert and emit no event. + // PRT uses this gate to keep the post-foreclosure drain pending until + // the advancer has processed every executable pre-foreclosure input. It does NOT + // wait for CLAIM_ACCEPTED because PRT tournaments cannot settle on a + // foreclosed IApplication, so waiting would stall forever. + HasUndrainedEpochsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + + // HasUnpublishedEpochBeforeBlock keeps the machine available while a healthy, + // enabled, foreclosed Authority/Quorum application still has a CLOSED epoch + // before both blockBound and foreclosure, within its publication cutoff. + // CLOSED -> INPUTS_PROCESSED needs the resident machine's accepted-state proof + // even when no NONE inputs remain. INPUTS_PROCESSED validation uses stored + // data and does not keep the machine. The caller separately gates on the + // input scanner reaching foreclosure and on HasUndrainedEpochsBeforeBlock. + HasUnpublishedEpochBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + + // HasUnreconciledClaimsBeforeBlock is the broader gate used by the + // Authority/Quorum claimer: it returns true while any epoch for the + // given application is still in OPEN/CLOSED/INPUTS_PROCESSED OR in + // CLAIM_COMPUTED/CLAIM_SUBMITTED/CLAIM_STAGED with FirstBlock <= + // blockBound. The extra states cover the new-node-bootstrap path + // (a fresh DB entry for an already-foreclosed contract): each + // pre-foreclosure on-chain-accepted claim must be mirrored to + // CLAIM_ACCEPTED locally, or marked CLAIM_FORECLOSED when the contract + // state proves acceptance is impossible, before the app is considered + // drained. Otherwise downstream tooling sees a final state that diverges + // from chain reality. + HasUnreconciledClaimsBeforeBlock(ctx context.Context, appID int64, blockBound uint64) (bool, error) + + // ForecloseUnacceptedEpochsAtOrAfterBlock marks Authority/Quorum epochs + // that overlap the foreclosure block as CLAIM_FORECLOSED. Under ET/IOR it + // also closes the suffix at or beyond the typed epoch cutoff: that suffix + // has no valid local claim path. Earlier epochs retain validation and + // reconciliation; CLAIM_ACCEPTED is preserved. FAILED/DC and disabled apps + // cannot authorize this terminalization. + ForecloseUnacceptedEpochsAtOrAfterBlock(ctx context.Context, appID int64, blockBound uint64) (int64, error) +} + +type InputRepository interface { + GetInput(ctx context.Context, nameOrAddress string, inputIndex uint64) (*Input, error) + GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) + GetLastProcessedInput(ctx context.Context, appAddress string) (*Input, error) + ListInputs(ctx context.Context, nameOrAddress string, f InputFilter, p Pagination, descending bool) ([]*Input, uint64, error) + GetNumberOfInputs(ctx context.Context, nameOrAddress string) (uint64, error) + UpdateInputSnapshotURI(ctx context.Context, appId int64, inputIndex uint64, snapshotURI string) error +} + +const MaxOutputRangeRows uint64 = 65_536 + +// EpochOutputRange describes one epoch's accepted outputs in one database snapshot. +type EpochOutputRange struct { + Count uint64 + First uint64 + Last uint64 +} + +// OutputScanFunc handles one output. rawData is valid only during the call; +// fn must not keep it after returning. +type OutputScanFunc func(index uint64, rawData []byte) error + +type OutputRepository interface { + GetEpochOutputRange(ctx context.Context, appID int64, epochIndex uint64) (EpochOutputRange, error) + // ScanOutputs streams both inclusive bounds in ascending index order. + // The range must contain between 1 and MaxOutputRangeRows indexes. + ScanOutputs(ctx context.Context, appID int64, first, last uint64, fn OutputScanFunc) error + GetOutput(ctx context.Context, nameOrAddress string, outputIndex uint64) (*Output, error) + UpdateOutputsExecution(ctx context.Context, nameOrAddress string, executedOutputs []*Output, blockNumber uint64) error + ListOutputs(ctx context.Context, nameOrAddress string, f OutputFilter, p Pagination, descending bool) ([]*Output, uint64, error) + GetNumberOfExecutedOutputs(ctx context.Context, nameOrAddress string) (uint64, error) + GetNumberOfPendingExecutableOutputs(ctx context.Context, nameOrAddress string) (uint64, error) +} + +type ReportRepository interface { + GetReport(ctx context.Context, nameOrAddress string, reportIndex uint64) (*Report, error) + ListReports(ctx context.Context, nameOrAddress string, f ReportFilter, p Pagination, descending bool) ([]*Report, uint64, error) +} + +type WithdrawalRepository interface { + // InsertWithdrawal records a Withdrawal(uint64 accountIndex, bytes account, + // bytes output) event observed on chain. Idempotent on the (application_id, + // account_index) primary key via ON CONFLICT DO NOTHING: re-processing the + // same block on restart cannot fail and cannot diverge from the first write. + // The contract marks each account index as withdrawn (see + // IApplication.wereAccountFundsWithdrawn), so the event fires at most once + // per slot per app — second observations are always restart artifacts. + InsertWithdrawal(ctx context.Context, w *Withdrawal) error + + // StoreWithdrawalEvents records Withdrawal events from a completed scanner + // window and advances the + // application's last_withdrawal_check_block in the same database + // transaction. This keeps the local withdrawal count and scanner cursor in + // sync: either both reflect the scanned window, or neither does. + StoreWithdrawalEvents( + ctx context.Context, + appID int64, + withdrawals []*Withdrawal, + blockNumber uint64, + ) error + + // GetNumberOfWithdrawals returns the number of Withdrawal rows stored for + // an application. The post-foreclosure scanner uses it as the local + // previous counter when resuming after last_withdrawal_check_block. + GetNumberOfWithdrawals(ctx context.Context, appID int64) (uint64, error) + + // GetWithdrawal returns a single withdrawal by (application, accountIndex). + // Returns (nil, nil) when the row does not exist — mirrors GetOutput and + // the project convention for Get* lookups; the JSON-RPC layer turns the + // nil into a resource-not-found error code. Application is identified by + // name or address. + GetWithdrawal(ctx context.Context, nameOrAddress string, accountIndex uint64) (*Withdrawal, error) + + // ListWithdrawals returns a paginated list for an application, optionally + // filtered by account_index. nameOrAddress + pagination/ordering shape + // mirror ListOutputs: default ascending by account_index, descending=true + // reverses it. + ListWithdrawals(ctx context.Context, nameOrAddress string, f WithdrawalFilter, p Pagination, descending bool) ([]*Withdrawal, uint64, error) +} + +const MaxStateHashPageSize uint64 = 100_000 + +type StateHashRepository interface { + // CountEpochStateHashes returns the number of stored rows in one epoch. + CountEpochStateHashes(ctx context.Context, appID int64, epochIndex uint64) (uint64, error) + // ListEpochStateHashes returns rows in ascending index order, exclusive of + // afterIndex. A nil cursor starts at the first row. The limit controls the + // caller's memory and must be between 1 and MaxStateHashPageSize. + // Processed epochs have immutable rows and can be read in separate pages. + ListEpochStateHashes(ctx context.Context, appID int64, epochIndex uint64, afterIndex *uint64, limit uint64) ([]EpochStateHash, error) +} + +type TournamentRepository interface { + // CreateTournament preserves immutable facts for an existing + // (application_id, epoch_index, address) and can refresh its current snapshot. + // Conflicting facts and older snapshots return ErrTournamentEventConflict. + CreateTournament(ctx context.Context, nameOrAddress string, t *Tournament) error + GetTournament(ctx context.Context, nameOrAddress string, address string) (*Tournament, error) + ListTournaments(ctx context.Context, nameOrAddress string, f TournamentFilter, + p Pagination, descending bool) ([]*Tournament, uint64, error) + // ListRecoverableRootBonds lists owned RECOVERABLE roots in ascending epoch order, + // regardless of local claim status or join history. observedBlock must not exceed + // the configured observation head. Each row certifies its own observation. + // afterEpoch is exclusive (nil includes epoch zero); throughEpoch is inclusive. + // limit must be positive and fit in a PostgreSQL signed integer. No count is read. + ListRecoverableRootBonds(ctx context.Context, appID int64, claimer common.Address, observedBlock uint64, + afterEpoch *uint64, throughEpoch uint64, limit uint64) ([]RootBondRecoveryCandidate, error) +} + +type CommitmentRepository interface { + CreateCommitment(ctx context.Context, nameOrAddress string, c *Commitment) error + GetCommitment(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, commitmentHex string) (*Commitment, error) + ListCommitments(ctx context.Context, nameOrAddress string, f CommitmentFilter, p Pagination, descending bool) ([]*Commitment, uint64, error) +} + +type MatchRepository interface { + CreateMatch(ctx context.Context, nameOrAddress string, m *Match) error + GetMatch(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, idHashHex string) (*Match, error) + ListMatches(ctx context.Context, nameOrAddress string, f MatchFilter, p Pagination, descending bool) ([]*Match, uint64, error) +} + +type MatchAdvancedRepository interface { + CreateMatchAdvanced(ctx context.Context, nameOrAddress string, m *MatchAdvanced) error + GetMatchAdvanced(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, txHash common.Hash, logIndex uint64) (*MatchAdvanced, error) + ListMatchAdvances(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + idHashHex string, p Pagination, descending bool) ([]*MatchAdvanced, uint64, error) +} + +type StandingChangedRepository interface { + GetStandingChange(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + txHash common.Hash, logIndex uint64) (*StandingChanged, error) + ListStandingChanges(ctx context.Context, nameOrAddress string, epochIndex uint64, tournamentAddress string, + p Pagination, descending bool) ([]*StandingChanged, uint64, error) +} + +type BondEventRepository interface { + GetBondEvent(ctx context.Context, nameOrAddress string, txHash common.Hash, logIndex uint64) (*BondEvent, error) + ListBondEvents(ctx context.Context, nameOrAddress string, f BondEventFilter, p Pagination, descending bool) ([]*BondEvent, uint64, error) +} + +// TournamentEventBatch contains one tournament projection and the events from +// a completed scan window. Batches are ordered with parents before children: +// a child tournament references a match written in its parent's batch. +type TournamentEventBatch struct { + Tournament *Tournament + Commitments []*Commitment + Matches []*Match + MatchAdvances []*MatchAdvanced + StandingChanges []*StandingChanged + BondEvents []*BondEvent +} + +type BulkOperationsRepository interface { + StoreAdvanceResult(ctx context.Context, appID int64, result *AdvanceResult) error + StoreClaimAndProofs(ctx context.Context, epoch *Epoch, write ClaimProofWriteFunc) error +} + +type NodeConfigReader interface { + LoadNodeConfigRaw(ctx context.Context, key string) (rawJSON []byte, createdAt, updatedAt time.Time, err error) +} + +// ClaimRoots is the result of one coherent local prefix read. Ready requires +// input coverage and validation of every stored predecessor, including CF rows. +type ClaimRoots struct { + Ready bool + MachineHash common.Hash + OutputsRoot common.Hash + ReasonClass string // Stable warning class; numeric details belong in Reason. + Reason string +} + +// TODO: migrate ClaimRow -> Application + Epoch and use the other interfaces +type ClaimerRepository interface { + ReadClaimRoots(ctx context.Context, appID int64, covered uint64) (ClaimRoots, error) + SelectClaimsToSubmitPage(ctx context.Context, cursor ClaimCursor, limit uint64) (ClaimPage, error) + SelectClaimsToStagePage(ctx context.Context, cursor ClaimCursor, limit uint64) (ClaimPage, error) + SelectClaimsToAcceptPage(ctx context.Context, cursor ClaimCursor, limit uint64) (ClaimPage, error) + + UpdateEpochWithSubmittedClaim( + ctx context.Context, + applicationID int64, + index uint64, + transactionHash common.Hash, + ) error + // RefreshClaimObservation replaces only audit metadata for the same still-open + // claim. The caller verifies matching events at the selected observation view. + // A nil hash records the absence of canonical own-vote evidence; it does not + // prevent recording the staging clock of a valid majority claim. + RefreshClaimObservation(ctx context.Context, expected *Epoch, transactionHash *common.Hash, stagedAtBlock *uint64) error + UpdateEpochToStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + // UpdateEpochStagingBlock refreshes only a still-staged PRT epoch whose saved + // staging block matches the caller's previous observation. The caller must + // verify that the claim content is unchanged at the selected chain view. + UpdateEpochStagingBlock(ctx context.Context, applicationID int64, index, expectedOldBlock, newBlock uint64) error + UpdateEpochReconciledStaged( + ctx context.Context, + applicationID int64, + index uint64, + stagedAtBlock uint64, + ) error + // UpdateEpochWithAcceptedClaim transitions an epoch to CLAIM_ACCEPTED. + // txHash is optional: pass non-nil to record claim_transaction_hash + // (catch-up reconciliations where the epoch never went through the + // CLAIM_SUBMITTED transition); pass nil to leave the column untouched + // (the normal-flow case where the column was populated during + // CLAIM_SUBMITTED). + UpdateEpochWithAcceptedClaim( + ctx context.Context, + applicationID int64, + index uint64, + txHash *common.Hash, + ) error + // UpdateEpochWithForeclosedClaim transitions a single epoch's non-terminal + // claim status to CLAIM_FORECLOSED after application foreclosure makes the + // claim path impossible. Keyed by (application, epoch index); applies to any + // consensus type. + UpdateEpochWithForeclosedClaim( + ctx context.Context, + applicationID int64, + index uint64, + ) error + // RejectEpochAndSetApplicationDiverged atomically attempts to mark an epoch + // as CLAIM_REJECTED and the application as DIVERGED. The returned result + // reports which conditional writes applied, so callers can mirror only the + // durable transitions. An existing DIVERGED/CORRUPTED diagnosis remains + // unchanged; execution/root stops may escalate while retaining their cutoff. + // An epoch outside CLAIM_COMPUTED/CLAIM_SUBMITTED is + // likewise left unchanged. + RejectEpochAndSetApplicationDiverged( + ctx context.Context, + applicationID int64, + index uint64, + reason string, + ) (RejectEpochAndDivergeResult, error) +} + +// RejectEpochAndDivergeResult describes the two conditional writes performed +// by RejectEpochAndSetApplicationDiverged when the returned error is nil. +type RejectEpochAndDivergeResult struct { + EpochRejected bool + ApplicationDiverged bool +} + +type Repository interface { + DeploymentRepository + DeliveryRepository + ReplayRepository + ApplicationRepository + EpochRepository + InputRepository + OutputRepository + ReportRepository + StateHashRepository + TournamentRepository + CommitmentRepository + MatchRepository + MatchAdvancedRepository + StandingChangedRepository + BondEventRepository + BulkOperationsRepository + NodeConfigReader + ClaimerRepository + WithdrawalRepository + Close() +} + +// LoadNodeConfig returns a non-nil record when err is nil. A missing JSON payload +// is an error, even if the repository reports a successful read. +func LoadNodeConfig[T any]( + ctx context.Context, + repo NodeConfigReader, + key string, +) (*NodeConfig[T], error) { + raw, createdAt, updatedAt, err := repo.LoadNodeConfigRaw(ctx, key) + if err != nil { + return nil, err + } + if raw == nil { + return nil, fmt.Errorf("node config %q has no JSON value", key) + } + var val T + if err := json.Unmarshal(raw, &val); err != nil { + return nil, fmt.Errorf("unmarshal node_config value failed: %w", err) + } + return &NodeConfig[T]{ + Key: key, + Value: val, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} diff --git a/internal/repository/repotest/advance_evidence_test_cases.go b/internal/repository/repotest/advance_evidence_test_cases.go new file mode 100644 index 000000000..2b2bd5bf1 --- /dev/null +++ b/internal/repository/repotest/advance_evidence_test_cases.go @@ -0,0 +1,137 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "bytes" + "fmt" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *BulkOperationsSuite) TestStoreAdvanceResultLargeEvidenceSet() { + // Cover the extended protocol's parameter boundary and the machine's row limit. + for _, rowCount := range []uint64{16384, 65536} { + s.Run(fmt.Sprintf("Accepted%d", rowCount), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + outputPayloads := make([][]byte, rowCount) + reportPayloads := make([][]byte, rowCount) + for i := range outputPayloads { + outputPayloads[i] = []byte(fmt.Sprintf("output-%d", i)) + reportPayloads[i] = []byte(fmt.Sprintf("report-%d", i)) + } + s.storeAdvanceResult(seed.App.ID, 0, 0, outputPayloads, reportPayloads) + + outputs, total, err := s.Repo.ListOutputs(s.Ctx, seed.App.Name, + repository.OutputFilter{}, repository.Pagination{Limit: rowCount}, false) + s.Require().NoError(err) + s.Equal(rowCount, total) + s.Require().Equal(rowCount, uint64(len(outputs))) + for i, output := range outputs { + s.Equal(uint64(i), output.Index) + s.Zero(output.InputIndex) + s.Equal(outputPayloads[i], output.RawData) + } + + reports, total, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: rowCount}, false) + s.Require().NoError(err) + s.Equal(rowCount, total) + s.Require().Equal(rowCount, uint64(len(reports))) + for i, report := range reports { + s.Equal(uint64(i), report.Index) + s.Zero(report.InputIndex) + s.Equal(reportPayloads[i], report.RawData) + } + }) + } +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultEvidencePayloads() { + s.Run("LargeAndEmpty", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + const payloadSize = 2 * 1024 * 1024 + payloads := [][]byte{bytes.Repeat([]byte("x"), payloadSize), {}, []byte("tail")} + s.storeAdvanceResult(seed.App.ID, 0, 0, payloads, payloads) + + for i, payload := range payloads { + output, err := s.Repo.GetOutput(s.Ctx, seed.App.Name, uint64(i)) + s.Require().NoError(err) + s.Require().NotNil(output) + s.Equal(payload, output.RawData) + s.Nil(output.Hash) + s.Nil(output.OutputHashesSiblings) + s.Nil(output.ExecutionTransactionHash) + s.False(output.CreatedAt.IsZero()) + s.True(output.CreatedAt.Equal(output.UpdatedAt)) + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, uint64(i)) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(payload, report.RawData) + s.False(report.CreatedAt.IsZero()) + s.True(report.CreatedAt.Equal(report.UpdatedAt)) + } + }) +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultRollsBackEvidence() { + for _, kind := range []string{"output", "report"} { + s.Run(kind, func() { s.checkEvidenceRollback(kind) }) + } +} + +func (s *BulkOperationsSuite) checkEvidenceRollback(kind string) { + seed := Seed(s.Ctx, s.T(), s.Repo) + const rowCount = 16384 + payloads := make([][]byte, rowCount) + for i := range payloads { + payloads[i] = []byte("valid payload") + } + // A late NULL must undo every earlier row in the result transaction. + payloads[len(payloads)-1] = nil + beforeEpoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + beforeApp, err := s.Repo.GetApplication(s.Ctx, seed.App.Name) + s.Require().NoError(err) + result := &model.AdvanceResult{ + Status: model.InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("must roll back")}, + Reports: payloads, + StateProof: *DummyStateProof(), + } + if kind == "output" { + result.Outputs, result.Reports = result.Reports, result.Outputs + } + err = s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + var constraint *pgconn.PgError + s.Require().ErrorAs(err, &constraint) + s.Equal("23502", constraint.Code) + s.Equal("raw_data", constraint.ColumnName) + s.Contains(err.Error(), "failed to copy "+kind+" rows") + + reports, reportCountAfter, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: 1}, false) + s.Require().NoError(err) + s.Empty(reports) + s.Zero(reportCountAfter) + outputs, outputCount, err := s.Repo.ListOutputs(s.Ctx, seed.App.Name, + repository.OutputFilter{}, repository.Pagination{Limit: 1}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Zero(outputCount) + input, err := s.Repo.GetInput(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Equal(model.InputCompletionStatus_None, input.Status) + s.Nil(input.MachineHash) + s.Nil(input.TxBufferDataBlock) + afterEpoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Equal(beforeEpoch, afterEpoch) + afterApp, err := s.Repo.GetApplication(s.Ctx, seed.App.Name) + s.Require().NoError(err) + s.Equal(beforeApp, afterApp) +} diff --git a/internal/repository/repotest/application_deadline_test_cases.go b/internal/repository/repotest/application_deadline_test_cases.go new file mode 100644 index 000000000..4f19bdb68 --- /dev/null +++ b/internal/repository/repotest/application_deadline_test_cases.go @@ -0,0 +1,65 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "time" + + "github.com/cartesi/rollups-node/internal/model" +) + +func (s *ApplicationSuite) TestCreateApplicationAdvanceDeadlines() { + const ( + legacyIncrement = 10 * time.Second + legacyMaximum = 180 * time.Second + prtIncrement = 60 * time.Second + prtMaximum = 3600 * time.Second + explicitIncrement = 7 * time.Second + explicitMaximum = 43 * time.Second + ) + + cases := []struct { + name string + consensus model.Consensus + explicit bool + increment time.Duration + maximum time.Duration + }{ + {"authority_default", model.Consensus_Authority, false, legacyIncrement, legacyMaximum}, + {"quorum_default", model.Consensus_Quorum, false, legacyIncrement, legacyMaximum}, + {"prt_default", model.Consensus_PRT, false, prtIncrement, prtMaximum}, + {"authority_explicit", model.Consensus_Authority, true, explicitIncrement, explicitMaximum}, + {"quorum_explicit", model.Consensus_Quorum, true, explicitIncrement, explicitMaximum}, + {"prt_explicit", model.Consensus_PRT, true, explicitIncrement, explicitMaximum}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + builder := NewApplicationBuilder().WithConsensus(tc.consensus) + if tc.explicit { + builder.WithExecutionParameters(model.ExecutionParameters{ + SnapshotPolicy: model.SnapshotPolicy_EveryEpoch, + AdvanceIncCycles: 1, + AdvanceMaxCycles: 1, + InspectIncCycles: 1, + InspectMaxCycles: 1, + AdvanceIncDeadline: explicitIncrement, + AdvanceMaxDeadline: explicitMaximum, + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second, + LoadDeadline: time.Second, + StoreDeadline: time.Second, + FastDeadline: time.Second, + MaxConcurrentInspects: 1, + }) + } + app := builder.Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetExecutionParameters(s.Ctx, app.ID) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(tc.increment, got.AdvanceIncDeadline) + s.Equal(tc.maximum, got.AdvanceMaxDeadline) + }) + } +} diff --git a/internal/repository/repotest/application_test_cases.go b/internal/repository/repotest/application_test_cases.go new file mode 100644 index 000000000..b9a47a8ce --- /dev/null +++ b/internal/repository/repotest/application_test_cases.go @@ -0,0 +1,1403 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/crypto" +) + +type ApplicationSuite struct { + BaseSuite +} + +func NewApplicationSuite(factory RepositoryFactory) *ApplicationSuite { + return &ApplicationSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *ApplicationSuite) TestCreateApplication() { + s.Run("ReturnsGeneratedID", func() { + app := NewApplicationBuilder().Build() + id, err := s.Repo.CreateApplication(s.Ctx, app, false) + s.Require().NoError(err) + s.Greater(id, int64(0)) + }) + + s.Run("WithExecutionParameters", func() { + ep := ExecutionParameters{ + SnapshotPolicy: SnapshotPolicy_EveryEpoch, + AdvanceIncCycles: 1000, + AdvanceMaxCycles: 1500, //nolint:mnd + InspectIncCycles: 2000, + InspectMaxCycles: 2500, //nolint:mnd + AdvanceIncDeadline: 10 * time.Second, + AdvanceMaxDeadline: 60 * time.Second, + InspectIncDeadline: 10 * time.Second, + InspectMaxDeadline: 60 * time.Second, + LoadDeadline: 30 * time.Second, + StoreDeadline: 30 * time.Second, + FastDeadline: 5 * time.Second, + MaxConcurrentInspects: 10, + } + app := NewApplicationBuilder(). + WithExecutionParameters(ep). + Create(s.Ctx, s.T(), s.Repo) + s.Greater(app.ID, int64(0)) + + got, err := s.Repo.GetExecutionParameters(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(ep.SnapshotPolicy, got.SnapshotPolicy) + s.Equal(ep.AdvanceIncCycles, got.AdvanceIncCycles) + s.Equal(ep.AdvanceMaxCycles, got.AdvanceMaxCycles) + s.Equal(ep.InspectIncCycles, got.InspectIncCycles) + s.Equal(ep.InspectMaxCycles, got.InspectMaxCycles) + s.Equal(ep.MaxConcurrentInspects, got.MaxConcurrentInspects) + }) +} + +func (s *ApplicationSuite) TestGetApplication() { + s.Run("ByName", func() { + app := NewApplicationBuilder(). + WithName("my-unique-app"). + Create(s.Ctx, s.T(), s.Repo) + + got, err := s.Repo.GetApplication(s.Ctx, "my-unique-app") + s.Require().NoError(err) + s.Equal(app.ID, got.ID) + s.Equal("my-unique-app", got.Name) + s.Equal(app.IApplicationAddress, got.IApplicationAddress) + s.Equal(app.IConsensusAddress, got.IConsensusAddress) + s.Equal(app.IInputBoxAddress, got.IInputBoxAddress) + s.Equal(app.TemplateHash, got.TemplateHash) + s.Equal(app.EpochLength, got.EpochLength) + s.Equal(app.ClaimStagingPeriod, got.ClaimStagingPeriod) + s.Equal(app.WithdrawalConfig, got.WithdrawalConfig) + s.Equal(app.ConsensusType, got.ConsensusType) + s.Equal(app.Enabled, got.Enabled) + s.Equal(app.Status, got.Status) + s.False(got.CreatedAt.IsZero(), "CreatedAt should be set") + s.False(got.UpdatedAt.IsZero(), "UpdatedAt should be set") + }) + + s.Run("ByAddress", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + got, err := s.Repo.GetApplication(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(app.ID, got.ID) + s.Equal(app.Name, got.Name) + }) + + s.Run("NotFound", func() { + got, err := s.Repo.GetApplication(s.Ctx, "nonexistent") + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *ApplicationSuite) TestListApplications() { + s.Run("EmptyResult", func() { + apps, total, err := s.Repo.ListApplications( + s.Ctx, repository.ApplicationFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(apps) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAllApps", func() { + for range 3 { + NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + } + apps, total, err := s.Repo.ListApplications( + s.Ctx, repository.ApplicationFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(apps, 3) + s.Equal(uint64(3), total) + }) + + s.Run("FilterByStatus", func() { + NewApplicationBuilder().WithStatus(ApplicationStatus_OK).Create(s.Ctx, s.T(), s.Repo) + failed := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + reason := "machine crashed" + s.Require().NoError(s.Repo.UpdateApplicationStatus(s.Ctx, failed.ID, ApplicationStatus_Failed, &reason)) + + status := ApplicationStatus_OK + apps, total, err := s.Repo.ListApplications( + s.Ctx, + repository.ApplicationFilter{Status: &status}, + repository.Pagination{Limit: 10}, + false, + ) + s.Require().NoError(err) + s.Len(apps, 1) + s.Equal(uint64(1), total) + s.Equal(ApplicationStatus_OK, apps[0].Status) + }) + + s.Run("ExecutableFilterExcludesTerminalOutcomes", func() { + const pageLimit = 10 + healthy := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, status := range []ApplicationStatus{ + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, + } { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + reason := "terminal machine outcome" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, status, &reason)) + } + + apps, total, err := s.Repo.ListApplications( + s.Ctx, + repository.ExecutableApplicationsFilter(), + repository.Pagination{Limit: pageLimit}, + false, + ) + s.Require().NoError(err) + s.Equal(uint64(1), total) + s.Require().Len(apps, 1) + s.Equal(healthy.ID, apps[0].ID) + }) + + s.Run("FilterByConsensus", func() { + NewApplicationBuilder().WithConsensus(Consensus_Authority).Create(s.Ctx, s.T(), s.Repo) + NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + + consensus := Consensus_PRT + apps, total, err := s.Repo.ListApplications( + s.Ctx, + repository.ApplicationFilter{ConsensusType: &consensus}, + repository.Pagination{Limit: 10}, + false, + ) + s.Require().NoError(err) + s.Len(apps, 1) + s.Equal(uint64(1), total) + s.Equal(Consensus_PRT, apps[0].ConsensusType) + }) + + s.Run("Pagination", func() { + for range 5 { + NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + } + + apps, total, err := s.Repo.ListApplications( + s.Ctx, + repository.ApplicationFilter{}, + repository.Pagination{Limit: 2, Offset: 0}, + false, + ) + s.Require().NoError(err) + s.Len(apps, 2) + s.Equal(uint64(5), total) + + apps2, total2, err := s.Repo.ListApplications( + s.Ctx, + repository.ApplicationFilter{}, + repository.Pagination{Limit: 2, Offset: 2}, + false, + ) + s.Require().NoError(err) + s.Len(apps2, 2) + s.Equal(uint64(5), total2) + // Pages should be different + s.NotEqual(apps[0].ID, apps2[0].ID) + }) + + s.Run("Descending", func() { + a1 := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + a2 := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + apps, _, err := s.Repo.ListApplications( + s.Ctx, repository.ApplicationFilter{}, repository.Pagination{Limit: 10}, true) + s.Require().NoError(err) + s.Require().Len(apps, 2) + // Descending: second created should be first + s.Equal(a2.ID, apps[0].ID) + s.Equal(a1.ID, apps[1].ID) + s.Equal(a2.IInputBoxAddress, apps[0].IInputBoxAddress) + s.Equal(a1.IInputBoxAddress, apps[1].IInputBoxAddress) + }) + + s.Run("CombinedFilters", func() { + // Create apps with different combinations of enabled flag, status, and consensus. + NewApplicationBuilder(). + WithStatus(ApplicationStatus_OK). + WithConsensus(Consensus_Authority). + Create(s.Ctx, s.T(), s.Repo) + NewApplicationBuilder(). + WithStatus(ApplicationStatus_OK). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + NewApplicationBuilder(). + WithEnabled(false). + WithConsensus(Consensus_Authority). + Create(s.Ctx, s.T(), s.Repo) + + enabled := true + status := ApplicationStatus_OK + consensus := Consensus_Authority + apps, total, err := s.Repo.ListApplications( + s.Ctx, + repository.ApplicationFilter{ + Enabled: &enabled, + Status: &status, + ConsensusType: &consensus, + }, + repository.Pagination{Limit: 10}, + false, + ) + s.Require().NoError(err) + s.Len(apps, 1) + s.Equal(uint64(1), total) + s.Equal(ApplicationStatus_OK, apps[0].Status) + s.Equal(Consensus_Authority, apps[0].ConsensusType) + }) + + // FilterByForeclosureRecorded pins the SQL behind the + // listEnabledForeclosedNonPRTApps query: ForecloseBlock > 0 selects only + // apps the evmreader has observed as foreclosed. An IS_NULL/IS_NOT_NULL + // swap or a GT/EQ swap in the SQL would silently disable the drain-from- + // idle path; the assertions here catch both directions. + s.Run("FilterByForeclosureRecorded", func() { + foreclosed := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure( + s.Ctx, foreclosed.ID, 1234, UniqueHash(), 1234)) + _ = NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) // not foreclosed + + yes := true + got, total, err := s.Repo.ListApplications(s.Ctx, + repository.ApplicationFilter{ForeclosureRecorded: &yes}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(got, 1) + s.Equal(uint64(1), total) + s.Equal(foreclosed.ID, got[0].ID) + + no := false + got, total, err = s.Repo.ListApplications(s.Ctx, + repository.ApplicationFilter{ForeclosureRecorded: &no}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(got, 1) + s.Equal(uint64(1), total) + s.NotEqual(foreclosed.ID, got[0].ID) + }) +} + +func (s *ApplicationSuite) TestUpdateApplicationStatus() { + s.Run("UpdatesStatus", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Equal(ApplicationStatus_OK, app.Status) + + reason := "machine crashed" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Failed, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Failed, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run("TriggerClearsReasonOnOK", func() { + // Even if a reason is passed, the DB trigger clears it for OK status. + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // First set to FAILED with a reason + reason := "machine crash" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Failed, &reason) + s.Require().NoError(err) + + // Recover to OK with a stale reason — trigger should clear it. + staleReason := "should be cleared" + err = s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_OK, &staleReason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_OK, got.Status) + s.Nil(got.Reason) + }) + + s.Run("MissingApplicationReturnsNotFound", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false)) + err := s.Repo.DeleteApplication(s.Ctx, app.ID) + s.Require().NoError(err) + + reason := "missing app" + err = s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Failed, &reason) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +func (s *ApplicationSuite) TestUpdateApplicationEnabled() { + s.Run("UpdatesOnlyEnabledFlag", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.False(got.Enabled) + s.Equal(ApplicationStatus_OK, got.Status) + }) + + s.Run("ReturnsNotFoundWhenRowMissing", func() { + err := s.Repo.UpdateApplicationEnabled(s.Ctx, int64(99_999_999), false) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +func (s *ApplicationSuite) TestEnableApplicationAndResetDeployment() { + for _, test := range []struct { + name string + status, expected ApplicationStatus + clearFailed bool + }{ + {"ClearsFailedStatusAndReason", ApplicationStatus_Failed, ApplicationStatus_OK, true}, + {"PreservesFailedWithoutRecoveryAuthorization", ApplicationStatus_Failed, ApplicationStatus_Failed, false}, + {"DoesNotClearCorrupted", ApplicationStatus_Corrupted, ApplicationStatus_Corrupted, true}, + {"DoesNotClearDiverged", ApplicationStatus_Diverged, ApplicationStatus_Diverged, true}, + } { + s.Run(test.name, func() { + app := NewApplicationBuilder().WithEnabled(false).Create(s.Ctx, s.T(), s.Repo) + reason := "retained boundary-free health" + s.Require().NoError(s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, test.status, &reason)) + s.Require().NoError(s.Repo.EnableApplicationAndResetDeployment(s.Ctx, app.ID, test.clearFailed)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.True(got.Enabled) + s.Equal(test.expected, got.Status) + if test.expected == ApplicationStatus_OK { + s.Nil(got.Reason) + } else { + s.Equal(&reason, got.Reason) + } + }) + } + s.Run("ReturnsNotFoundWhenRowMissing", func() { + const missingApplicationID = 99_999_999 + err := s.Repo.EnableApplicationAndResetDeployment(s.Ctx, missingApplicationID, false) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +func (s *ApplicationSuite) TestTerminalStatusIsTerminal() { + // helper: create an app and transition it to the given terminal status via + // UpdateApplicationStatus. + makeTerminal := func(status ApplicationStatus, reason string) *Application { + s.T().Helper() + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, status, &reason) + s.Require().NoError(err) + return app + } + + terminalStatuses := []ApplicationStatus{ + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, + } + + for _, status := range terminalStatuses { + s.Run(string(status)+"CannotChangeStatus", func() { + reason := "irrecoverable error" + app := makeTerminal(status, reason) + + newReason := "re-enabling" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_OK, &newReason) + s.Require().Error(err) + s.Contains(err.Error(), string(status)) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(status, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run(string(status)+"CannotChangeReason", func() { + reason := "original reason" + app := makeTerminal(status, reason) + + newReason := "different reason" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, status, &newReason) + s.Require().Error(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run(string(status)+"CanBeSetFromOtherStates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + reason := "fatal error" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, status, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(status, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run(string(status)+"ToSameStateAndReasonIsNoOp", func() { + reason := "irrecoverable" + app := makeTerminal(status, reason) + + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, status, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(status, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + } +} + +func (s *ApplicationSuite) TestForeclosedCanBecomeTerminal() { + s.Run("Diverged", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(1234) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, UniqueHash(), block)) + + reason := "post-foreclosure claim disagreement" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Diverged, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Diverged, got.Status) + s.True(got.IsForeclosed()) + s.Equal(block, got.ForecloseBlock) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run("Corrupted", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(1234) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, UniqueHash(), block)) + + reason := "post-foreclosure replay mismatch" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Corrupted, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Corrupted, got.Status) + s.True(got.IsForeclosed()) + s.Equal(block, got.ForecloseBlock) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) +} + +func (s *ApplicationSuite) TestExecutionTerminalCanEscalateToCorrupted() { + for _, status := range []ApplicationStatus{ + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + } { + s.Run(status.String(), func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + executionReason := "guest execution terminated" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, status, &executionReason)) + + corruptionReason := "post-foreclosure L1 history mismatch" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Corrupted, &corruptionReason)) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Corrupted, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(corruptionReason, *got.Reason) + }) + } +} + +func (s *ApplicationSuite) TestFailedCannotEnterExecutionTerminal() { + for _, status := range []ApplicationStatus{ + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + } { + s.Run(status.String(), func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + failedReason := "runtime unavailable" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Failed, &failedReason)) + + terminalReason := "must only be produced by an advance" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, status, &terminalReason) + s.Require().Error(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Failed, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(failedReason, *got.Reason) + }) + } +} + +func (s *ApplicationSuite) TestExecutionTerminalAllowsOnlyIntegrityEscalation() { + executionStatuses := []ApplicationStatus{ + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + } + for index, current := range executionStatuses { + for _, target := range []ApplicationStatus{ + ApplicationStatus_Failed, + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + executionStatuses[(index+1)%len(executionStatuses)], + } { + s.Run(current.String()+"To"+target.String(), func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + originalReason := "terminal machine outcome" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, current, &originalReason)) + + newReason := "later finding" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, target, &newReason) + escalates := target == ApplicationStatus_Diverged || target == ApplicationStatus_Corrupted + if escalates { + s.Require().NoError(err) + } else { + s.Require().Error(err) + } + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + if escalates { + s.Equal(target, got.Status) + s.Equal(newReason, *got.Reason) + } else { + s.Equal(current, got.Status) + s.Equal(originalReason, *got.Reason) + } + }) + } + } +} + +func (s *ApplicationSuite) TestIntegrityTerminalRejectsExecutionTerminalTransitions() { + for _, current := range []ApplicationStatus{ + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + } { + for _, target := range []ApplicationStatus{ + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + } { + s.Run(current.String()+"To"+target.String(), func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + originalReason := "integrity failure" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, current, &originalReason)) + + newReason := "invalid execution outcome" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, target, &newReason) + s.Require().Error(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(current, got.Status) + s.Equal(originalReason, *got.Reason) + }) + } + } +} + +// TestForeclosedCanBecomeFailed verifies that a foreclosed application (one +// with a non-zero foreclose_block) can still transition to FAILED with a +// reason; the row then reads FAILED with foreclose_block preserved. Health +// status and foreclosure live in independent columns. +func (s *ApplicationSuite) TestForeclosedCanBecomeFailed() { + s.Run("Ok", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(1234) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, UniqueHash(), block)) + + reason := "machine crashed after foreclosure" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Failed, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Failed, got.Status) + s.True(got.IsForeclosed()) + s.Equal(block, got.ForecloseBlock) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) +} + +// TestApplicationStatusTriggerInvariants pins the application trigger +// invariants exercised through the Repository interface: foreclosure is +// one-way (a repeated call with block 0 is an idempotent no-op that leaves the +// recorded block intact), and returning health to OK clears a stale reason. +func (s *ApplicationSuite) TestApplicationStatusTriggerInvariants() { + s.Run("ForecloseBlockSurvivesIdempotentZeroCall", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(4242) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, UniqueHash(), block)) + + // A second foreclosure call is guarded on foreclose_block = 0, so it + // cannot overwrite the recorded block back to zero. + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 0, UniqueHash(), block)) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(block, got.ForecloseBlock) + s.True(got.IsForeclosed()) + }) + + s.Run("StatusToOKClearsReason", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + reason := "machine crashed" + s.Require().NoError(s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Failed, &reason)) + + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_OK, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_OK, got.Status) + s.Nil(got.Reason) + }) +} + +func (s *ApplicationSuite) TestFailedStatusLifecycle() { + // helper: create an app and transition it to FAILED. + makeFailed := func(reason string) *Application { + s.T().Helper() + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Failed, &reason) + s.Require().NoError(err) + return app + } + + s.Run("CanRecoverFromFailed", func() { + reason := "machine crashed" + app := makeFailed(reason) + + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_OK, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_OK, got.Status) + s.Nil(got.Reason) + }) + + s.Run("CanEscalateFromFailedToCorrupted", func() { + app := makeFailed("machine error") + + reason := "data corruption detected" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Corrupted, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Corrupted, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run("CanEscalateFromFailedToDiverged", func() { + app := makeFailed("machine error") + + reason := "claim disagreement detected" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Diverged, &reason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Diverged, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run("ReasonClearedOnReEnable", func() { + app := makeFailed("OOM kill") + + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_OK, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_OK, got.Status) + s.Nil(got.Reason) + }) + + s.Run("FailedToFailedUpdatesReason", func() { + app := makeFailed("first crash") + + newReason := "second crash: different error" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Failed, &newReason) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Failed, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(newReason, *got.Reason) + }) + + s.Run("FullRecoveryCycle", func() { + // OK -> FAILED -> OK -> FAILED (verify full cycle works) + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // First failure + reason1 := "crash 1" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Failed, &reason1) + s.Require().NoError(err) + + // Recover to OK. + err = s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_OK, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_OK, got.Status) + s.Nil(got.Reason) + + // Second failure + reason2 := "crash 2" + err = s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, ApplicationStatus_Failed, &reason2) + s.Require().NoError(err) + + got, err = s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Failed, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason2, *got.Reason) + }) +} + +func (s *ApplicationSuite) TestDeleteApplication() { + s.Run("DeletesExistingApp", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + s.Require().NoError(s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false)) + err := s.Repo.DeleteApplication(s.Ctx, app.ID) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *ApplicationSuite) TestGetExecutionParameters() { + s.Run("DefaultParameters", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + ep, err := s.Repo.GetExecutionParameters(s.Ctx, app.ID) + s.Require().NoError(err) + s.NotNil(ep) + s.Zero(ep.AdvanceMaxCycles) + s.Zero(ep.InspectMaxCycles) + }) +} + +func (s *ApplicationSuite) TestUpdateExecutionParameters() { + s.Run("UpdatesValues", func() { + ep := ExecutionParameters{ + SnapshotPolicy: SnapshotPolicy_EveryInput, + AdvanceIncCycles: 2000, + AdvanceMaxCycles: 2500, //nolint:mnd + InspectIncCycles: 3000, + InspectMaxCycles: 3500, //nolint:mnd + AdvanceIncDeadline: 20 * time.Second, + AdvanceMaxDeadline: 120 * time.Second, + InspectIncDeadline: 20 * time.Second, + InspectMaxDeadline: 120 * time.Second, + LoadDeadline: 60 * time.Second, + StoreDeadline: 60 * time.Second, + FastDeadline: 10 * time.Second, + MaxConcurrentInspects: 5, + } + app := NewApplicationBuilder(). + WithExecutionParameters(ep). + Create(s.Ctx, s.T(), s.Repo) + + ep.ApplicationID = app.ID + ep.AdvanceIncCycles = 99999 + ep.AdvanceMaxCycles = MaxExecutionCycleSpan + err := s.Repo.UpdateExecutionParameters(s.Ctx, &ep) + s.Require().NoError(err) + + got, err := s.Repo.GetExecutionParameters(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(99999), got.AdvanceIncCycles) + s.Equal(MaxExecutionCycleSpan, got.AdvanceMaxCycles) + s.Equal(ep.InspectIncCycles, got.InspectIncCycles) + s.Equal(ep.InspectMaxCycles, got.InspectMaxCycles) + }) +} + +func (s *ApplicationSuite) TestEventLastCheckBlock() { + s.Run("DefaultIsZero", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, app.ID, MonitoredEvent_InputAdded) + s.Require().NoError(err) + s.Equal(uint64(0), block) + }) + + s.Run("UpdateAndGet", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, MonitoredEvent_InputAdded, 42) + s.Require().NoError(err) + + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, app.ID, MonitoredEvent_InputAdded) + s.Require().NoError(err) + s.Equal(uint64(42), block) + }) + + s.Run("DoesNotRegress", func() { + const ( + currentBlock = uint64(42) + staleBlock = uint64(41) + ) + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, MonitoredEvent_InputAdded, currentBlock) + s.Require().NoError(err) + err = s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, MonitoredEvent_InputAdded, staleBlock) + s.Require().NoError(err) + + block, err := s.Repo.GetEventLastCheckBlock( + s.Ctx, app.ID, MonitoredEvent_InputAdded) + s.Require().NoError(err) + s.Equal(currentBlock, block) + }) + + s.Run("AllMonitoredEventTypes", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // Events that map to the epoch check block column + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, MonitoredEvent_EpochSealed, 10) + s.Require().NoError(err) + block, err := s.Repo.GetEventLastCheckBlock( + s.Ctx, app.ID, MonitoredEvent_EpochSealed) + s.Require().NoError(err) + s.Equal(uint64(10), block) + + // Events that map to the output check block column + err = s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, MonitoredEvent_OutputExecuted, 20) + s.Require().NoError(err) + block, err = s.Repo.GetEventLastCheckBlock( + s.Ctx, app.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(uint64(20), block) + + // Tournament events all map to the tournament check block column + tournamentEvents := []MonitoredEvent{ + MonitoredEvent_CommitmentJoined, + MonitoredEvent_MatchAdvanced, + MonitoredEvent_MatchCreated, + MonitoredEvent_MatchDeleted, + MonitoredEvent_NewInnerTournament, + MonitoredEvent_StandingChanged, + } + for _, event := range tournamentEvents { + err = s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, event, 30) + s.Require().NoError(err) + block, err = s.Repo.GetEventLastCheckBlock(s.Ctx, app.ID, event) + s.Require().NoError(err) + s.Equal(uint64(30), block) + } + + // Submitted votes do not have an independent claim cursor. + _, err = s.Repo.GetEventLastCheckBlock( + s.Ctx, app.ID, MonitoredEvent_ClaimSubmitted) + s.Require().Error(err) + + const claimBlock = uint64(40) + err = s.Repo.UpdateEventLastCheckBlock(s.Ctx, []int64{app.ID}, MonitoredEvent_ClaimAccepted, claimBlock) + s.Require().NoError(err) + block, err = s.Repo.GetEventLastCheckBlock(s.Ctx, app.ID, MonitoredEvent_ClaimAccepted) + s.Require().NoError(err) + s.Equal(claimBlock, block) + }) + + s.Run("UpdateMultipleAppIDs", func() { + app1 := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + app2 := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app1.ID, app2.ID}, MonitoredEvent_InputAdded, 55) + s.Require().NoError(err) + + block1, err := s.Repo.GetEventLastCheckBlock( + s.Ctx, app1.ID, MonitoredEvent_InputAdded) + s.Require().NoError(err) + s.Equal(uint64(55), block1) + + block2, err := s.Repo.GetEventLastCheckBlock( + s.Ctx, app2.ID, MonitoredEvent_InputAdded) + s.Require().NoError(err) + s.Equal(uint64(55), block2) + }) + + s.Run("EmptyAppIDsIsNoOp", func() { + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{}, MonitoredEvent_InputAdded, 42) + s.Require().NoError(err) + }) +} + +func (s *ApplicationSuite) TestGetProcessedInputCount() { + s.Run("ReturnsZeroInitially", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + count, err := s.Repo.GetProcessedInputCount(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), count) + }) + + s.Run("ReturnsCountAfterProcessing", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // StoreAdvanceResult increments ProcessedInputs + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + count, err := s.Repo.GetProcessedInputCount( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(1), count) + }) +} + +func (s *ApplicationSuite) TestUpdateApplication() { + s.Run("UpdatesFields", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + app.EpochLength = 20 + app.IInputBoxAddress = UniqueAddress() + err := s.Repo.UpdateApplication(s.Ctx, app) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(20), got.EpochLength) + s.Equal(app.IInputBoxAddress, got.IInputBoxAddress) + }) + + // UpdateApplication must not touch status or foreclosure columns. Those + // fields are owned by UpdateApplicationStatus and the atomic foreclosure + // marker+cursor write. If UpdateApplication's column list ever re-includes + // them, a caller with a stale in-memory app could silently clear the marker + // or move the app back to OK while changing unrelated configuration. + s.Run("DoesNotClobberStatusOrForecloseColumns", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + block := uint64(12345) + txHash := crypto.Keccak256Hash([]byte("foreclose-tx")) + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, txHash, block) + s.Require().NoError(err) + + // Mutate an unrelated field on an in-memory copy whose + // ForecloseBlock / ForecloseTransaction are zero (simulating a caller + // that reads, modifies, and writes back without first refreshing the + // foreclosure status). UpdateApplication must leave the persisted + // foreclose columns alone. + app.EpochLength = 77 + s.Require().Zero(app.ForecloseBlock) + s.Require().Nil(app.ForecloseTransaction) + err = s.Repo.UpdateApplication(s.Ctx, app) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(77), got.EpochLength) + s.Require().NotZero(got.ForecloseBlock, "foreclose_block must not be cleared by UpdateApplication") + s.Equal(block, got.ForecloseBlock) + s.Require().NotNil(got.ForecloseTransaction) + s.Equal(txHash, *got.ForecloseTransaction) + s.True(got.IsForeclosed()) + }) + + s.Run("DoesNotClobberServiceProgressOrEnabled", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + app := seed.App + s.Require().NoError(s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false)) + s.Require().NoError(s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, MonitoredEvent_InputAdded, 42)) + s.Require().NoError(s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{app.ID}, MonitoredEvent_OutputExecuted, 43)) + s.Require().NoError(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + })) + + app.EpochLength = 33 + app.Enabled = true + app.LastInputCheckBlock = 0 + app.LastOutputCheckBlock = 0 + app.ProcessedInputs = 0 + err := s.Repo.UpdateApplication(s.Ctx, app) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(33), got.EpochLength) + s.False(got.Enabled) + s.Equal(uint64(42), got.LastInputCheckBlock) + s.Equal(uint64(43), got.LastOutputCheckBlock) + s.Equal(uint64(1), got.ProcessedInputs) + }) + + s.Run("ReturnsNotFoundWhenRowMissing", func() { + app := NewApplicationBuilder().Build() + app.ID = int64(99_999_999) + err := s.Repo.UpdateApplication(s.Ctx, app) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +func (s *ApplicationSuite) TestUpdateApplicationForeclosure() { + s.Run("WritesMarkerAndCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(1234) + head := uint64(1500) + txHash := UniqueHash() + + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, txHash, head) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(block, got.ForecloseBlock) + s.Require().NotNil(got.ForecloseTransaction) + s.Equal(txHash, *got.ForecloseTransaction) + s.Equal(head, got.LastForecloseCheckBlock) + s.True(got.IsForeclosed()) + s.Equal(ApplicationStatus_OK, got.Status) + s.Nil(got.Reason) + }) + + s.Run("PreservesTerminalStatusAndReason", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + reason := "divergent claim observed" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, ApplicationStatus_Diverged, &reason) + s.Require().NoError(err) + + block := uint64(1234) + head := uint64(1500) + txHash := UniqueHash() + err = s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, block, txHash, head) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(block, got.ForecloseBlock) + s.Equal(head, got.LastForecloseCheckBlock) + s.True(got.IsForeclosed()) + s.Equal(ApplicationStatus_Diverged, got.Status) + s.Require().NotNil(got.Reason) + s.Equal(reason, *got.Reason) + }) + + s.Run("DoesNotRegressCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 500)) + + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 300, UniqueHash(), 400) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastForecloseCheckBlock) + s.Equal(uint64(300), got.ForecloseBlock) + }) + + s.Run("IdempotentWhenAlreadyForeclosed", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + firstBlock := uint64(1234) + firstHead := uint64(1500) + firstTx := UniqueHash() + s.Require().NoError( + s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, firstBlock, firstTx, firstHead)) + + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 9999, UniqueHash(), 2000) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(firstBlock, got.ForecloseBlock) + s.Require().NotNil(got.ForecloseTransaction) + s.Equal(firstTx, *got.ForecloseTransaction) + s.Equal(firstHead, got.LastForecloseCheckBlock) + }) + + s.Run("ReturnsNotFoundWhenRowMissing", func() { + err := s.Repo.UpdateApplicationForeclosure( + s.Ctx, int64(99_999_999), 1, UniqueHash(), 2) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +// TestUpdateApplicationLastForecloseCheckBlock pins the strictly monotonic +// semantics of the write. Out-of-order or duplicate observations from a +// slow tick must not rewind last_foreclose_check_block and re-cause a +// full [deployment, head] rescan on the next tick. +func (s *ApplicationSuite) TestUpdateApplicationLastForecloseCheckBlock() { + s.Run("AdvancesFromZero", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 1234) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1234), got.LastForecloseCheckBlock) + }) + + s.Run("AdvancesForward", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 100)) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 200)) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(200), got.LastForecloseCheckBlock) + }) + + // Out-of-order ticks: a stale call carrying a lower block number must + // be a silent no-op, not an error and not a regression of the stored + // value. The repo returns nil (matches LastInputCheckBlock-style + // conventions); the caller cannot distinguish "I was stale" from + // "I was current". That is intentional — the next tick's read will + // surface the true value. + s.Run("RejectsRegressionSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 500)) + + err := s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 100) + s.Require().NoError(err, "regression attempts return nil; the WHERE guard makes it a no-op") + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastForecloseCheckBlock, + "last_foreclose_check_block must not regress below its previous value") + }) + + // Equal-value writes are also no-ops, mirroring the strict-less-than + // guard. Useful when two ticks happen to land on the same head block. + s.Run("RejectsEqualSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 777)) + + err := s.Repo.UpdateApplicationLastForecloseCheckBlock(s.Ctx, app.ID, 777) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(777), got.LastForecloseCheckBlock) + }) +} + +// TestUpdateApplicationLastAccountsDriveProvedCheckBlock mirrors the +// LastForecloseCheckBlock contract: strictly monotonic, regression and +// equal-value writes are silent no-ops. Out-of-order ticks must not rewind +// the cursor and re-cause a full [foreclose_block, head] rescan. +func (s *ApplicationSuite) TestUpdateApplicationLastAccountsDriveProvedCheckBlock() { + s.Run("AdvancesFromZero", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 1234)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1234), got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("AdvancesForward", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 100)) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 200)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(200), got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("RejectsRegressionSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 500)) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 100)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastAccountsDriveProvedCheckBlock, + "last_accounts_drive_proved_check_block must not regress below its previous value") + }) + + s.Run("RejectsEqualSilently", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 777)) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 777)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(777), got.LastAccountsDriveProvedCheckBlock) + }) +} + +func (s *ApplicationSuite) TestUpdateAccountsDriveProved() { + s.Run("WritesMarkerAndCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + block := uint64(4242) + head := uint64(4300) + txHash := UniqueHash() + root := UniqueHash() + + err := s.Repo.UpdateAccountsDriveProved(s.Ctx, app.ID, block, txHash, root, head) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(block, got.AccountsDriveProvedBlock) + s.Require().NotNil(got.AccountsDriveProvedTransaction) + s.Equal(txHash, *got.AccountsDriveProvedTransaction) + s.Require().NotNil(got.AccountsDriveMerkleRoot) + s.Equal(root, *got.AccountsDriveMerkleRoot) + s.Equal(head, got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("DoesNotRegressCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.UpdateApplicationLastAccountsDriveProvedCheckBlock(s.Ctx, app.ID, 500)) + + err := s.Repo.UpdateAccountsDriveProved( + s.Ctx, app.ID, 300, UniqueHash(), UniqueHash(), 400) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(500), got.LastAccountsDriveProvedCheckBlock) + s.Equal(uint64(300), got.AccountsDriveProvedBlock) + }) + + s.Run("IdempotentWhenAlreadyProved", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + firstBlock := uint64(4242) + firstHead := uint64(4300) + firstTx := UniqueHash() + firstRoot := UniqueHash() + s.Require().NoError(s.Repo.UpdateAccountsDriveProved( + s.Ctx, app.ID, firstBlock, firstTx, firstRoot, firstHead)) + + err := s.Repo.UpdateAccountsDriveProved( + s.Ctx, app.ID, 9999, UniqueHash(), UniqueHash(), 5000) + s.Require().NoError(err) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(firstBlock, got.AccountsDriveProvedBlock) + s.Require().NotNil(got.AccountsDriveProvedTransaction) + s.Equal(firstTx, *got.AccountsDriveProvedTransaction) + s.Require().NotNil(got.AccountsDriveMerkleRoot) + s.Equal(firstRoot, *got.AccountsDriveMerkleRoot) + s.Equal(firstHead, got.LastAccountsDriveProvedCheckBlock) + }) + + s.Run("ReturnsNotFoundWhenRowMissing", func() { + err := s.Repo.UpdateAccountsDriveProved( + s.Ctx, int64(99_999_999), 1, UniqueHash(), UniqueHash(), 2) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +func (s *ApplicationSuite) TestGetLastSnapshot() { + s.Run("NotFound", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetLastSnapshot(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Nil(got) + }) + + s.Run("ReturnsInputWithSnapshot", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // GetLastSnapshot requires Status == Accepted AND SnapshotURI IS NOT NULL. + // Use StoreAdvanceResult to set the input to Accepted first. + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + uri := "/snapshots/epoch-0-input-0" + err = s.Repo.UpdateInputSnapshotURI(s.Ctx, seed.App.ID, 0, uri) + s.Require().NoError(err) + + got, err := s.Repo.GetLastSnapshot( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Require().NotNil(got.SnapshotURI) + s.Equal(uri, *got.SnapshotURI) + s.Equal(uint64(0), got.Index) + }) +} diff --git a/internal/repository/repotest/builders.go b/internal/repository/repotest/builders.go new file mode 100644 index 000000000..a1d90bef6 --- /dev/null +++ b/internal/repository/repotest/builders.go @@ -0,0 +1,744 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + "fmt" + "math/big" + "sync/atomic" + "testing" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// defaultWithdrawalConfig returns a deterministic, non-zero WithdrawalConfig +// so tests detect missing-column bugs as "wrong values" rather than silently +// passing on all-zero defaults. +func defaultWithdrawalConfig() WithdrawalConfig { + return WithdrawalConfig{ + Guardian: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 20, + AccountsDriveStartIndex: 33554432, + WithdrawalOutputBuilder: common.HexToAddress("0x2222222222222222222222222222222222222222"), + } +} + +// counter provides unique values across all builders to avoid collisions. +var counter atomic.Uint64 + +func nextID() uint64 { + return counter.Add(1) +} + +// UniqueAddress returns a unique Ethereum address for test isolation. +func UniqueAddress() common.Address { + return common.BigToAddress(new(big.Int).SetUint64(nextID())) +} + +// UniqueHash returns a unique hash for test isolation. +func UniqueHash() common.Hash { + return common.BigToHash(new(big.Int).SetUint64(nextID())) +} + +// --------------------------------------------------------------------------- +// ApplicationBuilder +// --------------------------------------------------------------------------- + +type ApplicationBuilder struct { + app *Application + withExecutionParameters bool +} + +func NewApplicationBuilder() *ApplicationBuilder { + id := nextID() + return &ApplicationBuilder{ + app: &Application{ + Name: fmt.Sprintf("test-app-%d", id), + IApplicationAddress: UniqueAddress(), + IConsensusAddress: UniqueAddress(), + IInputBoxAddress: UniqueAddress(), + TemplateHash: UniqueHash(), + TemplateURI: fmt.Sprintf("/template/%d", id), + EpochLength: 10, + ClaimStagingPeriod: 7, + WithdrawalConfig: defaultWithdrawalConfig(), + ConsensusType: Consensus_Authority, + Enabled: true, + Status: ApplicationStatus_OK, + }, + } +} + +func (b *ApplicationBuilder) WithName(name string) *ApplicationBuilder { + b.app.Name = name + return b +} + +func (b *ApplicationBuilder) WithAddress(addr common.Address) *ApplicationBuilder { + b.app.IApplicationAddress = addr + return b +} + +func (b *ApplicationBuilder) WithConsensus(c Consensus) *ApplicationBuilder { + b.app.ConsensusType = c + return b +} + +func (b *ApplicationBuilder) WithStatus(s ApplicationStatus) *ApplicationBuilder { + b.app.Status = s + return b +} + +func (b *ApplicationBuilder) WithEnabled(enabled bool) *ApplicationBuilder { + b.app.Enabled = enabled + return b +} + +func (b *ApplicationBuilder) WithEpochLength(l uint64) *ApplicationBuilder { + b.app.EpochLength = l + return b +} + +func (b *ApplicationBuilder) WithClaimStagingPeriod(p uint64) *ApplicationBuilder { + b.app.ClaimStagingPeriod = p + return b +} + +func (b *ApplicationBuilder) WithWithdrawalConfig(wc WithdrawalConfig) *ApplicationBuilder { + b.app.WithdrawalConfig = wc + return b +} + +func (b *ApplicationBuilder) WithExecutionParameters(ep ExecutionParameters) *ApplicationBuilder { + b.app.ExecutionParameters = ep + b.withExecutionParameters = true + return b +} + +// Build returns a copy of the Application model without persisting it. +func (b *ApplicationBuilder) Build() *Application { + a := *b.app + return &a +} + +// Create persists the Application via the repository and returns a copy with the generated ID. +func (b *ApplicationBuilder) Create( + ctx context.Context, t *testing.T, repo repository.Repository, +) *Application { + t.Helper() + a := *b.app + id, err := repo.CreateApplication(ctx, &a, b.withExecutionParameters) + require.NoError(t, err) + a.ID = id + return &a +} + +// --------------------------------------------------------------------------- +// EpochBuilder +// --------------------------------------------------------------------------- + +type EpochBuilder struct { + epoch *Epoch +} + +func NewEpochBuilder(appID int64) *EpochBuilder { + return &EpochBuilder{ + epoch: &Epoch{ + ApplicationID: appID, + Index: 0, + VirtualIndex: 0, + FirstBlock: 0, + LastBlock: 9, + Status: EpochStatus_Open, + }, + } +} + +// WithIndex sets the epoch's physical Index. +// Note: VirtualIndex is auto-assigned by CreateEpochsAndInputs. +func (b *EpochBuilder) WithIndex(i uint64) *EpochBuilder { + b.epoch.Index = i + return b +} + +func (b *EpochBuilder) WithStatus(s EpochStatus) *EpochBuilder { + b.epoch.Status = s + return b +} + +func (b *EpochBuilder) WithBlocks(first, last uint64) *EpochBuilder { + b.epoch.FirstBlock = first + b.epoch.LastBlock = last + return b +} + +func (b *EpochBuilder) WithInputBounds(lower, upper uint64) *EpochBuilder { + b.epoch.InputIndexLowerBound = lower + b.epoch.InputIndexUpperBound = upper + return b +} + +func (b *EpochBuilder) WithTxBufferDataBlock(h common.Hash) *EpochBuilder { + b.epoch.TxBufferDataBlock = &h + return b +} + +func (b *EpochBuilder) WithClaimTransactionHash(h common.Hash) *EpochBuilder { + b.epoch.ClaimTransactionHash = &h + return b +} + +func (b *EpochBuilder) WithMachineHash(h common.Hash) *EpochBuilder { + b.epoch.MachineHash = &h + return b +} + +// WithStagedAtBlock sets the block number at which the chain staged our +// claim. Required when Status is EpochStatus_ClaimStaged (enforced by the +// staged_requires_block CHECK constraint at the DB). May also be set on +// ACCEPTED/REJECTED epochs to retain the staging block historically; the +// relaxed CHECK does not require clearing on transitions out of STAGED. +func (b *EpochBuilder) WithStagedAtBlock(block uint64) *EpochBuilder { + b.epoch.StagedAtBlock = &block + return b +} + +// Build returns a copy of the Epoch model without persisting it. +func (b *EpochBuilder) Build() *Epoch { + e := *b.epoch + return &e +} + +// --------------------------------------------------------------------------- +// InputBuilder +// --------------------------------------------------------------------------- + +type InputBuilder struct { + input *Input +} + +func NewInputBuilder() *InputBuilder { + return &InputBuilder{ + input: &Input{ + Index: 0, + BlockNumber: 1, + RawData: []byte("input-data"), + Status: InputCompletionStatus_None, + TransactionHash: UniqueHash(), + LogIndex: 0, + }, + } +} + +func (b *InputBuilder) WithIndex(i uint64) *InputBuilder { + b.input.Index = i + return b +} + +func (b *InputBuilder) WithEpochApplicationID(id int64) *InputBuilder { + b.input.EpochApplicationID = id + return b +} + +func (b *InputBuilder) WithEpochIndex(i uint64) *InputBuilder { + b.input.EpochIndex = i + return b +} + +func (b *InputBuilder) WithBlockNumber(n uint64) *InputBuilder { + b.input.BlockNumber = n + return b +} + +func (b *InputBuilder) WithStatus(s InputCompletionStatus) *InputBuilder { + b.input.Status = s + return b +} + +func (b *InputBuilder) WithRawData(data []byte) *InputBuilder { + b.input.RawData = data + return b +} + +func (b *InputBuilder) WithTransactionHash(h common.Hash) *InputBuilder { + b.input.TransactionHash = h + return b +} + +func (b *InputBuilder) WithLogIndex(i uint64) *InputBuilder { + b.input.LogIndex = i + return b +} + +// Build returns a copy of the Input model without persisting it. +func (b *InputBuilder) Build() *Input { + i := *b.input + return &i +} + +// --------------------------------------------------------------------------- +// TournamentBuilder +// --------------------------------------------------------------------------- + +type TournamentBuilder struct { + tournament *Tournament +} + +const observedRootSealBlock = 9 + +func NewTournamentBuilder(appID int64) *TournamentBuilder { + return &TournamentBuilder{ + tournament: &Tournament{ + ApplicationID: appID, + EpochIndex: 0, + Address: UniqueAddress(), + MaxLevel: 3, + Level: 0, + Log2Step: 20, + Height: 2, + Kind: TournamentKindNonLeaf, + InitialHash: UniqueHash(), + StartInstant: observedRootSealBlock, + Allowance: 100, + BondValue: Uint256{1}, + Snapshot: TournamentSnapshot{ + AsOfBlock: 100, + Standing: TournamentStandingAwaitingClosure, + AcceptsJoins: true, + BondRecovery: TournamentBondRecovery{Disposition: BondDispositionTournamentRunning}, + }, + }, + } +} + +func (b *TournamentBuilder) WithEpochIndex(i uint64) *TournamentBuilder { + b.tournament.EpochIndex = i + return b +} + +func (b *TournamentBuilder) WithAddress(addr common.Address) *TournamentBuilder { + b.tournament.Address = addr + return b +} + +func (b *TournamentBuilder) WithLevel(l uint64) *TournamentBuilder { + b.tournament.Level = l + if l == b.tournament.MaxLevel-1 { + b.tournament.Kind = TournamentKindLeaf + } else { + b.tournament.Kind = TournamentKindNonLeaf + } + return b +} + +func (b *TournamentBuilder) WithParent(addr common.Address, matchIDHash common.Hash) *TournamentBuilder { + b.tournament.ParentTournamentAddress = &addr + b.tournament.ParentMatchIDHash = &matchIDHash + b.tournament.CreationEvent = &TournamentCreationEvent{BlockNumber: 100, TxHash: UniqueHash()} + b.tournament.StartInstant = 100 + b.tournament.ContestedFinalStateOne = new(UniqueHash()) + b.tournament.ContestedFinalStateTwo = new(UniqueHash()) + b.tournament.Snapshot.InnerResult = &TournamentInnerResult{Disposition: InnerTournamentUnsettled} + return b +} + +// Build returns a copy of the Tournament model without persisting it. +func (b *TournamentBuilder) Build() *Tournament { + t := *b.tournament + return &t +} + +// --------------------------------------------------------------------------- +// CommitmentBuilder +// --------------------------------------------------------------------------- + +type CommitmentBuilder struct { + commitment *Commitment +} + +func NewCommitmentBuilder(appID int64) *CommitmentBuilder { + submitter := UniqueAddress() + return &CommitmentBuilder{ + commitment: &Commitment{ + ApplicationID: appID, + EpochIndex: 0, + TournamentAddress: UniqueAddress(), + Commitment: UniqueHash(), + FinalStateHash: UniqueHash(), + SubmitterAddress: submitter, + BlockNumber: 100, + TxHash: UniqueHash(), + Snapshot: CommitmentSnapshot{ + AsOfBlock: 100, + Claimer: submitter, + ClockAllowance: 100, + }, + }, + } +} + +func (b *CommitmentBuilder) WithEpochIndex(i uint64) *CommitmentBuilder { + b.commitment.EpochIndex = i + return b +} + +func (b *CommitmentBuilder) WithTournamentAddress(addr common.Address) *CommitmentBuilder { + b.commitment.TournamentAddress = addr + return b +} + +func (b *CommitmentBuilder) WithCommitmentHash(h common.Hash) *CommitmentBuilder { + b.commitment.Commitment = h + return b +} + +// Build returns a copy of the Commitment model without persisting it. +func (b *CommitmentBuilder) Build() *Commitment { + c := *b.commitment + return &c +} + +// --------------------------------------------------------------------------- +// MatchBuilder +// --------------------------------------------------------------------------- + +type MatchBuilder struct { + match *Match +} + +func NewMatchBuilder(appID int64) *MatchBuilder { + return &MatchBuilder{ + match: &Match{ + ApplicationID: appID, + EpochIndex: 0, + TournamentAddress: UniqueAddress(), + IDHash: UniqueHash(), + CommitmentOne: UniqueHash(), + CommitmentTwo: UniqueHash(), + LeftOfTwo: UniqueHash(), + BlockNumber: 100, + TxHash: UniqueHash(), + Winner: WinnerCommitment_NONE, + DeletionReason: MatchDeletionReason_NOT_DELETED, + Snapshot: MatchSnapshot{ + AsOfBlock: 100, + Phase: MatchPhaseBisecting, + TimeoutOutcome: MatchTimeoutNone, + Bisection: &MatchBisectionSnapshot{ + RevealingParent: UniqueHash(), + WaitingLeft: UniqueHash(), + WaitingRight: UniqueHash(), + CurrentHeight: new(uint64(2)), + Responder: CommitmentSideOne, + }, + }, + }, + } +} + +func (b *MatchBuilder) WithEpochIndex(i uint64) *MatchBuilder { + b.match.EpochIndex = i + return b +} + +func (b *MatchBuilder) WithTournamentAddress(addr common.Address) *MatchBuilder { + b.match.TournamentAddress = addr + return b +} + +func (b *MatchBuilder) WithIDHash(h common.Hash) *MatchBuilder { + b.match.IDHash = h + return b +} + +func (b *MatchBuilder) WithWinner(w WinnerCommitment) *MatchBuilder { + b.match.Winner = w + return b +} + +func (b *MatchBuilder) WithCommitmentOne(h common.Hash) *MatchBuilder { + b.match.CommitmentOne = h + return b +} + +func (b *MatchBuilder) WithCommitmentTwo(h common.Hash) *MatchBuilder { + b.match.CommitmentTwo = h + return b +} + +func (b *MatchBuilder) WithDeletion(r MatchDeletionReason, block uint64, txHash common.Hash) *MatchBuilder { + b.match.DeletionReason = r + b.match.DeletionBlockNumber = block + b.match.DeletionTxHash = new(txHash) + b.match.DeletionLogIndex = new(uint64(0)) + b.match.Snapshot = MatchSnapshot{ + AsOfBlock: max(b.match.Snapshot.AsOfBlock, block), Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, + } + return b +} + +// Build returns a copy of the Match model without persisting it. +func (b *MatchBuilder) Build() *Match { + m := *b.match + return &m +} + +// --------------------------------------------------------------------------- +// MatchAdvancedBuilder +// --------------------------------------------------------------------------- + +type MatchAdvancedBuilder struct { + ma *MatchAdvanced +} + +func NewMatchAdvancedBuilder(appID int64) *MatchAdvancedBuilder { + return &MatchAdvancedBuilder{ + ma: &MatchAdvanced{ + CurrentHeight: 1, + ApplicationID: appID, + EpochIndex: 0, + TournamentAddress: UniqueAddress(), + IDHash: UniqueHash(), + OtherParent: UniqueHash(), + LeftNode: UniqueHash(), + BlockNumber: 100, + TxHash: UniqueHash(), + }, + } +} + +func (b *MatchAdvancedBuilder) WithEpochIndex(i uint64) *MatchAdvancedBuilder { + b.ma.EpochIndex = i + return b +} + +func (b *MatchAdvancedBuilder) WithTournamentAddress(addr common.Address) *MatchAdvancedBuilder { + b.ma.TournamentAddress = addr + return b +} + +func (b *MatchAdvancedBuilder) WithIDHash(h common.Hash) *MatchAdvancedBuilder { + b.ma.IDHash = h + return b +} + +func (b *MatchAdvancedBuilder) WithOtherParent(h common.Hash) *MatchAdvancedBuilder { + b.ma.OtherParent = h + return b +} + +// Build returns a copy of the MatchAdvanced model without persisting it. +func (b *MatchAdvancedBuilder) Build() *MatchAdvanced { + m := *b.ma + return &m +} + +// --------------------------------------------------------------------------- +// Seed — convenience for creating a minimum viable Application + Epoch + Input +// --------------------------------------------------------------------------- + +// SeedResult holds the entities created by Seed. +type SeedResult struct { + App *Application + Epoch *Epoch + Input *Input +} + +// AdvanceEpochStatus transitions an epoch through the valid state machine to +// the target status, finding the shortest path through the transition graph. +// The graph mirrors the SQL trigger enforce_epoch_status_transition: +// +// OPEN → CLOSED → INPUTS_PROCESSED → CLAIM_COMPUTED +// OPEN → CLAIM_FORECLOSED +// CLOSED → CLAIM_FORECLOSED +// INPUTS_PROCESSED → CLAIM_FORECLOSED +// CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_ACCEPTED +// CLAIM_COMPUTED → CLAIM_ACCEPTED (PRT, sync catch-up, or reader-only mode) +// CLAIM_COMPUTED → CLAIM_REJECTED (rejected on-chain before node submits) +// CLAIM_SUBMITTED → CLAIM_REJECTED +// CLAIM_COMPUTED → CLAIM_FORECLOSED +// CLAIM_SUBMITTED → CLAIM_FORECLOSED +// CLAIM_STAGED → CLAIM_ACCEPTED +// CLAIM_STAGED → CLAIM_FORECLOSED +func AdvanceEpochStatus( + ctx context.Context, t *testing.T, + repo repository.Repository, + nameOrAddress string, + epoch *Epoch, + target EpochStatus, +) { + t.Helper() + + // Adjacency list mirrors the SQL trigger's valid transitions. + next := map[EpochStatus][]EpochStatus{ + EpochStatus_Open: {EpochStatus_Closed, EpochStatus_ClaimForeclosed}, + EpochStatus_Closed: {EpochStatus_InputsProcessed, EpochStatus_ClaimForeclosed}, + EpochStatus_InputsProcessed: {EpochStatus_ClaimComputed, EpochStatus_ClaimForeclosed}, + EpochStatus_ClaimComputed: { + EpochStatus_ClaimSubmitted, + EpochStatus_ClaimAccepted, + EpochStatus_ClaimRejected, + EpochStatus_ClaimForeclosed, + }, + EpochStatus_ClaimSubmitted: {EpochStatus_ClaimAccepted, EpochStatus_ClaimRejected, EpochStatus_ClaimForeclosed}, + EpochStatus_ClaimStaged: {EpochStatus_ClaimAccepted, EpochStatus_ClaimForeclosed}, + } + + // BFS to find shortest valid path. + type step struct { + status EpochStatus + path []EpochStatus + } + queue := []step{{status: epoch.Status, path: nil}} + visited := map[EpochStatus]bool{epoch.Status: true} + + var path []EpochStatus + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if cur.status == target { + path = cur.path + break + } + for _, n := range next[cur.status] { + if !visited[n] { + visited[n] = true + p := make([]EpochStatus, len(cur.path)+1) + copy(p, cur.path) + p[len(cur.path)] = n + queue = append(queue, step{n, p}) + } + } + } + if path == nil { + t.Fatalf("AdvanceEpochStatus: no valid path from %s to %s", + epoch.Status, target) + } + + for _, s := range path { + if s == EpochStatus_InputsProcessed { + proof := DummyStateProof() + err := repo.UpdateEpochInputsProcessed( + ctx, nameOrAddress, epoch.Index, proof, + ) + require.NoError(t, err) + persisted, err := repo.GetEpoch(ctx, nameOrAddress, epoch.Index) + require.NoError(t, err) + require.Equal(t, EpochStatus_InputsProcessed, persisted.Status) + epoch.Status = s + applyStateProofToEpoch(epoch, proof) + continue + } + + // PRT claims need dummy commitment fields when entering + // CLAIM_COMPUTED. The final state proof was published by the + // preceding INPUTS_PROCESSED transition. + if s == EpochStatus_ClaimComputed { + setDummyClaimFields(ctx, t, repo, nameOrAddress, epoch) + // For PRT apps StoreClaimAndProofs already set the status + // to CLAIM_COMPUTED, so skip the redundant UpdateEpochStatus. + app, err := repo.GetApplication(ctx, nameOrAddress) + require.NoError(t, err) + if app.IsDaveConsensus() { + epoch.Status = s + continue + } + } + epoch.Status = s + err := repo.UpdateEpochStatus(ctx, nameOrAddress, epoch) + require.NoError(t, err) + } +} + +func proofSiblingsToHashes(siblings [][32]byte) []common.Hash { + hashes := make([]common.Hash, len(siblings)) + for i := range siblings { + hashes[i] = common.Hash(siblings[i]) + } + return hashes +} + +func applyStateProofToEpoch(epoch *Epoch, proof *StateProof) { + epoch.MachineHash = Pointer(proof.MachineHash) + epoch.TxBufferDataBlock = Pointer(proof.TxBufferDataBlock) + epoch.TxBufferProof = proofSiblingsToHashes(proof.TxBufferProof) + epoch.IflagsYDataBlock = Pointer(proof.IflagsYDataBlock) + epoch.IflagsYProof = proofSiblingsToHashes(proof.IflagsYProof) + epoch.HtifTohostDataBlock = Pointer(proof.HtifTohostDataBlock) + epoch.HtifTohostProof = proofSiblingsToHashes(proof.HtifTohostProof) +} + +// DummyStateProof returns a structurally complete machine state proof for +// repository tests that exercise epoch state transitions rather than proof +// cryptography. pkg/machine tests cover proof construction and verification. +func DummyStateProof() *StateProof { + proof := &StateProof{ + TxBufferDataBlock: UniqueHash(), + MachineHash: UniqueHash(), + IflagsYDataBlock: UniqueHash(), + HtifTohostDataBlock: UniqueHash(), + } + for range StateProofSiblingCount { + proof.TxBufferProof = append(proof.TxBufferProof, UniqueHash()) + proof.IflagsYProof = append(proof.IflagsYProof, UniqueHash()) + proof.HtifTohostProof = append(proof.HtifTohostProof, UniqueHash()) + } + return proof +} + +// setDummyClaimFields supplies the PRT-only fields required for the +// INPUTS_PROCESSED → CLAIM_COMPUTED transition. +func setDummyClaimFields( + ctx context.Context, t *testing.T, + repo repository.Repository, + nameOrAddress string, + epoch *Epoch, +) { + t.Helper() + + app, err := repo.GetApplication(ctx, nameOrAddress) + require.NoError(t, err) + if app.IsDaveConsensus() { + commitHash := UniqueHash() + epoch.Commitment = &commitHash + epoch.CommitmentProof = []common.Hash{UniqueHash()} + epoch.Status = EpochStatus_ClaimComputed + err = repo.StoreClaimAndProofs(ctx, epoch, nil) + require.NoError(t, err) + } +} + +// Seed creates and persists a minimal Application with one Epoch and one Input. +func Seed(ctx context.Context, t *testing.T, repo repository.Repository) *SeedResult { + t.Helper() + + app := NewApplicationBuilder().Create(ctx, t, repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Closed). + WithBlocks(0, 9). + WithInputBounds(0, 0). + Build() + + input := NewInputBuilder(). + WithIndex(0). + WithBlockNumber(5). + Build() + + epochInputMap := map[*Epoch][]*Input{epoch: {input}} + err := repo.CreateEpochsAndInputs(ctx, app.IApplicationAddress.String(), epochInputMap, 10) + require.NoError(t, err) + + return &SeedResult{ + App: app, + Epoch: epoch, + Input: input, + } +} diff --git a/internal/repository/repotest/bulk_test_cases.go b/internal/repository/repotest/bulk_test_cases.go new file mode 100644 index 000000000..6cc9f59cd --- /dev/null +++ b/internal/repository/repotest/bulk_test_cases.go @@ -0,0 +1,1432 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + "fmt" + "sync" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +type BulkOperationsSuite struct { + BaseSuite +} + +func NewBulkOperationsSuite(factory RepositoryFactory) *BulkOperationsSuite { + return &BulkOperationsSuite{BaseSuite: BaseSuite{factory: factory}} +} + +//nolint:mnd // Numeric values are intentionally explicit repository fixtures. +func (s *BulkOperationsSuite) TestStoreAdvanceResult() { + s.Run("RejectsNilResult", func() { + err := s.Repo.StoreAdvanceResult(s.Ctx, 0, nil) + s.Require().EqualError(err, "advance result must not be nil") + }) + + s.Run("AcceptedInput", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + machineHash := crypto.Keccak256Hash([]byte("machine")) + txBufferDataBlock := crypto.Keccak256Hash([]byte("outputs")) + proof := DummyStateProof() + proof.MachineHash = machineHash + proof.TxBufferDataBlock = txBufferDataBlock + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output1"), []byte("output2")}, + Reports: [][]byte{[]byte("report1")}, + StateProof: *proof, + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + // Verify the input was updated + input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_Accepted, input.Status) + s.Require().NotNil(input.MachineHash) + s.Equal(machineHash, *input.MachineHash) + epoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(epoch.MachineHash) + s.Equal(machineHash, *epoch.MachineHash) + s.Require().NotNil(epoch.TxBufferDataBlock) + s.Equal(txBufferDataBlock, *epoch.TxBufferDataBlock) + s.True(epoch.HasCompleteStateProof()) + s.Equal(proof.IflagsYDataBlock, *epoch.IflagsYDataBlock) + s.Equal(proof.HtifTohostDataBlock, *epoch.HtifTohostDataBlock) + + // Verify outputs were created + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 2) + s.Equal(uint64(2), total) + + // Verify reports were created + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(reports, 1) + s.Equal(uint64(1), total) + }) + + s.Run("RejectedInput", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + machineHash := crypto.Keccak256Hash([]byte("machine-rejected")) + proof := DummyStateProof() + proof.MachineHash = machineHash + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Rejected, + Reports: [][]byte{[]byte("rejection report")}, + StateProof: *proof, + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_Rejected, input.Status) + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(result.Reports[0], report.RawData) + s.Zero(report.InputIndex) + s.Zero(report.EpochIndex) + epoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Nil(epoch.MachineHash, "rejection must leave the pre-input epoch state unchanged") + s.Nil(epoch.TxBufferDataBlock) + }) + + for _, status := range []InputCompletionStatus{ + InputCompletionStatus_Exception, + InputCompletionStatus_MachineHalted, + InputCompletionStatus_Overflow, + InputCompletionStatus_UnexpectedYield, + InputCompletionStatus_InvalidOutputsRoot, + } { + s.Run("CompletedStatus/"+status.String(), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + var exceptionData []byte + if status == InputCompletionStatus_Exception { + exceptionData = []byte{0xff, 0x00, 0x80} + } + proof := DummyStateProof() + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: status, + Reports: [][]byte{[]byte("terminal report")}, + ExceptionData: exceptionData, + StateProof: *proof, + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(status, input.Status) + s.Equal(exceptionData, input.ExceptionData) + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(result.Reports[0], report.RawData) + s.Zero(report.InputIndex) + s.Zero(report.EpochIndex) + epoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(epoch.MachineHash) + s.Equal(result.MachineHash, *epoch.MachineHash) + s.Require().NotNil(epoch.TxBufferDataBlock) + s.Equal(result.TxBufferDataBlock, *epoch.TxBufferDataBlock) + s.Equal(proofSiblingsToHashes(proof.TxBufferProof), epoch.TxBufferProof) + s.Equal(proof.IflagsYDataBlock, *epoch.IflagsYDataBlock) + s.Equal(proofSiblingsToHashes(proof.IflagsYProof), epoch.IflagsYProof) + s.Equal(proof.HtifTohostDataBlock, *epoch.HtifTohostDataBlock) + s.Equal(proofSiblingsToHashes(proof.HtifTohostProof), epoch.HtifTohostProof) + s.True(epoch.HasCompleteStateProof()) + + app, err := s.Repo.GetApplication(s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + expectedStatus, ok := status.TerminalApplicationStatus() + s.Require().True(ok) + s.Equal(expectedStatus, app.Status) + s.Require().NotNil(app.Reason) + s.Contains(*app.Reason, "input 0 completed with "+status.String()) + }) + } + + s.Run("RejectsOutputsForNonacceptedInput", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Rejected, + Outputs: [][]byte{[]byte("must-not-be-stored")}, + }) + s.Require().ErrorContains(err, "must not contain outputs") + }) + + s.Run("RejectsCursorAndEpochMismatches", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + base := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 1, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, base) + s.Require().ErrorIs(err, repository.ErrAdvanceCursorMismatch) + + base.InputIndex = 0 + base.EpochIndex = 1 + err = s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, base) + s.Require().ErrorIs(err, repository.ErrAdvanceCursorMismatch) + }) + + s.Run("RejectsResultAfterTerminalInput", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 1).Build() + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(6).Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, + app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, + 10, + ) + s.Require().NoError(err) + err = s.Repo.StoreAdvanceResult(s.Ctx, app.ID, &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_MachineHalted, + StateProof: *DummyStateProof(), + }) + s.Require().NoError(err) + + err = s.Repo.StoreAdvanceResult(s.Ctx, app.ID, &AdvanceResult{ + EpochIndex: 0, + InputIndex: 1, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + }) + s.Require().ErrorIs(err, repository.ErrAdvanceAfterTerminal) + s.Require().ErrorIs(err, repository.ErrApplicationNotRunnable) + }) + + s.Run("RejectsResultWhileApplicationFailedWithoutPartialWrites", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + reason := "runtime unavailable" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, seed.App.ID, ApplicationStatus_Failed, &reason)) + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("must roll back")}, + StateProof: *DummyStateProof(), + }) + s.Require().ErrorIs(err, repository.ErrApplicationNotRunnable) + s.NotErrorIs(err, repository.ErrAdvanceAfterTerminal) + + input, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status) + s.Nil(input.MachineHash) + + epoch, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Nil(epoch.MachineHash) + s.Nil(epoch.TxBufferDataBlock) + + processed, err := s.Repo.GetProcessedInputCount( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Zero(processed) + + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Zero(total) + }) + + for _, test := range []struct { + name string + status InputCompletionStatus + exceptionData []byte + }{ + {"ExceptionWithoutData", InputCompletionStatus_Exception, nil}, + {"AcceptedWithExceptionData", InputCompletionStatus_Accepted, []byte("unexpected")}, + } { + s.Run("Rejects"+test.name, func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: test.status, + ExceptionData: test.exceptionData, + StateProof: StateProof{ + MachineHash: UniqueHash(), + TxBufferDataBlock: UniqueHash(), + }, + }) + s.Require().ErrorContains(err, "exception data") + }) + } + + invalidStatuses := []InputCompletionStatus{ + InputCompletionStatus_None, + "OUTPUTS_LIMIT_EXCEEDED", + "REPORTS_LIMIT_EXCEEDED", + "CYCLE_LIMIT_EXCEEDED", + "TIME_LIMIT_EXCEEDED", + "PAYLOAD_LENGTH_LIMIT_EXCEEDED", + "INVALID", + } + for _, status := range invalidStatuses { + s.Run("RejectsNoncompletedStatus/"+status.String(), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: status, + Outputs: [][]byte{[]byte("must-not-be-stored")}, + StateProof: StateProof{ + MachineHash: UniqueHash(), + }, + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().ErrorContains(err, "noncompleted status") + input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status) + }) + } + + s.Run("WithNoOutputsOrReports", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + machineHash := crypto.Keccak256Hash([]byte("machine-empty")) + proof := DummyStateProof() + proof.MachineHash = machineHash + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *proof, + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_Accepted, input.Status) + }) + + // A result for the current input but a different epoch is rejected by the + // locked-row preflight before any child rows are inserted. + s.Run("RejectsEpochMismatchBeforeWrites", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + result := &AdvanceResult{ + EpochIndex: 99, // non-existent epoch + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("should-be-rolled-back")}, + Reports: [][]byte{[]byte("should-be-rolled-back")}, + StateProof: *DummyStateProof(), + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().ErrorIs(err, repository.ErrAdvanceCursorMismatch) + + // Input status should remain unchanged (NONE) + input, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status) + + // No outputs should have been persisted + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Equal(uint64(0), total) + + // No reports should have been persisted + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(reports) + s.Equal(uint64(0), total) + + // ProcessedInputs should remain at 0 + count, err := s.Repo.GetProcessedInputCount( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), count) + }) + + s.Run("DaveConsensusWithStateHashes", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + machineHash := crypto.Keccak256Hash([]byte("dave-machine")) + txBufferDataBlock := crypto.Keccak256Hash([]byte("dave-outputs")) + proof := DummyStateProof() + proof.MachineHash = machineHash + proof.TxBufferDataBlock = txBufferDataBlock + + hash1 := [32]byte(crypto.Keccak256Hash([]byte("state-1"))) + hash2 := [32]byte(crypto.Keccak256Hash([]byte("state-2"))) + hash3 := [32]byte(crypto.Keccak256Hash([]byte("state-3"))) + hashes := [][32]byte{hash1, hash2, hash3} + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("dave-output")}, + PeriodicStateHashes: hashes, + PaddingRepetitions: InputHashCollectionCapacity - uint64(len(hashes)), + IsDaveConsensus: true, + StateProof: *proof, + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + // Verify one row per collected entry plus the padding row. + epochIdx := uint64(0) + total, err := s.Repo.CountEpochStateHashes(s.Ctx, seed.App.ID, epochIdx) + s.Require().NoError(err) + stateHashes, err := s.Repo.ListEpochStateHashes(s.Ctx, seed.App.ID, epochIdx, nil, 10) + s.Require().NoError(err) + expectedStateHashRows := len(hashes) + 1 + s.Len(stateHashes, expectedStateHashRows) + s.Equal(uint64(expectedStateHashRows), total) + + // Verify intermediate hashes have Repetitions=1 + s.Equal(common.Hash(hash1), stateHashes[0].MachineHash) + s.Equal(uint64(1), stateHashes[0].Repetitions) + s.Equal(common.Hash(hash2), stateHashes[1].MachineHash) + s.Equal(uint64(1), stateHashes[1].Repetitions) + s.Equal(common.Hash(hash3), stateHashes[2].MachineHash) + s.Equal(uint64(1), stateHashes[2].Repetitions) + + // Verify the final entry has PaddingRepetitions as Repetitions. + tail := stateHashes[len(hashes)] + s.Equal(FinalStateEntry(machineHash), tail.MachineHash) + s.Equal(result.PaddingRepetitions, tail.Repetitions) + + // Verify outputs were also created + outputs, _, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 1) + + // Verify input was updated + input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_Accepted, input.Status) + }) + + const resultPageLimit = 10 + const hashesAboveExtendedProtocolParameterLimit = 11_000 + + s.Run("DaveConsensusStreamsStateHashesAboveParameterLimit", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + hashes := make([][32]byte, hashesAboveExtendedProtocolParameterLimit) + machineHash := UniqueHash() + proof := DummyStateProof() + proof.MachineHash = machineHash + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + PeriodicStateHashes: hashes, + PaddingRepetitions: InputHashCollectionCapacity - uint64(len(hashes)), + IsDaveConsensus: true, + StateProof: *proof, + } + + s.Require().NoError(s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result)) + epochIndex := uint64(0) + expectedRows := len(hashes) + 1 + total, err := s.Repo.CountEpochStateHashes(s.Ctx, seed.App.ID, epochIndex) + s.Require().NoError(err) + const pageSize = 10_000 + var stateHashes []EpochStateHash + var cursor *uint64 + for { + page, err := s.Repo.ListEpochStateHashes(s.Ctx, seed.App.ID, epochIndex, cursor, pageSize) + s.Require().NoError(err) + stateHashes = append(stateHashes, page...) + s.Require().LessOrEqual(len(stateHashes), expectedRows) + if len(page) < pageSize { + break + } + last := page[len(page)-1].Index + cursor = &last + } + for index, row := range stateHashes { + s.Equal(uint64(index), row.Index) + } + s.Equal(uint64(expectedRows), total) + s.Require().Len(stateHashes, expectedRows) + for _, index := range []int{0, len(hashes) / 2, len(hashes) - 1} { + s.Equal(common.Hash(hashes[index]), stateHashes[index].MachineHash) + s.Equal(uint64(index), stateHashes[index].Index) + s.Equal(uint64(1), stateHashes[index].Repetitions) + } + tail := stateHashes[len(hashes)] + s.Equal(FinalStateEntry(machineHash), tail.MachineHash) + s.Equal(uint64(len(hashes)), tail.Index) + s.Equal(InputHashCollectionCapacity-uint64(len(hashes)), tail.Repetitions) + }) + + s.Run("DaveConsensusEntriesRemainOrderedAcrossInputs", func() { + app := NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 2).Build() + inputs := []*Input{ + NewInputBuilder().WithIndex(0).WithBlockNumber(1).Build(), + NewInputBuilder().WithIndex(1).WithBlockNumber(2).Build(), + } + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, map[*Epoch][]*Input{epoch: inputs}, 10)) + entries := [][][32]byte{ + {[32]byte(UniqueHash()), [32]byte(UniqueHash())}, + {[32]byte(UniqueHash())}, + } + proof := DummyStateProof() + statuses := []InputCompletionStatus{InputCompletionStatus_Accepted, InputCompletionStatus_Rejected} + for inputIndex, inputEntries := range entries { + // A rejected input retains the preceding canonical state proof. + s.Require().NoError(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, &AdvanceResult{ + EpochIndex: epoch.Index, + InputIndex: uint64(inputIndex), + Status: statuses[inputIndex], + StateProof: *proof, + PeriodicStateHashes: inputEntries, + PaddingRepetitions: InputHashCollectionCapacity - uint64(len(inputEntries)), + IsDaveConsensus: true, + })) + } + total, err := s.Repo.CountEpochStateHashes(s.Ctx, app.ID, epoch.Index) + s.Require().NoError(err) + stored, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, epoch.Index, nil, 10) + s.Require().NoError(err) + s.Require().Len(stored, 5) + s.Equal(uint64(len(stored)), total) + var rowIndex uint64 + for inputIndex, inputEntries := range entries { + for _, entry := range inputEntries { + row := stored[rowIndex] + s.Equal(rowIndex, row.Index) + s.Equal(uint64(inputIndex), row.InputIndex) + s.Equal(common.Hash(entry), row.MachineHash) + s.Equal(uint64(1), row.Repetitions) + rowIndex++ + } + paddingRow := stored[rowIndex] + s.Equal(rowIndex, paddingRow.Index) + s.Equal(uint64(inputIndex), paddingRow.InputIndex) + s.Equal(FinalStateEntry(proof.MachineHash), paddingRow.MachineHash) + s.Equal(InputHashCollectionCapacity-uint64(len(inputEntries)), paddingRow.Repetitions) + rowIndex++ + } + }) + + s.Run("DaveConsensusRollsBackChildrenOnInvalidHashSpan", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + proof := DummyStateProof() + proof.TxBufferDataBlock = UniqueHash() + proof.MachineHash = UniqueHash() + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("must-roll-back")}, + Reports: [][]byte{[]byte("must-roll-back")}, + PaddingRepetitions: 0, + IsDaveConsensus: true, + StateProof: *proof, + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().ErrorContains(err, "does not cover input hash collection capacity") + // The complete proof and locked rows let this transaction insert the + // output and report before state-hash shape validation fails. Their + // absence below is the rollback witness. + + input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status) + outputs, outputCount, err := s.Repo.ListOutputs( + s.Ctx, + seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, + repository.Pagination{Limit: resultPageLimit}, + false, + ) + s.Require().NoError(err) + s.Empty(outputs) + s.Zero(outputCount) + reports, reportCount, err := s.Repo.ListReports( + s.Ctx, + seed.App.IApplicationAddress.String(), + repository.ReportFilter{}, + repository.Pagination{Limit: resultPageLimit}, + false, + ) + s.Require().NoError(err) + s.Empty(reports) + s.Zero(reportCount) + epochIndex := uint64(0) + stateHashCount, err := s.Repo.CountEpochStateHashes(s.Ctx, seed.App.ID, epochIndex) + s.Require().NoError(err) + stateHashes, err := s.Repo.ListEpochStateHashes(s.Ctx, seed.App.ID, epochIndex, nil, resultPageLimit) + s.Require().NoError(err) + s.Empty(stateHashes) + s.Zero(stateHashCount) + epoch, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch.Index) + s.Require().NoError(err) + s.False(epoch.HasCompleteStateProof()) + app, err := s.Repo.GetApplication(s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), app.ProcessedInputs) + s.Equal(ApplicationStatus_OK, app.Status) + }) +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultPreflight() { + // The application cursor is locked and checked before any child rows are + // written, so an unexpected input index is a preflight rejection. + s.Run("RejectsUnexpectedInputIndex", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 999, // non-existent input index + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("must-not-be-inserted")}, + Reports: [][]byte{[]byte("must-not-be-inserted")}, + StateProof: *DummyStateProof(), + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().ErrorIs(err, repository.ErrAdvanceCursorMismatch) + + // Verify the preflight rejected the result before inserting outputs. + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Equal(uint64(0), total) + + // Verify the preflight rejected the result before inserting reports. + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(reports) + s.Equal(uint64(0), total) + + // Verify the original input is untouched + input, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status) + + // Verify ProcessedInputs remains 0 + count, err := s.Repo.GetProcessedInputCount( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), count) + }) + + // Missing applications are rejected while acquiring the aggregate row. + s.Run("RejectsUnknownApplication", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("must-not-be-inserted")}, + Reports: [][]byte{[]byte("must-not-be-inserted")}, + StateProof: *DummyStateProof(), + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, 999999, result) + s.Require().ErrorIs(err, repository.ErrNotFound) + + // Verify the original input is untouched + input, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status) + + // Verify no outputs were persisted + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Equal(uint64(0), total) + }) + + // A Dave result that names the wrong epoch is also rejected by locked-row + // preflight; state-hash insertion is never reached. + s.Run("DaveConsensusRejectsEpochMismatch", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + hashes := [][32]byte{{1}, {2}} + + result := &AdvanceResult{ + EpochIndex: 99, // non-existent epoch + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("must-not-be-inserted")}, + PeriodicStateHashes: hashes, + PaddingRepetitions: InputHashCollectionCapacity - uint64(len(hashes)), + IsDaveConsensus: true, + StateProof: *DummyStateProof(), + } + + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().ErrorIs(err, repository.ErrAdvanceCursorMismatch) + + // Verify the preflight rejected the result before inserting outputs. + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Equal(uint64(0), total) + + // Verify the input remains unprocessed + input, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status) + }) +} + +func (s *BulkOperationsSuite) TestStoreClaimAndProofs() { + s.Run("StoresClaimAndOutputProofs", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // First store an advance result to create outputs. + machineHash := crypto.Keccak256Hash([]byte("machine")) + outputData := []byte("output-for-claim") + txBufferDataBlock := crypto.Keccak256Hash([]byte("outputs-merkle")) + stateProof := DummyStateProof() + stateProof.MachineHash = machineHash + stateProof.TxBufferDataBlock = txBufferDataBlock + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{outputData}, + StateProof: *stateProof, + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + // Publish the final state proof only after all inputs are stored. + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, EpochStatus_InputsProcessed) + + // Now store claim and proofs using Commitment/CommitmentProof fields + commitmentHash := crypto.Keccak256Hash([]byte("commitment")) + seed.Epoch.Commitment = &commitmentHash + seed.Epoch.CommitmentProof = []common.Hash{UniqueHash(), UniqueHash()} + + outputHash := crypto.Keccak256Hash(outputData) + proof := []common.Hash{UniqueHash(), UniqueHash()} + out := &Output{ + InputEpochApplicationID: seed.App.ID, + InputIndex: 0, + Index: 0, + RawData: outputData, + Hash: &outputHash, + OutputHashesSiblings: proof, + } + + err = s.Repo.StoreClaimAndProofs(s.Ctx, seed.Epoch, WriteProofs(out)) + s.Require().NoError(err) + + gotEpoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimComputed, gotEpoch.Status) + s.Require().NotNil(gotEpoch.Commitment) + s.Equal(commitmentHash, *gotEpoch.Commitment) + }) +} + +func (s *BulkOperationsSuite) TestStoreTournamentEvents() { + const ( + joinedEventScanEnd uint64 = 100 + advancedEventScanEnd uint64 = 200 + deletedEventScanEnd uint64 = 300 + ) + // setupTournamentWithMatch creates a PRT app with a tournament, two + // commitments, and one match, all stored via StoreTournamentEvents. + type tournamentSetup struct { + app *Application + tournAddr common.Address + tournament *Tournament + match *Match + } + setupTournamentWithMatch := func() *tournamentSetup { + s.T().Helper() + app := NewApplicationBuilder(). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + tournAddr := UniqueAddress() + tournament := NewTournamentBuilder(app.ID). + WithEpochIndex(0).WithAddress(tournAddr).Build() + err = s.Repo.CreateTournament( + s.Ctx, app.IApplicationAddress.String(), tournament) + s.Require().NoError(err) + + commitment1 := NewCommitmentBuilder(app.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + commitment2 := NewCommitmentBuilder(app.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + + matchIDHash := UniqueHash() + match := NewMatchBuilder(app.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + WithIDHash(matchIDHash). + WithCommitmentOne(commitment1.Commitment). + WithCommitmentTwo(commitment2.Commitment). + Build() + + err = StoreTournamentEvents( + s.Ctx, s.Repo, app.ID, + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{commitment1, commitment2}, Matches: []*Match{match}}}, joinedEventScanEnd) + s.Require().NoError(err) + + return &tournamentSetup{app: app, tournAddr: tournAddr, tournament: tournament, match: match} + } + + s.Run("StoresCommitmentsAndMatches", func() { + ts := setupTournamentWithMatch() + + // Verify commitment was stored + gotCommitment, err := s.Repo.GetCommitment( + s.Ctx, ts.app.IApplicationAddress.String(), + 0, ts.tournAddr.String(), ts.match.CommitmentOne.Hex()) + s.Require().NoError(err) + s.Equal(ts.match.CommitmentOne, gotCommitment.Commitment) + + // Verify match was stored + gotMatch, err := s.Repo.GetMatch( + s.Ctx, ts.app.IApplicationAddress.String(), + 0, ts.tournAddr.String(), ts.match.IDHash.Hex()) + s.Require().NoError(err) + s.Equal(ts.match.IDHash, gotMatch.IDHash) + }) + + s.Run("StoresMatchAdvanced", func() { + ts := setupTournamentWithMatch() + + // Now store a match advanced event for the existing match + ma := NewMatchAdvancedBuilder(ts.app.ID). + WithEpochIndex(0). + WithTournamentAddress(ts.tournAddr). + WithIDHash(ts.match.IDHash). + Build() + ts.tournament.Snapshot.AsOfBlock = advancedEventScanEnd + + err := StoreTournamentEvents( + s.Ctx, s.Repo, ts.app.ID, + []*repository.TournamentEventBatch{{Tournament: ts.tournament, MatchAdvances: []*MatchAdvanced{ma}}}, advancedEventScanEnd) + s.Require().NoError(err) + + // Verify match advanced was stored + gotMA, err := s.Repo.GetMatchAdvanced( + s.Ctx, ts.app.IApplicationAddress.String(), + 0, ts.tournAddr.String(), ts.match.IDHash.Hex(), ma.TxHash, ma.LogIndex) + s.Require().NoError(err) + s.Require().NotNil(gotMA) + s.Equal(ma.OtherParent, gotMA.OtherParent) + }) + + s.Run("UpdatesDeletedMatches", func() { + ts := setupTournamentWithMatch() + + // Mark the match as deleted (winner decided) + deletedMatch := *ts.match + deletedMatch.Winner = WinnerCommitment_ONE + deletedMatch.DeletionReason = MatchDeletionReason_TIMEOUT + deletedMatch.DeletionBlockNumber = advancedEventScanEnd + deletedMatch.DeletionTxHash = new(UniqueHash()) + deletedMatch.DeletionLogIndex = new(uint64(0)) + deletedMatch.Snapshot = MatchSnapshot{ + AsOfBlock: deletedEventScanEnd, Phase: MatchPhaseUninitialized, TimeoutOutcome: MatchTimeoutNone, + } + ts.tournament.Snapshot.AsOfBlock = deletedEventScanEnd + + err := StoreTournamentEvents( + s.Ctx, s.Repo, ts.app.ID, + []*repository.TournamentEventBatch{{Tournament: ts.tournament, Matches: []*Match{&deletedMatch}}}, deletedEventScanEnd) + s.Require().NoError(err) + + // Verify the match was updated + gotMatch, err := s.Repo.GetMatch( + s.Ctx, ts.app.IApplicationAddress.String(), + 0, ts.tournAddr.String(), ts.match.IDHash.Hex()) + s.Require().NoError(err) + s.Equal(WinnerCommitment_ONE, gotMatch.Winner) + s.Equal(MatchDeletionReason_TIMEOUT, gotMatch.DeletionReason) + s.Equal(deletedMatch.DeletionBlockNumber, gotMatch.DeletionBlockNumber) + s.Equal(deletedMatch.DeletionTxHash, gotMatch.DeletionTxHash) + }) +} + +//nolint:mnd // Numeric values are intentionally explicit concurrency fixtures. +func (s *BulkOperationsSuite) TestConcurrentStoreAdvanceResult() { + // Verify that concurrent StoreAdvanceResult calls for different + // applications succeed independently without corrupting data. + s.Run("DifferentApplications", func() { + seed1 := Seed(s.Ctx, s.T(), s.Repo) + seed2 := Seed(s.Ctx, s.T(), s.Repo) + + var wg sync.WaitGroup + errs := make([]error, 2) + + seeds := []*SeedResult{seed1, seed2} + for i, seed := range seeds { + wg.Add(1) + go func() { + defer wg.Done() + stateProof := DummyStateProof() + stateProof.TxBufferDataBlock = crypto.Keccak256Hash( + []byte(fmt.Sprintf("outputs-%d", i))) + stateProof.MachineHash = crypto.Keccak256Hash( + []byte(fmt.Sprintf("machine-%d", i))) + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte(fmt.Sprintf("output-%d", i))}, + Reports: [][]byte{[]byte(fmt.Sprintf("report-%d", i))}, + StateProof: *stateProof, + } + errs[i] = s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + }() + } + wg.Wait() + + s.Require().NoError(errs[0], "first app store should succeed") + s.Require().NoError(errs[1], "second app store should succeed") + + // Verify first application + input1, err := s.Repo.GetInput( + s.Ctx, seed1.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_Accepted, input1.Status) + + count1, err := s.Repo.GetProcessedInputCount( + s.Ctx, seed1.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(1), count1) + + outputs1, total1, err := s.Repo.ListOutputs( + s.Ctx, seed1.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs1, 1) + s.Equal(uint64(1), total1) + + // Verify second application + input2, err := s.Repo.GetInput( + s.Ctx, seed2.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_Accepted, input2.Status) + + count2, err := s.Repo.GetProcessedInputCount( + s.Ctx, seed2.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(1), count2) + + outputs2, total2, err := s.Repo.ListOutputs( + s.Ctx, seed2.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs2, 1) + s.Equal(uint64(1), total2) + }) + + // Verify that concurrent StoreAdvanceResult calls for the same input + // do not corrupt data. At least one goroutine must succeed and the + // final state must be consistent. + s.Run("SameInputAtomicity", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + const numGoroutines = 5 + var wg sync.WaitGroup + errs := make([]error, numGoroutines) + + for i := range numGoroutines { + wg.Add(1) + go func() { + defer wg.Done() + stateProof := DummyStateProof() + stateProof.TxBufferDataBlock = crypto.Keccak256Hash( + []byte(fmt.Sprintf("outputs-%d", i))) + stateProof.MachineHash = crypto.Keccak256Hash( + []byte(fmt.Sprintf("machine-%d", i))) + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte(fmt.Sprintf("output-%d", i))}, + StateProof: *stateProof, + } + errs[i] = s.Repo.StoreAdvanceResult( + s.Ctx, seed.App.ID, result) + }() + } + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + s.Equal(1, successCount, "exactly one concurrent store may advance the cursor") + + // Verify data integrity: the input must be in Accepted state + input, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_Accepted, input.Status) + + // ProcessedInputs must reflect exactly one successful processing + count, err := s.Repo.GetProcessedInputCount( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(1), count) + }) + + // Input ingestion and advance persistence both touch the current epoch, + // input rows, and application cursor. They must share a lock order so the + // event reader can index a later input while the advancer completes the + // preceding one. + s.Run("ConcurrentInputIngestion", func() { + for _, status := range []InputCompletionStatus{ + InputCompletionStatus_Accepted, + InputCompletionStatus_MachineHalted, + } { + s.Run(status.String(), func() { + const attempts = 10 + for range attempts { + seed := Seed(s.Ctx, s.T(), s.Repo) + nextEpoch := NewEpochBuilder(seed.App.ID). + WithIndex(0). + WithStatus(EpochStatus_Closed). + WithBlocks(0, 9). + WithInputBounds(0, 1). + Build() + nextInput := NewInputBuilder(). + WithIndex(1). + WithBlockNumber(6). + Build() + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: status, + StateProof: *DummyStateProof(), + } + + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(2) + go func() { + defer wg.Done() + <-start + errs[0] = s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + }() + go func() { + defer wg.Done() + <-start + errs[1] = s.Repo.CreateEpochsAndInputs( + s.Ctx, + seed.App.IApplicationAddress.String(), + map[*Epoch][]*Input{nextEpoch: {nextInput}}, + 11, + ) + }() + close(start) + wg.Wait() + + s.Require().NoError(errs[0], "store advance result") + s.Require().NoError(errs[1], "index later input") + + stored, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), nextInput.Index) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, stored.Status) + } + }) + } + }) +} + +func (s *BulkOperationsSuite) TestStoreClaimAndProofsRollback() { + // When the epoch doesn't exist, updateEpochClaim should fail with + // RowsAffected == 0 and the whole transaction should be rolled back. + s.Run("RollbackOnEpochUpdateFailure", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // Store advance result first so the epoch has outputs + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output")}, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + // Build an epoch that doesn't exist in the DB + commitmentHash := crypto.Keccak256Hash([]byte("commitment")) + badEpoch := &Epoch{ + ApplicationID: seed.App.ID, + Index: 99, // doesn't exist + Commitment: &commitmentHash, + CommitmentProof: []common.Hash{UniqueHash()}, + } + + err = s.Repo.StoreClaimAndProofs(s.Ctx, badEpoch, nil) + s.Require().Error(err) + + // Verify the real epoch is untouched + gotEpoch, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, gotEpoch.Status) + s.Nil(gotEpoch.Commitment) + }) + + // When updateOutputs fails (output doesn't exist), the epoch status + // change from updateEpochClaim must also be rolled back. + s.Run("RollbackOnOutputProofUpdateFailure", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // Store advance result to create one output (index 0) + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("real-output")}, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + // Publish the final state proof only after all inputs are stored. + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, + EpochStatus_InputsProcessed) + + // Prepare a valid epoch claim (this part would succeed) + commitmentHash := crypto.Keccak256Hash([]byte("commitment")) + seed.Epoch.Commitment = &commitmentHash + seed.Epoch.CommitmentProof = []common.Hash{UniqueHash()} + + // Prepare an output with a non-existent index to trigger failure + badHash := UniqueHash() + nonExistentOutput := &Output{ + InputEpochApplicationID: seed.App.ID, + InputIndex: 0, + Index: 999, // doesn't exist + Hash: &badHash, + OutputHashesSiblings: []common.Hash{UniqueHash()}, + } + + err = s.Repo.StoreClaimAndProofs( + s.Ctx, seed.Epoch, WriteProofs(nonExistentOutput)) + s.Require().Error(err) + + // Verify the epoch status was rolled back — still InputsProcessed + gotEpoch, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_InputsProcessed, gotEpoch.Status, + "epoch status should be rolled back to InputsProcessed") + s.Nil(gotEpoch.Commitment, + "commitment should not have been persisted") + + // Verify the real output's hash was NOT changed + realOutput, err := s.Repo.GetOutput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Nil(realOutput.Hash, + "existing output hash should remain nil after rollback") + }) +} + +func (s *BulkOperationsSuite) TestStoreTournamentEventsRollback() { + const failedWindowEnd uint64 = 100 + // Helper: create a PRT application with one closed epoch and a tournament. + setupPRTApp := func() (app *Application, tournament *Tournament) { + s.T().Helper() + app = NewApplicationBuilder(). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + tournAddr := UniqueAddress() + tournament = NewTournamentBuilder(app.ID). + WithEpochIndex(0).WithAddress(tournAddr).Build() + err = s.Repo.CreateTournament( + s.Ctx, app.IApplicationAddress.String(), tournament) + s.Require().NoError(err) + return app, tournament + } + + // Insert valid commitments + a match that references a non-existent + // commitment, causing an FK violation. The commitments + // inserted in the same transaction must be rolled back. + s.Run("RollbackOnMatchInsertFailure", func() { + app, tournament := setupPRTApp() + tournAddr := tournament.Address + + // Valid commitment targeting the real tournament + commitment := NewCommitmentBuilder(app.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + + // Match referencing a non-existent second commitment → FK violation + match := NewMatchBuilder(app.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + WithCommitmentOne(commitment.Commitment). + WithCommitmentTwo(UniqueHash()). + Build() + + err := StoreTournamentEvents( + s.Ctx, s.Repo, app.ID, + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{commitment}, Matches: []*Match{match}}}, failedWindowEnd) + s.Require().Error(err) + + // Verify the commitment was rolled back + got, err := s.Repo.GetCommitment( + s.Ctx, app.IApplicationAddress.String(), + 0, tournAddr.String(), commitment.Commitment.Hex()) + s.Require().NoError(err) + s.Nil(got, "commitment should have been rolled back") + }) + + // A newly observed deleted match still needs both joined commitments. + // Its missing second commitment must roll back the valid first join. + s.Run("RollbackOnDeletedMatchInsertFailure", func() { + app, tournament := setupPRTApp() + tournAddr := tournament.Address + + // Valid new commitment + newCommitment := NewCommitmentBuilder(app.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + + deletedMatch := NewMatchBuilder(app.ID). + WithTournamentAddress(tournAddr). + WithCommitmentOne(newCommitment.Commitment). + WithCommitmentTwo(UniqueHash()). + WithWinner(WinnerCommitment_ONE). + WithDeletion(MatchDeletionReason_TIMEOUT, failedWindowEnd, UniqueHash()).Build() + + err := StoreTournamentEvents( + s.Ctx, s.Repo, app.ID, + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{newCommitment}, Matches: []*Match{deletedMatch}}}, failedWindowEnd) + s.Require().Error(err) + + // Verify the new commitment was rolled back + got, err := s.Repo.GetCommitment( + s.Ctx, app.IApplicationAddress.String(), + 0, tournAddr.String(), newCommitment.Commitment.Hex()) + s.Require().NoError(err) + s.Nil(got, "commitment should have been rolled back") + }) + + // Insert a match advanced event for a match that doesn't exist, + // causing an FK violation. Commitments in the same tx should roll back. + s.Run("RollbackOnMatchAdvancedInsertFailure", func() { + app, tournament := setupPRTApp() + tournAddr := tournament.Address + + // Valid new commitment + newCommitment := NewCommitmentBuilder(app.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + + // Match advanced for a non-existent match → FK violation + bogusMA := NewMatchAdvancedBuilder(app.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + WithIDHash(UniqueHash()). // match doesn't exist + Build() + + err := StoreTournamentEvents( + s.Ctx, s.Repo, app.ID, + []*repository.TournamentEventBatch{{Tournament: tournament, + Commitments: []*Commitment{newCommitment}, MatchAdvances: []*MatchAdvanced{bogusMA}}}, failedWindowEnd) + s.Require().Error(err) + + // Verify the commitment was rolled back + got, err := s.Repo.GetCommitment( + s.Ctx, app.IApplicationAddress.String(), + 0, tournAddr.String(), newCommitment.Commitment.Hex()) + s.Require().NoError(err) + s.Nil(got, "commitment should have been rolled back") + }) +} + +func (s *BulkOperationsSuite) TestContextCancellation() { + // Verify that StoreAdvanceResult respects a cancelled context + // and does not persist any data. + s.Run("StoreAdvanceResultWithCancelledContext", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + cancelledCtx, cancel := context.WithCancel(s.Ctx) + cancel() // cancel immediately + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("should-not-persist")}, + Reports: [][]byte{[]byte("should-not-persist")}, + StateProof: *DummyStateProof(), + } + + err := s.Repo.StoreAdvanceResult(cancelledCtx, seed.App.ID, result) + s.Require().Error(err) + s.Require().ErrorIs(err, context.Canceled) + + // Data should not have been persisted + input, err := s.Repo.GetInput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(InputCompletionStatus_None, input.Status, + "input should remain unprocessed after context cancellation") + + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Equal(uint64(0), total) + }) + + // Verify that CreateEpochsAndInputs respects a cancelled context. + s.Run("CreateEpochsAndInputsWithCancelledContext", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + cancelledCtx, cancel := context.WithCancel(s.Ctx) + cancel() // cancel immediately + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + cancelledCtx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().Error(err) + + // The epoch should not have been persisted + got, err := s.Repo.GetEpoch( + s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Nil(got, "epoch should not exist after context cancellation") + }) + + // Verify that StoreClaimAndProofs respects a cancelled context. + s.Run("StoreClaimAndProofsWithCancelledContext", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // Store an advance result so the epoch has outputs + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output")}, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + cancelledCtx, cancel := context.WithCancel(s.Ctx) + cancel() + + commitmentHash := crypto.Keccak256Hash([]byte("commitment")) + seed.Epoch.Commitment = &commitmentHash + seed.Epoch.CommitmentProof = []common.Hash{UniqueHash()} + + err = s.Repo.StoreClaimAndProofs(cancelledCtx, seed.Epoch, nil) + s.Require().Error(err) + + // Epoch should remain in Closed state + gotEpoch, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, gotEpoch.Status, + "epoch status should not change after context cancellation") + }) +} diff --git a/internal/repository/repotest/claim_proof_cases.go b/internal/repository/repotest/claim_proof_cases.go new file mode 100644 index 000000000..27dfec135 --- /dev/null +++ b/internal/repository/repotest/claim_proof_cases.go @@ -0,0 +1,101 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + "errors" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *BulkOperationsSuite) TestClaimProofWriterTransaction() { + const ( + noWriter = "nil" + scanOutputs = "scan" + errorAfterBatches = "two batches then error" + outputCount uint64 = 2 + missingOutputIndex uint64 = 999 + ) + for _, outcome := range []string{noWriter, scanOutputs, errorAfterBatches, "missing output", "over cap"} { + s.Run(outcome, func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + raw := [][]byte{[]byte("first"), []byte("second")} + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, seed.App.ID, seed.Epoch.Index, seed.Input.Index, + model.InputCompletionStatus_Accepted, raw, nil) + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, seed.App.Name, seed.Epoch, model.EpochStatus_InputsProcessed) + hash := UniqueHash() + proofs := []repository.OutputProof{{Index: 0, Hash: hash, Siblings: []common.Hash{UniqueHash()}}, + {Index: 1, Hash: hash, Siblings: []common.Hash{UniqueHash()}}} + sentinel := errors.New("failure after both batches") + called := false + var write repository.ClaimProofWriteFunc + if outcome != noWriter { + write = func(ctx context.Context, writer repository.ClaimProofWriter) error { + called = true + switch outcome { + case scanOutputs: + rows := uint64(0) + err := writer.ScanOutputs(ctx, 0, 1, func(index uint64, data []byte) error { + s.Require().Less(rows, outputCount) + s.Equal(rows, index) + s.Equal(raw[rows], data) + rows++ + return nil + }) + s.Require().NoError(err) + s.Equal(outputCount, rows) + return writer.StoreOutputProofs(ctx, proofs) + case errorAfterBatches: + for _, proof := range proofs { + if err := writer.StoreOutputProofs(ctx, []repository.OutputProof{proof}); err != nil { + return err + } + } + return sentinel + case "missing output": + if err := writer.StoreOutputProofs(ctx, proofs[:1]); err != nil { + return err + } + return writer.StoreOutputProofs(ctx, []repository.OutputProof{{Index: missingOutputIndex, Hash: hash}}) + default: + return writer.StoreOutputProofs(ctx, make([]repository.OutputProof, repository.MaxOutputRangeRows+1)) + } + } + } + err := s.Repo.StoreClaimAndProofs(s.Ctx, seed.Epoch, write) + success := outcome == noWriter || outcome == scanOutputs + if success { + s.Require().NoError(err) + } else { + s.Require().Error(err) + } + if outcome == errorAfterBatches { + s.ErrorIs(err, sentinel) + } + s.Equal(outcome != noWriter, called) + stored, err := s.Repo.GetEpoch(s.Ctx, seed.App.Name, seed.Epoch.Index) + s.Require().NoError(err) + status := model.EpochStatus_InputsProcessed + if success { + status = model.EpochStatus_ClaimComputed + } + s.Equal(status, stored.Status) + for _, proof := range proofs { + output, err := s.Repo.GetOutput(s.Ctx, seed.App.Name, proof.Index) + s.Require().NoError(err) + if outcome == scanOutputs { + s.Equal(&proof.Hash, output.Hash) + s.Equal(proof.Siblings, output.OutputHashesSiblings) + } else { + s.Nil(output.Hash) + s.Empty(output.OutputHashesSiblings) + } + } + }) + } +} diff --git a/internal/repository/repotest/claimer_test_cases.go b/internal/repository/repotest/claimer_test_cases.go new file mode 100644 index 000000000..82265d78a --- /dev/null +++ b/internal/repository/repotest/claimer_test_cases.go @@ -0,0 +1,988 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +type ClaimerSuite struct { + BaseSuite +} + +func NewClaimerSuite(factory RepositoryFactory) *ClaimerSuite { + return &ClaimerSuite{BaseSuite: BaseSuite{factory: factory}} +} + +// createAppWithClaimComputedEpoch creates an app with one epoch at ClaimComputed status. +func (s *ClaimerSuite) createAppWithClaimComputedEpoch() *Application { + s.T().Helper() + return s.createAppWithClaimComputedEpochForConsensus(Consensus_Authority) +} + +func (s *ClaimerSuite) createAppWithClaimComputedEpochForConsensus(c Consensus) *Application { + s.T().Helper() + app := NewApplicationBuilder().WithConsensus(c).Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimComputed) + + return app +} + +// oldestClaimsFromPages applies the existing selector assertions to the real +// paged queries. It collects only each application's oldest row; deep-window +// traversal is covered separately by the PostgreSQL paging fixtures. +func (s *ClaimerSuite) oldestClaimsFromPages( + ctx context.Context, + query func(context.Context, repository.ClaimCursor, uint64) (repository.ClaimPage, error), +) (map[int64]*Epoch, map[int64]*Application, error) { + epochs, apps := map[int64]*Epoch{}, map[int64]*Application{} + seen := map[int64]bool{} + cursor := repository.ClaimCursor{} + const minimumWindow = 2 // One oldest row and one later-row slot. + for { + page, err := query(ctx, cursor, minimumWindow) + if err != nil { + return nil, nil, err + } + if page.ApplicationID == 0 || seen[page.ApplicationID] { + return epochs, apps, nil + } + seen[page.ApplicationID] = true + if len(page.Work) != 0 { + first := page.Work[0] + epochs[first.Application.ID], apps[first.Application.ID] = first.Epoch, first.Application + } + cursor.AfterApplicationID = page.ApplicationID + } +} + +func (s *ClaimerSuite) TestSelectClaimsToSubmitPages() { + s.Run("EmptyWhenNoClaimComputed", func() { + NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // Returns the oldest computed epoch and its application. + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.Empty(computed) + s.Empty(apps) + }) + + s.Run("ReturnsPairWhenClaimComputed", func() { + app := s.createAppWithClaimComputedEpoch() + + // Returns the oldest computed epoch and its application. + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.NotEmpty(computed) + s.NotEmpty(apps) + s.Contains(computed, app.ID) + s.Contains(apps, app.ID) + }) + + s.Run("LoadsCompleteStateProofForComputedClaim", func() { + const ( + firstEpochLastBlock uint64 = 9 + secondEpochFirstBlock uint64 = 10 + secondEpochLastBlock uint64 = 19 + firstInputBlock uint64 = 5 + secondInputBlock uint64 = 15 + thirdEpochFirstBlock uint64 = 20 + ) + + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, firstEpochLastBlock).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(secondEpochFirstBlock, secondEpochLastBlock).WithInputBounds(1, 1).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, + app.IApplicationAddress.String(), + map[*Epoch][]*Input{ + epoch0: {NewInputBuilder().WithIndex(0).WithBlockNumber(firstInputBlock).Build()}, + epoch1: {NewInputBuilder().WithIndex(1).WithBlockNumber(secondInputBlock).Build()}, + }, + thirdEpochFirstBlock, + ) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch0, EpochStatus_ClaimComputed) + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, epoch0.Index, UniqueHash()) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch1, EpochStatus_ClaimComputed) + + computed, _, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.Require().Contains(computed, app.ID) + + assertStateProof := func(expected, actual *Epoch) { + s.Require().True(actual.HasCompleteStateProof()) + s.Equal(expected.MachineHash, actual.MachineHash) + s.Equal(expected.TxBufferDataBlock, actual.TxBufferDataBlock) + s.Equal(expected.TxBufferProof, actual.TxBufferProof) + s.Equal(expected.IflagsYDataBlock, actual.IflagsYDataBlock) + s.Equal(expected.IflagsYProof, actual.IflagsYProof) + s.Equal(expected.HtifTohostDataBlock, actual.HtifTohostDataBlock) + s.Equal(expected.HtifTohostProof, actual.HtifTohostProof) + } + + s.Equal(epoch1.Index, computed[app.ID].Index) + s.Equal(EpochStatus_ClaimComputed, computed[app.ID].Status) + assertStateProof(epoch1, computed[app.ID]) + }) + + s.Run("IncludesForeclosedComputedAppForTerminalization", func() { + app := s.createAppWithClaimComputedEpoch() + err := s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100) + s.Require().NoError(err) + + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.Require().Contains(computed, app.ID) + s.Equal(EpochStatus_ClaimComputed, computed[app.ID].Status) + s.Require().Contains(apps, app.ID) + s.NotZero(apps[app.ID].ForecloseBlock) + }) + + s.Run("MultipleAppsReturnsSeparateEntries", func() { + app1 := s.createAppWithClaimComputedEpoch() + app2 := s.createAppWithClaimComputedEpoch() + + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.Len(computed, 2) + s.Len(apps, 2) + s.Contains(computed, app1.ID) + s.Contains(computed, app2.ID) + s.Contains(apps, app1.ID) + s.Contains(apps, app2.ID) + }) + + s.Run("SubmittedOnlyAppsHaveNoComputedCandidate", func() { + // Submitted epochs have their own observation selector and do not occupy + // slots in the computed-claim selector. + app1 := s.createAppWithClaimComputedEpoch() + app2 := s.createAppWithClaimComputedEpoch() + + // Move both to ClaimSubmitted + txHash1 := UniqueHash() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app1.ID, 0, txHash1) + s.Require().NoError(err) + + txHash2 := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app2.ID, 0, txHash2) + s.Require().NoError(err) + + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.Empty(computed) + s.Empty(apps) + }) + + s.Run("StagedPredecessorDoesNotHideComputedCandidate", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch0, EpochStatus_ClaimComputed) + + txHash := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, 0, 30) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch1, EpochStatus_ClaimComputed) + + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + + s.Require().Contains(computed, app.ID) + s.Equal(uint64(1), computed[app.ID].Index) + s.Contains(apps, app.ID) + }) + + // Regression guard: verify map keys are actual application IDs + // and that each epoch is stored under the correct key. + s.Run("MultiAppMapKeysMatchEpochApplicationIDs", func() { + app1 := s.createAppWithClaimComputedEpoch() + app2 := s.createAppWithClaimComputedEpoch() + + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + + for appID, epoch := range computed { + s.NotEqual(int64(0), appID, "map key must not be zero") + s.Equal(appID, epoch.ApplicationID, + "epoch stored under wrong key") + } + for appID, app := range apps { + s.NotEqual(int64(0), appID, "map key must not be zero") + s.Equal(appID, app.ID, + "application stored under wrong key") + } + + // Verify specific app data integrity + s.Equal(app1.IApplicationAddress, apps[app1.ID].IApplicationAddress) + s.Equal(app2.IApplicationAddress, apps[app2.ID].IApplicationAddress) + }) + + s.Run("ExcludesPRTApps", func() { + app := NewApplicationBuilder(). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimComputed) + + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.Empty(computed) + s.Empty(apps) + }) + + s.Run("ExcludesDisabledApps", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimComputed) + + err = s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false) + s.Require().NoError(err) + + computed, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + s.Empty(computed) + s.Empty(apps) + }) + + s.Run("ContextCancellation", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := s.oldestClaimsFromPages(ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().Error(err) + }) +} + +func (s *ClaimerSuite) TestSelectClaimsToStagePages() { + s.Run("EmptyWhenNoClaimSubmitted", func() { + NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + s.Empty(submitted) + s.Empty(apps) + }) + + s.Run("AcceptedOnlyAppHasNoStagingCandidate", func() { + app := s.createAppWithClaimComputedEpoch() + + // Move to ClaimSubmitted + txHash := UniqueHash() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + + // Move to ClaimAccepted + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + s.Empty(submitted) + s.Empty(apps) + }) + + s.Run("MultipleAppsReturnsSeparateEntries", func() { + app1 := s.createAppWithClaimComputedEpoch() + app2 := s.createAppWithClaimComputedEpoch() + + // Submit both applications. + for _, app := range []*Application{app1, app2} { + txHash := UniqueHash() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + } + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + want := len([]*Application{app1, app2}) + s.Len(submitted, want) + s.Len(apps, want) + s.Contains(submitted, app1.ID) + s.Contains(submitted, app2.ID) + }) + + // Regression guard: map keys must match each epoch's application ID. + s.Run("MultiAppMapKeysMatchEpochApplicationIDs", func() { + app1 := s.createAppWithClaimComputedEpoch() + app2 := s.createAppWithClaimComputedEpoch() + + for _, app := range []*Application{app1, app2} { + txHash := UniqueHash() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + } + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + + for appID, epoch := range submitted { + s.NotEqual(int64(0), appID, "submitted map key must not be zero") + s.Equal(appID, epoch.ApplicationID, + "submitted epoch stored under wrong key") + } + for appID, app := range apps { + s.NotEqual(int64(0), appID, "apps map key must not be zero") + s.Equal(appID, app.ID, + "application stored under wrong key") + } + + s.Contains(submitted, app1.ID) + s.Contains(submitted, app2.ID) + }) + + s.Run("ExcludesPRTApps", func() { + app := NewApplicationBuilder(). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimComputed) + + txHash := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + s.Empty(submitted) + s.Empty(apps) + }) + + s.Run("ExcludesDisabledApps", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimComputed) + + txHash := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + err = s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false) + s.Require().NoError(err) + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + s.Empty(submitted) + s.Empty(apps) + }) + + s.Run("SubmittedCandidateIsIndependentOfAcceptedHistory", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // Create two epochs with inputs + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + + // Move epoch 0 to ClaimAccepted + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch0, EpochStatus_ClaimComputed) + + txHash0 := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash0) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + // Move epoch 1 to ClaimSubmitted + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch1, EpochStatus_ClaimComputed) + + txHash1 := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 1, txHash1) + s.Require().NoError(err) + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + + // submitted contains epoch 1 (oldest submitted) + s.Len(submitted, 1) + s.Contains(submitted, app.ID) + s.Equal(uint64(1), submitted[app.ID].Index) + s.Equal(EpochStatus_ClaimSubmitted, submitted[app.ID].Status) + + // apps contains the application + s.Len(apps, 1) + s.Contains(apps, app.ID) + s.Equal(app.IApplicationAddress, apps[app.ID].IApplicationAddress) + }) + + s.Run("IncludesForeclosedSubmittedAppForTerminalization", func() { + app := s.createAppWithClaimComputedEpoch() + txHash := UniqueHash() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + err = s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100) + s.Require().NoError(err) + + submitted, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + s.Require().Contains(submitted, app.ID) + s.Equal(EpochStatus_ClaimSubmitted, submitted[app.ID].Status) + s.Require().Contains(apps, app.ID) + s.NotZero(apps[app.ID].ForecloseBlock) + }) + + s.Run("ContextCancellation", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := s.oldestClaimsFromPages(ctx, s.Repo.SelectClaimsToStagePage) + s.Require().Error(err) + }) +} + +func (s *ClaimerSuite) TestSelectClaimsToAcceptPages() { + s.Run("IncludesForeclosedStagedAppForTerminalization", func() { + app := s.createAppWithClaimComputedEpoch() + txHash := UniqueHash() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, 0, 42) + s.Require().NoError(err) + err = s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100) + s.Require().NoError(err) + + staged, apps, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToAcceptPage) + s.Require().NoError(err) + s.Require().Contains(staged, app.ID) + s.Equal(EpochStatus_ClaimStaged, staged[app.ID].Status) + s.Require().Contains(apps, app.ID) + s.NotZero(apps[app.ID].ForecloseBlock) + }) +} + +//nolint:mnd // Explicit epoch and block values define the candidate ordering. +func (s *ClaimerSuite) TestOldestClaimFiltersStatusBeforeLimit() { + s.Run("PreservesProofsAndForeclosedCandidates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epochs := make([]*Epoch, 3) + inputs := make(map[*Epoch][]*Input, len(epochs)) + for i := range epochs { + index := uint64(i) + epochs[i] = NewEpochBuilder(app.ID).WithIndex(index).WithStatus(EpochStatus_Closed). + WithBlocks(index*10, index*10+9).WithInputBounds(index, index).Build() + inputs[epochs[i]] = []*Input{NewInputBuilder().WithIndex(index).WithBlockNumber(index*10 + 5).Build()} + } + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, inputs, 30)) + for _, epoch := range epochs { + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, app.Name, epoch, EpochStatus_ClaimComputed) + } + for index := range uint64(2) { + s.Require().NoError(s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, index, UniqueHash())) + s.Require().NoError(s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, index, 40+index)) + } + acceptedTx := UniqueHash() + s.Require().NoError(s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, &acceptedTx)) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure(s.Ctx, app.ID, 100, UniqueHash(), 100)) + + submit, _, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToSubmitPage) + s.Require().NoError(err) + stage, _, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToStagePage) + s.Require().NoError(err) + accept, _, err := s.oldestClaimsFromPages(s.Ctx, s.Repo.SelectClaimsToAcceptPage) + s.Require().NoError(err) + s.Empty(stage, "accepted and staged epochs are not submitted candidates") + for _, result := range []struct { + candidates map[int64]*Epoch + index uint64 + }{{submit, 2}, {accept, 1}} { + s.Require().Contains(result.candidates, app.ID) + actual := result.candidates[app.ID] + expected, err := s.Repo.GetEpoch(s.Ctx, app.Name, result.index) + s.Require().NoError(err) + s.Equal(expected.Index, actual.Index) + s.Equal(expected.Status, actual.Status) + s.Equal(expected.StagedAtBlock, actual.StagedAtBlock) + s.Equal(expected.ClaimTransactionHash, actual.ClaimTransactionHash) + s.Equal(expected.VirtualIndex, actual.VirtualIndex) + s.Equal(expected.CreatedAt, actual.CreatedAt) + s.Equal(expected.UpdatedAt, actual.UpdatedAt) + s.Require().True(actual.HasCompleteStateProof()) + s.Equal(expected.MachineHash, actual.MachineHash) + s.Equal(expected.TxBufferDataBlock, actual.TxBufferDataBlock) + s.Equal(expected.TxBufferProof, actual.TxBufferProof) + s.Equal(expected.IflagsYDataBlock, actual.IflagsYDataBlock) + s.Equal(expected.IflagsYProof, actual.IflagsYProof) + s.Equal(expected.HtifTohostDataBlock, actual.HtifTohostDataBlock) + s.Equal(expected.HtifTohostProof, actual.HtifTohostProof) + } + }) +} + +func (s *ClaimerSuite) TestUpdateEpochWithSubmittedClaim() { + s.Run("SetsClaimSubmitted", func() { + app := s.createAppWithClaimComputedEpoch() + + txHash := common.HexToHash("0xdeadbeef") + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimSubmitted, got.Status) + s.Require().NotNil(got.ClaimTransactionHash) + s.Equal(txHash, *got.ClaimTransactionHash) + }) + + s.Run("ErrorWhenEpochNotClaimComputed", func() { + // Create an app with an epoch still in Closed status (not ClaimComputed) + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + txHash := UniqueHash() + err = s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash) + s.Require().Error(err) + }) +} + +func (s *ClaimerSuite) TestUpdateEpochWithAcceptedClaim() { + s.Run("SetsClaimAccepted", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimSubmitted) + + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + }) + + s.Run("ComputedToAcceptedIsAllowed", func() { + // In v3, CLAIM_COMPUTED → CLAIM_ACCEPTED is a legal transition + // (deep reader-mode catch-up and PRT). The trigger permits it and + // UpdateEpochWithAcceptedClaim's WHERE clause accepts COMPUTED as + // a valid source. This test pins that behavior. + app := s.createAppWithClaimComputedEpoch() + + err := s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + }) + + s.Run("ComputedToAcceptedWithNilTxHashLeavesColumnNull", func() { + app := s.createAppWithClaimComputedEpoch() + + err := s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + s.Nil(got.ClaimTransactionHash, + "getClaim-driven COMPUTED -> ACCEPTED reconciliation has no event tx hash to record") + }) + + // Catch-up reconciliation path: an epoch coming from CLAIM_COMPUTED + // (the read-only scan caught a matching ClaimAccepted on chain) needs + // to record the observed event's tx hash, because the epoch never went + // through the CLAIM_SUBMITTED transition that normally populates the + // column. Pass a non-nil txHash and assert it lands on the row. + s.Run("ComputedToAcceptedRecordsTxHashWhenProvided", func() { + app := s.createAppWithClaimComputedEpoch() + txHash := UniqueHash() + + err := s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, &txHash) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + s.Require().NotNil(got.ClaimTransactionHash, + "catch-up reconciliation with a known event tx must populate claim_transaction_hash") + s.Equal(txHash, *got.ClaimTransactionHash) + }) + + // Symmetric to the above for the normal flow: when txHash is nil, + // claim_transaction_hash is left untouched. The submit-flow caller + // relies on this: the column was set during CLAIM_SUBMITTED and must + // carry through the CLAIM_STAGED → CLAIM_ACCEPTED steps unchanged. + s.Run("NilTxHashPreservesExistingColumn", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + // Drive through INPUTS_PROCESSED → CLAIM_COMPUTED (which seeds the + // proof fields via the test helper) then submit via the real + // repository method that records the submit-tx hash. This mirrors + // the production submit flow exactly. + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, EpochStatus_ClaimComputed) + submitTx := UniqueHash() + s.Require().NoError(s.Repo.UpdateEpochWithSubmittedClaim( + s.Ctx, app.ID, 0, submitTx)) + + err = s.Repo.UpdateEpochWithAcceptedClaim(s.Ctx, app.ID, 0, nil) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + s.Require().NotNil(got.ClaimTransactionHash, + "nil txHash must NOT clear an existing claim_transaction_hash") + s.Equal(submitTx, *got.ClaimTransactionHash, + "the value seeded during CLAIM_SUBMITTED must carry through to CLAIM_ACCEPTED") + }) +} + +func (s *ClaimerSuite) TestRejectEpochAndSetApplicationDiverged() { + assertRejected := func(app *Application, reason string) { + gotEpoch, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimRejected, gotEpoch.Status) + + gotApp, err := s.Repo.GetApplication(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Diverged, gotApp.Status) + s.Require().NotNil(gotApp.Reason) + s.Equal(reason, *gotApp.Reason) + } + + s.Run("RejectsComputedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + reason := "quorum_divergence_at_acceptance: rejected computed epoch" + + result, err := s.Repo.RejectEpochAndSetApplicationDiverged(s.Ctx, app.ID, 0, reason) + s.Require().NoError(err) + s.True(result.EpochRejected) + s.True(result.ApplicationDiverged) + + assertRejected(app, reason) + }) + + s.Run("RejectsSubmittedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, UniqueHash()) + s.Require().NoError(err) + + reason := "quorum_divergence_at_staging: rejected submitted epoch" + result, err := s.Repo.RejectEpochAndSetApplicationDiverged(s.Ctx, app.ID, 0, reason) + s.Require().NoError(err) + s.True(result.EpochRejected) + s.True(result.ApplicationDiverged) + + assertRejected(app, reason) + }) + + for _, terminalStatus := range []ApplicationStatus{ + ApplicationStatus_MachineHalted, + ApplicationStatus_Corrupted, + ApplicationStatus_InvalidOutputsRoot, + } { + s.Run("IntegrityFindingAfter/"+terminalStatus.String(), func() { + app := s.createAppWithClaimComputedEpoch() + originalReason := "earlier terminal cause" + s.Require().NoError(s.Repo.UpdateApplicationStatus( + s.Ctx, app.ID, terminalStatus, &originalReason)) + + result, err := s.Repo.RejectEpochAndSetApplicationDiverged( + s.Ctx, app.ID, 0, "later claim disagreement") + s.Require().NoError(err) + s.True(result.EpochRejected) + s.Equal(terminalStatus != ApplicationStatus_Corrupted, result.ApplicationDiverged) + + gotEpoch, err := s.Repo.GetEpoch( + s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimRejected, gotEpoch.Status) + + gotApp, err := s.Repo.GetApplication( + s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Require().NotNil(gotApp.Reason) + if terminalStatus == ApplicationStatus_Corrupted { + s.Equal(terminalStatus, gotApp.Status) + s.Equal(originalReason, *gotApp.Reason) + } else { + s.Equal(ApplicationStatus_Diverged, gotApp.Status) + s.Equal("later claim disagreement", *gotApp.Reason) + } + }) + } + + // A CLAIM_STAGED epoch is outside the COMPUTED/SUBMITTED set, so the + // best-effort epoch reject matches no row and the epoch stays CLAIM_STAGED. + // The application halt is unconditional: it still becomes DIVERGED so a + // detected divergence can never leave the application runnable. + s.Run("MarksDivergedButLeavesStagedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, UniqueHash()) + s.Require().NoError(err) + err = s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, 0, 42) + s.Require().NoError(err) + + reason := "quorum_divergence_at_acceptance: staged epoch is not a normal rejection source" + result, err := s.Repo.RejectEpochAndSetApplicationDiverged(s.Ctx, app.ID, 0, reason) + s.Require().NoError(err) + s.False(result.EpochRejected) + s.True(result.ApplicationDiverged) + + gotEpoch, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimStaged, gotEpoch.Status) + + gotApp, err := s.Repo.GetApplication(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Diverged, gotApp.Status) + s.Require().NotNil(gotApp.Reason) + s.Equal(reason, *gotApp.Reason) + }) + + // The epoch reject is best-effort: even an epoch that cannot be rejected + // (here CLOSED) does not block the unconditional application halt. + s.Run("MarksApplicationEvenWhenEpochCannotBeRejected", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + reason := "divergence detected against a non-rejectable epoch" + result, err := s.Repo.RejectEpochAndSetApplicationDiverged( + s.Ctx, app.ID, 0, reason) + s.Require().NoError(err) + s.False(result.EpochRejected) + s.True(result.ApplicationDiverged) + + gotEpoch, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, gotEpoch.Status) + + gotApp, err := s.Repo.GetApplication(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(ApplicationStatus_Diverged, gotApp.Status) + s.Require().NotNil(gotApp.Reason) + s.Equal(reason, *gotApp.Reason) + }) + + // A missing application row surfaces ErrNotFound, distinguishing a genuine + // "no such app" from the best-effort epoch reject matching no row. + s.Run("ReturnsNotFoundWhenApplicationMissing", func() { + _, err := s.Repo.RejectEpochAndSetApplicationDiverged( + s.Ctx, int64(99_999_999), 0, "missing application") + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +func (s *ClaimerSuite) TestUpdateEpochWithForeclosedClaim() { + markForeclosed := func(app *Application) { + s.T().Helper() + s.Require().NoError(s.Repo.UpdateApplicationForeclosure( + s.Ctx, app.ID, 100, UniqueHash(), 100)) + } + + s.Run("ForeclosesComputedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + markForeclosed(app) + + err := s.Repo.UpdateEpochWithForeclosedClaim(s.Ctx, app.ID, 0) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + }) + + s.Run("ForeclosesSubmittedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + markForeclosed(app) + txHash := UniqueHash() + s.Require().NoError(s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, txHash)) + + err := s.Repo.UpdateEpochWithForeclosedClaim(s.Ctx, app.ID, 0) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + s.Require().NotNil(got.ClaimTransactionHash) + s.Equal(txHash, *got.ClaimTransactionHash) + }) + + s.Run("ForeclosesStagedEpoch", func() { + app := s.createAppWithClaimComputedEpoch() + markForeclosed(app) + s.Require().NoError(s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, app.ID, 0, UniqueHash())) + stagedAt := uint64(42) + s.Require().NoError(s.Repo.UpdateEpochToStaged(s.Ctx, app.ID, 0, stagedAt)) + + err := s.Repo.UpdateEpochWithForeclosedClaim(s.Ctx, app.ID, 0) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + s.Require().NotNil(got.StagedAtBlock) + s.Equal(stagedAt, *got.StagedAtBlock) + }) + + s.Run("RequiresApplicationForeclosure", func() { + app := s.createAppWithClaimComputedEpoch() + + err := s.Repo.UpdateEpochWithForeclosedClaim(s.Ctx, app.ID, 0) + s.Require().Error(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimComputed, got.Status) + }) + + // The PRT drain terminalizes pre-foreclosure epochs the tournament never + // accepted through this same call, so it must apply to PRT apps too. + s.Run("ForeclosesPRTComputedEpoch", func() { + app := s.createAppWithClaimComputedEpochForConsensus(Consensus_PRT) + markForeclosed(app) + + err := s.Repo.UpdateEpochWithForeclosedClaim(s.Ctx, app.ID, 0) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + }) +} diff --git a/internal/repository/repotest/commitment_test_cases.go b/internal/repository/repotest/commitment_test_cases.go new file mode 100644 index 000000000..44316b627 --- /dev/null +++ b/internal/repository/repotest/commitment_test_cases.go @@ -0,0 +1,148 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +type CommitmentSuite struct { + BaseSuite +} + +func NewCommitmentSuite(factory RepositoryFactory) *CommitmentSuite { + return &CommitmentSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *CommitmentSuite) createTournament() (*SeedResult, common.Address) { + seed := Seed(s.Ctx, s.T(), s.Repo) + tournAddr := UniqueAddress() + t := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).WithAddress(tournAddr).Build() + err := s.Repo.CreateTournament(s.Ctx, seed.App.IApplicationAddress.String(), t) + s.Require().NoError(err) + return seed, tournAddr +} + +func (s *CommitmentSuite) TestCreateCommitment() { + s.Run("CreatesSuccessfully", func() { + seed, tournAddr := s.createTournament() + + c := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + Build() + + err := s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c) + s.Require().NoError(err) + }) +} + +func (s *CommitmentSuite) TestGetCommitment() { + s.Run("ExistingCommitment", func() { + seed, tournAddr := s.createTournament() + + c := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + Build() + + err := s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c) + s.Require().NoError(err) + + got, err := s.Repo.GetCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), + 0, tournAddr.String(), c.Commitment.Hex()) + s.Require().NoError(err) + s.Equal(c.Commitment, got.Commitment) + s.Equal(c.FinalStateHash, got.FinalStateHash) + }) + + s.Run("NotFound", func() { + seed, tournAddr := s.createTournament() + + got, err := s.Repo.GetCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), + 0, tournAddr.String(), UniqueHash().Hex()) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *CommitmentSuite) TestListCommitments() { + s.Run("EmptyResult", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + commitments, total, err := s.Repo.ListCommitments( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.CommitmentFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(commitments) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAll", func() { + seed, tournAddr := s.createTournament() + for range 3 { + c := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + Build() + err := s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c) + s.Require().NoError(err) + } + + commitments, total, err := s.Repo.ListCommitments( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.CommitmentFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(commitments, 3) + s.Equal(uint64(3), total) + }) + + s.Run("FilterByEpochIndex", func() { + seed, tournAddr := s.createTournament() + + c := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + Build() + err := s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c) + s.Require().NoError(err) + + epochIdx := uint64(0) + commitments, total, err := s.Repo.ListCommitments( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.CommitmentFilter{EpochIndex: &epochIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(commitments, 1) + s.Equal(uint64(1), total) + }) + + s.Run("FilterByTournamentAddress", func() { + seed, tournAddr := s.createTournament() + + c := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + Build() + err := s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c) + s.Require().NoError(err) + + addrStr := tournAddr.String() + commitments, total, err := s.Repo.ListCommitments( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.CommitmentFilter{TournamentAddress: &addrStr}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(commitments, 1) + s.Equal(uint64(1), total) + }) +} diff --git a/internal/repository/repotest/epoch_test_cases.go b/internal/repository/repotest/epoch_test_cases.go new file mode 100644 index 000000000..d4c2a93f7 --- /dev/null +++ b/internal/repository/repotest/epoch_test_cases.go @@ -0,0 +1,1647 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "errors" + "strings" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type EpochSuite struct { + BaseSuite +} + +func NewEpochSuite(factory RepositoryFactory) *EpochSuite { + return &EpochSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *EpochSuite) TestCreateEpochsAndInputs() { + s.Run("SingleEpochSingleInput", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID).WithIndex(0).WithStatus(EpochStatus_Closed).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(uint64(0), got.Index) + s.Equal(EpochStatus_Closed, got.Status) + }) + + s.Run("MultipleEpochsMultipleInputs", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Open).WithBlocks(10, 19).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1).WithBlockNumber(15).Build() + + epochInputMap := map[*Epoch][]*Input{ + epoch0: {input0}, + epoch1: {input1}, + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), epochInputMap, 20) + s.Require().NoError(err) + + got0, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got0.Status) + + got1, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 1) + s.Require().NoError(err) + s.Equal(EpochStatus_Open, got1.Status) + }) + + s.Run("EpochWithNoInputs", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {}}, 10) + s.Require().NoError(err) + + // Epoch should exist + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(EpochStatus_Closed, got.Status) + + // No inputs should exist + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(inputs) + s.Equal(uint64(0), total) + }) + + s.Run("UpsertExistingEpoch", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Open).WithBlocks(0, 9).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + // Upsert the same epoch with updated status + epoch2 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + input2 := NewInputBuilder().WithIndex(1).WithBlockNumber(8).Build() + + err = s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch2: {input2}}, 10) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got.Status) + }) + + s.Run("HistoricalCatchUpDoesNotRewindInputCursor", func() { + const ( + currentBlock uint64 = 100 + historicalBlock uint64 = 90 + ) + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, currentBlock-1).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {}}, currentBlock) + s.Require().NoError(err) + + err = s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {}}, historicalBlock) + s.Require().NoError(err) + + block, err := s.Repo.GetEventLastCheckBlock( + s.Ctx, app.ID, MonitoredEvent_InputAdded) + s.Require().NoError(err) + s.Equal(currentBlock, block) + }) +} + +func (s *EpochSuite) TestGetEpoch() { + s.Run("ExistingEpoch", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(seed.App.ID, got.ApplicationID) + s.Equal(uint64(0), got.Index) + s.Equal(seed.Epoch.FirstBlock, got.FirstBlock) + s.Equal(seed.Epoch.LastBlock, got.LastBlock) + s.Equal(seed.Epoch.InputIndexLowerBound, got.InputIndexLowerBound) + s.Equal(seed.Epoch.InputIndexUpperBound, got.InputIndexUpperBound) + s.Equal(EpochStatus_Closed, got.Status) + s.Equal(uint64(0), got.VirtualIndex) + s.Nil(got.MachineHash) + s.Nil(got.TxBufferDataBlock) + s.Nil(got.ClaimTransactionHash) + s.Nil(got.Commitment) + s.False(got.CreatedAt.IsZero(), "CreatedAt should be set") + s.False(got.UpdatedAt.IsZero(), "UpdatedAt should be set") + }) + + s.Run("NotFound", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 99) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *EpochSuite) TestGetEpochByVirtualIndex() { + s.Run("ExistingVirtualIndex", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetEpochByVirtualIndex( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(uint64(0), got.VirtualIndex) + }) + + s.Run("NotFound", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetEpochByVirtualIndex( + s.Ctx, app.IApplicationAddress.String(), 99) + s.Require().NoError(err) + s.Nil(got) + }) + + s.Run("DivergentVirtualAndPhysicalIndex", func() { + // CreateEpochsAndInputs auto-assigns VirtualIndex as MAX(VirtualIndex)+1. + // By creating an epoch with Index=5, VirtualIndex will be auto-assigned as 0. + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(5). + WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + // Lookup by virtual index 0 (auto-assigned) should find the epoch with Index=5 + got, err := s.Repo.GetEpochByVirtualIndex( + s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(uint64(5), got.Index) + s.Equal(uint64(0), got.VirtualIndex) + + // Lookup by virtual index 5 should NOT find it + got, err = s.Repo.GetEpochByVirtualIndex( + s.Ctx, app.IApplicationAddress.String(), 5) + s.Require().NoError(err) + s.Nil(got) + }) +} + +//nolint:mnd // Numeric values are intentionally explicit repository fixtures. +func (s *EpochSuite) TestGetLastAcceptedEpochIndex() { + s.Run("WithAcceptedEpoch", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed).WithBlocks(10, 19).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + AdvanceEpochStatus( + s.Ctx, s.T(), s.Repo, app.IApplicationAddress.String(), epoch0, + EpochStatus_ClaimAccepted, + ) + + idx, err := s.Repo.GetLastAcceptedEpochIndex(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), idx) + }) + + s.Run("ErrorWhenNoAcceptedEpochs", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + _, err = s.Repo.GetLastAcceptedEpochIndex( + s.Ctx, app.IApplicationAddress.String()) + s.Require().Error(err) + s.True(errors.Is(err, repository.ErrNotFound)) + }) +} + +func (s *EpochSuite) TestGetLastNonOpenEpoch() { + s.Run("ReturnsClosedEpoch", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + got, err := s.Repo.GetLastNonOpenEpoch(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got.Status) + }) + + s.Run("NilWhenAllEpochsAreOpen", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Open). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + got, err := s.Repo.GetLastNonOpenEpoch( + s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Nil(got) + }) +} + +//nolint:mnd // Numeric values are intentionally explicit repository fixtures. +func (s *EpochSuite) TestListEpochs() { + s.Run("EmptyResult", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(epochs) + s.Equal(uint64(0), total) + }) + + s.Run("FilterByStatus", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed).WithBlocks(0, 9).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Open).WithBlocks(10, 19).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{Status: []EpochStatus{EpochStatus_Closed}}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(epochs, 1) + s.Equal(uint64(1), total) + s.Equal(EpochStatus_Closed, epochs[0].Status) + }) + + s.Run("ReturnsStateProofDataBlocks", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + proof := DummyStateProof() + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *proof, + }) + s.Require().NoError(err) + + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, + seed.App.IApplicationAddress.String(), + repository.EpochFilter{}, + repository.Pagination{Limit: 10}, + false, + ) + s.Require().NoError(err) + s.Equal(uint64(1), total) + s.Require().Len(epochs, 1) + s.Require().NotNil(epochs[0].MachineHash) + s.Require().NotNil(epochs[0].TxBufferDataBlock) + s.Require().NotNil(epochs[0].IflagsYDataBlock) + s.Require().NotNil(epochs[0].HtifTohostDataBlock) + s.Equal(proof.MachineHash, *epochs[0].MachineHash) + s.Equal(proof.TxBufferDataBlock, *epochs[0].TxBufferDataBlock) + s.Equal(proof.IflagsYDataBlock, *epochs[0].IflagsYDataBlock) + s.Equal(proof.HtifTohostDataBlock, *epochs[0].HtifTohostDataBlock) + s.Nil(epochs[0].TxBufferProof) + s.Nil(epochs[0].IflagsYProof) + s.Nil(epochs[0].HtifTohostProof) + }) + + s.Run("FilterByBeforeBlock", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + epoch2 := NewEpochBuilder(app.ID). + WithIndex(2).WithStatus(EpochStatus_Open). + WithBlocks(20, 29).WithInputBounds(2, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1).WithBlockNumber(15).Build() + input2 := NewInputBuilder().WithIndex(2).WithEpochIndex(2).WithBlockNumber(25).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}, epoch2: {input2}}, 30) + s.Require().NoError(err) + + // BeforeBlock=15 means LastBlock < 15, so epoch0 (LastBlock=9) matches + beforeBlock := uint64(15) + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{BeforeBlock: &beforeBlock}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(epochs, 1) + s.Equal(uint64(1), total) + s.Equal(uint64(0), epochs[0].Index) + }) + + s.Run("Pagination", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epochInputMap := make(map[*Epoch][]*Input) + for i := range uint64(5) { + e := NewEpochBuilder(app.ID). + WithIndex(i).WithStatus(EpochStatus_Closed). + WithBlocks(i*10, i*10+9).WithInputBounds(i, i).Build() + inp := NewInputBuilder().WithIndex(i).WithEpochIndex(i).WithBlockNumber(i*10 + 5).Build() + epochInputMap[e] = []*Input{inp} + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), epochInputMap, 50) + s.Require().NoError(err) + + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{}, + repository.Pagination{Limit: 2, Offset: 0}, false) + s.Require().NoError(err) + s.Len(epochs, 2) + s.Equal(uint64(5), total) + }) + + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epochInputMap := make(map[*Epoch][]*Input) + for i := range uint64(5) { + epoch := NewEpochBuilder(app.ID). + WithIndex(i).WithStatus(EpochStatus_Closed). + WithBlocks(i*10, i*10+9).WithInputBounds(i, i).Build() + input := NewInputBuilder().WithIndex(i).WithEpochIndex(i).WithBlockNumber(i*10 + 5).Build() + epochInputMap[epoch] = []*Input{input} + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), epochInputMap, 50) + s.Require().NoError(err) + + indexRange := repository.Range{Start: 1, End: 3} + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(epochs, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), epochs[0].Index) + }) + + s.Run("Descending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epochInputMap := make(map[*Epoch][]*Input) + for i := range uint64(3) { + e := NewEpochBuilder(app.ID). + WithIndex(i).WithStatus(EpochStatus_Closed). + WithBlocks(i*10, i*10+9).WithInputBounds(i, i).Build() + inp := NewInputBuilder().WithIndex(i).WithEpochIndex(i). + WithBlockNumber(i*10 + 5).Build() + epochInputMap[e] = []*Input{inp} + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), epochInputMap, 30) + s.Require().NoError(err) + + epochs, _, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{}, + repository.Pagination{Limit: 10}, true) + s.Require().NoError(err) + s.Require().Len(epochs, 3) + // Descending: highest index first + s.Equal(uint64(2), epochs[0].Index) + s.Equal(uint64(1), epochs[1].Index) + s.Equal(uint64(0), epochs[2].Index) + }) + + s.Run("FilterByMultipleStatuses", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Open). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + epoch2 := NewEpochBuilder(app.ID). + WithIndex(2).WithStatus(EpochStatus_ClaimForeclosed). + WithBlocks(20, 29).WithInputBounds(2, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1).WithBlockNumber(15).Build() + input2 := NewInputBuilder().WithIndex(2).WithEpochIndex(2).WithBlockNumber(25).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}, epoch2: {input2}}, 30) + s.Require().NoError(err) + + // Filter for two distinct terminal/non-open statuses. + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{ + Status: []EpochStatus{EpochStatus_Closed, EpochStatus_ClaimForeclosed}, + }, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(epochs, 2) + s.Equal(uint64(2), total) + for _, e := range epochs { + s.True( + e.Status == EpochStatus_Closed || e.Status == EpochStatus_ClaimForeclosed, + "unexpected status: %s", e.Status) + } + }) +} + +func (s *EpochSuite) TestUpdateEpochStatus() { + s.Run("UpdatesStatus", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + epoch := seed.Epoch + epoch.Status = EpochStatus_ClaimForeclosed + + err := s.Repo.UpdateEpochStatus(s.Ctx, seed.App.IApplicationAddress.String(), epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + }) + + s.Run("NotFoundForNonExistentEpoch", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + nonExistentEpoch := &Epoch{Index: 99, Status: EpochStatus_InputsProcessed} + + err := s.Repo.UpdateEpochStatus( + s.Ctx, app.IApplicationAddress.String(), nonExistentEpoch) + s.Require().Error(err) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +//nolint:mnd // Numeric values are intentionally explicit repository fixtures. +func (s *EpochSuite) TestUpdateEpochInputsProcessed() { + s.Run("MarksEpochProcessed", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + proof := DummyStateProof() + + err := s.Repo.UpdateEpochInputsProcessed( + s.Ctx, seed.App.IApplicationAddress.String(), 0, proof) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_InputsProcessed, got.Status) + s.True(got.HasCompleteStateProof()) + s.Equal(proof.MachineHash, *got.MachineHash) + s.Equal(proof.TxBufferDataBlock, *got.TxBufferDataBlock) + s.Equal(proofSiblingsToHashes(proof.TxBufferProof), got.TxBufferProof) + s.Equal(proof.IflagsYDataBlock, *got.IflagsYDataBlock) + s.Equal(proofSiblingsToHashes(proof.IflagsYProof), got.IflagsYProof) + s.Equal(proof.HtifTohostDataBlock, *got.HtifTohostDataBlock) + s.Equal(proofSiblingsToHashes(proof.HtifTohostProof), got.HtifTohostProof) + }) + + s.Run("RejectsIncompleteProofBeforeUpdate", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateEpochInputsProcessed( + s.Ctx, seed.App.IApplicationAddress.String(), 0, &StateProof{}) + s.Require().ErrorIs(err, repository.ErrInvalidStateProof) + + got, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got.Status) + s.False(got.HasCompleteStateProof()) + }) + + for _, status := range []ApplicationStatus{ + ApplicationStatus_Failed, + ApplicationStatus_Diverged, + ApplicationStatus_Corrupted, + ApplicationStatus_GuestException, + ApplicationStatus_MachineHalted, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, + } { + s.Run("RejectsApplicationStatus/"+status.String(), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + reason := "application cannot publish a claimable epoch" + err := s.Repo.UpdateApplicationStatus( + s.Ctx, seed.App.ID, status, &reason, + ) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochInputsProcessed( + s.Ctx, seed.App.IApplicationAddress.String(), 0, DummyStateProof()) + s.Require().ErrorIs(err, repository.ErrNoUpdate) + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got.Status) + s.False(got.HasCompleteStateProof()) + }) + } + + // The update should fail when the previous epoch is still Open + // (i.e., not yet past Closed). The SQL condition requires that the + // previous epoch status is NOT IN (Open, Closed). + s.Run("RejectsWhenPreviousEpochStillOpen", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Open). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1). + WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + + // Advance the durable cursor in order, while deliberately leaving + // epoch0's status Open to exercise the previous-epoch gate. + result0 := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + } + err = s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result0) + s.Require().NoError(err) + + result1 := &AdvanceResult{ + EpochIndex: 1, + InputIndex: 1, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + } + err = s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result1) + s.Require().NoError(err) + + // Try to mark epoch1 as InputsProcessed; previous epoch0 is Open + err = s.Repo.UpdateEpochInputsProcessed( + s.Ctx, app.IApplicationAddress.String(), 1, DummyStateProof()) + s.Require().ErrorIs(err, repository.ErrNoUpdate) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 1) + s.Require().NoError(err) + // Epoch1 should remain Closed -- not promoted to InputsProcessed + s.Equal(EpochStatus_Closed, got.Status) + }) + + // The update should fail when the epoch still has pending + // (unprocessed) inputs. The SQL requires pending_count == 0. + s.Run("RejectsWhenPendingInputsRemain", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // Create a single-epoch setup with 2 inputs + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(3).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(7).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, 10) + s.Require().NoError(err) + + // Process only 1 of the 2 inputs + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + } + err = s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result) + s.Require().NoError(err) + + // Try to mark epoch as InputsProcessed; input1 is still pending + err = s.Repo.UpdateEpochInputsProcessed( + s.Ctx, app.IApplicationAddress.String(), 0, DummyStateProof()) + s.Require().ErrorIs(err, repository.ErrNoUpdate) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + // Epoch should remain Closed since not all inputs are processed + s.Equal(EpochStatus_Closed, got.Status) + }) + + // The update should fail when not all expected inputs are present. + // total_count != (upper_bound - lower_bound). + s.Run("RejectsWhenInputCountDoesNotMatchBounds", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // Epoch expects 3 inputs (bounds 0..3) but we only provide 1. + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 3).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0}}, 10) + s.Require().NoError(err) + + // Process the single input we do have + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + StateProof: *DummyStateProof(), + } + err = s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result) + s.Require().NoError(err) + + // Try to mark epoch as InputsProcessed + err = s.Repo.UpdateEpochInputsProcessed( + s.Ctx, app.IApplicationAddress.String(), 0, DummyStateProof()) + s.Require().ErrorIs(err, repository.ErrNoUpdate) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + // Epoch should remain Closed since not all expected inputs are present + s.Equal(EpochStatus_Closed, got.Status) + }) + + s.Run("RejectsNonExistentEpoch", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.UpdateEpochInputsProcessed( + s.Ctx, app.IApplicationAddress.String(), 99, DummyStateProof()) + s.Require().ErrorIs(err, repository.ErrNoUpdate) + }) +} + +func (s *EpochSuite) TestUpdateEpochClaimTransactionHash() { + s.Run("SetsTransactionHash", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + txHash := UniqueHash() + seed.Epoch.ClaimTransactionHash = &txHash + + err := s.Repo.UpdateEpochClaimTransactionHash( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got.ClaimTransactionHash) + s.Equal(txHash, *got.ClaimTransactionHash) + }) + + s.Run("NotFoundForNonExistentEpoch", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + txHash := UniqueHash() + nonExistentEpoch := &Epoch{Index: 99, ClaimTransactionHash: &txHash} + + err := s.Repo.UpdateEpochClaimTransactionHash( + s.Ctx, app.IApplicationAddress.String(), nonExistentEpoch) + s.Require().Error(err) + s.Require().ErrorIs(err, repository.ErrNotFound) + }) +} + +// TestUpsertPreservesNonOpenEpoch verifies the CASE/WHEN crash-recovery guard +// in CreateEpochsAndInputs: re-upserting an epoch that has advanced past OPEN +// must preserve the existing row's fields (status, block range, input bounds). +// +//nolint:mnd // Numeric values are intentionally explicit repository fixtures. +func (s *EpochSuite) TestUpsertPreservesNonOpenEpoch() { + s.Run("PreservesClosedEpochFields", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // Create and persist an epoch as CLOSED with known field values. + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 99).WithInputBounds(0, 5).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 100) + s.Require().NoError(err) + + // Re-upsert the same epoch index with DIFFERENT field values. + // The guard should silently preserve the existing CLOSED row. + conflicting := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 200).WithInputBounds(0, 10).Build() + + err = s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{conflicting: {}}, 200) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got.Status) + s.Equal(uint64(99), got.LastBlock, + "LastBlock should be preserved from original epoch") + s.Equal(uint64(5), got.InputIndexUpperBound, + "InputIndexUpperBound should be preserved from original epoch") + }) + + s.Run("PreservesInputsProcessedEpochFields", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 50).WithInputBounds(0, 1).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 51) + s.Require().NoError(err) + StoreAdvanceResult( + s.Ctx, s.T(), s.Repo, app.ID, 0, 0, + InputCompletionStatus_Accepted, nil, nil, + ) + + // Advance past CLOSED so it is no longer OPEN. + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, + EpochStatus_InputsProcessed) + + // Re-upsert with different values. + conflicting := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 999).WithInputBounds(0, 99).Build() + + err = s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{conflicting: {}}, 999) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_InputsProcessed, got.Status, + "status should be preserved, not overwritten") + s.Equal(uint64(50), got.LastBlock, + "LastBlock should be preserved from original epoch") + s.Equal(uint64(1), got.InputIndexUpperBound, + "InputIndexUpperBound should be preserved from original epoch") + }) + + s.Run("AllowsUpdateOfOpenEpoch", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + // Create an OPEN epoch. + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Open). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + // Upsert with CLOSED status and new LastBlock. + updated := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 50).WithInputBounds(0, 3).Build() + + err = s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{updated: {}}, 50) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got.Status, + "OPEN epoch should be updated to CLOSED") + s.Equal(uint64(50), got.LastBlock, + "LastBlock should be updated for OPEN epoch") + }) +} + +// TestEpochStatusTransitionTrigger verifies the database trigger that enforces +// valid epoch status transitions. +func (s *EpochSuite) TestEpochStatusTransitionTrigger() { + s.Run("RejectsSkippedTransition", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // CLOSED -> CLAIM_COMPUTED skips INPUTS_PROCESSED — must fail. + seed.Epoch.Status = EpochStatus_ClaimComputed + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().Error(err) + s.Contains(err.Error(), "invalid epoch status transition") + }) + + s.Run("RejectsBackwardsTransition", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, + EpochStatus_InputsProcessed) + + // INPUTS_PROCESSED -> CLOSED is backwards — must fail. + seed.Epoch.Status = EpochStatus_Closed + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().Error(err) + s.Contains(err.Error(), "invalid epoch status transition") + }) + + s.Run("RejectsOpenToInputsProcessed", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Open). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + // OPEN -> INPUTS_PROCESSED skips CLOSED — must fail. + epoch.Status = EpochStatus_InputsProcessed + err = s.Repo.UpdateEpochStatus( + s.Ctx, app.IApplicationAddress.String(), epoch) + s.Require().Error(err) + s.Contains(err.Error(), "invalid epoch status transition") + }) + + // CLAIM_COMPUTED -> CLAIM_ACCEPTED (skipping SUBMITTED) is valid for: + // PRT apps (no claim submission step), node syncing from scratch when + // the claim was already accepted, or reader-only mode with tx submission + // disabled. + s.Run("AllowsDirectAcceptance", func() { + app := NewApplicationBuilder(). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Closed). + WithBlocks(0, 9). + WithInputBounds(0, 0). + Build() + + input := NewInputBuilder(). + WithIndex(0). + WithBlockNumber(5). + Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, + EpochStatus_ClaimComputed) + + // CLAIM_COMPUTED -> CLAIM_ACCEPTED skips SUBMITTED. + epoch.Status = EpochStatus_ClaimAccepted + err = s.Repo.UpdateEpochStatus( + s.Ctx, app.IApplicationAddress.String(), epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, got.Status) + }) + + // CLAIM_COMPUTED -> CLAIM_REJECTED is valid when a claim is rejected + // on-chain before the node submits it (e.g. a conflicting claim was + // already accepted). + s.Run("AllowsDirectRejection", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, + EpochStatus_ClaimComputed) + + // CLAIM_COMPUTED -> CLAIM_REJECTED skips SUBMITTED. + seed.Epoch.Status = EpochStatus_ClaimRejected + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimRejected, got.Status) + }) + + s.Run("AllowsForeclosedClaimTerminalStatus", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, + EpochStatus_ClaimComputed) + + seed.Epoch.Status = EpochStatus_ClaimForeclosed + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + }) + + s.Run("AllowsForeclosedClaimFromEarlierStatuses", func() { + s.Run(EpochStatus_Open.String(), func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Open). + WithBlocks(0, 9). + WithInputBounds(0, 0). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, + app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {}}, + 5, + ) + s.Require().NoError(err) + + epoch.Status = EpochStatus_ClaimForeclosed + err = s.Repo.UpdateEpochStatus(s.Ctx, app.IApplicationAddress.String(), epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + }) + + for _, target := range []EpochStatus{ + EpochStatus_Closed, + EpochStatus_InputsProcessed, + } { + s.Run(target.String(), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + if target != EpochStatus_Closed { + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, target) + } + + seed.Epoch.Status = EpochStatus_ClaimForeclosed + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + }) + } + }) + + s.Run("RejectsStagedToRejected", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, + EpochStatus_ClaimComputed) + + err := s.Repo.UpdateEpochWithSubmittedClaim(s.Ctx, seed.App.ID, seed.Epoch.Index, UniqueHash()) + s.Require().NoError(err) + + err = s.Repo.UpdateEpochToStaged(s.Ctx, seed.App.ID, seed.Epoch.Index, 42) + s.Require().NoError(err) + + seed.Epoch.Status = EpochStatus_ClaimRejected + err = s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().Error(err) + s.Contains(err.Error(), "invalid epoch status transition") + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimStaged, got.Status) + }) + + s.Run("AllowsSameStatusUpdate", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // CLOSED -> CLOSED should be allowed (idempotent). + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().NoError(err) + }) + + // Verify the schema rejects publication when proof fields are missing. + s.Run("RejectsInputsProcessedWithoutProofFields", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // Bypass the atomic publication API and try CLOSED -> + // INPUTS_PROCESSED without the three proof leaves. + seed.Epoch.Status = EpochStatus_InputsProcessed + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().Error(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_Closed, got.Status) + }) + + // Verify CLAIM_COMPUTED succeeds when all required fields are present. + s.Run("AllowsClaimComputedWithProofFields", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + seed.App.IApplicationAddress.String(), seed.Epoch, + EpochStatus_InputsProcessed) + + seed.Epoch.Status = EpochStatus_ClaimComputed + err := s.Repo.UpdateEpochStatus( + s.Ctx, seed.App.IApplicationAddress.String(), seed.Epoch) + s.Require().NoError(err) + + got, err := s.Repo.GetEpoch( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimComputed, got.Status) + }) + + // Verify PRT apps require commitment fields for CLAIM_COMPUTED. + s.Run("RejectsPRTClaimComputedWithoutCommitment", func() { + app := NewApplicationBuilder(). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + // Publish the machine proof but not the PRT commitment. + err = s.Repo.UpdateEpochInputsProcessed( + s.Ctx, app.IApplicationAddress.String(), epoch.Index, + DummyStateProof(), + ) + s.Require().NoError(err) + epoch.Status = EpochStatus_InputsProcessed + + // INPUTS_PROCESSED -> CLAIM_COMPUTED without commitment — must fail. + epoch.Status = EpochStatus_ClaimComputed + err = s.Repo.UpdateEpochStatus( + s.Ctx, app.IApplicationAddress.String(), epoch) + s.Require().Error(err) + s.Contains(err.Error(), "PRT") + }) + + newPRTComputedEpoch := func() (*Application, *Epoch) { + app := NewApplicationBuilder(). + WithConsensus(Consensus_PRT). + Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0). + Build() + input := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 10) + s.Require().NoError(err) + + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), epoch, + EpochStatus_ClaimComputed) + return app, epoch + } + + // Dave/PRT stages a finished tournament result directly from + // CLAIM_COMPUTED. The repository records the staging block without a + // transaction hash. The acceptance evidence supplies that hash later. + s.Run("AllowsPRTStagedThenAccepted", func() { + const stagedAtBlock uint64 = 42 + + app, epoch := newPRTComputedEpoch() + + err := s.Repo.UpdateEpochReconciledStaged(s.Ctx, app.ID, epoch.Index, stagedAtBlock) + s.Require().NoError(err) + + staged, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimStaged, staged.Status) + s.Require().NotNil(staged.StagedAtBlock) + s.Equal(stagedAtBlock, *staged.StagedAtBlock) + s.Nil(staged.ClaimTransactionHash) + + acceptanceTransactionHash := UniqueHash() + err = s.Repo.UpdateEpochWithAcceptedClaim( + s.Ctx, app.ID, epoch.Index, &acceptanceTransactionHash) + s.Require().NoError(err) + + accepted, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), epoch.Index) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimAccepted, accepted.Status) + s.Require().NotNil(accepted.StagedAtBlock) + s.Equal(stagedAtBlock, *accepted.StagedAtBlock) + s.Require().NotNil(accepted.ClaimTransactionHash) + s.Equal(acceptanceTransactionHash, *accepted.ClaimTransactionHash) + }) + + // Verify the trigger / CHECK constraint rejects any transition into + // CLAIM_STAGED on a row whose staged_at_block is NULL. UpdateEpochStatus + // only writes the Status column, so it cannot set staged_at_block + // atomically — that is exactly the situation this invariant is meant + // to catch. + s.Run("RejectsPRTStagedWithoutBlock", func() { + app, epoch := newPRTComputedEpoch() + + // Sanity: staged_at_block is NULL on this freshly built row. + got, err := s.Repo.GetEpoch( + s.Ctx, app.IApplicationAddress.String(), epoch.Index) + s.Require().NoError(err) + s.Require().Nil(got.StagedAtBlock) + + epoch.Status = EpochStatus_ClaimStaged + err = s.Repo.UpdateEpochStatus( + s.Ctx, app.IApplicationAddress.String(), epoch) + s.Require().Error(err) + // The trigger surfaces first with this exact phrasing; if a future + // refactor disables the trigger, the CHECK constraint + // epoch_staged_requires_block fires with "violates check constraint" + // — either is acceptable evidence the invariant holds. + errStr := err.Error() + s.True( + strings.Contains(errStr, "CLAIM_STAGED requires staged_at_block") || + strings.Contains(errStr, "epoch_staged_requires_block"), + "unexpected error: %s", errStr, + ) + }) +} + +// TestDrainGates exercises both foreclosure-drain gates against the same +// fixtures so the contract difference is visible: +// +// HasUndrainedEpochsBeforeBlock (PRT — advancer/validator only) +// HasUnreconciledClaimsBeforeBlock (Authority/Quorum — also claimer) +// +// The narrow gate must return false for any epoch whose status is at least +// CLAIM_COMPUTED; the broad gate must continue to return true until the +// claimer drives every pre-foreclosure epoch to CLAIM_ACCEPTED or +// CLAIM_FORECLOSED. Both gates must ignore epochs after blockBound (the +// foreclose block); blockBound itself is included for same-block +// input-before-foreclosure events. +func (s *EpochSuite) TestDrainGates() { + const forecloseBlock uint64 = 100 + + // advance creates one epoch with one input at block `first+1`. The + // input's status mirrors what the FSM would have produced for the + // target epoch status: epochs at or beyond INPUTS_PROCESSED imply the + // advancer has run and inputs have a non-NONE terminal status. + advance := func(app *Application, idx, first, last uint64, target EpochStatus) *Epoch { + ep := NewEpochBuilder(app.ID). + WithIndex(idx). + WithStatus(EpochStatus_Closed). + WithBlocks(first, last). + WithInputBounds(idx, idx). + Build() + + inputStatus := InputCompletionStatus_None + switch target { + case EpochStatus_Open, EpochStatus_Closed: + // These states have no completed input. + case EpochStatus_InputsProcessed, + EpochStatus_ClaimComputed, + EpochStatus_ClaimSubmitted, + EpochStatus_ClaimStaged, + EpochStatus_ClaimAccepted, + EpochStatus_ClaimRejected, + EpochStatus_ClaimForeclosed: + inputStatus = InputCompletionStatus_Accepted + } + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: { + NewInputBuilder().WithIndex(idx).WithEpochIndex(idx). + WithBlockNumber(first + 1).Build(), + }}, last+1) + s.Require().NoError(err) + if inputStatus.IsCompleted() { + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, idx, idx, inputStatus, nil, nil) + } + + if target != EpochStatus_Closed { + AdvanceEpochStatus(s.Ctx, s.T(), + s.Repo, app.IApplicationAddress.String(), ep, target) + } + return ep + } + + // emptyOpen creates a straddling OPEN epoch with no inputs. This + // mirrors a valid PRT state (empty epochs are legal on DaveConsensus); + // Authority/Quorum never persists empty epochs but the synthetic + // setup lets us pin the gate divergence on a single shared fixture. + emptyOpen := func(app *Application, idx, first, last uint64) *Epoch { + ep := NewEpochBuilder(app.ID). + WithIndex(idx). + WithStatus(EpochStatus_Open). + WithBlocks(first, last). + WithInputBounds(idx, idx). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: {}}, last+1) + s.Require().NoError(err) + return ep + } + + s.Run("OpenEpochUndrainedAndUnreconciled", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_Closed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(drained, "CLOSED before forecloseBlock counts as undrained for PRT") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, "CLOSED before forecloseBlock counts as unreconciled for claimer") + }) + + s.Run("ComputedEpochOnlyUnreconciled", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimComputed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained, + "PRT gate treats CLAIM_COMPUTED as drained — tournaments cannot settle "+ + "under foreclosure, so waiting would stall forever") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, + "claimer gate keeps the drain pending until CLAIM_ACCEPTED or CLAIM_FORECLOSED") + }) + + s.Run("AcceptedEpochClearsBothGates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimAccepted) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(recon) + }) + + s.Run("ForeclosedClaimClearsBothGates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimForeclosed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(recon) + }) + + s.Run("MixedEpochsBroadGateBlocksUntilTerminal", func() { + // Mirrors the foreclosure-replay scenario: three pre-foreclosure + // epochs at increasing block ranges, partially terminal. The narrow + // gate has already flipped to false; the broad gate must still block + // until the remaining COMPUTED epoch is accepted or foreclosed. + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimAccepted) + _ = advance(app, 1, 10, 19, EpochStatus_ClaimForeclosed) + _ = advance(app, 2, 20, 29, EpochStatus_ClaimComputed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained, "no OPEN/CLOSED/INPUTS_PROCESSED rows remain") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, "one CLAIM_COMPUTED row still needs reconciliation or foreclosure") + }) + + s.Run("PostForecloseEpochsAreIgnoredByBothGates", func() { + // An epoch whose first_block > forecloseBlock started entirely + // after the foreclosure point and has no on-chain claim to + // reconcile against. Both gates must exclude it via the + // first_block <= blockBound filter (broad gate) and the + // input-level block_number <= blockBound filter (narrow gate). + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + // Pre-foreclosure epoch: already accepted. + _ = advance(app, 0, 0, 9, EpochStatus_ClaimAccepted) + // Post-foreclosure epoch: first_block > forecloseBlock. + _ = advance(app, 1, forecloseBlock+1, forecloseBlock+9, EpochStatus_ClaimComputed) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(recon, + "post-foreclosure CLAIM_COMPUTED epochs must not block the drain — "+ + "the chain emits no ClaimAccepted for them so reconciliation cannot succeed") + }) + + s.Run("SameBlockInputBeforeForeclosureIsIncluded", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ep := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Open). + WithBlocks(forecloseBlock, forecloseBlock+9). + WithInputBounds(0, 0). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: { + NewInputBuilder().WithIndex(0).WithEpochIndex(0). + WithBlockNumber(forecloseBlock). + Build(), + }}, forecloseBlock+10) + s.Require().NoError(err) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(drained, + "valid InputAdded events in the Foreclosure block executed before Foreclosure") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, "epoch starting at the Foreclosure block can contain a valid same-block input") + }) + + // A straddling OPEN epoch with first_block < forecloseBlock and + // last_block >= forecloseBlock carries pre-foreclosure inputs that + // drain must wait for. A predicate of last_block < blockBound would + // exclude such straddlers and make the app look drained while the + // unprocessed pre-foreclosure inputs were still in the DB. + s.Run("StraddlingOpenEpochWithPreFInputsCaughtByBothGates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ep := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Open). + WithBlocks(forecloseBlock-10, forecloseBlock+10). + WithInputBounds(0, 0). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: { + NewInputBuilder().WithIndex(0).WithEpochIndex(0). + WithBlockNumber(forecloseBlock - 5). // pre-F, status defaults to NONE. + Build(), + }}, forecloseBlock+11) + s.Require().NoError(err) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(drained, + "narrow gate must see a NONE input at block_number <= forecloseBlock — "+ + "abandoning it would lose pre-foreclosure work") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, + "broad gate must see the OPEN epoch with first_block <= forecloseBlock") + }) + + s.Run("ForecloseUnacceptedOverlappingEpochClearsGates", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ep := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Open). + WithBlocks(forecloseBlock-10, forecloseBlock+10). + WithInputBounds(0, 0). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: { + NewInputBuilder().WithIndex(0).WithEpochIndex(0). + WithBlockNumber(forecloseBlock - 5). + Build(), + }}, forecloseBlock+11) + s.Require().NoError(err) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure( + s.Ctx, app.ID, forecloseBlock, UniqueHash(), forecloseBlock)) + + n, err := s.Repo.ForecloseUnacceptedEpochsAtOrAfterBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.Equal(int64(1), n) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained, "terminal CLAIM_FORECLOSED epochs no longer require input drain") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(recon) + }) + + // H1 boundary. An input that executes in the same block as foreclosure + // (ordered before the foreclose() tx) is valid on chain, and when + // foreclosure lands on an epoch boundary it opens an epoch whose first_block + // equals forecloseBlock. The inclusive predicate (first_block <= blockBound) + // must terminalize it; an exclusive bound would leave it dangling. This is + // the tightest counterpart to ForecloseUnacceptedOverlappingEpochClearsGates, + // where the input sits strictly before the bound. + s.Run("ForecloseUnacceptedEpochStartingAtBoundTerminalizes", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ep := NewEpochBuilder(app.ID). + WithIndex(0). + WithStatus(EpochStatus_Closed). + WithBlocks(forecloseBlock, forecloseBlock+9). + WithInputBounds(0, 0). + Build() + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{ep: { + NewInputBuilder().WithIndex(0).WithEpochIndex(0). + WithBlockNumber(forecloseBlock).Build(), + }}, forecloseBlock+10) + s.Require().NoError(err) + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, 0, + InputCompletionStatus_Accepted, nil, nil) + AdvanceEpochStatus(s.Ctx, s.T(), s.Repo, + app.IApplicationAddress.String(), ep, EpochStatus_ClaimComputed) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure( + s.Ctx, app.ID, forecloseBlock, UniqueHash(), forecloseBlock)) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, "an epoch beginning exactly at forecloseBlock is pre-foreclosure work") + + n, err := s.Repo.ForecloseUnacceptedEpochsAtOrAfterBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.Equal(int64(1), n, "epoch with first_block == forecloseBlock must terminalize (inclusive bound)") + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimForeclosed, got.Status) + + recon, err = s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(recon, "after terminalization the broad gate clears") + }) + + s.Run("ForecloseUnacceptedLeavesEarlierEpochForReconciliation", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimComputed) + s.Require().NoError(s.Repo.UpdateApplicationForeclosure( + s.Ctx, app.ID, forecloseBlock, UniqueHash(), forecloseBlock)) + + n, err := s.Repo.ForecloseUnacceptedEpochsAtOrAfterBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.Equal(int64(0), n) + + got, err := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(EpochStatus_ClaimComputed, got.Status) + }) + + // The PRT empty-epoch invariant: a straddling OPEN epoch with zero + // inputs is valid for DaveConsensus and represents no pending work + // for the narrow gate. The broad gate, by contrast, sees the row via + // the first_block predicate — this divergence is correct because + // Authority/Quorum apps never persist empty epoch rows so the broad + // gate's "false positive" here can never fire in production. + s.Run("EmptyStraddlingEpochOnlyBlocksBroadGate", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = emptyOpen(app, 0, forecloseBlock-10, forecloseBlock+10) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained, + "narrow gate (input-level) returns false on empty straddling epoch — "+ + "PRT's empty-epoch invariant means there is nothing to drain") + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon, + "broad gate matches the OPEN row by first_block <= forecloseBlock; "+ + "Authority/Quorum never persists empty epochs so this branch is "+ + "unreachable in production but is exercised here to pin the divergence") + }) + + s.Run("SubmittedAndStagedBlockBroadGate", func() { + // CLAIM_SUBMITTED and CLAIM_STAGED are intermediate post-broadcast + // states; both must continue to register as unreconciled until a + // terminal CLAIM_ACCEPTED or CLAIM_FORECLOSED transition lands. + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + _ = advance(app, 0, 0, 9, EpochStatus_ClaimSubmitted) + + drained, err := s.Repo.HasUndrainedEpochsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.False(drained) + + recon, err := s.Repo.HasUnreconciledClaimsBeforeBlock(s.Ctx, app.ID, forecloseBlock) + s.Require().NoError(err) + s.True(recon) + }) +} diff --git a/internal/repository/repotest/input_test_cases.go b/internal/repository/repotest/input_test_cases.go new file mode 100644 index 000000000..3d42326a7 --- /dev/null +++ b/internal/repository/repotest/input_test_cases.go @@ -0,0 +1,602 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type InputSuite struct { + BaseSuite +} + +func NewInputSuite(factory RepositoryFactory) *InputSuite { + return &InputSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *InputSuite) TestGetInput() { + s.Run("ExistingInput", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(seed.App.ID, got.EpochApplicationID) + s.Equal(uint64(0), got.EpochIndex) + s.Equal(uint64(0), got.Index) + s.Equal(seed.Input.BlockNumber, got.BlockNumber) + s.Equal(seed.Input.RawData, got.RawData) + s.Equal(InputCompletionStatus_None, got.Status) + s.Equal(seed.Input.TransactionHash, got.TransactionHash) + s.Equal(seed.Input.LogIndex, got.LogIndex) + s.Nil(got.MachineHash) + s.Nil(got.TxBufferDataBlock) + s.Nil(got.SnapshotURI) + s.False(got.CreatedAt.IsZero(), "CreatedAt should be set") + s.False(got.UpdatedAt.IsZero(), "UpdatedAt should be set") + }) + + s.Run("NotFound", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetInput(s.Ctx, app.IApplicationAddress.String(), 99) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *InputSuite) TestGetLastInput() { + s.Run("ReturnsLastInput", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(10).Build() + input2 := NewInputBuilder().WithIndex(2).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 20) + s.Require().NoError(err) + + got, err := s.Repo.GetLastInput(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(uint64(2), got.Index) + }) +} + +func (s *InputSuite) TestGetLastProcessedInput() { + s.Run("ReturnsLastProcessed", func() { + const ( + processedInputBlock uint64 = 5 + pendingInputBlock uint64 = 10 + ) + + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 1).Build() + + input0 := NewInputBuilder(). + WithIndex(0).WithBlockNumber(processedInputBlock).Build() + input1 := NewInputBuilder(). + WithIndex(1).WithBlockNumber(pendingInputBlock). + WithStatus(InputCompletionStatus_None).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, 20) + s.Require().NoError(err) + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, 0, + InputCompletionStatus_Accepted, nil, nil) + + got, err := s.Repo.GetLastProcessedInput(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), got.Index) + s.Equal(InputCompletionStatus_Accepted, got.Status) + }) +} + +func (s *InputSuite) TestCreateEpochsAndInputsWithSameTransactionHash() { + s.Run("AllowsMultipleLogsFromSameTransaction", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 1).Build() + + txHash := UniqueHash() + input0 := NewInputBuilder(). + WithIndex(0). + WithTransactionHash(txHash). + WithLogIndex(7). + Build() + input1 := NewInputBuilder(). + WithIndex(1). + WithTransactionHash(txHash). + WithLogIndex(8). + Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, 20) + s.Require().NoError(err) + + got0, err := s.Repo.GetInput(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got0) + s.Equal(txHash, got0.TransactionHash) + s.Equal(uint64(7), got0.LogIndex) + + got1, err := s.Repo.GetInput(s.Ctx, app.IApplicationAddress.String(), 1) + s.Require().NoError(err) + s.Require().NotNil(got1) + s.Equal(txHash, got1.TransactionHash) + s.Equal(uint64(8), got1.LogIndex) + + lastBlock, err := s.Repo.GetEventLastCheckBlock(s.Ctx, app.ID, MonitoredEvent_InputAdded) + s.Require().NoError(err) + s.Equal(uint64(20), lastBlock) + + err = s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, 20) + s.Require().NoError(err) + + count, err := s.Repo.GetNumberOfInputs(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(2), count) + }) + + s.Run("RejectsDuplicateLogIdentity", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 1).Build() + + blockBefore, blockErr := s.Repo.GetEventLastCheckBlock(s.Ctx, app.ID, MonitoredEvent_InputAdded) + s.Require().NoError(blockErr) + + txHash := UniqueHash() + input0 := NewInputBuilder(). + WithIndex(0). + WithTransactionHash(txHash). + WithLogIndex(7). + Build() + input1 := NewInputBuilder(). + WithIndex(1). + WithTransactionHash(txHash). + WithLogIndex(7). + Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, 20) + s.Require().ErrorIs(err, repository.ErrInputLogIdentityConflict) + + // The whole batch must roll back: no inputs, no epoch row, and the + // input cursor must keep its exact previous value. + count, countErr := s.Repo.GetNumberOfInputs(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(countErr) + s.Equal(uint64(0), count) + + gotEpoch, epochErr := s.Repo.GetEpoch(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(epochErr) + s.Nil(gotEpoch) + + blockAfter, blockErr := s.Repo.GetEventLastCheckBlock(s.Ctx, app.ID, MonitoredEvent_InputAdded) + s.Require().NoError(blockErr) + s.Equal(blockBefore, blockAfter) + }) + + s.Run("AllowsSameLogIdentityInDifferentApplications", func() { + // The L1 log identity constraint is scoped per application: the same + // (transaction_hash, log_index) must persist independently for two + // different applications. + appA := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + appB := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + txHash := UniqueHash() + for _, app := range []*Application{appA, appB} { + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 0).Build() + input := NewInputBuilder(). + WithIndex(0). + WithTransactionHash(txHash). + WithLogIndex(7). + Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input}}, 20) + s.Require().NoError(err) + } + + for _, app := range []*Application{appA, appB} { + got, err := s.Repo.GetInput(s.Ctx, app.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(txHash, got.TransactionHash) + s.Equal(uint64(7), got.LogIndex) + } + }) +} + +func (s *InputSuite) TestListInputs() { + const ( + firstFilteredInputBlock uint64 = 5 + secondFilteredInputBlock uint64 = 10 + ) + + s.Run("EmptyResult", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(inputs) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAllInputs", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 29).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(10).Build() + input2 := NewInputBuilder().WithIndex(2).WithBlockNumber(20).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 30) + s.Require().NoError(err) + + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(inputs, 3) + s.Equal(uint64(3), total) + }) + + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 49).WithInputBounds(0, 4).Build() + inputs := make([]*Input, 5) + for i := range uint64(5) { + inputs[i] = NewInputBuilder().WithIndex(i).WithBlockNumber(i*10 + 5).Build() + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), map[*Epoch][]*Input{epoch: inputs}, 50) + s.Require().NoError(err) + + indexRange := repository.Range{Start: 1, End: 3} + got, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(got, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), got[0].Index) + }) + + s.Run("FilterByEpochIndex", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Open). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + + epochIdx := uint64(1) + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{EpochIndex: &epochIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(inputs, 1) + s.Equal(uint64(1), total) + s.Equal(uint64(1), inputs[0].Index) + }) + + s.Run("FilterByStatus", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 1).Build() + + input0 := NewInputBuilder(). + WithIndex(0).WithBlockNumber(firstFilteredInputBlock).Build() + input1 := NewInputBuilder(). + WithIndex(1).WithBlockNumber(secondFilteredInputBlock). + WithStatus(InputCompletionStatus_None).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, 20) + s.Require().NoError(err) + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, 0, + InputCompletionStatus_Accepted, nil, nil) + + status := InputCompletionStatus_Accepted + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{Status: &status}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(inputs, 1) + s.Equal(uint64(1), total) + s.Equal(InputCompletionStatus_Accepted, inputs[0].Status) + }) + + s.Run("FilterByNotStatus", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder(). + WithIndex(0).WithBlockNumber(firstFilteredInputBlock).Build() + input1 := NewInputBuilder(). + WithIndex(1).WithBlockNumber(secondFilteredInputBlock).Build() + input2 := NewInputBuilder(). + WithIndex(2).WithBlockNumber(15). + WithStatus(InputCompletionStatus_None).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 20) + s.Require().NoError(err) + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, 0, + InputCompletionStatus_Accepted, nil, nil) + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, 1, + InputCompletionStatus_Rejected, nil, nil) + + notStatus := InputCompletionStatus_None + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{NotStatus: ¬Status}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(inputs, 2) + s.Equal(uint64(2), total) + for _, inp := range inputs { + s.NotEqual(InputCompletionStatus_None, inp.Status) + } + }) + + s.Run("FilterBySender", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 1).Build() + + // The Sender filter uses SUBSTR(raw_data, 81, 20) to extract a + // 20-byte sender address from the ABI-encoded input payload. + senderAddr := UniqueAddress() + rawWithSender := make([]byte, 101) + copy(rawWithSender[80:100], senderAddr.Bytes()) + + otherAddr := UniqueAddress() + rawWithOther := make([]byte, 101) + copy(rawWithOther[80:100], otherAddr.Bytes()) + + input0 := NewInputBuilder(). + WithIndex(0).WithBlockNumber(5). + WithRawData(rawWithSender).Build() + input1 := NewInputBuilder(). + WithIndex(1).WithBlockNumber(10). + WithRawData(rawWithOther).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1}}, 20) + s.Require().NoError(err) + + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{Sender: &senderAddr}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(inputs, 1) + s.Equal(uint64(1), total) + s.Equal(uint64(0), inputs[0].Index) + }) + + s.Run("FilterByTransactionHash", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + otherApp := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 29).WithInputBounds(0, 2).Build() + otherEpoch := NewEpochBuilder(otherApp.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + + txHash := UniqueHash() + input0 := NewInputBuilder(). + WithIndex(0).WithTransactionHash(txHash).WithLogIndex(10).Build() + input1 := NewInputBuilder(). + WithIndex(1).WithTransactionHash(UniqueHash()).WithLogIndex(11).Build() + input2 := NewInputBuilder(). + WithIndex(2).WithTransactionHash(txHash).WithLogIndex(12).Build() + otherInput := NewInputBuilder(). + WithIndex(0).WithTransactionHash(txHash).WithLogIndex(13).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 30) + s.Require().NoError(err) + err = s.Repo.CreateEpochsAndInputs( + s.Ctx, otherApp.IApplicationAddress.String(), + map[*Epoch][]*Input{otherEpoch: {otherInput}}, 10) + s.Require().NoError(err) + + inputs, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{TransactionHash: &txHash}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Require().Len(inputs, 2) + s.Equal(uint64(2), total) + s.Equal(uint64(0), inputs[0].Index) + s.Equal(uint64(2), inputs[1].Index) + for _, input := range inputs { + s.Equal(txHash, input.TransactionHash) + } + + missing := UniqueHash() + inputs, total, err = s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{TransactionHash: &missing}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(inputs) + s.Equal(uint64(0), total) + }) + + s.Run("Pagination", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 49).WithInputBounds(0, 4).Build() + + inputs := make([]*Input, 5) + for i := range uint64(5) { + inputs[i] = NewInputBuilder().WithIndex(i).WithBlockNumber(i*10 + 5).Build() + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: inputs}, 50) + s.Require().NoError(err) + + got, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{}, + repository.Pagination{Limit: 2, Offset: 0}, false) + s.Require().NoError(err) + s.Len(got, 2) + s.Equal(uint64(5), total) + }) + + s.Run("TotalCountCorrectWhenOffsetExceedsRows", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 29).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(10).Build() + input2 := NewInputBuilder().WithIndex(2).WithBlockNumber(20).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 30) + s.Require().NoError(err) + + // OFFSET == total rows: 0 data rows, but totalCount must still be 3 + got, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{}, + repository.Pagination{Limit: 10, Offset: 3}, false) + s.Require().NoError(err) + s.Empty(got) + s.Equal(uint64(3), total, "totalCount must be correct even when OFFSET skips all rows") + + // OFFSET > total rows: same expectation + got, total, err = s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{}, + repository.Pagination{Limit: 10, Offset: 100}, false) + s.Require().NoError(err) + s.Empty(got) + s.Equal(uint64(3), total, "totalCount must be correct even when OFFSET far exceeds rows") + }) + + s.Run("Descending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 29).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(10).Build() + input2 := NewInputBuilder().WithIndex(2).WithBlockNumber(20).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 30) + s.Require().NoError(err) + + inputs, _, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{}, + repository.Pagination{Limit: 10}, true) + s.Require().NoError(err) + s.Require().Len(inputs, 3) + // Descending: highest index first + s.Equal(uint64(2), inputs[0].Index) + s.Equal(uint64(1), inputs[1].Index) + s.Equal(uint64(0), inputs[2].Index) + }) +} + +func (s *InputSuite) TestGetNumberOfInputs() { + s.Run("ReturnsCount", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 19).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(10).Build() + input2 := NewInputBuilder().WithIndex(2).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 20) + s.Require().NoError(err) + + count, err := s.Repo.GetNumberOfInputs(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(3), count) + }) + + s.Run("ZeroWhenEmpty", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + count, err := s.Repo.GetNumberOfInputs(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), count) + }) +} + +func (s *InputSuite) TestUpdateInputSnapshotURI() { + s.Run("SetsSnapshotURI", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + uri := "/snapshots/test" + + err := s.Repo.UpdateInputSnapshotURI(s.Ctx, seed.App.ID, 0, uri) + s.Require().NoError(err) + + got, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got.SnapshotURI) + s.Equal(uri, *got.SnapshotURI) + }) +} diff --git a/internal/repository/repotest/invalid_outputs_root_test_cases.go b/internal/repository/repotest/invalid_outputs_root_test_cases.go new file mode 100644 index 000000000..5fda90ab1 --- /dev/null +++ b/internal/repository/repotest/invalid_outputs_root_test_cases.go @@ -0,0 +1,221 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +func (s *ApplicationSuite) TestInvalidOutputsRootStatus() { + const diagnosis = "epoch 0: declared outputs root differs from calculated root" + for _, current := range model.ApplicationStatusAllValues { + s.Run("EnterFrom/"+current.String(), func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + var oldReason *string + if current != model.ApplicationStatus_OK { + reason := "earlier diagnosis" + oldReason = &reason + s.Require().NoError(s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, current, oldReason)) + } + // The caller still has its original OK object. The stored status, + // not that stale snapshot, must decide whether this write is valid. + s.Equal(model.ApplicationStatus_OK, app.Status) + reason := diagnosis + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, model.ApplicationStatus_InvalidOutputsRoot, &reason) + allowed := current == model.ApplicationStatus_OK + if allowed { + s.Require().NoError(err) + } else { + s.Require().Error(err) + } + stored, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + if allowed { + s.Equal(model.ApplicationStatus_InvalidOutputsRoot, stored.Status) + s.Equal(&reason, stored.Reason) + } else { + s.Equal(current, stored.Status) + s.Equal(oldReason, stored.Reason) + } + }) + } + for _, target := range model.ApplicationStatusAllValues { + s.Run("CannotReplaceWith/"+target.String(), func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + reason := diagnosis + s.Require().NoError(s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, model.ApplicationStatus_InvalidOutputsRoot, &reason)) + newReason := "later finding" + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, target, &newReason) + escalates := target == model.ApplicationStatus_Diverged || target == model.ApplicationStatus_Corrupted + if escalates { + s.Require().NoError(err) + } else { + s.Require().Error(err) + } + stored, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + if escalates { + s.Equal(target, stored.Status) + s.Equal(&newReason, stored.Reason) + } else { + s.Equal(model.ApplicationStatus_InvalidOutputsRoot, stored.Status) + s.Equal(&reason, stored.Reason) + } + }) + } + for _, missing := range []struct { + name string + reason *string + }{ + {name: "null"}, + {name: "empty", reason: new("")}, + } { + s.Run("RequiresReason/"+missing.name, func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + err := s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, model.ApplicationStatus_InvalidOutputsRoot, missing.reason) + s.Require().Error(err) + stored, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(model.ApplicationStatus_OK, stored.Status) + s.Nil(stored.Reason) + }) + } + s.Run("EnablePreservesDiagnosis", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + reason := diagnosis + s.Require().NoError(s.Repo.UpdateApplicationStatus(s.Ctx, app.ID, model.ApplicationStatus_InvalidOutputsRoot, &reason)) + s.Require().NoError(s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false)) + s.Require().NoError(s.Repo.EnableApplicationAndResetDeployment(s.Ctx, app.ID, true)) + stored, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.True(stored.Enabled) + s.Equal(model.ApplicationStatus_InvalidOutputsRoot, stored.Status) + s.Equal(&reason, stored.Reason) + }) +} + +func (s *BulkOperationsSuite) TestInvalidOutputsRootAdvance() { + const ( + stateHashPageSize = 10 + epochLength = 10 + inputBlock = 5 + ) + for _, consensus := range model.ConsensusAllValues { + s.Run(consensus.String(), func() { + app := NewApplicationBuilder().WithConsensus(consensus).Create(s.Ctx, s.T(), s.Repo) + input := NewInputBuilder().WithIndex(0).WithBlockNumber(inputBlock).Build() + next := NewInputBuilder().WithIndex(1).WithBlockNumber(inputBlock + 1).Build() + inputs := []*model.Input{input, next} + epoch := NewEpochBuilder(app.ID).WithIndex(0).WithStatus(model.EpochStatus_Closed). + WithBlocks(0, epochLength-1).WithInputBounds(0, uint64(len(inputs))).Build() + s.Require().NoError(s.Repo.CreateEpochsAndInputs( + s.Ctx, app.Name, map[*model.Epoch][]*model.Input{epoch: inputs}, epochLength)) + result := &model.AdvanceResult{ + EpochIndex: epoch.Index, + InputIndex: input.Index, + Status: model.InputCompletionStatus_InvalidOutputsRoot, + StateProof: *DummyStateProof(), + IsDaveConsensus: consensus == model.Consensus_PRT, + } + if result.IsDaveConsensus { + result.PeriodicStateHashes = [][32]byte{UniqueHash()} + result.PaddingRepetitions = model.InputHashCollectionCapacity - uint64(len(result.PeriodicStateHashes)) + } + s.Require().NoError(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result)) + storedInput, err := s.Repo.GetInput(s.Ctx, app.Name, input.Index) + s.Require().NoError(err) + s.Equal(model.InputCompletionStatus_InvalidOutputsRoot, storedInput.Status) + s.Equal(&result.MachineHash, storedInput.MachineHash) + s.Equal(&result.TxBufferDataBlock, storedInput.TxBufferDataBlock) + s.Nil(storedInput.ExceptionData) + storedEpoch, err := s.Repo.GetEpoch(s.Ctx, app.Name, epoch.Index) + s.Require().NoError(err) + proof, err := storedEpoch.StateProof() + s.Require().NoError(err) + s.Equal(result.StateProof, proof) + storedApp, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1), storedApp.ProcessedInputs) + s.Equal(model.ApplicationStatus_InvalidOutputsRoot, storedApp.Status) + s.Require().NotNil(storedApp.Reason) + s.Equal("input 0 completed with INVALID_OUTPUTS_ROOT: an accepted yield must declare exactly 32 bytes", *storedApp.Reason) + summary, err := s.Repo.ReplaySummary(s.Ctx, app.IApplicationAddress, repository.ReplayVerificationFull) + s.Require().NoError(err) + s.Equal(uint64(1), summary.ProcessedInputs) + records, err := s.Repo.ReplayPage(s.Ctx, repository.ReplayPageRequest{ + ApplicationID: app.ID, FromInput: 0, ToInputExclusive: 1, Limit: 1, + Verification: repository.ReplayVerificationFull, + }) + s.Require().NoError(err) + s.Require().Len(records, 1) + s.Equal(result.Status, records[0].Input.Status) + s.Equal(&result.MachineHash, records[0].Input.MachineHash) + s.Equal(&result.TxBufferDataBlock, records[0].Input.TxBufferDataBlock) + if result.IsDaveConsensus { + total, err := s.Repo.CountEpochStateHashes(s.Ctx, app.ID, epoch.Index) + s.Require().NoError(err) + hashes, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, epoch.Index, nil, stateHashPageSize) + s.Require().NoError(err) + expectedHashRows := len(result.PeriodicStateHashes) + 1 + s.Equal(uint64(expectedHashRows), total) + s.Require().Len(hashes, expectedHashRows) + s.Equal(common.Hash(result.PeriodicStateHashes[0]), hashes[0].MachineHash) + s.Equal(uint64(1), hashes[0].Repetitions) + s.Equal(model.FinalStateEntry(result.MachineHash), hashes[1].MachineHash) + s.Equal(result.PaddingRepetitions, hashes[1].Repetitions) + s.Equal([]model.ReplayStateHash{ + {Index: 0, MachineHash: common.Hash(result.PeriodicStateHashes[0]), Repetitions: 1}, + {Index: 1, MachineHash: model.FinalStateEntry(result.MachineHash), Repetitions: result.PaddingRepetitions}, + }, records[0].StateHashes) + } else { + s.Empty(records[0].StateHashes) + } + // A repeated save cannot consume the input twice, and a stale + // writer cannot append an accepted result for its successor. + s.Require().ErrorIs(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result), repository.ErrAdvanceAfterTerminal) + result.InputIndex = next.Index + result.Status = model.InputCompletionStatus_Accepted + result.Outputs = [][]byte{[]byte("must not be stored")} + s.Require().ErrorIs(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result), repository.ErrAdvanceAfterTerminal) + pending, err := s.Repo.GetInput(s.Ctx, app.Name, next.Index) + s.Require().NoError(err) + s.Equal(model.InputCompletionStatus_None, pending.Status) + outputs, total, err := s.Repo.ListOutputs(s.Ctx, app.Name, repository.OutputFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Zero(total) + storedApp, err = s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1), storedApp.ProcessedInputs) + }) + } + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_Failed, model.ApplicationStatus_InvalidOutputsRoot} { + s.Run("RejectsStaleResult/"+status.String(), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + reason := "another service stopped the application" + s.Require().NoError(s.Repo.UpdateApplicationStatus(s.Ctx, seed.App.ID, status, &reason)) + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, &model.AdvanceResult{ + EpochIndex: seed.Epoch.Index, + InputIndex: seed.Input.Index, + Status: model.InputCompletionStatus_InvalidOutputsRoot, + StateProof: *DummyStateProof(), + }) + s.Require().ErrorIs(err, repository.ErrApplicationNotRunnable) + input, err := s.Repo.GetInput(s.Ctx, seed.App.Name, seed.Input.Index) + s.Require().NoError(err) + s.Equal(model.InputCompletionStatus_None, input.Status) + s.Nil(input.MachineHash) + epoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.Name, seed.Epoch.Index) + s.Require().NoError(err) + s.False(epoch.HasCompleteStateProof()) + app, err := s.Repo.GetApplication(s.Ctx, seed.App.Name) + s.Require().NoError(err) + s.Zero(app.ProcessedInputs) + s.Equal(status, app.Status) + s.Equal(&reason, app.Reason) + }) + } +} diff --git a/internal/repository/repotest/match_advanced_test_cases.go b/internal/repository/repotest/match_advanced_test_cases.go new file mode 100644 index 000000000..b352e6706 --- /dev/null +++ b/internal/repository/repotest/match_advanced_test_cases.go @@ -0,0 +1,138 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +type MatchAdvancedSuite struct { + BaseSuite +} + +func NewMatchAdvancedSuite(factory RepositoryFactory) *MatchAdvancedSuite { + return &MatchAdvancedSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *MatchAdvancedSuite) createTournamentAndMatch() ( + *SeedResult, common.Address, common.Hash, +) { + seed := Seed(s.Ctx, s.T(), s.Repo) + tournAddr := UniqueAddress() + t := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).WithAddress(tournAddr).Build() + err := s.Repo.CreateTournament(s.Ctx, seed.App.IApplicationAddress.String(), t) + s.Require().NoError(err) + + // Create commitments to satisfy match FK constraints + c1 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + err = s.Repo.CreateCommitment(s.Ctx, seed.App.IApplicationAddress.String(), c1) + s.Require().NoError(err) + + c2 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + err = s.Repo.CreateCommitment(s.Ctx, seed.App.IApplicationAddress.String(), c2) + s.Require().NoError(err) + + matchIDHash := UniqueHash() + match := NewMatchBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + WithIDHash(matchIDHash). + WithCommitmentOne(c1.Commitment). + WithCommitmentTwo(c2.Commitment). + Build() + err = s.Repo.CreateMatch(s.Ctx, seed.App.IApplicationAddress.String(), match) + s.Require().NoError(err) + + return seed, tournAddr, matchIDHash +} + +func (s *MatchAdvancedSuite) TestCreateMatchAdvanced() { + s.Run("CreatesSuccessfully", func() { + seed, tournAddr, matchIDHash := s.createTournamentAndMatch() + + ma := NewMatchAdvancedBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + WithIDHash(matchIDHash). + Build() + + err := s.Repo.CreateMatchAdvanced( + s.Ctx, seed.App.IApplicationAddress.String(), ma) + s.Require().NoError(err) + }) +} + +func (s *MatchAdvancedSuite) TestGetMatchAdvanced() { + s.Run("ExistingMatchAdvanced", func() { + seed, tournAddr, matchIDHash := s.createTournamentAndMatch() + + ma := NewMatchAdvancedBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + WithIDHash(matchIDHash). + Build() + + err := s.Repo.CreateMatchAdvanced( + s.Ctx, seed.App.IApplicationAddress.String(), ma) + s.Require().NoError(err) + + got, err := s.Repo.GetMatchAdvanced( + s.Ctx, seed.App.IApplicationAddress.String(), + 0, tournAddr.String(), matchIDHash.Hex(), ma.TxHash, ma.LogIndex) + s.Require().NoError(err) + s.Equal(ma.IDHash, got.IDHash) + s.Equal(ma.OtherParent, got.OtherParent) + }) + + s.Run("NotFound", func() { + seed, tournAddr, matchIDHash := s.createTournamentAndMatch() + nonExistent := UniqueHash() + got, err := s.Repo.GetMatchAdvanced( + s.Ctx, seed.App.IApplicationAddress.String(), + 0, tournAddr.String(), matchIDHash.Hex(), nonExistent, 0) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *MatchAdvancedSuite) TestListMatchAdvances() { + s.Run("EmptyResult", func() { + seed, tournAddr, matchIDHash := s.createTournamentAndMatch() + advances, total, err := s.Repo.ListMatchAdvances( + s.Ctx, seed.App.IApplicationAddress.String(), + 0, tournAddr.String(), matchIDHash.Hex(), + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(advances) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAll", func() { + seed, tournAddr, matchIDHash := s.createTournamentAndMatch() + const advanceCount = 3 + for height := range uint64(advanceCount) { + ma := NewMatchAdvancedBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(tournAddr). + WithIDHash(matchIDHash). + Build() + ma.CurrentHeight = height + 1 + err := s.Repo.CreateMatchAdvanced( + s.Ctx, seed.App.IApplicationAddress.String(), ma) + s.Require().NoError(err) + } + + advances, total, err := s.Repo.ListMatchAdvances( + s.Ctx, seed.App.IApplicationAddress.String(), + 0, tournAddr.String(), matchIDHash.Hex(), + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(advances, 3) + s.Equal(uint64(3), total) + }) +} diff --git a/internal/repository/repotest/match_test_cases.go b/internal/repository/repotest/match_test_cases.go new file mode 100644 index 000000000..6ef6d9c21 --- /dev/null +++ b/internal/repository/repotest/match_test_cases.go @@ -0,0 +1,192 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +type MatchSuite struct { + BaseSuite +} + +func NewMatchSuite(factory RepositoryFactory) *MatchSuite { + return &MatchSuite{BaseSuite: BaseSuite{factory: factory}} +} + +// matchSetup holds a tournament and two commitments needed for match FK constraints. +type matchSetup struct { + seed *SeedResult + tournAddr common.Address + commitHash1 common.Hash + commitHash2 common.Hash +} + +func (s *MatchSuite) setupTournamentWithCommitments() *matchSetup { + seed := Seed(s.Ctx, s.T(), s.Repo) + tournAddr := UniqueAddress() + t := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).WithAddress(tournAddr).Build() + err := s.Repo.CreateTournament(s.Ctx, seed.App.IApplicationAddress.String(), t) + s.Require().NoError(err) + + c1 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + err = s.Repo.CreateCommitment(s.Ctx, seed.App.IApplicationAddress.String(), c1) + s.Require().NoError(err) + + c2 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(tournAddr).Build() + err = s.Repo.CreateCommitment(s.Ctx, seed.App.IApplicationAddress.String(), c2) + s.Require().NoError(err) + + return &matchSetup{ + seed: seed, + tournAddr: tournAddr, + commitHash1: c1.Commitment, + commitHash2: c2.Commitment, + } +} + +func (s *MatchSuite) TestCreateMatch() { + s.Run("CreatesSuccessfully", func() { + setup := s.setupTournamentWithCommitments() + match := NewMatchBuilder(setup.seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(setup.tournAddr). + WithCommitmentOne(setup.commitHash1). + WithCommitmentTwo(setup.commitHash2). + Build() + + err := s.Repo.CreateMatch( + s.Ctx, setup.seed.App.IApplicationAddress.String(), match) + s.Require().NoError(err) + }) +} + +func (s *MatchSuite) TestGetMatch() { + s.Run("ExistingMatch", func() { + setup := s.setupTournamentWithCommitments() + match := NewMatchBuilder(setup.seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(setup.tournAddr). + WithCommitmentOne(setup.commitHash1). + WithCommitmentTwo(setup.commitHash2). + Build() + + err := s.Repo.CreateMatch( + s.Ctx, setup.seed.App.IApplicationAddress.String(), match) + s.Require().NoError(err) + + got, err := s.Repo.GetMatch( + s.Ctx, setup.seed.App.IApplicationAddress.String(), + 0, setup.tournAddr.String(), match.IDHash.Hex()) + s.Require().NoError(err) + s.Equal(match.IDHash, got.IDHash) + s.Equal(match.CommitmentOne, got.CommitmentOne) + }) + + s.Run("NotFound", func() { + setup := s.setupTournamentWithCommitments() + got, err := s.Repo.GetMatch( + s.Ctx, setup.seed.App.IApplicationAddress.String(), + 0, setup.tournAddr.String(), UniqueHash().Hex()) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *MatchSuite) TestListMatches() { + s.Run("EmptyResult", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + matches, total, err := s.Repo.ListMatches( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.MatchFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(matches) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAll", func() { + setup := s.setupTournamentWithCommitments() + + // Each match needs unique commitment pairs (matches_unique_pair_idx). + // Create additional commitments for more matches. + for range 3 { + c1 := NewCommitmentBuilder(setup.seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(setup.tournAddr).Build() + err := s.Repo.CreateCommitment( + s.Ctx, setup.seed.App.IApplicationAddress.String(), c1) + s.Require().NoError(err) + + c2 := NewCommitmentBuilder(setup.seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(setup.tournAddr).Build() + err = s.Repo.CreateCommitment( + s.Ctx, setup.seed.App.IApplicationAddress.String(), c2) + s.Require().NoError(err) + + m := NewMatchBuilder(setup.seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(setup.tournAddr). + WithCommitmentOne(c1.Commitment). + WithCommitmentTwo(c2.Commitment). + Build() + err = s.Repo.CreateMatch( + s.Ctx, setup.seed.App.IApplicationAddress.String(), m) + s.Require().NoError(err) + } + + matches, total, err := s.Repo.ListMatches( + s.Ctx, setup.seed.App.IApplicationAddress.String(), + repository.MatchFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(matches, 3) + s.Equal(uint64(3), total) + }) + + s.Run("FilterByEpochIndex", func() { + setup := s.setupTournamentWithCommitments() + m := NewMatchBuilder(setup.seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(setup.tournAddr). + WithCommitmentOne(setup.commitHash1). + WithCommitmentTwo(setup.commitHash2). + Build() + err := s.Repo.CreateMatch( + s.Ctx, setup.seed.App.IApplicationAddress.String(), m) + s.Require().NoError(err) + + epochIdx := uint64(0) + matches, total, err := s.Repo.ListMatches( + s.Ctx, setup.seed.App.IApplicationAddress.String(), + repository.MatchFilter{EpochIndex: &epochIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(matches, 1) + s.Equal(uint64(1), total) + }) + + s.Run("FilterByTournamentAddress", func() { + setup := s.setupTournamentWithCommitments() + m := NewMatchBuilder(setup.seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(setup.tournAddr). + WithCommitmentOne(setup.commitHash1). + WithCommitmentTwo(setup.commitHash2). + Build() + err := s.Repo.CreateMatch( + s.Ctx, setup.seed.App.IApplicationAddress.String(), m) + s.Require().NoError(err) + + addrStr := setup.tournAddr.String() + matches, total, err := s.Repo.ListMatches( + s.Ctx, setup.seed.App.IApplicationAddress.String(), + repository.MatchFilter{TournamentAddress: &addrStr}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(matches, 1) + s.Equal(uint64(1), total) + }) +} diff --git a/internal/repository/repotest/node_config.go b/internal/repository/repotest/node_config.go new file mode 100644 index 000000000..4a73bf70d --- /dev/null +++ b/internal/repository/repotest/node_config.go @@ -0,0 +1,55 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +// NodeConfigInitializer provisions configuration fixtures without replacing +// an existing value. Production services use the merging registration methods. +type NodeConfigInitializer interface { + repository.NodeConfigReader + InitializeNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error +} + +// NodeConfigWriter permits whole-value replacement for configuration fixtures. +// Production repository interfaces expose reads and dedicated merging methods. +type NodeConfigWriter interface { + SaveNodeConfigRaw(ctx context.Context, key string, rawJSON []byte) error +} + +// SaveNodeConfig writes a typed configuration fixture. +func SaveNodeConfig[T any]( + ctx context.Context, + repo NodeConfigWriter, + nc *model.NodeConfig[T], +) error { + data, err := json.Marshal(nc.Value) + if err != nil { + return fmt.Errorf("marshal node_config value failed: %w", err) + } + return repo.SaveNodeConfigRaw(ctx, nc.Key, data) +} + +// InitializeNodeConfig saves only a missing fixture key, then returns the stored +// value. Callers must validate it: a concurrent initializer may have won with a +// different value. +func InitializeNodeConfig[T any]( + ctx context.Context, repo NodeConfigInitializer, nc *model.NodeConfig[T], +) (*model.NodeConfig[T], error) { + data, err := json.Marshal(nc.Value) + if err != nil { + return nil, fmt.Errorf("marshal initial node_config value: %w", err) + } + if err := repo.InitializeNodeConfigRaw(ctx, nc.Key, data); err != nil { + return nil, fmt.Errorf("initialize node_config %q: %w", nc.Key, err) + } + return repository.LoadNodeConfig[T](ctx, repo, nc.Key) +} diff --git a/internal/repository/repotest/node_config_test_cases.go b/internal/repository/repotest/node_config_test_cases.go new file mode 100644 index 000000000..a34d41479 --- /dev/null +++ b/internal/repository/repotest/node_config_test_cases.go @@ -0,0 +1,171 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/cartesi/rollups-node/internal/config" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +const nodeConfigTestChainID = 31337 + +func (s *NodeConfigSuite) TestConcurrentConfigInitializationPreservesWinner() { + s.Run("opposite submission modes", func() { + const key = "concurrent-init" + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for _, enabled := range []bool{false, true} { + wg.Go(func() { + <-start + requested := config.PersistentSubmitterConfig{ + DefaultBlock: DefaultBlock_Finalized, ChainID: nodeConfigTestChainID, ClaimSubmissionEnabled: enabled, + } + if enabled { + requested.ResolvedSignerAddress = common.HexToAddress("0x1") + } + stored, err := InitializeNodeConfig( + s.Ctx, s.Repo.(NodeConfigInitializer), &NodeConfig[config.PersistentSubmitterConfig]{ + Key: key, Value: requested, + }) + if err == nil { + err = stored.Value.CheckRequested(requested) + } + errs <- err + }) + } + close(start) + wg.Wait() + close(errs) + var successes, conflicts int + for err := range errs { + if err == nil { + successes++ + } else { + s.Require().ErrorContains(err, "claim submission mode mismatch") + conflicts++ + } + } + s.Equal(1, successes) + s.Equal(1, conflicts) + winner, err := repository.LoadNodeConfig[config.PersistentSubmitterConfig](s.Ctx, s.Repo, key) + s.Require().NoError(err) + opposite := winner.Value + opposite.ClaimSubmissionEnabled = !opposite.ClaimSubmissionEnabled + retained, err := InitializeNodeConfig( + s.Ctx, s.Repo.(NodeConfigInitializer), &NodeConfig[config.PersistentSubmitterConfig]{ + Key: key, Value: opposite, + }) + s.Require().NoError(err) + s.Equal(winner.Value, retained.Value) + s.Equal(winner.CreatedAt, retained.CreatedAt) + s.Equal(winner.UpdatedAt, retained.UpdatedAt) + }) +} + +func (s *NodeConfigSuite) TestConfigInitializationDoesNotRepairInvalidSavedValue() { + for i, raw := range []string{`{}`, `null`, `{"ChainID":0,"DefaultBlock":"FINALIZED"}`} { + s.Run(fmt.Sprint(i), func() { + key := fmt.Sprintf("invalid-config-%d", i) + s.Require().NoError(s.Repo.(NodeConfigWriter).SaveNodeConfigRaw(s.Ctx, key, []byte(raw))) + _, err := InitializeNodeConfig( + s.Ctx, s.Repo.(NodeConfigInitializer), &NodeConfig[config.PersistentChainConfig]{ + Key: key, Value: config.PersistentChainConfig{DefaultBlock: DefaultBlock_Finalized, ChainID: nodeConfigTestChainID}, + }) + s.Require().Error(err) + got, _, _, err := s.Repo.LoadNodeConfigRaw(s.Ctx, key) + s.Require().NoError(err) + s.JSONEq(raw, string(got)) + }) + } +} + +type NodeConfigSuite struct { + BaseSuite +} + +func NewNodeConfigSuite(factory RepositoryFactory) *NodeConfigSuite { + return &NodeConfigSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *NodeConfigSuite) TestSaveAndLoadNodeConfigRaw() { + s.Run("RoundTrip", func() { + key := "test-config" + value := map[string]string{"foo": "bar"} + data, err := json.Marshal(value) + s.Require().NoError(err) + + err = s.Repo.(NodeConfigWriter).SaveNodeConfigRaw(s.Ctx, key, data) + s.Require().NoError(err) + + got, createdAt, updatedAt, err := s.Repo.LoadNodeConfigRaw(s.Ctx, key) + s.Require().NoError(err) + + // Compare JSON semantically (PostgreSQL may reformat whitespace) + var expected, actual map[string]string + s.Require().NoError(json.Unmarshal(data, &expected)) + s.Require().NoError(json.Unmarshal(got, &actual)) + s.Equal(expected, actual) + + s.False(createdAt.IsZero()) + s.False(updatedAt.IsZero()) + }) + + s.Run("UpdateExistingKey", func() { + key := "update-test" + data1, err := json.Marshal("value1") + s.Require().NoError(err) + data2, err := json.Marshal("value2") + s.Require().NoError(err) + + err = s.Repo.(NodeConfigWriter).SaveNodeConfigRaw(s.Ctx, key, data1) + s.Require().NoError(err) + + err = s.Repo.(NodeConfigWriter).SaveNodeConfigRaw(s.Ctx, key, data2) + s.Require().NoError(err) + + got, _, _, err := s.Repo.LoadNodeConfigRaw(s.Ctx, key) + s.Require().NoError(err) + + // Compare JSON semantically + var expected, actual string + s.Require().NoError(json.Unmarshal(data2, &expected)) + s.Require().NoError(json.Unmarshal(got, &actual)) + s.Equal(expected, actual) + }) + + s.Run("NotFound", func() { + _, _, _, err := s.Repo.LoadNodeConfigRaw(s.Ctx, "nonexistent-key") + s.ErrorIs(err, repository.ErrNotFound) + }) +} + +func (s *NodeConfigSuite) TestGenericNodeConfig() { + s.Run("SaveAndLoadTyped", func() { + type TestConfig struct { + Name string `json:"name"` + Count int `json:"count"` + } + + nc := &NodeConfig[TestConfig]{ + Key: "typed-config", + Value: TestConfig{Name: "test", Count: 42}, + } + + err := SaveNodeConfig(s.Ctx, s.Repo.(NodeConfigWriter), nc) + s.Require().NoError(err) + + got, err := repository.LoadNodeConfig[TestConfig](s.Ctx, s.Repo, "typed-config") + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal("test", got.Value.Name) + s.Equal(42, got.Value.Count) + }) +} diff --git a/internal/repository/repotest/nonaccepted_reports_test_cases.go b/internal/repository/repotest/nonaccepted_reports_test_cases.go new file mode 100644 index 000000000..9abc6123d --- /dev/null +++ b/internal/repository/repotest/nonaccepted_reports_test_cases.go @@ -0,0 +1,129 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func seedReportInputs(ctx context.Context, t *testing.T, repo repository.Repository) *model.Application { + t.Helper() + const ( + inputCount = 3 + epochEnd = 9 + scanHead = epochEnd + 1 + ) + app := NewApplicationBuilder().Create(ctx, t, repo) + epoch := NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed). + WithBlocks(0, epochEnd).WithInputBounds(0, inputCount).Build() + inputs := make([]*model.Input, inputCount) + for i := range inputs { + inputs[i] = NewInputBuilder().WithIndex(uint64(i)).WithBlockNumber(uint64(i) + 1).Build() + } + require.NoError(t, repo.CreateEpochsAndInputs(ctx, app.Name, + map[*model.Epoch][]*model.Input{epoch: inputs}, scanHead)) + return app +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultReportSequence() { + for _, firstStatus := range []model.InputCompletionStatus{ + model.InputCompletionStatus_Accepted, + model.InputCompletionStatus_Rejected, + } { + s.Run(firstStatus.String()+"/REJECTED/ACCEPTED", func() { + app := seedReportInputs(s.Ctx, s.T(), s.Repo) + statuses := []model.InputCompletionStatus{ + firstStatus, model.InputCompletionStatus_Rejected, model.InputCompletionStatus_Accepted, + } + for i, status := range statuses { + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, uint64(i), status, + nil, [][]byte{[]byte(fmt.Sprintf("report-%d", i))}) + } + reports, total, err := s.Repo.ListReports(s.Ctx, app.Name, + repository.ReportFilter{}, repository.Pagination{Limit: uint64(len(statuses))}, false) + s.Require().NoError(err) + s.Equal(uint64(len(statuses)), total) + s.Require().Len(reports, len(statuses)) + for i, report := range reports { + s.Equal(uint64(i), report.Index) + s.Equal(uint64(i), report.InputIndex) + s.Zero(report.EpochIndex) + s.Equal([]byte(fmt.Sprintf("report-%d", i)), report.RawData) + } + }) + } +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultLargeRejectedReportSet() { + // Cover the extended protocol's parameter boundary and the machine's report limit. + for _, count := range []uint64{16384, 65536} { + s.Run(fmt.Sprintf("%d", count), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + payloads := make([][]byte, count) + for i := range payloads { + payloads[i] = []byte(fmt.Sprintf("rejected-report-%d", i)) + } + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, seed.App.ID, 0, 0, + model.InputCompletionStatus_Rejected, nil, payloads) + reports, total, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: count}, false) + s.Require().NoError(err) + s.Equal(count, total) + s.Require().Equal(count, uint64(len(reports))) + for i, report := range reports { + s.Equal(uint64(i), report.Index) + s.Zero(report.InputIndex) + s.Equal(payloads[i], report.RawData) + } + }) + } +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultReportsBeyondBindMessageLimit() { + if testing.Short() { + s.T().Skip("transfers more than 1 GiB of report data") + } + s.Run("Rejected", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + const ( + reportCount = 520 + payloadSize = 2 * 1024 * 1024 + ) + // Share the source payload and read back samples to bound test memory. + payload := bytes.Repeat([]byte("x"), payloadSize) + reports := make([][]byte, reportCount) + for i := range reports { + reports[i] = payload + } + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, seed.App.ID, 0, 0, + model.InputCompletionStatus_Rejected, nil, reports) + page, total, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: 1}, false) + s.Require().NoError(err) + s.Equal(uint64(reportCount), total) + s.Require().Len(page, 1) + for _, index := range []uint64{0, reportCount / 2, reportCount - 1} { + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, index) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(index, report.Index) + s.Zero(report.InputIndex) + s.Equal(payload, report.RawData) + } + input, err := s.Repo.GetInput(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Equal(model.InputCompletionStatus_Rejected, input.Status) + app, err := s.Repo.GetApplication(s.Ctx, seed.App.Name) + s.Require().NoError(err) + s.Equal(uint64(1), app.ProcessedInputs) + }) +} diff --git a/internal/repository/repotest/output_test_cases.go b/internal/repository/repotest/output_test_cases.go new file mode 100644 index 000000000..fd54e386e --- /dev/null +++ b/internal/repository/repotest/output_test_cases.go @@ -0,0 +1,832 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "bytes" + "errors" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type OutputSuite struct { + BaseSuite +} + +func NewOutputSuite(factory RepositoryFactory) *OutputSuite { + return &OutputSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *OutputSuite) TestEpochOutputRanges() { + s.Run("EmptyEpoch", func() { + app := NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, map[*Epoch][]*Input{epoch: nil}, epoch.LastBlock+1)) + got, err := s.Repo.GetEpochOutputRange(s.Ctx, app.ID, epoch.Index) + s.Require().NoError(err) + s.Require().Equal(repository.EpochOutputRange{}, got) + }) + + s.Run("AcceptedEpochIsolationAndInclusiveScan", func() { + const ( + targetEpoch = 1 + targetFirstBlock = 10 + targetLastBlock = 19 + targetInputUpper = 4 + rejectedInput = 2 + targetOutputCount = 3 + otherOutputCount = 5 + scanThrough = 20 + ) + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + before := NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).WithInputBounds(0, 1).Build() + target := NewEpochBuilder(app.ID).WithIndex(targetEpoch).WithBlocks(targetFirstBlock, targetLastBlock). + WithStatus(EpochStatus_Closed).WithInputBounds(1, targetInputUpper).Build() + inputs := []*Input{ + NewInputBuilder().WithIndex(0).WithBlockNumber(1).Build(), + NewInputBuilder().WithIndex(1).WithBlockNumber(target.FirstBlock + 1).Build(), + NewInputBuilder().WithIndex(rejectedInput).WithBlockNumber(target.FirstBlock + rejectedInput).Build(), + NewInputBuilder().WithIndex(targetInputUpper - 1).WithBlockNumber(target.FirstBlock + targetInputUpper - 1).Build(), + } + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, + map[*Epoch][]*Input{before: inputs[:1], target: inputs[1:]}, scanThrough)) + payloads := [][][]byte{ + {[]byte("before")}, {[]byte("target0"), []byte("target1")}, nil, {[]byte("target2")}, + } + for index, input := range inputs { + status := InputCompletionStatus_Accepted + epochIndex := uint64(targetEpoch) + if index == 0 { + epochIndex = before.Index + } + if index == rejectedInput { + status = InputCompletionStatus_Rejected + } + s.Require().NoError(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, &AdvanceResult{ + EpochIndex: epochIndex, InputIndex: input.Index, Status: status, + Outputs: payloads[index], StateProof: *DummyStateProof(), + })) + } + other := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + otherBefore := NewEpochBuilder(other.ID).WithStatus(EpochStatus_Closed).Build() + otherTarget := NewEpochBuilder(other.ID).WithIndex(targetEpoch).WithBlocks(targetFirstBlock, targetLastBlock). + WithStatus(EpochStatus_Closed).WithInputBounds(0, 1).Build() + otherInput := NewInputBuilder().WithIndex(0).WithBlockNumber(otherTarget.FirstBlock + 1).Build() + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, other.Name, + map[*Epoch][]*Input{otherBefore: nil, otherTarget: {otherInput}}, scanThrough)) + s.storeAdvanceResult(other.ID, targetEpoch, 0, + [][]byte{[]byte("other0"), []byte("other1"), []byte("other2"), []byte("other3"), []byte("other4")}, nil) + got, err := s.Repo.GetEpochOutputRange(s.Ctx, app.ID, target.Index) + s.Require().NoError(err) + s.Require().Equal(repository.EpochOutputRange{Count: targetOutputCount, First: 1, Last: targetOutputCount}, got) + previous, err := s.Repo.GetEpochOutputRange(s.Ctx, app.ID, before.Index) + s.Require().NoError(err) + s.Require().Equal(repository.EpochOutputRange{Count: 1, First: 0, Last: 0}, previous) + otherRange, err := s.Repo.GetEpochOutputRange(s.Ctx, other.ID, targetEpoch) + s.Require().NoError(err) + s.Require().Equal(repository.EpochOutputRange{Count: otherOutputCount, First: 0, Last: otherOutputCount - 1}, otherRange) + + expected := [][]byte{[]byte("target0"), []byte("target1"), []byte("target2")} + var read uint64 + s.Require().NoError(s.Repo.ScanOutputs(s.Ctx, app.ID, got.First, got.Last, func(index uint64, rawData []byte) error { + s.Equal(got.First+read, index) + s.Equal(expected[read], rawData) + read++ + return nil + })) + s.Equal(got.Count, read) + const middleIndex = 2 + read = 0 + s.Require().NoError(s.Repo.ScanOutputs(s.Ctx, app.ID, middleIndex, middleIndex, func(index uint64, rawData []byte) error { + s.Equal(uint64(middleIndex), index) + s.Equal(expected[1], rawData) + read++ + return nil + })) + s.Equal(uint64(1), read) + want := errors.New("stop output scan") + read = 0 + err = s.Repo.ScanOutputs(s.Ctx, app.ID, got.First, got.Last, func(uint64, []byte) error { + read++ + return want + }) + s.Require().ErrorIs(err, want) + s.Equal(uint64(1), read) + for _, bounds := range [][2]uint64{{1, 0}, {0, repository.MaxOutputRangeRows}} { + err := s.Repo.ScanOutputs(s.Ctx, app.ID, bounds[0], bounds[1], func(uint64, []byte) error { return nil }) + s.Require().ErrorContains(err, "output scan range must contain between 1 and 65536 indexes") + } + }) + + s.Run("WholeTwoMiBOutput", func() { + const outputSize = 2 << 20 + seed := Seed(s.Ctx, s.T(), s.Repo) + payload := bytes.Repeat([]byte{0xab}, outputSize) + s.storeAdvanceResult(seed.App.ID, 0, 0, [][]byte{payload}, nil) + calls := 0 + s.Require().NoError(s.Repo.ScanOutputs(s.Ctx, seed.App.ID, 0, 0, func(index uint64, rawData []byte) error { + s.Equal(uint64(0), index) + s.Require().Len(rawData, outputSize) + s.Equal(payload, rawData) + calls++ + return nil + })) + s.Equal(1, calls) + }) +} + +func (s *OutputSuite) TestGetOutput() { + s.Run("ExistingOutput", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("output-data")}, nil) + + got, err := s.Repo.GetOutput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(uint64(0), got.Index) + s.Equal([]byte("output-data"), got.RawData) + }) + + s.Run("NotFound", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetOutput(s.Ctx, app.IApplicationAddress.String(), 99) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *OutputSuite) TestListOutputs() { + s.Run("EmptyResult", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, app.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAllOutputs", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2")}, nil) + + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 3) + s.Equal(uint64(3), total) + }) + + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2"), []byte("o3"), []byte("o4")}, nil) + + indexRange := repository.Range{Start: 1, End: 3} + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(outputs, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), outputs[0].Index) + }) + + s.Run("FilterByEpochIndex", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // EpochIndex filter also requires input.status = ACCEPTED, + // so use StoreAdvanceResult to create the output with accepted input. + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("epoch-output")}, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + epochIdx := uint64(0) + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{EpochIndex: &epochIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 1) + s.Equal(uint64(1), total) + }) + + s.Run("FilterByInputIndex", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2")}, nil) + + inputIdx := uint64(0) + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{InputIndex: &inputIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 3) + s.Equal(uint64(3), total) + }) + + s.Run("FilterByBlockRange", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 99).WithInputBounds(0, 2).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(10).Build() + input1 := NewInputBuilder().WithIndex(1).WithBlockNumber(50).Build() + input2 := NewInputBuilder().WithIndex(2).WithBlockNumber(90).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch: {input0, input1, input2}}, 100) + s.Require().NoError(err) + + // Store advance results to create outputs with accepted inputs + for i := range uint64(3) { + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: i, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output-data")}, + StateProof: *DummyStateProof(), + } + err = s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result) + s.Require().NoError(err) + } + + // Filter for block range 40-60 (should match input1 at block 50) + blockRange := repository.Range{Start: 40, End: 60} + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, app.IApplicationAddress.String(), + repository.OutputFilter{BlockRange: &blockRange}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 1) + s.Equal(uint64(1), total) + }) + + // Regression: DaveConsensus epochs overlap by one block — a sealed epoch's + // last block equals the next epoch's first block. When inputs straddle that + // boundary block (one in the sealed epoch, one in the open epoch, both on the + // shared block), a block-range output query is ambiguous and returns both + // epochs' outputs, while an epoch-index query stays unambiguous. The validator + // scopes epoch outputs by epoch index for exactly this reason. + s.Run("EpochIndexDisambiguatesOverlappingBoundaryBlock", func() { + app := NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + addr := app.IApplicationAddress.String() + + const sealBlock = 100 + sealedEpoch := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(90, sealBlock).WithInputBounds(0, 1).Build() + openEpoch := NewEpochBuilder(app.ID). + WithIndex(2).WithStatus(EpochStatus_Open). + WithBlocks(sealBlock, 110).WithInputBounds(1, 2).Build() + + // Both inputs sit on the shared seal block, one on each side of the seal. + inputBefore := NewInputBuilder().WithIndex(0).WithEpochIndex(1).WithBlockNumber(sealBlock).Build() + inputAfter := NewInputBuilder().WithIndex(1).WithEpochIndex(2).WithBlockNumber(sealBlock).Build() + + err := s.Repo.CreateEpochsAndInputs(s.Ctx, addr, + map[*Epoch][]*Input{ + sealedEpoch: {inputBefore}, + openEpoch: {inputAfter}, + }, 111) + s.Require().NoError(err) + + for _, e := range []struct{ epoch, input uint64 }{{1, 0}, {2, 1}} { + result := &AdvanceResult{ + EpochIndex: e.epoch, + InputIndex: e.input, + Status: InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output-data")}, + StateProof: *DummyStateProof(), + } + s.Require().NoError(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, result)) + } + + // Epoch-index scoping returns exactly one output per epoch. + sealedIdx := uint64(1) + sealedOutputs, _, err := s.Repo.ListOutputs(s.Ctx, addr, + repository.OutputFilter{EpochIndex: &sealedIdx}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(sealedOutputs, 1, "sealed epoch must own only its own output") + + openIdx := uint64(2) + openOutputs, _, err := s.Repo.ListOutputs(s.Ctx, addr, + repository.OutputFilter{EpochIndex: &openIdx}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(openOutputs, 1, "open epoch must own only its own output") + + // Block-range scoping over the sealed epoch is ambiguous at the boundary: + // it also returns the open epoch's output, since both are on the seal block. + boundary := repository.Range{Start: sealedEpoch.FirstBlock, End: sealBlock} + rangeOutputs, _, err := s.Repo.ListOutputs(s.Ctx, addr, + repository.OutputFilter{BlockRange: &boundary}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(rangeOutputs, 2, + "block-range scoping is ambiguous at the overlap — it returns both epochs' "+ + "outputs, which is why the validator scopes by epoch index instead") + }) + + s.Run("FilterByOutputType", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // OutputType filter uses SUBSTR(raw_data, 1, 4) to match the first 4 bytes + targetType := []byte{0xef, 0x01, 0xab, 0xcd} + rawWithType := make([]byte, 32) + copy(rawWithType[0:4], targetType) + + otherType := []byte{0x00, 0x00, 0x00, 0x00} + rawWithOther := make([]byte, 32) + copy(rawWithOther[0:4], otherType) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{rawWithType, rawWithOther}, nil) + + targetTypes := [][]byte{targetType} + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{OutputType: &targetTypes}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 1) + s.Equal(uint64(1), total) + s.Equal(rawWithType, outputs[0].RawData) + }) + + s.Run("FilterByEmptyOutputType", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // OutputType filter uses SUBSTR(raw_data, 1, 4) to match the first 4 bytes + targetType := []byte{0xef, 0x01, 0xab, 0xcd} + rawWithType := make([]byte, 32) + copy(rawWithType[0:4], targetType) + + otherType := []byte{0x00, 0x00, 0x00, 0x00} + rawWithOther := make([]byte, 32) + copy(rawWithOther[0:4], otherType) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{rawWithType, rawWithOther}, nil) + + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{OutputType: &[][]byte{}}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 2) + s.Equal(uint64(2), total) + s.Equal(rawWithType, outputs[0].RawData) + s.Equal(rawWithOther, outputs[1].RawData) + }) + + s.Run("FilterByOutputTypesAndExecutionStatus", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + voucherSelector := []byte{0x23, 0x7a, 0x81, 0x6f} + delegateCallVoucherSelector := []byte{0x10, 0x32, 0x1e, 0x8b} + voucher := append([]byte{}, voucherSelector...) + delegateCallVoucher := append([]byte{}, delegateCallVoucherSelector...) + notice := []byte{0xc2, 0x58, 0xd6, 0xe5} + executedVoucher := append([]byte{}, voucherSelector...) + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{voucher, delegateCallVoucher, notice, executedVoucher}, nil) + + txHash := UniqueHash() + err := s.Repo.UpdateOutputsExecution( + s.Ctx, + seed.App.IApplicationAddress.String(), + []*Output{{ + InputEpochApplicationID: seed.App.ID, + Index: 3, + ExecutionTransactionHash: &txHash, + }}, + 200, + ) + s.Require().NoError(err) + + outputTypes := [][]byte{voucherSelector, delegateCallVoucherSelector} + executed := false + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{OutputType: &outputTypes, Executed: &executed}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Require().Len(outputs, 2) + s.Equal(uint64(2), total) + s.Equal(uint64(0), outputs[0].Index) + s.Equal(uint64(1), outputs[1].Index) + + executed = true + outputs, total, err = s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{OutputType: &outputTypes, Executed: &executed}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Require().Len(outputs, 1) + s.Equal(uint64(1), total) + s.Equal(uint64(3), outputs[0].Index) + + // The validator uses the nil-filter path to reproduce epoch claims; + // it must continue to include every output type and execution state. + outputs, total, err = s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, + repository.Pagination{}, false) + s.Require().NoError(err) + s.Len(outputs, 4) + s.Equal(uint64(4), total) + }) + + s.Run("FilterByVoucherAddress", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // VoucherAddress filter matches substring(raw_data FROM 17 FOR 20) + // (the ABI head destination) but only on voucher-typed outputs: + // bytes 17-36 of other output types are arbitrary payload. + voucherSelector := []byte{0x23, 0x7a, 0x81, 0x6f} + delegateCallVoucherSelector := []byte{0x10, 0x32, 0x1e, 0x8b} + noticeSelector := []byte{0xc2, 0x58, 0xd6, 0xe5} + + voucherAddr := UniqueAddress() + rawWithVoucher := make([]byte, 64) + copy(rawWithVoucher[0:4], voucherSelector) + copy(rawWithVoucher[16:36], voucherAddr.Bytes()) + + otherAddr := UniqueAddress() + rawWithOther := make([]byte, 64) + copy(rawWithOther[0:4], voucherSelector) + copy(rawWithOther[16:36], otherAddr.Bytes()) + + // A notice whose payload happens to contain the searched address at + // bytes 17-36 must not match the voucher-address filter. + rawNoticeLookalike := make([]byte, 64) + copy(rawNoticeLookalike[0:4], noticeSelector) + copy(rawNoticeLookalike[16:36], voucherAddr.Bytes()) + + // Delegate-call vouchers carry a destination too and must match. + rawWithDelegateCall := make([]byte, 64) + copy(rawWithDelegateCall[0:4], delegateCallVoucherSelector) + copy(rawWithDelegateCall[16:36], voucherAddr.Bytes()) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{rawWithVoucher, rawWithOther, rawNoticeLookalike, rawWithDelegateCall}, nil) + + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{VoucherAddress: &voucherAddr}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 2) + s.Equal(uint64(2), total) + s.Equal(rawWithVoucher, outputs[0].RawData) + s.Equal(rawWithDelegateCall, outputs[1].RawData) + }) + + s.Run("Pagination", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + data := make([][]byte, 5) + for i := range data { + data[i] = []byte("output-data") + } + s.storeAdvanceResult(seed.App.ID, 0, 0, data, nil) + + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, + repository.Pagination{Limit: 2, Offset: 0}, false) + s.Require().NoError(err) + s.Len(outputs, 2) + s.Equal(uint64(5), total) + }) + + s.Run("Descending", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2")}, nil) + + outputs, _, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, + repository.Pagination{Limit: 10}, true) + s.Require().NoError(err) + s.Require().Len(outputs, 3) + // Descending: highest index first + s.Equal(uint64(2), outputs[0].Index) + s.Equal(uint64(1), outputs[1].Index) + s.Equal(uint64(0), outputs[2].Index) + }) +} + +func (s *OutputSuite) TestUpdateOutputsExecution() { + const rollbackCheckpoint = uint64(100) + for _, test := range []struct { + name string + previousBlock uint64 + requestedBlock uint64 + expectedBlock uint64 + }{ + {"ForwardCursor", 100, 200, 200}, + {"EqualCursor", 200, 200, 200}, + {"OlderCursor", 200, 100, 200}, + } { + s.Run(test.name, func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("output-data")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, test.previousBlock) + s.Require().NoError(err) + + txHash := UniqueHash() + out := &Output{ + InputEpochApplicationID: seed.App.ID, + Index: 0, + ExecutionTransactionHash: &txHash, + } + err = s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), []*Output{out}, test.requestedBlock) + s.Require().NoError(err) + + got, err := s.Repo.GetOutput(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Require().NotNil(got.ExecutionTransactionHash) + s.Equal(txHash, *got.ExecutionTransactionHash, + "output updates must commit even when the cursor is already ahead") + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(test.expectedBlock, block) + }) + } + + // Regression guard: all output updates must be transactional. + // Verify multiple outputs are updated atomically in a single call. + s.Run("MultipleOutputsUpdatedAtomically", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2")}, nil) + + txHash := UniqueHash() + outputs := make([]*Output, 3) + for i := range uint64(3) { + outputs[i] = &Output{ + InputEpochApplicationID: seed.App.ID, + Index: i, + ExecutionTransactionHash: &txHash, + } + } + + err := s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), outputs, 200) + s.Require().NoError(err) + + // Verify all 3 were updated + for i := range uint64(3) { + got, err := s.Repo.GetOutput( + s.Ctx, seed.App.IApplicationAddress.String(), i) + s.Require().NoError(err) + s.Require().NotNil(got.ExecutionTransactionHash, + "output %d should have execution hash", i) + s.Equal(txHash, *got.ExecutionTransactionHash) + } + }) + + s.Run("DescendingIndexesUpdatedAtomically", func() { + const outputCount = 3 + const scanBlock = 200 + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2")}, nil) + + txHash := UniqueHash() + outputs := make([]*Output, outputCount) + for i := range uint64(outputCount) { + outputs[i] = &Output{ + InputEpochApplicationID: seed.App.ID, + Index: uint64(outputCount) - 1 - i, + ExecutionTransactionHash: &txHash, + } + } + + err := s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), outputs, scanBlock) + s.Require().NoError(err) + + // Verify all 3 were updated + for i := range uint64(outputCount) { + got, err := s.Repo.GetOutput( + s.Ctx, seed.App.IApplicationAddress.String(), i) + s.Require().NoError(err) + s.Require().NotNil(got.ExecutionTransactionHash, + "output %d should have execution hash", i) + s.Equal(txHash, *got.ExecutionTransactionHash) + } + }) + + s.Run("NilExecutionHashReturnsError", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("output-data")}, nil) + + // ExecutionTransactionHash is nil — should fail + badOutput := &Output{ + InputEpochApplicationID: seed.App.ID, + Index: 0, + } + err := s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), + []*Output{badOutput}, 100) + s.Require().Error(err) + }) + + // Verify that a failure mid-loop rolls back all prior output updates. + // We create 3 outputs, set valid hashes on the first two, and use a + // non-existent index for the third. The third update should fail + // (RowsAffected == 0), rolling back the first two. + s.Run("RollbackOnPartialFailure", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, rollbackCheckpoint) + s.Require().NoError(err) + + txHash := UniqueHash() + outputs := []*Output{ + { + InputEpochApplicationID: seed.App.ID, + Index: 0, + ExecutionTransactionHash: &txHash, + }, + { + InputEpochApplicationID: seed.App.ID, + Index: 1, + ExecutionTransactionHash: &txHash, + }, + { + InputEpochApplicationID: seed.App.ID, + Index: 999, // non-existent + ExecutionTransactionHash: &txHash, + }, + } + + err = s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), outputs, 200) + s.Require().Error(err) + + // Verify that the first two outputs were NOT updated (rolled back) + for i := range uint64(2) { + got, err := s.Repo.GetOutput( + s.Ctx, seed.App.IApplicationAddress.String(), i) + s.Require().NoError(err) + s.Nil(got.ExecutionTransactionHash, + "output %d should not have execution hash after rollback", i) + } + + // Verify no executed outputs exist + count, err := s.Repo.GetNumberOfExecutedOutputs( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), count) + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(rollbackCheckpoint, block, "failed output updates must not advance the cursor") + }) + + // Verify that a nil hash on the second output rolls back the first + // output's successful update. This exercises the nil-check rollback + // path at the top of the loop. + s.Run("NilHashMidLoopRollsBackPrior", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1")}, nil) + err := s.Repo.UpdateEventLastCheckBlock( + s.Ctx, []int64{seed.App.ID}, MonitoredEvent_OutputExecuted, rollbackCheckpoint) + s.Require().NoError(err) + + txHash := UniqueHash() + outputs := []*Output{ + { + InputEpochApplicationID: seed.App.ID, + Index: 0, + ExecutionTransactionHash: &txHash, + }, + { + InputEpochApplicationID: seed.App.ID, + Index: 1, + // nil ExecutionTransactionHash — triggers error + }, + } + + err = s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), outputs, 200) + s.Require().Error(err) + + // First output should NOT have been updated (rolled back) + got, err := s.Repo.GetOutput( + s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Nil(got.ExecutionTransactionHash, + "output 0 should not have execution hash after rollback") + block, err := s.Repo.GetEventLastCheckBlock(s.Ctx, seed.App.ID, MonitoredEvent_OutputExecuted) + s.Require().NoError(err) + s.Equal(rollbackCheckpoint, block, "invalid output data must not advance the cursor") + }) +} + +func (s *OutputSuite) TestGetNumberOfExecutedOutputs() { + s.Run("ReturnsZeroWhenNone", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + count, err := s.Repo.GetNumberOfExecutedOutputs(s.Ctx, app.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(0), count) + }) + + s.Run("ReturnsCountAfterExecution", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2")}, nil) + + // Execute 2 of the 3 outputs + txHash := UniqueHash() + out0 := &Output{ + InputEpochApplicationID: seed.App.ID, + Index: 0, + ExecutionTransactionHash: &txHash, + } + out1 := &Output{ + InputEpochApplicationID: seed.App.ID, + Index: 1, + ExecutionTransactionHash: &txHash, + } + err := s.Repo.UpdateOutputsExecution( + s.Ctx, seed.App.IApplicationAddress.String(), + []*Output{out0, out1}, 200) + s.Require().NoError(err) + + count, err := s.Repo.GetNumberOfExecutedOutputs( + s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(2), count) + }) +} + +func (s *OutputSuite) TestGetNumberOfPendingExecutableOutputs() { + s.Run("CountsOnlyUnexecutedVouchers", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{ + {0x23, 0x7a, 0x81, 0x6f, 0x01}, // Voucher + {0x10, 0x32, 0x1e, 0x8b, 0x02}, // DelegateCallVoucher + {0xba, 0xad, 0xf0, 0x0d, 0x03}, // non-executable output type + }, nil) + + count, err := s.Repo.GetNumberOfPendingExecutableOutputs(s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(2), count) + + txHash := UniqueHash() + err = s.Repo.UpdateOutputsExecution( + s.Ctx, + seed.App.IApplicationAddress.String(), + []*Output{{ + InputEpochApplicationID: seed.App.ID, + Index: 0, + ExecutionTransactionHash: &txHash, + }}, + 200, + ) + s.Require().NoError(err) + + count, err = s.Repo.GetNumberOfPendingExecutableOutputs(s.Ctx, seed.App.IApplicationAddress.String()) + s.Require().NoError(err) + s.Equal(uint64(1), count) + }) +} diff --git a/internal/repository/repotest/proofs.go b/internal/repository/repotest/proofs.go new file mode 100644 index 000000000..375486753 --- /dev/null +++ b/internal/repository/repotest/proofs.go @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +// WriteProofs adapts stored test outputs to bounded claim proof batches. +func WriteProofs(outputs ...*model.Output) repository.ClaimProofWriteFunc { + return func(ctx context.Context, writer repository.ClaimProofWriter) error { + for begin := 0; begin < len(outputs); { + end := min(begin+int(repository.MaxOutputRangeRows), len(outputs)) + proofs := make([]repository.OutputProof, end-begin) + for i, output := range outputs[begin:end] { + proofs[i] = repository.OutputProof{Index: output.Index, Hash: *output.Hash, Siblings: output.OutputHashesSiblings} + } + if err := writer.StoreOutputProofs(ctx, proofs); err != nil { + return err + } + begin = end + } + return nil + } +} diff --git a/internal/repository/repotest/report_filter_test_cases.go b/internal/repository/repotest/report_filter_test_cases.go new file mode 100644 index 000000000..c2230378f --- /dev/null +++ b/internal/repository/repotest/report_filter_test_cases.go @@ -0,0 +1,74 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *ReportSuite) TestListReportsCompletedStatuses() { + for _, terminal := range model.InputCompletionStatusAllValues { + if !terminal.IsTerminal() { + continue + } + s.Run(terminal.String(), func() { + app := seedReportInputs(s.Ctx, s.T(), s.Repo) + statuses := []model.InputCompletionStatus{ + model.InputCompletionStatus_Accepted, model.InputCompletionStatus_Rejected, terminal, + } + for i, status := range statuses { + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, uint64(i), status, + nil, [][]byte{[]byte(fmt.Sprintf("report-%d", i))}) + } + epoch, missingEpoch, rejectedInput := uint64(0), uint64(1), uint64(1) + const allReports, rejectedAndTerminalReports, terminalReportIndex = 3, 2, 2 + for _, query := range []struct { + name string + filter repository.ReportFilter + pagination repository.Pagination + descending bool + indices []uint64 + total uint64 + }{ + {name: "unfiltered", indices: []uint64{0, 1, terminalReportIndex}, total: allReports}, + {name: "epoch", filter: repository.ReportFilter{EpochIndex: &epoch}, + indices: []uint64{0, 1, terminalReportIndex}, total: allReports}, + {name: "input", filter: repository.ReportFilter{InputIndex: &rejectedInput}, indices: []uint64{1}, total: 1}, + {name: "epoch and input", filter: repository.ReportFilter{EpochIndex: &epoch, InputIndex: &rejectedInput}, + indices: []uint64{1}, total: 1}, + {name: "epoch pagination", filter: repository.ReportFilter{EpochIndex: &epoch}, + pagination: repository.Pagination{Limit: 1, Offset: 1}, descending: true, indices: []uint64{1}, total: allReports}, + {name: "epoch and range", filter: repository.ReportFilter{ + EpochIndex: &epoch, IndexRange: &repository.Range{Start: 1, End: terminalReportIndex}, + }, descending: true, indices: []uint64{terminalReportIndex, 1}, total: rejectedAndTerminalReports}, + {name: "missing epoch", filter: repository.ReportFilter{EpochIndex: &missingEpoch}}, + } { + reports, total, err := s.Repo.ListReports(s.Ctx, app.Name, + query.filter, query.pagination, query.descending) + s.Require().NoError(err, query.name) + s.Equal(query.total, total, query.name) + s.Require().Len(reports, len(query.indices), query.name) + for i, report := range reports { + index := query.indices[i] + s.Equal(index, report.Index, query.name) + s.Equal(index, report.InputIndex, query.name) + s.Zero(report.EpochIndex, query.name) + s.Equal([]byte(fmt.Sprintf("report-%d", index)), report.RawData, query.name) + } + } + for i := range statuses { + report, err := s.Repo.GetReport(s.Ctx, app.Name, uint64(i)) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(uint64(i), report.Index) + s.Equal(uint64(i), report.InputIndex) + s.Zero(report.EpochIndex) + s.Equal([]byte(fmt.Sprintf("report-%d", i)), report.RawData) + } + }) + } +} diff --git a/internal/repository/repotest/report_test_cases.go b/internal/repository/repotest/report_test_cases.go new file mode 100644 index 000000000..5e77e4dcd --- /dev/null +++ b/internal/repository/repotest/report_test_cases.go @@ -0,0 +1,155 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type ReportSuite struct { + BaseSuite +} + +func NewReportSuite(factory RepositoryFactory) *ReportSuite { + return &ReportSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *ReportSuite) TestGetReport() { + s.Run("ExistingReport", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + nil, [][]byte{[]byte("report-payload")}) + + got, err := s.Repo.GetReport(s.Ctx, seed.App.IApplicationAddress.String(), 0) + s.Require().NoError(err) + s.Equal(uint64(0), got.Index) + s.Equal([]byte("report-payload"), got.RawData) + }) + + s.Run("NotFound", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetReport(s.Ctx, app.IApplicationAddress.String(), 99) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *ReportSuite) TestListReports() { + s.Run("EmptyResult", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + reports, total, err := s.Repo.ListReports( + s.Ctx, app.IApplicationAddress.String(), + repository.ReportFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(reports) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAllReports", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + nil, [][]byte{[]byte("r0"), []byte("r1"), []byte("r2")}) + + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(reports, 3) + s.Equal(uint64(3), total) + }) + + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + s.storeAdvanceResult(seed.App.ID, 0, 0, nil, + [][]byte{[]byte("r0"), []byte("r1"), []byte("r2"), []byte("r3"), []byte("r4")}) + + indexRange := repository.Range{Start: 1, End: 3} + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(reports, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), reports[0].Index) + }) + + s.Run("FilterByEpochIndex", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + result := &AdvanceResult{ + EpochIndex: 0, + InputIndex: 0, + Status: InputCompletionStatus_Accepted, + Reports: [][]byte{[]byte("epoch-report")}, + StateProof: *DummyStateProof(), + } + err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + s.Require().NoError(err) + + epochIdx := uint64(0) + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{EpochIndex: &epochIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(reports, 1) + s.Equal(uint64(1), total) + }) + + s.Run("FilterByInputIndex", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + nil, [][]byte{[]byte("r0"), []byte("r1"), []byte("r2")}) + + inputIdx := uint64(0) + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{InputIndex: &inputIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(reports, 3) + s.Equal(uint64(3), total) + }) + + s.Run("Pagination", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + data := make([][]byte, 5) + for i := range data { + data[i] = []byte("report-data") + } + s.storeAdvanceResult(seed.App.ID, 0, 0, nil, data) + + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{}, + repository.Pagination{Limit: 2, Offset: 0}, false) + s.Require().NoError(err) + s.Len(reports, 2) + s.Equal(uint64(5), total) + }) + + s.Run("Descending", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + nil, [][]byte{[]byte("r0"), []byte("r1"), []byte("r2")}) + + reports, _, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{}, + repository.Pagination{Limit: 10}, true) + s.Require().NoError(err) + s.Require().Len(reports, 3) + // Descending: highest index first + s.Equal(uint64(2), reports[0].Index) + s.Equal(uint64(1), reports[1].Index) + s.Equal(uint64(0), reports[2].Index) + }) +} diff --git a/internal/repository/repotest/repotest.go b/internal/repository/repotest/repotest.go new file mode 100644 index 000000000..45562afa5 --- /dev/null +++ b/internal/repository/repotest/repotest.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + "testing" + "time" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const testTimeout = 300 * time.Second + +// RepositoryFactory creates a fresh repository backed by an empty schema. +// Called once per sub-test for full isolation. +type RepositoryFactory func(ctx context.Context, t *testing.T) ( + repo repository.Repository, + cleanup func(), +) + +// BaseSuite is embedded by every per-interface suite. +// SetupSubTest calls the factory to get a fresh repo per s.Run() block. +// TearDownSubTest calls cleanup. +type BaseSuite struct { + suite.Suite + factory RepositoryFactory + Repo repository.Repository + cleanup func() + Ctx context.Context + cancel context.CancelFunc +} + +// StoreAdvanceResult is a test helper that creates outputs and/or reports +// for an input via the production StoreAdvanceResult method. +func StoreAdvanceResult( + ctx context.Context, t *testing.T, repo repository.Repository, + appID int64, epochIdx, inputIdx uint64, + status InputCompletionStatus, outputs [][]byte, reports [][]byte, +) { + t.Helper() + result := &AdvanceResult{ + EpochIndex: epochIdx, + InputIndex: inputIdx, + Status: status, + Outputs: outputs, + Reports: reports, + StateProof: *DummyStateProof(), + } + if status == InputCompletionStatus_Exception { + result.ExceptionData = []byte{} + } + err := repo.StoreAdvanceResult(ctx, appID, result) + require.NoError(t, err) +} + +// storeAdvanceResult delegates to the standalone StoreAdvanceResult helper +// with InputCompletionStatus_Accepted as the default status. +func (s *BaseSuite) storeAdvanceResult( + appID int64, epochIdx, inputIdx uint64, outputs [][]byte, reports [][]byte, +) { + s.T().Helper() + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, appID, epochIdx, inputIdx, + InputCompletionStatus_Accepted, outputs, reports) +} + +func (s *BaseSuite) SetupSuite() { + s.Ctx, s.cancel = context.WithTimeout(context.Background(), testTimeout) +} + +func (s *BaseSuite) TearDownSuite() { + s.cancel() +} + +func (s *BaseSuite) SetupSubTest() { + s.Repo, s.cleanup = s.factory(s.Ctx, s.T()) +} + +func (s *BaseSuite) TearDownSubTest() { + if s.cleanup != nil { + s.cleanup() + } +} + +// RunAllSuites is the single entry point backends call. +func RunAllSuites(t *testing.T, factory RepositoryFactory) { + t.Run("Application", func(t *testing.T) { suite.Run(t, NewApplicationSuite(factory)) }) + t.Run("Epoch", func(t *testing.T) { suite.Run(t, NewEpochSuite(factory)) }) + t.Run("Input", func(t *testing.T) { suite.Run(t, NewInputSuite(factory)) }) + t.Run("Output", func(t *testing.T) { suite.Run(t, NewOutputSuite(factory)) }) + t.Run("Report", func(t *testing.T) { suite.Run(t, NewReportSuite(factory)) }) + t.Run("StateHash", func(t *testing.T) { suite.Run(t, NewStateHashSuite(factory)) }) + t.Run("BulkOperations", func(t *testing.T) { suite.Run(t, NewBulkOperationsSuite(factory)) }) + t.Run("NodeConfig", func(t *testing.T) { suite.Run(t, NewNodeConfigSuite(factory)) }) + t.Run("Claimer", func(t *testing.T) { suite.Run(t, NewClaimerSuite(factory)) }) + t.Run("Tournament", func(t *testing.T) { suite.Run(t, NewTournamentSuite(factory)) }) + t.Run("Commitment", func(t *testing.T) { suite.Run(t, NewCommitmentSuite(factory)) }) + t.Run("Match", func(t *testing.T) { suite.Run(t, NewMatchSuite(factory)) }) + t.Run("MatchAdvanced", func(t *testing.T) { suite.Run(t, NewMatchAdvancedSuite(factory)) }) + t.Run("Withdrawal", func(t *testing.T) { suite.Run(t, NewWithdrawalSuite(factory)) }) +} diff --git a/internal/repository/repotest/state_hash_test_cases.go b/internal/repository/repotest/state_hash_test_cases.go new file mode 100644 index 000000000..7c7ba0378 --- /dev/null +++ b/internal/repository/repotest/state_hash_test_cases.go @@ -0,0 +1,144 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "math" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type StateHashSuite struct { + BaseSuite +} + +func NewStateHashSuite(factory RepositoryFactory) *StateHashSuite { + return &StateHashSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *StateHashSuite) seedEpochStateHashes(app *Application, epochIndex, firstInput uint64) []EpochStateHash { + s.T().Helper() + const ( + inputCount = 2 + blocksPerEpoch = 10 + expectedRows = 5 + ) + epoch := NewEpochBuilder(app.ID).WithIndex(epochIndex).WithStatus(EpochStatus_Closed). + WithBlocks(epochIndex*blocksPerEpoch, (epochIndex+1)*blocksPerEpoch-1). + WithInputBounds(firstInput, firstInput+inputCount).Build() + firstInputBlock := epoch.FirstBlock + 1 + inputs := []*Input{ + NewInputBuilder().WithIndex(firstInput).WithBlockNumber(firstInputBlock).Build(), + NewInputBuilder().WithIndex(firstInput + 1).WithBlockNumber(firstInputBlock + 1).Build(), + } + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, map[*Epoch][]*Input{epoch: inputs}, epoch.LastBlock+1)) + expected := make([]EpochStateHash, 0, expectedRows) + for offset, input := range inputs { + entries := [][32]byte{UniqueHash()} + if offset == 0 { + entries = append(entries, UniqueHash()) + } + proof := DummyStateProof() + s.Require().NoError(s.Repo.StoreAdvanceResult(s.Ctx, app.ID, &AdvanceResult{ + EpochIndex: epoch.Index, InputIndex: input.Index, Status: InputCompletionStatus_Accepted, + StateProof: *proof, PeriodicStateHashes: entries, + PaddingRepetitions: InputHashCollectionCapacity - uint64(len(entries)), IsDaveConsensus: true, + })) + for _, entry := range entries { + expected = append(expected, EpochStateHash{ + Index: uint64(len(expected)), InputIndex: input.Index, MachineHash: entry, Repetitions: 1, + }) + } + expected = append(expected, EpochStateHash{ + Index: uint64(len(expected)), InputIndex: input.Index, + MachineHash: FinalStateEntry(proof.MachineHash), Repetitions: InputHashCollectionCapacity - uint64(len(entries)), + }) + } + return expected +} + +func (s *StateHashSuite) TestEpochStateHashPages() { + const ( + pageSize = 2 + secondEpochFirstInput = 2 + ) + s.Run("EmptyEpoch", func() { + app := NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID).WithStatus(EpochStatus_Closed).Build() + s.Require().NoError(s.Repo.CreateEpochsAndInputs(s.Ctx, app.Name, map[*Epoch][]*Input{epoch: nil}, epoch.LastBlock+1)) + count, err := s.Repo.CountEpochStateHashes(s.Ctx, app.ID, epoch.Index) + s.Require().NoError(err) + s.Zero(count) + rows, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, epoch.Index, nil, pageSize) + s.Require().NoError(err) + s.Empty(rows) + }) + + s.Run("IsolationAndCursorBoundaries", func() { + app := NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + expected := s.seedEpochStateHashes(app, 0, 0) + secondEpoch := s.seedEpochStateHashes(app, 1, secondEpochFirstInput) + otherApp := NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + otherRows := s.seedEpochStateHashes(otherApp, 0, 0) + s.Equal(uint64(0), secondEpoch[0].Index) + s.Equal(uint64(0), otherRows[0].Index) + count, err := s.Repo.CountEpochStateHashes(s.Ctx, app.ID, 0) + s.Require().NoError(err) + s.Equal(uint64(len(expected)), count) + + var paged []EpochStateHash + var cursor *uint64 + for { + page, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, 0, cursor, pageSize) + s.Require().NoError(err) + if len(page) == 0 { + break + } + s.LessOrEqual(len(page), pageSize) + paged = append(paged, page...) + s.Require().LessOrEqual(len(paged), len(expected)) + last := page[len(page)-1].Index + cursor = &last + } + s.Equal(expected, paged) + s.Equal(count, uint64(len(paged))) + + zero := uint64(0) + afterZero, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, 0, &zero, pageSize) + s.Require().NoError(err) + s.Equal(expected[1:3], afterZero) + full, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, 0, nil, uint64(len(expected))) + s.Require().NoError(err) + s.Equal(expected, full) + for _, after := range []uint64{expected[len(expected)-1].Index, math.MaxUint64} { + page, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, 0, &after, pageSize) + s.Require().NoError(err) + s.Empty(page) + } + full, err = s.Repo.ListEpochStateHashes(s.Ctx, app.ID, 0, nil, repository.MaxStateHashPageSize) + s.Require().NoError(err) + s.Equal(expected, full) + for _, limit := range []uint64{0, repository.MaxStateHashPageSize + 1, math.MaxUint64} { + _, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, 0, nil, limit) + s.Require().ErrorContains(err, "state hash page limit must be between 1 and 100000") + } + }) + + s.Run("ApplicationDeleteCascades", func() { + app := NewApplicationBuilder().WithConsensus(Consensus_PRT).Create(s.Ctx, s.T(), s.Repo) + expected := s.seedEpochStateHashes(app, 0, 0) + count, err := s.Repo.CountEpochStateHashes(s.Ctx, app.ID, 0) + s.Require().NoError(err) + s.Equal(uint64(len(expected)), count) + s.Require().NoError(s.Repo.UpdateApplicationEnabled(s.Ctx, app.ID, false)) + s.Require().NoError(s.Repo.DeleteApplication(s.Ctx, app.ID)) + count, err = s.Repo.CountEpochStateHashes(s.Ctx, app.ID, 0) + s.Require().NoError(err) + s.Zero(count) + page, err := s.Repo.ListEpochStateHashes(s.Ctx, app.ID, 0, nil, pageSize) + s.Require().NoError(err) + s.Empty(page) + }) +} diff --git a/internal/repository/repotest/state_proof.go b/internal/repository/repotest/state_proof.go new file mode 100644 index 000000000..7d1d40c06 --- /dev/null +++ b/internal/repository/repotest/state_proof.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "encoding/binary" + + . "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// KeccakStateProof builds a valid machine proof with accepted-state registers. +// DummyStateProof remains available for non-cryptographic fixtures. +func KeccakStateProof(outputsRoot common.Hash) StateProof { + const ( + iflagsYDataBlockAddress uint64 = 0x300 + htifTohostDataBlockAddress uint64 = 0x320 + txBufferDataBlockAddress = 0x60800000 + acceptedHtifTohost uint64 = 2<<56 | 1<<48 | 1<<32 + ) + var iflagsYDataBlock common.Hash + binary.LittleEndian.PutUint64(iflagsYDataBlock[8:], 1) + var htifTohostDataBlock common.Hash + binary.LittleEndian.PutUint64(htifTohostDataBlock[16:], acceptedHtifTohost) + blocks := map[uint64]common.Hash{ + iflagsYDataBlockAddress: iflagsYDataBlock, + htifTohostDataBlockAddress: htifTohostDataBlock, + txBufferDataBlockAddress: outputsRoot, + } + machineHash, proofs := buildKeccakStateProofs(blocks) + return StateProof{ + MachineHash: machineHash, + IflagsYDataBlock: iflagsYDataBlock, + IflagsYProof: proofs[iflagsYDataBlockAddress], + HtifTohostDataBlock: htifTohostDataBlock, + HtifTohostProof: proofs[htifTohostDataBlockAddress], + TxBufferDataBlock: outputsRoot, + TxBufferProof: proofs[txBufferDataBlockAddress], + } +} + +func buildKeccakStateProofs(blocks map[uint64]common.Hash) (common.Hash, map[uint64][][32]byte) { + const log2DataBlockSize = 5 + defaultHashes := make([]common.Hash, StateProofSiblingCount+1) + defaultHashes[0] = crypto.Keccak256Hash(make([]byte, 1<> log2DataBlockSize + indexes[address] = index + current[index] = crypto.Keccak256Hash(block[:]) + siblings[address] = make([][32]byte, 0, StateProofSiblingCount) + } + for level := 0; level < StateProofSiblingCount; level++ { + for address, originalIndex := range indexes { + index := originalIndex >> level + sibling, ok := current[index^1] + if !ok { + sibling = defaultHashes[level] + } + siblings[address] = append(siblings[address], sibling) + } + parents := make(map[uint64]struct{}, len(current)) + for index := range current { + parents[index>>1] = struct{}{} + } + next := make(map[uint64]common.Hash, len(parents)) + for parent := range parents { + left, ok := current[parent<<1] + if !ok { + left = defaultHashes[level] + } + right, ok := current[parent<<1|1] + if !ok { + right = defaultHashes[level] + } + next[parent] = crypto.Keccak256Hash(left[:], right[:]) + } + current = next + } + return current[0], siblings +} diff --git a/internal/repository/repotest/state_proof_test.go b/internal/repository/repotest/state_proof_test.go new file mode 100644 index 000000000..9783a817b --- /dev/null +++ b/internal/repository/repotest/state_proof_test.go @@ -0,0 +1,46 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "encoding/binary" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestKeccakStateProof(t *testing.T) { + outputsRoot := common.HexToHash("0xa1") + proof := KeccakStateProof(outputsRoot) + require.True(t, proof.IsComplete()) + require.Equal(t, outputsRoot, proof.TxBufferDataBlock) + require.Equal(t, uint64(1), binary.LittleEndian.Uint64(proof.IflagsYDataBlock[8:16])) + require.Equal(t, uint64(2)<<56|uint64(1)<<48|uint64(1)<<32, binary.LittleEndian.Uint64(proof.HtifTohostDataBlock[16:24])) + for _, leaf := range []struct { + name string + address uint64 + data common.Hash + siblings [][32]byte + }{ + {name: "iflags.Y", address: 0x300, data: proof.IflagsYDataBlock, siblings: proof.IflagsYProof}, + {name: "htif.tohost", address: 0x320, data: proof.HtifTohostDataBlock, siblings: proof.HtifTohostProof}, + {name: "outputs root", address: 0x60800000, data: proof.TxBufferDataBlock, siblings: proof.TxBufferProof}, + } { + t.Run(leaf.name, func(t *testing.T) { + hash := crypto.Keccak256Hash(leaf.data[:]) + index := leaf.address >> 5 + for _, sibling := range leaf.siblings { + if index&1 == 0 { + hash = crypto.Keccak256Hash(hash[:], sibling[:]) + } else { + hash = crypto.Keccak256Hash(sibling[:], hash[:]) + } + index >>= 1 + } + require.Equal(t, proof.MachineHash, hash) + }) + } +} diff --git a/internal/repository/repotest/tournament_events.go b/internal/repository/repotest/tournament_events.go new file mode 100644 index 000000000..94e161872 --- /dev/null +++ b/internal/repository/repotest/tournament_events.go @@ -0,0 +1,26 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/repository" +) + +// StoreTournamentEvents installs PostgreSQL package fixtures through a writer +// compiled exclusively into that package's tests. Production exposes only the +// checked StoreTournamentInterval method. +func StoreTournamentEvents( + ctx context.Context, repo repository.Repository, appID int64, batches []*repository.TournamentEventBatch, lastBlock uint64, +) error { + writer, ok := repo.(interface { + StoreTournamentEvents(context.Context, int64, []*repository.TournamentEventBatch, uint64) error + }) + if !ok { + return errors.New("repository does not support tournament event fixtures") + } + return writer.StoreTournamentEvents(ctx, appID, batches, lastBlock) +} diff --git a/internal/repository/repotest/tournament_test_cases.go b/internal/repository/repotest/tournament_test_cases.go new file mode 100644 index 000000000..24e60f9bc --- /dev/null +++ b/internal/repository/repotest/tournament_test_cases.go @@ -0,0 +1,298 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +type TournamentSuite struct { + BaseSuite +} + +func NewTournamentSuite(factory RepositoryFactory) *TournamentSuite { + return &TournamentSuite{BaseSuite: BaseSuite{factory: factory}} +} + +func (s *TournamentSuite) seedWithEpoch() *SeedResult { + return Seed(s.Ctx, s.T(), s.Repo) +} + +func (s *TournamentSuite) TestCreateTournament() { + s.Run("CreatesSuccessfully", func() { + seed := s.seedWithEpoch() + tournament := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).Build() + + err := s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), tournament) + s.Require().NoError(err) + }) + + s.Run("ExactReplayIsIdempotent", func() { + seed := s.seedWithEpoch() + first := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).Build() + s.Require().NoError(s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), first)) + + replay := *first + err := s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), &replay) + s.Require().NoError(err) + + got, err := s.Repo.GetTournament( + s.Ctx, seed.App.IApplicationAddress.String(), first.Address.String()) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(first.MaxLevel, got.MaxLevel, "an exact replay must not overwrite the first observation") + }) + + s.Run("ConflictingDescriptorIsAnError", func() { + seed := s.seedWithEpoch() + first := NewTournamentBuilder(seed.App.ID).Build() + s.Require().NoError(s.Repo.CreateTournament(s.Ctx, seed.App.Name, first)) + + conflicting := *first + conflicting.MaxLevel++ + s.Require().Error(s.Repo.CreateTournament(s.Ctx, seed.App.Name, &conflicting)) + + got, err := s.Repo.GetTournament(s.Ctx, seed.App.Name, first.Address.String()) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(first.MaxLevel, got.MaxLevel) + }) + + s.Run("DifferentRootForSameEpochIsAnError", func() { + seed := s.seedWithEpoch() + first := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).Build() + s.Require().NoError(s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), first)) + + conflicting := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).Build() + err := s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), conflicting) + s.Require().Error(err, "only the exact tournament identity may be replayed") + }) +} + +func (s *TournamentSuite) TestGetTournament() { + s.Run("ExistingTournament", func() { + seed := s.seedWithEpoch() + tournament := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).Build() + + err := s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), tournament) + s.Require().NoError(err) + + got, err := s.Repo.GetTournament( + s.Ctx, seed.App.IApplicationAddress.String(), tournament.Address.String()) + s.Require().NoError(err) + s.Equal(tournament.Address, got.Address) + s.Equal(tournament.Level, got.Level) + }) + + s.Run("NotFound", func() { + seed := s.seedWithEpoch() + got, err := s.Repo.GetTournament( + s.Ctx, seed.App.IApplicationAddress.String(), UniqueAddress().String()) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *TournamentSuite) TestListTournaments() { + s.Run("EmptyResult", func() { + seed := s.seedWithEpoch() + tournaments, total, err := s.Repo.ListTournaments( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.TournamentFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Empty(tournaments) + s.Equal(uint64(0), total) + }) + + s.Run("ReturnsAll", func() { + // Create 3 root tournaments in different epochs + // (unique_root_per_epoch_idx allows only 1 root per epoch) + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epochInputMap := make(map[*Epoch][]*Input) + for i := range uint64(3) { + e := NewEpochBuilder(app.ID). + WithIndex(i).WithStatus(EpochStatus_Closed). + WithBlocks(i*10, i*10+9).WithInputBounds(i, i).Build() + inp := NewInputBuilder().WithIndex(i).WithEpochIndex(i). + WithBlockNumber(i*10 + 5).Build() + epochInputMap[e] = []*Input{inp} + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), epochInputMap, 30) + s.Require().NoError(err) + + for i := range uint64(3) { + t := NewTournamentBuilder(app.ID).WithEpochIndex(i).Build() + err := s.Repo.CreateTournament( + s.Ctx, app.IApplicationAddress.String(), t) + s.Require().NoError(err) + } + + tournaments, total, err := s.Repo.ListTournaments( + s.Ctx, app.IApplicationAddress.String(), + repository.TournamentFilter{}, repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(tournaments, 3) + s.Equal(uint64(3), total) + }) + + s.Run("FilterByEpochIndex", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + epoch0 := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 9).WithInputBounds(0, 0).Build() + epoch1 := NewEpochBuilder(app.ID). + WithIndex(1).WithStatus(EpochStatus_Closed). + WithBlocks(10, 19).WithInputBounds(1, 1).Build() + + input0 := NewInputBuilder().WithIndex(0).WithBlockNumber(5).Build() + input1 := NewInputBuilder().WithIndex(1).WithEpochIndex(1).WithBlockNumber(15).Build() + + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), + map[*Epoch][]*Input{epoch0: {input0}, epoch1: {input1}}, 20) + s.Require().NoError(err) + + t0 := NewTournamentBuilder(app.ID).WithEpochIndex(0).Build() + err = s.Repo.CreateTournament(s.Ctx, app.IApplicationAddress.String(), t0) + s.Require().NoError(err) + + t1 := NewTournamentBuilder(app.ID).WithEpochIndex(1).Build() + err = s.Repo.CreateTournament(s.Ctx, app.IApplicationAddress.String(), t1) + s.Require().NoError(err) + + epochIdx := uint64(0) + tournaments, total, err := s.Repo.ListTournaments( + s.Ctx, app.IApplicationAddress.String(), + repository.TournamentFilter{EpochIndex: &epochIdx}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(tournaments, 1) + s.Equal(uint64(1), total) + }) + + s.Run("FilterByLevel", func() { + seed := s.seedWithEpoch() + + // Root tournament at level 0 + t0 := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).WithLevel(0).Build() + err := s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), t0) + s.Require().NoError(err) + + // Create commitments required by match FK constraints + c1 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(t0.Address).Build() + err = s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c1) + s.Require().NoError(err) + + c2 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(t0.Address).Build() + err = s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c2) + s.Require().NoError(err) + + // Create a match in the root tournament so the FK constraint is satisfied + matchIDHash := UniqueHash() + match := NewMatchBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(t0.Address). + WithIDHash(matchIDHash). + WithCommitmentOne(c1.Commitment). + WithCommitmentTwo(c2.Commitment). + Build() + err = s.Repo.CreateMatch( + s.Ctx, seed.App.IApplicationAddress.String(), match) + s.Require().NoError(err) + + // Sub-tournament at level 1 with parent referencing the match + t1 := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).WithLevel(1). + WithParent(t0.Address, matchIDHash).Build() + err = s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), t1) + s.Require().NoError(err) + + level := uint64(1) + tournaments, total, err := s.Repo.ListTournaments( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.TournamentFilter{Level: &level}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(tournaments, 1) + s.Equal(uint64(1), total) + s.Equal(uint64(1), tournaments[0].Level) + }) + + s.Run("FilterByParentTournamentAddress", func() { + seed := s.seedWithEpoch() + + // Root tournament + rootAddr := UniqueAddress() + t0 := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).WithAddress(rootAddr).Build() + err := s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), t0) + s.Require().NoError(err) + + // Create commitments required by match FK constraints + c1 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(rootAddr).Build() + err = s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c1) + s.Require().NoError(err) + + c2 := NewCommitmentBuilder(seed.App.ID). + WithEpochIndex(0).WithTournamentAddress(rootAddr).Build() + err = s.Repo.CreateCommitment( + s.Ctx, seed.App.IApplicationAddress.String(), c2) + s.Require().NoError(err) + + // Create a match in the root tournament so the FK constraint is satisfied + matchIDHash := UniqueHash() + match := NewMatchBuilder(seed.App.ID). + WithEpochIndex(0). + WithTournamentAddress(rootAddr). + WithIDHash(matchIDHash). + WithCommitmentOne(c1.Commitment). + WithCommitmentTwo(c2.Commitment). + Build() + err = s.Repo.CreateMatch( + s.Ctx, seed.App.IApplicationAddress.String(), match) + s.Require().NoError(err) + + // Sub-tournament with parent referencing the match + t1 := NewTournamentBuilder(seed.App.ID). + WithEpochIndex(0).WithLevel(1). + WithParent(rootAddr, matchIDHash).Build() + err = s.Repo.CreateTournament( + s.Ctx, seed.App.IApplicationAddress.String(), t1) + s.Require().NoError(err) + + tournaments, total, err := s.Repo.ListTournaments( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.TournamentFilter{ParentTournamentAddress: &rootAddr}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(tournaments, 1) + s.Equal(uint64(1), total) + s.Require().NotNil(tournaments[0].ParentTournamentAddress) + s.Equal(rootAddr, *tournaments[0].ParentTournamentAddress) + }) +} diff --git a/internal/repository/repotest/withdrawal_test_cases.go b/internal/repository/repotest/withdrawal_test_cases.go new file mode 100644 index 000000000..c196eeebf --- /dev/null +++ b/internal/repository/repotest/withdrawal_test_cases.go @@ -0,0 +1,330 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" +) + +type WithdrawalSuite struct { + BaseSuite +} + +func NewWithdrawalSuite(factory RepositoryFactory) *WithdrawalSuite { + return &WithdrawalSuite{BaseSuite: BaseSuite{factory: factory}} +} + +// newWithdrawalFixture builds a Withdrawal for the given application + index, +// with unique-enough auxiliary data so equality assertions catch any field +// silently swapping between rows. +func newWithdrawalFixture(appID int64, accountIndex uint64) *Withdrawal { + return &Withdrawal{ + ApplicationID: appID, + AccountIndex: accountIndex, + Account: []byte{0xaa, byte(accountIndex)}, + Output: []byte{0xbb, byte(accountIndex), byte(accountIndex >> 8)}, + BlockNumber: 1000 + accountIndex, + TransactionHash: UniqueHash(), + LogIndex: uint(accountIndex % 4), + } +} + +// TestInsertWithdrawal pins the idempotent-on-conflict contract of the +// (application_id, account_index) primary key. evmreader re-processes blocks +// on restart, so a second insert with the same key must be a silent no-op +// (not an error and not an overwrite); first writer wins matches the chain +// invariant that each account index is withdrawn at most once. +func (s *WithdrawalSuite) TestInsertWithdrawal() { + s.Run("Happy", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + w := newWithdrawalFixture(app.ID, 7) + + err := s.Repo.InsertWithdrawal(s.Ctx, w) + s.Require().NoError(err) + + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 7) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(w.ApplicationID, got.ApplicationID) + s.Equal(w.AccountIndex, got.AccountIndex) + s.Equal(w.Account, got.Account) + s.Equal(w.Output, got.Output) + s.Equal(w.BlockNumber, got.BlockNumber) + s.Equal(w.TransactionHash, got.TransactionHash) + s.Equal(w.LogIndex, got.LogIndex) + }) + + // Restart-safety: a second insert with the same (app, accountIndex) but + // different auxiliary fields must be a silent no-op. The chain marks + // each account index as withdrawn (wereAccountFundsWithdrawn), so a + // second observation is always a restart artifact — silently keeping + // the first observation is correct. + s.Run("IdempotentOnConflict", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + first := newWithdrawalFixture(app.ID, 7) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, first)) + + second := newWithdrawalFixture(app.ID, 7) + second.Account = []byte{0xff, 0xff, 0xff} + second.Output = []byte{0xee, 0xee} + second.BlockNumber = first.BlockNumber + 100 + second.TransactionHash = UniqueHash() + second.LogIndex = first.LogIndex + 1 + err := s.Repo.InsertWithdrawal(s.Ctx, second) + s.Require().NoError(err, "ON CONFLICT DO NOTHING must not surface the conflict as an error") + + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 7) + s.Require().NoError(err) + s.Require().NotNil(got) + // First writer wins on every field. + s.Equal(first.Account, got.Account) + s.Equal(first.Output, got.Output) + s.Equal(first.BlockNumber, got.BlockNumber) + s.Equal(first.TransactionHash, got.TransactionHash) + s.Equal(first.LogIndex, got.LogIndex) + }) + + s.Run("RequiresValidApplication", func() { + w := newWithdrawalFixture(99_999_999, 0) + err := s.Repo.InsertWithdrawal(s.Ctx, w) + s.Require().Error(err, "FK to application(id) must reject orphan inserts") + }) +} + +func (s *WithdrawalSuite) TestGetWithdrawal() { + s.Run("Found", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + w := newWithdrawalFixture(app.ID, 3) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, w)) + + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 3) + s.Require().NoError(err) + s.Require().NotNil(got) + s.Equal(uint64(3), got.AccountIndex) + }) + + // Project convention for Get* endpoints: not-found returns (nil, nil), + // not ErrNotFound. The JSON-RPC layer translates the nil into a + // resource-not-found error code. + s.Run("NotFoundForUnknownAccountIndex", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + got, err := s.Repo.GetWithdrawal(s.Ctx, app.Name, 99) + s.Require().NoError(err) + s.Nil(got) + }) + + s.Run("NotFoundForUnknownApplication", func() { + got, err := s.Repo.GetWithdrawal(s.Ctx, "no-such-app", 0) + s.Require().NoError(err) + s.Nil(got) + }) +} + +func (s *WithdrawalSuite) TestListWithdrawals() { + s.Run("Empty", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Empty(ws) + s.Equal(uint64(0), total) + }) + + // Default ordering is ascending by account_index. The on-chain order is + // unconstrained between blocks; ascending account_index gives clients a + // stable iteration order. + s.Run("MultipleAscending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{5, 1, 3} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(3), total) + s.Require().Len(ws, 3) + s.Equal(uint64(1), ws[0].AccountIndex) + s.Equal(uint64(3), ws[1].AccountIndex) + s.Equal(uint64(5), ws[2].AccountIndex) + }) + + s.Run("DescendingOrder", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{1, 3, 5} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, repository.Pagination{}, true) + s.Require().NoError(err) + s.Equal(uint64(3), total) + s.Require().Len(ws, 3) + s.Equal(uint64(5), ws[0].AccountIndex) + s.Equal(uint64(3), ws[1].AccountIndex) + s.Equal(uint64(1), ws[2].AccountIndex) + }) + + s.Run("FilteredByAccountIndex", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{1, 3, 5} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + want := uint64(3) + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{AccountIndex: &want}, + repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(1), total) + s.Require().Len(ws, 1) + s.Equal(uint64(3), ws[0].AccountIndex) + }) + + // Cross-app isolation: ListWithdrawals(appA) must not surface appB rows. + // FK cascades on application delete, but the filter must also stand + // alone since rows from multiple apps can coexist. + s.Run("CrossAppIsolation", func() { + appA := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + appB := NewApplicationBuilder().WithName("other-app").Create(s.Ctx, s.T(), s.Repo) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(appA.ID, 1))) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(appB.ID, 2))) + + wsA, totalA, err := s.Repo.ListWithdrawals( + s.Ctx, appA.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(1), totalA) + s.Require().Len(wsA, 1) + s.Equal(appA.ID, wsA[0].ApplicationID) + + wsB, totalB, err := s.Repo.ListWithdrawals( + s.Ctx, appB.Name, repository.WithdrawalFilter{}, repository.Pagination{}, false) + s.Require().NoError(err) + s.Equal(uint64(1), totalB) + s.Require().Len(wsB, 1) + s.Equal(appB.ID, wsB[0].ApplicationID) + }) + + s.Run("Pagination", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + for _, idx := range []uint64{1, 2, 3} { + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, idx))) + } + ws, total, err := s.Repo.ListWithdrawals( + s.Ctx, app.Name, repository.WithdrawalFilter{}, + repository.Pagination{Limit: 1, Offset: 1}, false) + s.Require().NoError(err) + s.Equal(uint64(3), total, "total_count reports the unpaginated cardinality") + s.Require().Len(ws, 1) + s.Equal(uint64(2), ws[0].AccountIndex, "default-ascending order, offset 1 → account_index 2") + }) +} + +func (s *WithdrawalSuite) TestGetNumberOfWithdrawals() { + s.Run("CountsRowsForOneApplication", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + other := NewApplicationBuilder().WithName("other-app").Create(s.Ctx, s.T(), s.Repo) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(0), count) + + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, 1))) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(app.ID, 2))) + s.Require().NoError(s.Repo.InsertWithdrawal(s.Ctx, newWithdrawalFixture(other.ID, 3))) + + count, err = s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(2), count) + }) +} + +func (s *WithdrawalSuite) TestStoreWithdrawalEvents() { + s.Run("PersistsRowsAndCursorAtomically", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ws := []*Withdrawal{ + newWithdrawalFixture(app.ID, 1), + newWithdrawalFixture(app.ID, 2), + } + + err := s.Repo.StoreWithdrawalEvents(s.Ctx, app.ID, ws, 1234) + s.Require().NoError(err) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(2), count) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1234), got.LastWithdrawalCheckBlock) + }) + + s.Run("EmptyBatchStillAdvancesCursor", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + + err := s.Repo.StoreWithdrawalEvents( + s.Ctx, app.ID, []*Withdrawal{}, 777) + s.Require().NoError(err) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(0), count) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(777), got.LastWithdrawalCheckBlock) + }) + + s.Run("RollsBackRowsWhenBatchIsInvalid", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + other := NewApplicationBuilder().WithName("other-app").Create(s.Ctx, s.T(), s.Repo) + ws := []*Withdrawal{ + newWithdrawalFixture(app.ID, 1), + newWithdrawalFixture(other.ID, 2), + } + + err := s.Repo.StoreWithdrawalEvents(s.Ctx, app.ID, ws, 1234) + s.Require().Error(err) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(0), count) + + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(0), got.LastWithdrawalCheckBlock) + }) + + // Replay safety: evmreader re-scans blocks on restart, so the same batch can + // be re-applied — and a re-applied batch may carry a lower block than the + // cursor already reached. The replayed rows must be idempotent (no + // duplicates, first writer wins) and the cursor must never regress. + s.Run("ReplayIsIdempotentAndCursorDoesNotRegress", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + ws := []*Withdrawal{newWithdrawalFixture(app.ID, 1)} + + s.Require().NoError(s.Repo.StoreWithdrawalEvents(s.Ctx, app.ID, ws, 1000)) + got, err := s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1000), got.LastWithdrawalCheckBlock) + + // Re-apply the same withdrawal at a lower block. + s.Require().NoError(s.Repo.StoreWithdrawalEvents(s.Ctx, app.ID, ws, 500)) + + count, err := s.Repo.GetNumberOfWithdrawals(s.Ctx, app.ID) + s.Require().NoError(err) + s.Equal(uint64(1), count, "replayed withdrawal must not duplicate") + + got, err = s.Repo.GetApplication(s.Ctx, app.Name) + s.Require().NoError(err) + s.Equal(uint64(1000), got.LastWithdrawalCheckBlock, + "cursor must not regress when a batch is replayed with a lower block") + }) +} + +// Compile-time check that the Withdrawal-related fields on Application are +// not silently dropped on round-trip via JSON or the repo. The repository +// implementation has many SELECT/scan column lists; a missing column in any +// of them would surface here. +var _ = (*Withdrawal)(nil) +var _ = common.Hash{} diff --git a/internal/services/command.go b/internal/services/command.go deleted file mode 100644 index 9eb9aa0d0..000000000 --- a/internal/services/command.go +++ /dev/null @@ -1,134 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "context" - "fmt" - "log/slog" - "net" - "os/exec" - "regexp" - "strings" - "syscall" - "time" -) - -const ( - DefaultPollInterval = 100 * time.Millisecond -) - -// CommandService encapsulates the execution of an executable via exec.Command. -// It assumes the executable accepts TCP connections at HealthcheckPort, -// which it uses to determine if it is ready or not. -type CommandService struct { - - // Name that identifies the service. - Name string - - // Port used to verify if the service is ready. - HealthcheckPort int - - // Path to the service binary. - Path string - - // Args to the service binary. - Args []string - - // Environment variables. - Env []string - - // Working Directory - WorkDir string -} - -func (s CommandService) Start(ctx context.Context, ready chan<- struct{}) error { - cmd := exec.CommandContext(ctx, s.Path, s.Args...) - cmd.Env = s.Env - cmd.Stderr = newLineWriter(commandLogger{s.Name}) - cmd.Stdout = newLineWriter(commandLogger{s.Name}) - cmd.Cancel = func() error { - err := cmd.Process.Signal(syscall.SIGTERM) - if err != nil { - slog.Warn("Failed to send SIGTERM", "service", s, "error", err) - } - return err - } - - if s.WorkDir != "" { - cmd.Dir = s.WorkDir - } - - go s.pollTcp(ctx, ready) - err := cmd.Run() - - if ctx.Err() != nil { - return ctx.Err() - } - return err -} - -// Blocks until the service is ready or the context is canceled. -func (s CommandService) pollTcp(ctx context.Context, ready chan<- struct{}) { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - for { - conn, err := net.Dial("tcp", fmt.Sprintf("0.0.0.0:%v", s.HealthcheckPort)) - if err == nil { - slog.Debug("Service is ready", "service", s) - conn.Close() - ready <- struct{}{} - return - } - select { - case <-ctx.Done(): - return - case <-time.After(DefaultPollInterval): - } - } -} - -func (s CommandService) String() string { - return s.Name -} - -// A wrapper around slog.Default that writes log output from a services.CommandService -// to the correct log level -type commandLogger struct { - Name string -} - -func (l commandLogger) Write(data []byte) (int, error) { - // If data does has no alphanumeric characters, ignore it. - if match := alphanumericRegex.Find(data); match == nil { - return 0, nil - } - msg := strings.TrimSpace(string(data)) - level := l.logLevelForMessage(msg) - slog.Log(context.Background(), level, msg, "service", l.Name) - return len(msg), nil -} - -var ( - errorRegex = regexp.MustCompile(`(?i)(error|fatal)`) - warnRegex = regexp.MustCompile(`(?i)warn`) - infoRegex = regexp.MustCompile(`(?i)info`) - debugRegex = regexp.MustCompile(`(?i)(debug|trace)`) - alphanumericRegex = regexp.MustCompile("[a-zA-Z0-9]") -) - -// Uses regular expressions to determine the correct log level. If there is no match, -// returns slog.LevelInfo -func (l commandLogger) logLevelForMessage(msg string) slog.Level { - if match := infoRegex.FindString(msg); len(match) > 0 { - return slog.LevelInfo - } else if match = debugRegex.FindString(msg); len(match) > 0 { - return slog.LevelDebug - } else if match = warnRegex.FindString(msg); len(match) > 0 { - return slog.LevelWarn - } else if match = errorRegex.FindString(msg); len(match) > 0 { - return slog.LevelError - } - return slog.LevelInfo -} diff --git a/internal/services/command_test.go b/internal/services/command_test.go deleted file mode 100644 index e8e0c5eff..000000000 --- a/internal/services/command_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/suite" -) - -type CommandServiceSuite struct { - suite.Suite - tmpDir string - servicePort int -} - -func (s *CommandServiceSuite) SetupSuite() { - s.buildFakeService() - s.servicePort = 55555 -} - -func (s *CommandServiceSuite) TearDownSuite() { - err := os.RemoveAll(s.tmpDir) - if err != nil { - panic(err) - } -} - -func (s *CommandServiceSuite) SetupTest() { - s.servicePort++ - serviceAdress := "0.0.0.0:" + fmt.Sprint(s.servicePort) - os.Setenv("SERVICE_ADDRESS", serviceAdress) -} - -func (s *CommandServiceSuite) TestItStopsWhenContextIsCancelled() { - service := CommandService{ - Name: "fake-service", - Path: "fake-service", - HealthcheckPort: s.servicePort, - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // start service in goroutine - result := make(chan error) - ready := make(chan struct{}) - go func() { - result <- service.Start(ctx, ready) - }() - - // assert service started successfully - select { - case err := <-result: - s.FailNow("service failed to start", err) - case <-ready: - } - - cancel() - - err := <-result - s.ErrorIs(err, context.Canceled, "service exited for the wrong reason: %v", err) -} - -// Service should stop if timeout is reached and it isn't ready yet -func (s *CommandServiceSuite) TestItTimesOut() { - service := CommandService{ - Name: "fake-service", - Path: "fake-service", - HealthcheckPort: 0, // wrong port - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // start service in goroutine˝ - result := make(chan error) - ready := make(chan struct{}) - go func() { - result <- service.Start(ctx, ready) - }() - - // expect timeout because of wrong port - select { - case <-ready: - s.FailNow("service should have timed out") - case <-time.After(2 * time.Second): - cancel() - err := <-result - s.ErrorIs(err, context.Canceled, "service exited for the wrong reason: %v", err) - } -} - -func (s *CommandServiceSuite) TestItFailsToStartIfExecutableNotInPath() { - service := CommandService{ - Name: "fake-service", - Path: "wrong-path", - HealthcheckPort: s.servicePort, - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ready := make(chan struct{}) - - err := service.Start(ctx, ready) - - s.ErrorIs(err, exec.ErrNotFound, "service exited for the wrong reason: %v", err) -} - -// Builds the fake-service binary and adds it to PATH -func (s *CommandServiceSuite) buildFakeService() { - tempDir, err := os.MkdirTemp("", "") - if err != nil { - panic(err) - } - s.tmpDir = tempDir - - cmd := exec.Command( - "go", - "build", - "-o", - filepath.Join(s.tmpDir, "fake-service"), - "fakeservice/main.go", - ) - if err := cmd.Run(); err != nil { - panic(err) - } - - os.Setenv("PATH", os.Getenv("PATH")+":"+s.tmpDir) -} - -func TestCommandService(t *testing.T) { - suite.Run(t, new(CommandServiceSuite)) -} diff --git a/internal/services/fakeservice/main.go b/internal/services/fakeservice/main.go deleted file mode 100644 index ede94ad01..000000000 --- a/internal/services/fakeservice/main.go +++ /dev/null @@ -1,17 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// This file creates a dummy webserver with the sole pupose of being used -// as a binary to test the services.Service struct -package main - -import ( - "net/http" - "os" -) - -func main() { - addr := os.Getenv("SERVICE_ADDRESS") - err := http.ListenAndServe(addr, nil) - panic(err) -} diff --git a/internal/services/http.go b/internal/services/http.go deleted file mode 100644 index aac8d52ec..000000000 --- a/internal/services/http.go +++ /dev/null @@ -1,56 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "context" - "errors" - "log/slog" - "net" - "net/http" -) - -type HttpService struct { - Name string - Address string - Handler http.Handler -} - -func (s HttpService) String() string { - return s.Name -} - -func (s HttpService) Start(ctx context.Context, ready chan<- struct{}) error { - server := http.Server{ - Addr: s.Address, - Handler: s.Handler, - ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelError), - } - - listener, err := net.Listen("tcp", s.Address) - if err != nil { - return err - } - - slog.Info("HTTP server started listening", "service", s, "port", listener.Addr()) - ready <- struct{}{} - - done := make(chan error, 1) - go func() { - err := server.Serve(listener) - if !errors.Is(err, http.ErrServerClosed) { - slog.Warn("Service exited with error", "service", s, "error", err) - } - done <- err - }() - - select { - case err = <-done: - return err - case <-ctx.Done(): - ctx, cancel := context.WithTimeout(context.Background(), DefaultServiceTimeout) - defer cancel() - return server.Shutdown(ctx) - } -} diff --git a/internal/services/http_test.go b/internal/services/http_test.go deleted file mode 100644 index b8ef59fea..000000000 --- a/internal/services/http_test.go +++ /dev/null @@ -1,156 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "context" - "fmt" - "io" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/suite" -) - -type HttpServiceSuite struct { - suite.Suite - ServicePort int - ServiceAddr string -} - -func TestHttpService(t *testing.T) { - suite.Run(t, new(HttpServiceSuite)) -} - -func (s *HttpServiceSuite) SetupSuite() { - s.ServicePort = 5555 -} - -func (s *HttpServiceSuite) SetupTest() { - s.ServicePort++ - s.ServiceAddr = fmt.Sprintf("127.0.0.1:%v", s.ServicePort) -} - -func (s *HttpServiceSuite) TestItStopsWhenContextIsClosed() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - service := HttpService{Name: "http", Address: s.ServiceAddr, Handler: http.NewServeMux()} - - result := make(chan error, 1) - ready := make(chan struct{}, 1) - go func() { - result <- service.Start(ctx, ready) - }() - - select { - case <-ready: - cancel() - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for HttpService to be ready") - } - - select { - case err := <-result: - s.Nil(err) - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for HttpService to stop") - } -} - -func (s *HttpServiceSuite) TestItRespondsToRequests() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - router := http.NewServeMux() - router.HandleFunc("/test", defaultHandler) - service := HttpService{Name: "http", Address: s.ServiceAddr, Handler: router} - - result := make(chan error, 1) - ready := make(chan struct{}, 1) - go func() { - result <- service.Start(ctx, ready) - }() - - select { - case <-ready: - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for HttpService to be ready") - } - - resp, err := http.Get(fmt.Sprintf("http://%v/test", s.ServiceAddr)) - if err != nil { - s.FailNow(err.Error()) - } - s.assertResponse(resp) -} - -func (s *HttpServiceSuite) TestItRespondsOngoingRequestsAfterContextIsClosed() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - router := http.NewServeMux() - router.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - // simulate a long-running request - <-time.After(100 * time.Millisecond) - fmt.Fprintf(w, "test") - }) - service := HttpService{Name: "http", Address: s.ServiceAddr, Handler: router} - - result := make(chan error, 1) - ready := make(chan struct{}, 1) - go func() { - result <- service.Start(ctx, ready) - }() - - select { - case <-ready: - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out wating for HttpService to be ready") - } - - clientResult := make(chan ClientResult, 1) - go func() { - resp, err := http.Get(fmt.Sprintf("http://%v/test", s.ServiceAddr)) - clientResult <- ClientResult{Response: resp, Error: err} - }() - - // wait a bit so server has enough time to start responding the request - <-time.After(200 * time.Millisecond) - cancel() - - select { - case res := <-clientResult: - s.Nil(res.Error) - s.assertResponse(res.Response) - err := <-result - s.Nil(err) - case <-result: - s.FailNow("HttpService closed before responding") - } -} - -type ClientResult struct { - Response *http.Response - Error error -} - -func defaultHandler(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "test") -} - -func (s *HttpServiceSuite) assertResponse(resp *http.Response) { - s.Equal(http.StatusOK, resp.StatusCode) - - defer resp.Body.Close() - - bytes, err := io.ReadAll(resp.Body) - if err != nil { - s.FailNow("failed to read response body. ", err) - } - s.Equal([]byte("test"), bytes) -} diff --git a/internal/services/linewriter.go b/internal/services/linewriter.go deleted file mode 100644 index 44dc3e482..000000000 --- a/internal/services/linewriter.go +++ /dev/null @@ -1,48 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "bytes" - "io" -) - -// lineWriter accumulates the received data in a buffer and writes it to the inner writer when it -// encounters a new line, ignoring empty lines in the process. -// lineWriter assumes the inner writer does not returns an error. -type lineWriter struct { - inner io.Writer - buffer bytes.Buffer -} - -func newLineWriter(inner io.Writer) *lineWriter { - return &lineWriter{ - inner: inner, - } -} - -func (w *lineWriter) Write(data []byte) (int, error) { - _, err := w.buffer.Write(data) - if err != nil { - // Not possible given bytes.Buffer spec - panic(err) - } - for { - if !bytes.ContainsRune(w.buffer.Bytes(), '\n') { - break - } - line, err := w.buffer.ReadBytes('\n') - if err != nil { - // Not possible because we looked for the \n rune - panic(err) - } - if len(line) > 1 { - if _, err := w.inner.Write(line); err != nil { - // Assume the writer doesn't return an error - panic(err) - } - } - } - return len(data), nil -} diff --git a/internal/services/linewriter_test.go b/internal/services/linewriter_test.go deleted file mode 100644 index a65481cc2..000000000 --- a/internal/services/linewriter_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "bytes" - "errors" - "testing" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" -) - -type mockWriter struct { - mock.Mock -} - -func (w *mockWriter) Write(p []byte) (int, error) { - args := w.Called(p) - return args.Int(0), args.Error(1) -} - -type LineWriterSuite struct { - suite.Suite - mock *mockWriter - writer *lineWriter -} - -func TestLineWriterSuite(t *testing.T) { - suite.Run(t, &LineWriterSuite{}) -} - -func (s *LineWriterSuite) SetupTest() { - s.mock = &mockWriter{} - s.writer = newLineWriter(s.mock) -} - -func (s *LineWriterSuite) TestItWritesLines() { - s.mock.On("Write", mock.Anything).Return(0, nil) - lines := [][]byte{ - []byte("hello\n"), - []byte("world\n"), - []byte("nugget\n"), - } - for _, line := range lines { - n, err := s.writer.Write(line) - s.Equal(n, len(line)) - s.Nil(err) - } - for _, line := range lines { - s.mock.AssertCalled(s.T(), "Write", line) - } -} - -func (s *LineWriterSuite) TestItSplitMultipleLines() { - s.mock.On("Write", mock.Anything).Return(0, nil) - lines := [][]byte{ - []byte("hello\n"), - []byte("world\n"), - []byte("nugget\n"), - } - data := bytes.Join(lines, nil) - n, err := s.writer.Write(data) - s.Equal(n, len(data)) - s.Nil(err) - for _, line := range lines { - s.mock.AssertCalled(s.T(), "Write", line) - } -} - -func (s *LineWriterSuite) TestItIgnoresEmptyLines() { - s.mock.On("Write", mock.Anything).Return(0, nil) - lines := [][]byte{ - []byte("hello\n"), - []byte("world\n"), - []byte("nugget\n"), - } - parts := [][]byte{ - {'\n'}, - {'\n'}, - lines[0], - {'\n'}, - {'\n'}, - lines[1], - {'\n'}, - {'\n'}, - lines[2], - {'\n'}, - {'\n'}, - } - for _, part := range parts { - n, err := s.writer.Write(part) - s.Equal(n, len(part)) - s.Nil(err) - } - for _, line := range lines { - s.mock.AssertCalled(s.T(), "Write", line) - } -} - -func (s *LineWriterSuite) TestItDoesNotWriteStringWithoutNewLine() { - s.mock.On("Write", mock.Anything).Return(0, nil) - data := []byte("hello nugget") - n, err := s.writer.Write(data) - s.Equal(n, len(data)) - s.Nil(err) - s.mock.AssertNotCalled(s.T(), "Write") -} - -func (s *LineWriterSuite) TestItJoinsStringsIntoSingleLine() { - s.mock.On("Write", mock.Anything).Return(0, nil) - parts := [][]byte{ - []byte("hello "), - []byte("world "), - []byte("nugget"), - []byte("\n"), - } - line := bytes.Join(parts, nil) - for _, part := range parts { - n, err := s.writer.Write(part) - s.Equal(n, len(part)) - s.Nil(err) - } - s.mock.AssertCalled(s.T(), "Write", line) -} - -func (s *LineWriterSuite) TestItPanicsIfWriterReturnsError() { - s.mock.On("Write", mock.Anything).Return(0, errors.New("test error")) - s.PanicsWithError("test error", func() { - _, _ = s.writer.Write([]byte("hello nugget\n")) - }) -} diff --git a/internal/services/server-manager.go b/internal/services/server-manager.go deleted file mode 100644 index 3ff61ac76..000000000 --- a/internal/services/server-manager.go +++ /dev/null @@ -1,140 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "context" - "fmt" - "log/slog" - "net" - "os" - "os/exec" - "strconv" - "strings" - "syscall" - "time" -) - -// ServerManager is a variation of CommandService used to manually stop -// the orphaned cartesi-machines left after server-manager exits. -// For more information, check https://github.com/cartesi/server-manager/issues/18 -type ServerManager struct { - // Name that identifies the service. - Name string - - // Port used to verify if the service is ready. - HealthcheckPort int - - // Path to the service binary. - Path string - - // Args to the service binary. - Args []string - - // Environment variables. - Env []string - - // Bypass the log and write directly to stdout/stderr. - BypassLog bool - - // Working Directory - WorkDir string -} - -const waitDelay = 200 * time.Millisecond - -func (s ServerManager) Start(ctx context.Context, ready chan<- struct{}) error { - cmd := exec.CommandContext(ctx, s.Path, s.Args...) - cmd.Env = s.Env - if s.WorkDir != "" { - cmd.Dir = s.WorkDir - } - if s.BypassLog { - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - } else { - cmd.Stderr = newLineWriter(commandLogger{s.Name}) - cmd.Stdout = newLineWriter(commandLogger{s.Name}) - } - // Without a delay, cmd.Wait() will block forever waiting for the I/O pipes - // to be closed - cmd.WaitDelay = waitDelay - cmd.Cancel = func() error { - err := killChildProcesses(cmd.Process.Pid) - if err != nil { - slog.Warn("Failed to kill child processes", "service", s, "error", err) - } - err = cmd.Process.Signal(syscall.SIGTERM) - if err != nil { - slog.Warn("Failed to send SIGTERM", "service", s, "error", err) - } - return err - } - - go s.pollTcp(ctx, ready) - err := cmd.Run() - - if ctx.Err() != nil { - return ctx.Err() - } - return err -} - -// Blocks until the service is ready or the context is canceled -func (s ServerManager) pollTcp(ctx context.Context, ready chan<- struct{}) { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - for { - conn, err := net.Dial("tcp", fmt.Sprintf("0.0.0.0:%v", s.HealthcheckPort)) - if err == nil { - slog.Debug("Service is ready", "service", s) - conn.Close() - ready <- struct{}{} - return - } - select { - case <-ctx.Done(): - return - case <-time.After(DefaultPollInterval): - } - } -} - -func (s ServerManager) String() string { - return s.Name -} - -// Kills all child processes spawned by pid -func killChildProcesses(pid int) error { - children, err := getChildrenPid(pid) - if err != nil { - return fmt.Errorf("failed to get child processes. %v", err) - } - for _, child := range children { - err = syscall.Kill(child, syscall.SIGKILL) - if err != nil { - return fmt.Errorf("failed to kill child process: %v. %v\n", child, err) - } - } - return nil -} - -// Returns a list of processes whose parent is ppid -func getChildrenPid(ppid int) ([]int, error) { - output, err := exec.Command("pgrep", "-P", fmt.Sprint(ppid)).CombinedOutput() - if err != nil { - return nil, fmt.Errorf("failed to exec pgrep: %v: %v", err, string(output)) - } - - var children []int - pids := strings.Split(strings.TrimSpace(string(output)), "\n") - for _, pid := range pids { - childPid, err := strconv.Atoi(pid) - if err != nil { - return nil, fmt.Errorf("failed to parse pid: %v", err) - } - children = append(children, childPid) - } - return children, nil -} diff --git a/internal/services/service.go b/internal/services/service.go deleted file mode 100644 index caf9083eb..000000000 --- a/internal/services/service.go +++ /dev/null @@ -1,18 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// Package services provides mechanisms to start multiple services in the -// background -package services - -import ( - "context" - "fmt" -) - -type Service interface { - fmt.Stringer - - // Starts a service and sends a message to the channel when ready - Start(ctx context.Context, ready chan<- struct{}) error -} diff --git a/internal/services/supervisor.go b/internal/services/supervisor.go deleted file mode 100644 index 81c54fe81..000000000 --- a/internal/services/supervisor.go +++ /dev/null @@ -1,121 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "context" - "errors" - "log/slog" - "time" - - "golang.org/x/sync/errgroup" -) - -const DefaultServiceTimeout = 5 * time.Second - -var ( - ServiceTimeoutError = errors.New("timed out waiting for service to be ready") - SupervisorTimeoutError = errors.New("timed out waiting for services to stop") -) - -// SupervisorService is a simple implementation of a supervisor. -// It runs its services until the first returns a non-nil error. -type SupervisorService struct { - // Name of the service - Name string - - // Services to be managed - Services []Service - - // The amount of time to wait for a service to be ready. - // Default is 5 seconds - ReadyTimeout time.Duration - - // The amount of time to wait for a service to exit after - // its context is canceled. Default is 5 seconds - StopTimeout time.Duration -} - -func (s SupervisorService) String() string { - return s.Name -} - -func (s SupervisorService) Start(ctx context.Context, ready chan<- struct{}) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - group, ctx := errgroup.WithContext(ctx) - - // flag indicating if a service timed out during start - var serviceTimedOut bool - readyTimeout := s.ReadyTimeout - if readyTimeout <= 0 { - readyTimeout = DefaultServiceTimeout - } - stopTimeout := s.StopTimeout - if stopTimeout <= 0 { - stopTimeout = DefaultServiceTimeout - } - -Loop: - // start services one by one - for _, service := range s.Services { - service := service - serviceReady := make(chan struct{}, 1) - - group.Go(func() error { - err := service.Start(ctx, serviceReady) - if err != nil && !errors.Is(err, context.Canceled) { - slog.Error("Service exited with error", - "service", service, - "error", err, - ) - } else { - slog.Info("Service exited successfully", "service", service) - } - return err - }) - - select { - // service is ready, move along - case <-serviceReady: - slog.Info("Service is ready", "service", service) - // a service exited with error - case <-ctx.Done(): - break Loop - // service took too long to become ready - case <-time.After(readyTimeout): - slog.Error("Service timed out", "service", service) - cancel() - serviceTimedOut = true - break Loop - } - } - - // if nothing went wrong while starting services, SupervisorService is ready - if ctx.Err() == nil { - ready <- struct{}{} - slog.Info("All services are ready", "service", s.Name) - } - - // wait until a service exits with error or the external context is canceled - <-ctx.Done() - - // wait for all services to stop - wait := make(chan error) - go func() { - wait <- group.Wait() - }() - - select { - case err := <-wait: - slog.Info("All services exited successfully", "service", s.Name) - if serviceTimedOut { - return ServiceTimeoutError - } - return err - case <-time.After(stopTimeout): - slog.Error("Service timed out", "service", s.Name, "error", SupervisorTimeoutError) - return SupervisorTimeoutError - } -} diff --git a/internal/services/supervisor_test.go b/internal/services/supervisor_test.go deleted file mode 100644 index 87defb930..000000000 --- a/internal/services/supervisor_test.go +++ /dev/null @@ -1,363 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -package services - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" -) - -type SupervisorServiceSuite struct { - suite.Suite -} - -func TestSupervisorService(t *testing.T) { - suite.Run(t, new(SupervisorServiceSuite)) -} - -func (s *SupervisorServiceSuite) TestItIsReadyAfterStartingAllServices() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var services = []Service{ - NewMockService("Mock1", 0), - NewMockService("Mock2", 0), - NewMockService("Mock3", 0), - } - - ctxClosed := make(chan time.Time) - go func() { - <-ctx.Done() - close(ctxClosed) - }() - - for _, service := range services { - mockService := service.(*MockService) - mockService. - On("Start", mock.Anything, mock.Anything). - Return(nil). - WaitUntil(ctxClosed) - } - - supervisor := SupervisorService{ - Name: "supervisor", - Services: services, - } - - ready := make(chan struct{}) - go func() { - _ = supervisor.Start(ctx, ready) - }() - - select { - case <-ready: - for _, service := range services { - mockService := service.(*MockService) - mockService.AssertCalled(s.T(), "Start", mock.Anything, mock.Anything) - } - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for supervisor to be ready") - } -} - -func (s *SupervisorServiceSuite) TestItStopsAllServicesWhenContextIsCanceled() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - services := []Service{ - NewMockService("Mock1", 0), - NewMockService("Mock2", 0), - NewMockService("Mock3", 0), - } - - ctxClosed := make(chan time.Time) - go func() { - <-ctx.Done() - close(ctxClosed) - }() - - for _, service := range services { - mockService := service.(*MockService) - mockService. - On("Start", mock.Anything, mock.Anything). - Return(context.Canceled). - WaitUntil(ctxClosed) - } - supervisor := SupervisorService{ - Name: "supervisor", - Services: services, - } - - result := make(chan error) - ready := make(chan struct{}) - go func() { - result <- supervisor.Start(ctx, ready) - }() - - select { - case <-ready: - cancel() - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for supervisor to be ready") - } - - select { - case err := <-result: - s.ErrorIs(err, context.Canceled) - for _, service := range services { - mockService := service.(*MockService) - mockService.AssertExpectations(s.T()) - } - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for supervisor to be ready") - } -} - -func (s *SupervisorServiceSuite) TestItStopsAllServicesIfAServiceStops() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - mockErr := errors.New("err") - services := []Service{ - NewMockService("Mock1", 0), - NewMockService("Mock2", 0), - NewMockService("Mock3", 0), - } - - for idx, service := range services { - mockService := service.(*MockService) - if idx == len(services)-1 { - mockService. - On("Start", mock.Anything, mock.Anything). - Return(mockErr). - After(100 * time.Millisecond) - } else { - mockService. - On("Start", mock.Anything, mock.Anything). - Return(context.Canceled). - After(500 * time.Millisecond) - } - } - - supervisor := SupervisorService{ - Name: "supervisor", - Services: services, - } - - result := make(chan error, 1) - ready := make(chan struct{}, 1) - go func() { - result <- supervisor.Start(ctx, ready) - }() - - select { - case err := <-result: - s.ErrorIs(err, mockErr) - for _, service := range services { - mockService := service.(*MockService) - mockService.AssertExpectations(s.T()) - } - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for supervisor to return") - } -} - -func (s *SupervisorServiceSuite) TestItStopsCreatingServicesIfAServiceFailsToStart() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - mockErr := errors.New("err") - services := []Service{ - NewMockService("Mock1", 0), - NewMockService("Mock2", -1), - NewMockService("Mock3", 0), - } - - for idx, service := range services { - mockService := service.(*MockService) - if idx == 1 { - mockService.On("Start", mock.Anything, mock.Anything).Return(mockErr) - } else { - mockService. - On("Start", mock.Anything, mock.Anything). - Return(context.Canceled). - After(300 * time.Millisecond) - } - } - - supervisor := SupervisorService{ - Name: "supervisor", - Services: services, - } - - result := make(chan error, 1) - ready := make(chan struct{}, 1) - go func() { - result <- supervisor.Start(ctx, ready) - }() - - select { - case err := <-result: - s.ErrorIs(err, mockErr) - last := services[len(services)-1].(*MockService) - last.AssertNotCalled(s.T(), "Start", mock.Anything, mock.Anything) - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for supervisor to return") - } -} - -func (s *SupervisorServiceSuite) TestItStopsCreatingServicesIfContextIsCanceled() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - services := []Service{ - NewMockService("Mock1", 0), - NewMockService("Mock2", time.Second), - NewMockService("Mock3", 0), - NewMockService("Mock4", 0), - } - - ctxClosed := make(chan time.Time) - go func() { - <-ctx.Done() - close(ctxClosed) - }() - - for _, service := range services { - mockService := service.(*MockService) - mockService. - On("Start", mock.Anything, mock.Anything). - Return(context.Canceled). - WaitUntil(ctxClosed) - } - supervisor := SupervisorService{ - Name: "supervisor", - Services: services, - } - - result := make(chan error) - ready := make(chan struct{}, 1) - go func() { - result <- supervisor.Start(ctx, ready) - }() - - <-time.After(300 * time.Millisecond) - cancel() - - select { - case err := <-result: - s.ErrorIs(err, context.Canceled) - for idx, service := range services { - mockService := service.(*MockService) - if idx > 1 { - mockService.AssertNotCalled(s.T(), "Start", mock.Anything, mock.Anything) - } else { - mockService.AssertExpectations(s.T()) - } - } - case <-ready: - s.FailNow("supervisor shouldn't be ready") - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for supervisor to return") - } -} - -func (s *SupervisorServiceSuite) TestItTimesOutIfServiceTakesTooLongToBeReady() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - mock1 := NewMockService("Mock1", 500*time.Millisecond) - mock1. - On("Start", mock.Anything, mock.Anything). - Return(context.Canceled). - After(time.Second) - - supervisor := SupervisorService{ - Name: "supervisor", - Services: []Service{mock1}, - ReadyTimeout: 200 * time.Millisecond, - } - - result := make(chan error) - ready := make(chan struct{}, 1) - go func() { - result <- supervisor.Start(ctx, ready) - }() - - select { - case err := <-result: - s.ErrorIs(err, ServiceTimeoutError) - mock1.AssertCalled(s.T(), "Start", mock.Anything, mock.Anything) - case <-ready: - s.FailNow("supervisor shouldn't be ready") - case <-time.After(DefaultServiceTimeout): - s.FailNow("timed out waiting for supervisor to return") - } -} - -func (s *SupervisorServiceSuite) TestItTimesOutIfServicesTakeTooLongToStop() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - mock1 := NewMockService("Mock1", 0) - mock1. - On("Start", mock.Anything, mock.Anything). - Return(context.Canceled). - After(time.Second) - - timeout := 500 * time.Millisecond - supervisor := SupervisorService{ - Name: "supervisor", - Services: []Service{mock1}, - StopTimeout: timeout, - } - - result := make(chan error) - ready := make(chan struct{}, 1) - go func() { - result <- supervisor.Start(ctx, ready) - }() - - <-ready - cancel() - - err := <-result - s.ErrorIs(err, SupervisorTimeoutError) -} - -type MockService struct { - mock.Mock - Name string - // The time to wait before notifying it is ready. Provide a negative value - // to prevent such notification from being sent - ReadyDelay time.Duration -} - -func (m *MockService) Start(ctx context.Context, ready chan<- struct{}) error { - if m.ReadyDelay >= 0 { - go func() { - <-time.After(m.ReadyDelay) - ready <- struct{}{} - }() - } - - returnArgs := m.Called(ctx, ready) - return returnArgs.Error(0) -} - -func (m *MockService) String() string { - return m.Name -} - -func NewMockService(name string, readyDelay time.Duration) *MockService { - return &MockService{ - Name: name, - ReadyDelay: readyDelay, - } -} diff --git a/internal/txsender/classify.go b/internal/txsender/classify.go new file mode 100644 index 000000000..c666f55b0 --- /dev/null +++ b/internal/txsender/classify.go @@ -0,0 +1,87 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "errors" + + "github.com/cartesi/rollups-node/internal/errutil" +) + +// Keep shutdown cancellation pure. Joining a wait sentinel would make the +// service mistake cancellation of an in-flight RPC for an operational failure. +func inconclusive(err error) error { + if errutil.IsOnlyCancellation(err) { + return err + } + return errors.Join(ErrInconclusive, err) +} + +// HardFailure reports a protected-state contradiction that stops publication +// until an operator restarts the service. +func HardFailure(err error) bool { return errors.Is(err, ErrFinality) || errors.Is(err, ErrContent) } + +// PassInvalid reports an error that invalidates the rest of a publication pass. +func PassInvalid(err error) bool { + return errors.Is(err, ErrStale) || errors.Is(err, ErrNonCanonical) || + HardFailure(err) || errors.Is(err, ErrUnsupported) +} + +// WaitOnly reports whether every cause of err is an ordinary wait. A joined +// RPC or database failure remains an error. Callers may name extra waits. +func WaitOnly(err error, extra ...error) bool { + switch cause := err.(type) { + case nil: + return false + case *PublicationError: + return cause.NonceLowOnly() + case interface{ Unwrap() []error }: + children := cause.Unwrap() + for _, child := range children { + if !WaitOnly(child, extra...) { + return false + } + } + return len(children) > 0 + case interface{ Unwrap() error }: + return WaitOnly(cause.Unwrap(), extra...) + } + for _, wait := range append([]error{ErrStale, ErrInconclusive, ErrIneligible}, extra...) { + if err == wait { + return true + } + } + return false +} + +// ShutdownOnly reports whether shutdown interrupted an otherwise ordinary wait. +// Every joined cause must be cancellation or a named wait; real failures and +// expired operation deadlines remain visible even when shutdown has started. +func ShutdownOnly(ctx context.Context, err error, extra ...error) bool { + if ctx.Err() != context.Canceled || err == nil { + return false + } + return shutdownCauseOnly(err, extra...) +} + +func shutdownCauseOnly(err error, extra ...error) bool { + if err == context.Canceled || WaitOnly(err, extra...) { + return true + } + switch cause := err.(type) { + case interface{ Unwrap() []error }: + children := cause.Unwrap() + for _, child := range children { + if !shutdownCauseOnly(child, extra...) { + return false + } + } + return len(children) > 0 + case interface{ Unwrap() error }: + return shutdownCauseOnly(cause.Unwrap(), extra...) + default: + return false + } +} diff --git a/internal/txsender/fees.go b/internal/txsender/fees.go new file mode 100644 index 000000000..3fee8e712 --- /dev/null +++ b/internal/txsender/fees.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "math/big" +) + +type quote struct { + cap, tip *big.Int + legacy bool +} + +func (s *Sender[A]) fees(ctx context.Context, snap account) (quote, error) { + q := quote{legacy: s.policy.Legacy || snap.header.BaseFee == nil} + var err error + if q.legacy { + q.cap, err = s.backend.Price(ctx) + } else { + q.tip, err = s.backend.Tip(ctx) + if q.tip != nil { + q.cap = new(big.Int).Mul(snap.header.BaseFee, big.NewInt(baseFeeMultiplier)) + q.cap.Add(q.cap, q.tip) + } + } + if err != nil { + return quote{}, err + } + if q.cap == nil || q.cap.Sign() <= 0 || q.cap.BitLen() > 256 || + (!q.legacy && (q.tip == nil || q.tip.Sign() < 0 || q.tip.BitLen() > 256)) { + return quote{}, ErrInconclusive + } + if s.policy.MaxFee != nil { + q.cap = minimum(q.cap, s.policy.MaxFee) + } + if q.legacy { + q.tip = copyInt(q.cap) + } else { + q.tip = minimum(q.tip, q.cap) + } + return q, nil +} + +func (s *Sender[A]) retryFees(q quote) quote { + r := quote{legacy: q.legacy, cap: copyInt(q.cap), tip: copyInt(q.cap)} + if q.legacy { + r.cap = replacementMinimum(q.cap) + if s.policy.MaxFee != nil { + r.cap = minimum(r.cap, s.policy.MaxFee) + } + r.tip = copyInt(r.cap) + } + return r +} + +// A pool can require a larger bump. This matches geth's default without keeping +// a history of bids or promising that a replacement will be accepted. +func clearsReplacement(q, old quote) bool { + return q.legacy == old.legacy && q.cap.Cmp(replacementMinimum(old.cap)) >= 0 && + q.tip.Cmp(replacementMinimum(old.tip)) >= 0 +} + +func coversQuotedTip(old, fresh quote, baseFee *big.Int) bool { + if baseFee == nil { + return true + } + required := copyInt(baseFee) + if !old.legacy { + required.Add(required, minimum(old.tip, fresh.tip)) + } else { + return old.cap.Cmp(baseFee) > 0 + } + return old.cap.Cmp(required) >= 0 +} + +func (s *Sender[A]) replacementFees(q, old quote) quote { + feeCap := maximum(q.cap, replacementMinimum(old.cap)) + if s.policy.MaxFee != nil && feeCap.Cmp(s.policy.MaxFee) > 0 { + // The old copy may have left the pool. Try the fresh capped quote; a + // definitive refusal preserves the previous current request. + return q + } + q.cap = feeCap + q.tip = maximum(q.tip, replacementMinimum(old.tip)) + if q.legacy { + q.tip = copyInt(q.cap) + } + return q +} + +func maximum(a, b *big.Int) *big.Int { + if a.Cmp(b) > 0 { + return copyInt(a) + } + return copyInt(b) +} + +func priceRefusal(class ReplyClass) bool { + return class == ReplacementUnderpriced || class == Underpriced || class == BaseFeeLow +} diff --git a/internal/txsender/fees_test.go b/internal/txsender/fees_test.go new file mode 100644 index 000000000..80be4bd8e --- /dev/null +++ b/internal/txsender/fees_test.go @@ -0,0 +1,134 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFreshDynamicQuoteAndSingleFullCapRetry(t *testing.T) { + s, b, _ := testSender(t) + b.publishFn = func([]byte) error { + if len(b.published) == 1 { + return testRPCError{replacementUnderpricedMessage, -32000} + } + return nil + } + report, err := s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + require.Equal(t, Accepted, report.Class) + require.Len(t, b.published, 2) + first, retry := publishedTx(t, b, 0), publishedTx(t, b, 1) + require.Equal(t, big.NewInt(22), first.GasFeeCap()) + require.Equal(t, big.NewInt(2), first.GasTipCap()) + require.Equal(t, first.GasFeeCap(), retry.GasFeeCap()) + require.Equal(t, retry.GasFeeCap(), retry.GasTipCap()) +} + +func TestFreshTipAboveOperatorCeilingUsesActualClampedCap(t *testing.T) { + s, b, _ := testSender(t) + s.policy.MaxFee = big.NewInt(5) + b.head.BaseFee, b.tip = big.NewInt(1), big.NewInt(10) + b.nonces[b.head.Hash()] = 0 + _, err := s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + require.Len(t, b.published, 1) + tx := publishedTx(t, b, 0) + require.Equal(t, big.NewInt(5), tx.GasFeeCap()) + require.Equal(t, big.NewInt(5), tx.GasTipCap()) +} + +func TestDynamicRetryUsesActualClampedCapAndNeverCompounds(t *testing.T) { + s, b, _ := testSender(t) + s.policy.MaxFee = big.NewInt(15) + b.publishErr = testRPCError{replacementUnderpricedMessage, -32000} + for range 3 { + _, err := s.PublishNext(t.Context(), newPlanner()) + require.Error(t, err) + } + require.Len(t, b.published, 6) + for index := range b.published { + tx := publishedTx(t, b, index) + require.Equal(t, big.NewInt(15), tx.GasFeeCap()) + if index%2 == 0 { + require.Equal(t, big.NewInt(2), tx.GasTipCap()) + } else { + require.Equal(t, big.NewInt(15), tx.GasTipCap()) + } + } + require.Nil(t, s.current, "a definite refusal establishes no preferred newly published transaction") +} + +func TestLegacyRetryUsesOneFreshRoundedStep(t *testing.T) { + for _, test := range []struct { + quote int64 + cap int64 + want int64 + }{ + {1, 0, 2}, {10, 0, 11}, {101, 0, 112}, {101, 110, 110}, {101, 101, 101}, + } { + t.Run(big.NewInt(test.quote).String()+"/"+big.NewInt(test.cap).String(), func(t *testing.T) { + s, b, _ := testSender(t) + s.policy.Legacy, b.price = true, big.NewInt(test.quote) + b.head.BaseFee = nil + b.nonces[b.head.Hash()] = 0 + if test.cap > 0 { + s.policy.MaxFee = big.NewInt(test.cap) + } + b.publishErr = testRPCError{replacementUnderpricedMessage, -32000} + _, err := s.PublishNext(t.Context(), newPlanner()) + require.Error(t, err) + last := publishedTx(t, b, len(b.published)-1) + require.Equal(t, big.NewInt(test.want), last.GasPrice()) + if test.cap == test.quote { + require.Len(t, b.published, 1, "an identical retry has no benefit") + } + }) + } +} + +func TestLegacySmallQuoteChangeRetainsBytesUntilReplacementIsPossible(t *testing.T) { + s, b, _ := testSender(t) + s.policy.Legacy = true + b.price = big.NewInt(100) + b.publishFn = poolRule(t) + _, err := s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + b.price = big.NewInt(101) + _, err = s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + require.Equal(t, b.published[0], b.published[1], "a small quote change does not require a replacement") + b.price = big.NewInt(110) + _, err = s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + require.Equal(t, big.NewInt(110), publishedTx(t, b, 2).GasPrice()) +} + +func TestZeroQuoteAndCeilingBelowBaseRefuseExplicitly(t *testing.T) { + for _, test := range []struct { + name string + setup func(*Sender[int], *fakeBackend) + want error + }{ + {"zero legacy quote", func(s *Sender[int], b *fakeBackend) { s.policy.Legacy = true; b.price.SetInt64(0) }, ErrInconclusive}, + {"zero dynamic quote", func(_ *Sender[int], b *fakeBackend) { + b.head.BaseFee.SetInt64(0) + b.tip.SetInt64(0) + b.nonces[b.head.Hash()] = 0 + }, ErrInconclusive}, + {"ceiling below base", func(s *Sender[int], _ *fakeBackend) { s.policy.MaxFee = big.NewInt(9) }, ErrLimit}, + } { + t.Run(test.name, func(t *testing.T) { + s, b, f := testSender(t) + test.setup(s, b) + _, err := s.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, test.want) + require.Zero(t, f.signs) + require.Empty(t, b.published) + }) + } +} diff --git a/internal/txsender/fixtures_test.go b/internal/txsender/fixtures_test.go new file mode 100644 index 000000000..b733cc262 --- /dev/null +++ b/internal/txsender/fixtures_test.go @@ -0,0 +1,215 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "errors" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +const knownPublicationMessage = "already known" +const replacementUnderpricedMessage = "replacement transaction underpriced" +const admissionUnderpricedMessage = "transaction underpriced" +const nonceLowMessage = "nonce too low" + +type fakeBackend struct { + head *types.Header + headers map[uint64]*types.Header + nonces map[common.Hash]uint64 + balance *big.Int + tip, price *big.Int + code []byte + estimate uint64 + estimateErr error + estimateFn func(ethereum.CallMsg) (uint64, error) + estimateBlocks []BlockRef + calls []ethereum.CallMsg + callBlocks []BlockRef + published [][]byte + tags []string + callFn func(ethereum.CallMsg) ([]byte, error) + publishFn func([]byte) error + publishErr error + headerErr error + nonceErr error +} + +func newFakeBackend() *fakeBackend { + b := &fakeBackend{headers: make(map[uint64]*types.Header), nonces: make(map[common.Hash]uint64), + balance: big.NewInt(1_000_000_000_000_000_000), tip: big.NewInt(2), price: big.NewInt(12), estimate: 21_000} + b.advance(1, 0) + return b +} + +func (b *fakeBackend) advance(number, nonce uint64) { + h := &types.Header{Number: new(big.Int).SetUint64(number), BaseFee: big.NewInt(10), + GasLimit: 30_000_000, GasUsed: 15_000_000, Time: number, Extra: []byte{1}} + b.headers[number], b.nonces[h.Hash()], b.head = h, nonce, h +} + +func (b *fakeBackend) Header(_ context.Context, tag string) (*types.Header, error) { + b.tags = append(b.tags, tag) + if b.headerErr != nil { + return nil, b.headerErr + } + if tag == latestBlockTag { + return types.CopyHeader(b.head), nil + } + n, err := hexutil.DecodeUint64(tag) + if err != nil { + return nil, err + } + if h := b.headers[n]; h != nil { + return types.CopyHeader(h), nil + } + return nil, ethereum.NotFound +} +func (b *fakeBackend) Canonical(_ context.Context, ref BlockRef) (bool, error) { + h := b.headers[ref.Number] + return h != nil && h.Hash() == ref.Hash, nil +} +func (b *fakeBackend) Nonce(_ context.Context, _ common.Address, ref BlockRef) (uint64, error) { + if b.nonceErr != nil { + return 0, b.nonceErr + } + n, ok := b.nonces[ref.Hash] + if !ok { + return 0, ethereum.NotFound + } + return n, nil +} +func (b *fakeBackend) Balance(context.Context, common.Address, BlockRef) (*big.Int, error) { + return copyInt(b.balance), nil +} +func (b *fakeBackend) Code(context.Context, common.Address, BlockRef) ([]byte, error) { + return b.code, nil +} +func (b *fakeBackend) Call(_ context.Context, msg ethereum.CallMsg, block BlockRef) ([]byte, error) { + b.calls, b.callBlocks = append(b.calls, msg), append(b.callBlocks, block) + if b.callFn != nil { + return b.callFn(msg) + } + return []byte{1}, nil +} +func (b *fakeBackend) Estimate(_ context.Context, msg ethereum.CallMsg, block BlockRef) (uint64, error) { + b.estimateBlocks = append(b.estimateBlocks, block) + if b.estimateFn != nil { + return b.estimateFn(msg) + } + return b.estimate, b.estimateErr +} +func (b *fakeBackend) Tip(context.Context) (*big.Int, error) { return copyInt(b.tip), nil } +func (b *fakeBackend) Price(context.Context) (*big.Int, error) { return copyInt(b.price), nil } +func (b *fakeBackend) Publish(_ context.Context, raw []byte) (common.Hash, error) { + b.published = append(b.published, append([]byte(nil), raw...)) + var tx types.Transaction + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + if b.publishFn != nil { + return tx.Hash(), b.publishFn(raw) + } + return tx.Hash(), b.publishErr +} + +type fakeSigner struct { + opts *bind.TransactOpts + signs int + afterSign func() + mutate func(*types.Transaction) *types.Transaction +} + +func newFakeSigner(t *testing.T) *fakeSigner { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(31337)) + require.NoError(t, err) + return &fakeSigner{opts: opts} +} +func (f *fakeSigner) From() common.Address { return f.opts.From } +func (f *fakeSigner) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts := *f.opts + opts.Context = ctx + opts.Signer = func(address common.Address, tx *types.Transaction) (*types.Transaction, error) { + f.signs++ + if f.mutate != nil { + tx = f.mutate(tx) + } + signed, err := f.opts.Signer(address, tx) + if f.afterSign != nil { + f.afterSign() + } + return signed, err + } + return &opts, nil +} + +type fakePlanner struct { + action int + request Request + decision decisionKind + nextErr error + eligibility Eligibility + recheckErr error + nextFn func(BlockRef, *Current[int]) (Decision[int], error) + recheckFn func(BlockRef, int) (Eligibility, error) + seen []*Current[int] +} + +func newPlanner() *fakePlanner { + return &fakePlanner{action: 1, request: testRequest(), decision: publishDecision} +} +func (p *fakePlanner) Next(_ context.Context, block BlockRef, current *Current[int]) (Decision[int], error) { + p.seen = append(p.seen, current) + if p.nextFn != nil { + return p.nextFn(block, current) + } + return Decision[int]{kind: p.decision, action: p.action, request: p.request}, p.nextErr +} +func (p *fakePlanner) Recheck(_ context.Context, block BlockRef, action int) (Eligibility, error) { + if p.recheckFn != nil { + return p.recheckFn(block, action) + } + return p.eligibility, p.recheckErr +} +func testRequest() Request { + return Request{To: common.HexToAddress("0x100"), Data: []byte{1, 2, 3}, Value: new(big.Int)} +} +func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } +func testSender(t *testing.T) (*Sender[int], *fakeBackend, *fakeSigner) { + t.Helper() + b, f := newFakeBackend(), newFakeSigner(t) + s, err := New[int](b, f, 31337, Policy{}, func(ctx context.Context) error { return ctx.Err() }, testLogger()) + require.NoError(t, err) + return s, b, f +} +func publishedTx(t *testing.T, b *fakeBackend, index int) *types.Transaction { + t.Helper() + tx := new(types.Transaction) + require.NoError(t, tx.UnmarshalBinary(b.published[index])) + return tx +} + +type testRPCError struct { + message string + code int +} + +func (e testRPCError) Error() string { return e.message } +func (e testRPCError) ErrorCode() int { return e.code } + +var errLostReply = errors.New("connection closed after publication") diff --git a/internal/txsender/gas_test.go b/internal/txsender/gas_test.go new file mode 100644 index 000000000..7c4ef7095 --- /dev/null +++ b/internal/txsender/gas_test.go @@ -0,0 +1,233 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/stretchr/testify/require" +) + +func TestServiceGasFloorDoesNotHideL2PostingEstimate(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + p.request.GasLimit = 650_000 + b.estimate = 1_200_000 // Execution plus L1 posting cost, expressed in L2 gas. + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, uint64(1_800_000), publishedTx(t, b, 0).Gas()) +} + +func TestGasSizingUsesPreparedBlockEvenWhenSigningCrossesAHead(t *testing.T) { + s, b, signer := testSender(t) + prepared := BlockRef{Number: b.head.Number.Uint64(), Hash: b.head.Hash()} + signer.afterSign = func() { b.advance(2, 0) } + report, err := s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, []BlockRef{prepared}, b.estimateBlocks) + require.Equal(t, prepared, b.callBlocks[0]) + require.Equal(t, uint64(2), b.callBlocks[len(b.callBlocks)-1].Number) +} + +func TestNeutralUsesEstimatedPostingGasWithoutContractBranchPadding(t *testing.T) { + for _, estimate := range []uint64{21_000, 40_000} { + t.Run(new(big.Int).SetUint64(estimate).String(), func(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.estimate = estimate + p.decision, p.eligibility = cancelDecision, Obsolete + _, err = s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, estimate, publishedTx(t, b, 1).Gas()) + }) + } +} + +func TestGasEstimateRefusalIsNotReclassifiedAsInsufficientBalance(t *testing.T) { + for _, refusal := range []testRPCError{ + {"execution reverted: action is not useful", 3}, + {unfundedEstimateMessage, -32000}, + } { + t.Run(refusal.Error(), func(t *testing.T) { + s, b, signer := testSender(t) + b.estimateErr = refusal + _, err := s.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, refusal) + require.ErrorContains(t, err, "gas estimate refused") + require.NotContains(t, err.Error(), "gas estimate unavailable") + require.NotErrorIs(t, err, ErrLimit) + require.Zero(t, signer.signs) + require.Empty(t, b.published) + }) + } +} + +func TestGasEstimateUnavailableDoesNotInventANewAllowance(t *testing.T) { + s, b, signer := testSender(t) + b.estimateErr = errLostReply + b.balance = big.NewInt(22_000_000) + _, err := s.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, errLostReply) + require.ErrorContains(t, err, "gas estimate unavailable") + require.NotContains(t, err.Error(), "gas estimate refused") + require.NotErrorIs(t, err, ErrLimit) + require.Zero(t, signer.signs) + require.Empty(t, b.published) +} + +func TestGasEstimateAboveSupportedCeilingRefusesBeforeSigning(t *testing.T) { + s, b, signer := testSender(t) + b.estimate = supportedTransactionGasCeiling + 1 + _, err := s.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, ErrLimit) + require.Zero(t, signer.signs) + require.Empty(t, b.published) +} + +func TestCachedGasIsReusedForEstimateJitterButReplacedWhenInsufficient(t *testing.T) { + for _, test := range []struct { + name string + estimate uint64 + floor uint64 + reuse bool + }{ + {"padding jitter", 21_001, 0, true}, + {"L2 posting increase", 180_000, 0, false}, + {"service floor increase", 21_000, 200_000, false}, + } { + t.Run(test.name, func(t *testing.T) { + s, b, signer := testSender(t) + b.publishFn = poolRule(t) + p := newPlanner() + first, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.estimate, p.request.GasLimit = test.estimate, test.floor + second, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + if test.reuse { + require.Equal(t, first.Hash, second.Hash) + require.Equal(t, 1, signer.signs) + } else { + require.NotEqual(t, first.Hash, second.Hash) + require.Equal(t, 2, signer.signs) + require.Equal(t, Accepted, second.Class, "changed gas needs a bid the pool can replace") + require.GreaterOrEqual(t, publishedTx(t, b, 1).Gas(), max(test.estimate, test.floor)) + } + require.Len(t, b.published, 2) + }) + } +} + +func TestEstimatorOutageReusesHeldGasWithoutIncreasingFunding(t *testing.T) { + for _, neutral := range []bool{false, true} { + t.Run(fmt.Sprintf("neutral=%t", neutral), func(t *testing.T) { + s, b, signer := testSender(t) + b.publishFn = poolRule(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + if neutral { + p.decision, p.eligibility = cancelDecision, Obsolete + _, err = s.PublishNext(t.Context(), p) + require.NoError(t, err) + } + old := s.current + signs := signer.signs + b.balance.Mul(old.cap, new(big.Int).SetUint64(old.gas)).Add(b.balance, old.request.Value) + b.estimateErr = errLostReply + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Known, report.Class) + require.Equal(t, old.raw, s.current.raw) + require.Equal(t, old.gas, s.current.gas) + require.Equal(t, signs, signer.signs) + // Keeping gas never bypasses the exact execution check. + b.callFn = func(ethereum.CallMsg) ([]byte, error) { return nil, errLostReply } + before := len(b.published) + _, err = s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, errLostReply) + require.Len(t, b.published, before) + }) + } +} + +func TestEstimatorOutageCannotSizeChangedWork(t *testing.T) { + for _, kind := range []string{"new action", "cancellation", "higher service floor"} { + t.Run(kind, func(t *testing.T) { + s, b, signer := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := s.current + b.estimateErr = errLostReply + switch kind { + case "new action": + p.action = 2 + case "cancellation": + p.decision, p.eligibility = cancelDecision, Obsolete + default: + p.request.GasLimit = old.gas + 1 + } + report, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, errLostReply) + require.False(t, report.Attempted) + require.Same(t, old, s.current) + require.Equal(t, 1, signer.signs) + require.Len(t, b.published, 1) + }) + } +} + +func TestFreshQuoteDoesNotRejectFundsSufficientForReusableBytes(t *testing.T) { + for _, neutral := range []bool{false, true} { + t.Run(fmt.Sprintf("neutral=%t", neutral), func(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + p.request.GasLimit = 200_000 + b.publishFn = poolRule(t) + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + if neutral { + p.decision, p.eligibility = cancelDecision, Obsolete + _, err = s.PublishNext(t.Context(), p) + require.NoError(t, err) + } + old := s.current + b.balance.Mul(old.cap, new(big.Int).SetUint64(old.gas)) + b.advance(2, 0) + b.head.BaseFee.SetInt64(12) // Higher fresh cap; unchanged tip cannot replace adequate held bytes. + b.nonces[b.head.Hash()] = 0 + estimateRefusal := testRPCError{unfundedEstimateMessage, -32000} + b.estimateFn = func(msg ethereum.CallMsg) (uint64, error) { + cost := new(big.Int).Mul(msg.GasFeeCap, new(big.Int).SetUint64(old.gas)) + if cost.Cmp(b.balance) > 0 { + return 0, estimateRefusal + } + return b.estimate, nil + } + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Known, report.Class) + require.Equal(t, old.raw, s.current.raw) + // Once the retained quote is inadequate, sizing must use the fresh + // quote and report its refusal rather than reuse the cheaper fees. + b.advance(3, 0) + b.head.BaseFee.Add(old.cap, big.NewInt(1)) + b.nonces[b.head.Hash()] = 0 + before := len(b.published) + report, err = s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, estimateRefusal) + require.ErrorContains(t, err, "gas estimate refused") + require.False(t, report.Attempted) + require.Len(t, b.published, before) + require.Equal(t, old.raw, s.current.raw) + }) + } +} diff --git a/internal/txsender/http_retry_test.go b/internal/txsender/http_retry_test.go new file mode 100644 index 000000000..25a738832 --- /dev/null +++ b/internal/txsender/http_retry_test.go @@ -0,0 +1,145 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type wirePublicationBackend struct { + *fakeBackend + publication ProtectedClient +} + +func (b wirePublicationBackend) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + return b.publication.Publish(ctx, raw) +} + +func TestLostPublicationReplyRetriesIdenticalBytesAndReturnsKnownWithoutError(t *testing.T) { + var mu sync.Mutex + var posted [][]byte + var handlerErrors []error + recordError := func(err error) { + mu.Lock() + defer mu.Unlock() + handlerErrors = append(handlerErrors, err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []hexutil.Bytes `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + recordError(err) + w.WriteHeader(http.StatusBadRequest) + return + } + if request.Method != "eth_sendRawTransaction" || len(request.Params) != 1 { + recordError(fmt.Errorf("unexpected publication request %q", request.Method)) + w.WriteHeader(http.StatusBadRequest) + return + } + mu.Lock() + posted = append(posted, append([]byte(nil), request.Params[0]...)) + first := len(posted) == 1 + mu.Unlock() + if first { + // The provider records the bytes, then loses its response. The + // production retry transport must post the same bytes again. + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + recordError(err) + return + } + if err := conn.Close(); err != nil { + recordError(err) + } + return + } + response := struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + }{JSONRPC: "2.0", ID: request.ID} + response.Error.Code, response.Error.Message = -32000, knownPublicationMessage + if err := json.NewEncoder(w).Encode(response); err != nil { + recordError(err) + } + })) + defer server.Close() + client, err := ethutil.NewEthClient(t.Context(), server.URL, testLogger(), ethutil.RetryConfig{ + MaxRetries: 2, RetryMinWait: time.Millisecond, RetryMaxWait: time.Millisecond, RequestTimeout: time.Second, + }) + require.NoError(t, err) + defer client.Close() + s, backend, signer := testSender(t) + s.backend = wirePublicationBackend{fakeBackend: backend, publication: ProtectedClient{Client: client}} + planner := newPlanner() + for block := uint64(1); block <= 2; block++ { + backend.advance(block, 0) + o, err := s.PublishNext(t.Context(), planner) + require.NoError(t, err) + require.Equal(t, Known, o.Class) + require.True(t, o.Attempted) + } + mu.Lock() + defer mu.Unlock() + require.Empty(t, handlerErrors) + require.Len(t, posted, 3, "one HTTP retry, then one rebroadcast on the following block") + require.Equal(t, posted[0], posted[1]) + require.Equal(t, posted[0], posted[2]) + require.Equal(t, 1, signer.signs) +} + +func TestProtectedPreparationThroughRetryClientReturnsTransportFailure(t *testing.T) { + for _, status := range []int{http.StatusTooManyRequests, http.StatusBadGateway} { + for _, retries := range []uint64{0, 2} { + t.Run(fmt.Sprintf("http%d/retries%d", status, retries), func(t *testing.T) { + var calls atomic.Uint64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(status) + })) + defer server.Close() + client, err := ethutil.NewEthClient(t.Context(), server.URL, testLogger(), ethutil.RetryConfig{ + MaxRetries: retries, RetryMinWait: time.Millisecond, RetryMaxWait: time.Millisecond, RequestTimeout: time.Second, + }) + require.NoError(t, err) + defer client.Close() + backend := ProtectedClient{Client: client} + to := common.HexToAddress("0x100") + _, err = backend.Call(t.Context(), ethereum.CallMsg{To: &to, Data: []byte{1}}, + BlockRef{Hash: common.HexToHash("0x1")}) + require.Error(t, err) + var transport *url.Error + require.ErrorAs(t, err, &transport) + var rpcHTTP rpc.HTTPError + require.False(t, errors.As(err, &rpcHTTP), "production retry exhaustion is not a raw RPC HTTP error") + require.Equal(t, retries+1, calls.Load()) + require.Equal(t, Uncertain, ClassifyPublication(err), "transport failures cannot create fee evidence") + }) + } + } +} diff --git a/internal/txsender/inventory_test.go b/internal/txsender/inventory_test.go new file mode 100644 index 000000000..c6444e86e --- /dev/null +++ b/internal/txsender/inventory_test.go @@ -0,0 +1,42 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Forbidigo checks normalized binding and client symbols. This companion +// check covers RPC method literals, which forbidigo does not inspect. +func TestServicesCannotPublishRawTransactions(t *testing.T) { + for _, directory := range []string{"../claimer", "../prt"} { + files, err := filepath.Glob(filepath.Join(directory, "*.go")) + require.NoError(t, err) + for _, name := range files { + if strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) + require.NoError(t, err) + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + require.NoError(t, err) + require.NotEqual(t, "eth_sendRawTransaction", value, "%s bypasses the owned sender", name) + return true + }) + } + } +} diff --git a/internal/txsender/limits.go b/internal/txsender/limits.go new file mode 100644 index 000000000..f710b8b6f --- /dev/null +++ b/internal/txsender/limits.go @@ -0,0 +1,34 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "fmt" + "math/big" +) + +// LimitError identifies the refused resource in its natural units: wei, gas, +// bytes or item count. Required and Available are independent amount snapshots. +type LimitError struct { + Limit string + Required, Available *big.Int +} + +func (e *LimitError) Error() string { + return fmt.Sprintf("%s: %s requires %s, available %s", ErrLimit, e.Limit, e.Required, e.Available) +} + +func (*LimitError) Unwrap() error { return ErrLimit } + +func limitError(name string, required, available *big.Int) error { + return &LimitError{Limit: name, Required: copyInt(required), Available: copyInt(available)} +} + +func countLimit(name string, required, available int) error { + return limitError(name, big.NewInt(int64(required)), big.NewInt(int64(available))) +} + +func gasLimit(name string, required, available uint64) error { + return limitError(name, new(big.Int).SetUint64(required), new(big.Int).SetUint64(available)) +} diff --git a/internal/txsender/logging_test.go b/internal/txsender/logging_test.go new file mode 100644 index 000000000..59fba90ed --- /dev/null +++ b/internal/txsender/logging_test.go @@ -0,0 +1,39 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "bytes" + "encoding/json" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPublicationAndCancellationPreserveHumanActionContext(t *testing.T) { + s, _, _ := testSender(t) + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, nil)) + p := newPlanner() + p.request.Logger = logger.With("application", "example", "application_address", "0xapp", "epoch", 7, + "action", "join tournament") + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + p.decision, p.eligibility = cancelDecision, Obsolete + _, err = s.PublishNext(t.Context(), p) + require.NoError(t, err) + decoder := json.NewDecoder(&logs) + var last map[string]any + for decoder.More() { + require.NoError(t, decoder.Decode(&last)) + } + require.Equal(t, "example", last["application"]) + require.Equal(t, "0xapp", last["application_address"]) + require.Equal(t, "join tournament", last["action"]) + require.EqualValues(t, 7, last["epoch"]) + require.Equal(t, true, last["neutral"]) + require.NotContains(t, logs.String(), "Identity") + require.NotContains(t, logs.String(), "action_key") +} diff --git a/internal/txsender/policy.go b/internal/txsender/policy.go new file mode 100644 index 000000000..271dba37f --- /dev/null +++ b/internal/txsender/policy.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "fmt" + "math/big" + "time" +) + +const ( + latestBlockTag = "latest" + decimalBase = 10 + uint256Bits = 256 + percentBase = 100 + legacyRetryPercent = 10 + baseFeeMultiplier = 2 + gasPaddingDivisor = 2 + defaultCallBytes = 128 << 10 + supportedTransactionGasCeiling = 1 << 24 + minimumGasPadding = 150_000 + intrinsicGas = 21_000 +) + +type FinancialConfig struct { + MaxFeePerGas string + Legacy bool + SigningTimeout time.Duration +} + +func NewPolicy(c FinancialConfig) (Policy, error) { + p := Policy{Legacy: c.Legacy, SigningTimeout: c.SigningTimeout} + if c.MaxFeePerGas != "" { + for _, digit := range c.MaxFeePerGas { + if digit < '0' || digit > '9' { + return Policy{}, fmt.Errorf("CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS must be positive decimal wei") + } + } + var ok bool + p.MaxFee, ok = new(big.Int).SetString(c.MaxFeePerGas, decimalBase) + if !ok { + return Policy{}, fmt.Errorf("CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS must be positive decimal wei") + } + } + return p, p.Validate() +} + +func RuntimePolicy() Policy { return Policy{} } + +func (p Policy) Validate() error { + if p.SigningTimeout < 0 { + return fmt.Errorf("transaction signing timeout must not be negative") + } + if p.MaxFee != nil && (p.MaxFee.Sign() <= 0 || p.MaxFee.BitLen() > uint256Bits) { + return fmt.Errorf("CARTESI_BLOCKCHAIN_TX_MAX_FEE_PER_GAS must be a positive uint256 amount in wei") + } + return nil +} + +func copyInt(x *big.Int) *big.Int { + if x == nil { + return new(big.Int) + } + return new(big.Int).Set(x) +} + +func minimum(a, b *big.Int) *big.Int { + if a.Cmp(b) < 0 { + return copyInt(a) + } + return copyInt(b) +} + +// replacementMinimum is one rounded step, including a strict increase at zero. +func replacementMinimum(x *big.Int) *big.Int { + rounded := new(big.Int).Mul(x, big.NewInt(percentBase+legacyRetryPercent)) + rounded.Add(rounded, big.NewInt(percentBase-1)).Div(rounded, big.NewInt(percentBase)) + plusOne := new(big.Int).Add(x, big.NewInt(1)) + if rounded.Cmp(plusOne) < 0 { + return plusOne + } + return rounded +} diff --git a/internal/txsender/policy_test.go b/internal/txsender/policy_test.go new file mode 100644 index 000000000..82647f3ad --- /dev/null +++ b/internal/txsender/policy_test.go @@ -0,0 +1,50 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPolicyHasOnlyOptionalFeeCapAndExistingLegacyMode(t *testing.T) { + for _, value := range []string{"", "1", "99999999999999999999999999999999"} { + _, err := NewPolicy(FinancialConfig{MaxFeePerGas: value}) + require.NoError(t, err) + } + for _, value := range []string{"0", "-1", "+1", "1.5", " 1", "0x10"} { + _, err := NewPolicy(FinancialConfig{MaxFeePerGas: value}) + require.Error(t, err) + } + tooLarge := new(big.Int).Lsh(big.NewInt(1), 256).String() + _, err := NewPolicy(FinancialConfig{MaxFeePerGas: tooLarge}) + require.Error(t, err) + p, err := NewPolicy(FinancialConfig{Legacy: true}) + require.NoError(t, err) + require.True(t, p.Legacy) + require.Nil(t, p.MaxFee) +} + +func TestPolicyInputsAreCopiedAndCannotChangeAnActiveSender(t *testing.T) { + b, signer := newFakeBackend(), newFakeSigner(t) + ceiling := big.NewInt(100) + s, err := New[int](b, signer, 31337, Policy{MaxFee: ceiling}, func(context.Context) error { return nil }, testLogger()) + require.NoError(t, err) + ceiling.SetInt64(1) + _, err = s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + require.Equal(t, big.NewInt(100), s.policy.MaxFee) +} + +func TestLimitErrorNamesResourceAndCopiesAmounts(t *testing.T) { + required, available := big.NewInt(20), big.NewInt(10) + err := limitError("signer balance", required, available) + required.SetInt64(1) + available.SetInt64(2) + require.ErrorIs(t, err, ErrLimit) + require.Contains(t, err.Error(), "signer balance requires 20, available 10") +} diff --git a/internal/txsender/provider_test.go b/internal/txsender/provider_test.go new file mode 100644 index 000000000..6f385c0ca --- /dev/null +++ b/internal/txsender/provider_test.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package txsender + +import ( + "context" + "math/big" + "os" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// The fixture uses a fresh random funded account. It does not reset, snapshot, +// revert, or borrow any configured node or CLI nonce sequence. +func TestProviderAnvilProtectedPublicationAndLatestConsumption(t *testing.T) { + if os.Getenv("CARTESI_TXSENDER_ANVIL_TEST") != "1" { + t.Skip("CARTESI_TXSENDER_ANVIL_TEST=1 is required to fund and publish on the declared Anvil fixture") + } + endpoint := os.Getenv("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT") + if endpoint == "" { + t.Skip("CARTESI_BLOCKCHAIN_HTTP_ENDPOINT is required for the Anvil provider fixture") + } + client, err := ethutil.DialEthClient(t.Context(), endpoint) + require.NoError(t, err) + defer client.Close() + chainID, err := client.ChainID(t.Context()) + require.NoError(t, err) + require.Equal(t, big.NewInt(31337), chainID, "this funding fixture is for the declared Anvil profile") + signer := newFakeSigner(t) + var funded any + err = client.Client().CallContext(t.Context(), &funded, "anvil_setBalance", + signer.From(), hexutil.EncodeBig(big.NewInt(1_000_000_000_000_000_000))) + require.NoError(t, err) + backend := ProtectedClient{Client: client} + s, err := New[int](backend, signer, 31337, Policy{MaxFee: big.NewInt(100_000_000_000)}, + func(ctx context.Context) error { return ctx.Err() }, testLogger()) + require.NoError(t, err) + planner := newPlanner() + planner.request = Request{To: signer.From(), Value: big.NewInt(1), GasLimit: 21_000} + observation, err := s.PublishNext(t.Context(), planner) + require.NoError(t, err) + prepared := s.current + require.True(t, observation.Attempted) + require.Equal(t, Accepted, observation.Class) + var tx *types.Transaction + require.Eventually(t, func() bool { + readCtx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + var pending bool + var readErr error + tx, pending, readErr = client.TransactionByHash(readCtx, observation.Hash) + return readErr == nil && !pending + }, 10*time.Second, time.Second, "the existing devnet producer must include the fixture's transaction") + require.Equal(t, prepared.gas, tx.Gas()) + require.Equal(t, prepared.cap, tx.GasFeeCap()) + require.Equal(t, prepared.tip, tx.GasTipCap()) + // Retirement depends on latest consumption even while selected observers lag. + planner.decision = waitDecision + require.Eventually(t, func() bool { + readCtx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + report, readErr := s.PublishNext(readCtx, planner) + return readErr == nil && report.Current == nil && report.Nonce > observation.Nonce + }, 10*time.Second, time.Second, "latest mined nonce must retire the publication cache") + _, err = backend.Call(t.Context(), ethereum.CallMsg{ + From: signer.From(), To: &planner.request.To, Gas: prepared.gas, Value: planner.request.Value, + GasFeeCap: prepared.cap, GasTipCap: prepared.tip, + }, observation.Block) + require.NoError(t, err, "a historical exact execution must honor requireCanonical hash selectors") + t.Run("gas estimate stays on preparation block after inclusion", func(t *testing.T) { + testProtectedGasEstimateAfterInclusion(t, backend, signer) + }) +} + +func testProtectedGasEstimateAfterInclusion(t *testing.T, backend ProtectedClient, signer *fakeSigner) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + opts := *signer.opts + opts.Context = ctx + // The first call initializes storage; a repeated call reverts with + // ClockAlreadyInitialized(), reproducing a completed tournament join. + init := hexutil.MustDecode("0x6018600c60003960186000f35f5415601257630ef476875f526004601cfd5b60015f5500") + target, deployment, contract, err := bind.DeployContract(&opts, abi.ABI{}, init, backend.Client) + require.NoError(t, err) + receipt, err := bind.WaitMined(ctx, backend.Client, deployment) + require.NoError(t, err) + require.Equal(t, uint64(types.ReceiptStatusSuccessful), receipt.Status) + header, err := backend.Header(ctx, latestBlockTag) + require.NoError(t, err) + prepared := BlockRef{Number: header.Number.Uint64(), Hash: header.Hash()} + msg := ethereum.CallMsg{From: opts.From, To: &target} + before, err := backend.Estimate(ctx, msg, prepared) + require.NoError(t, err) + tx, err := contract.RawTransact(&opts, nil) + require.NoError(t, err) + receipt, err = bind.WaitMined(ctx, backend.Client, tx) + require.NoError(t, err) + require.Equal(t, uint64(types.ReceiptStatusSuccessful), receipt.Status) + _, err = backend.Client.EstimateGasAtBlock(ctx, msg, nil) + require.ErrorContains(t, err, "0x0ef47687", "latest now contains the already completed action") + pinned, err := backend.Estimate(ctx, msg, prepared) + require.NoError(t, err, "sizing must use the same canonical block as the prepared action") + require.Equal(t, before, pinned) + msg.Gas = pinned + _, err = backend.Call(ctx, msg, prepared) + require.NoError(t, err, "the prepared-block exact preflight and estimate must agree") + t.Logf("prepared_block=%d included_block=%d pinned_estimate=%d latest=ClockAlreadyInitialized", + prepared.Number, receipt.BlockNumber.Uint64(), pinned) +} diff --git a/internal/txsender/publication_closure_test.go b/internal/txsender/publication_closure_test.go new file mode 100644 index 000000000..ec4a40267 --- /dev/null +++ b/internal/txsender/publication_closure_test.go @@ -0,0 +1,95 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRisingBaseWithConstantTipReusesAdequateBytes(t *testing.T) { + for _, base := range []int64{104, 120, 150} { + t.Run(big.NewInt(base).String(), func(t *testing.T) { + s, b, signer := testSender(t) + b.head.BaseFee, b.tip = big.NewInt(100), big.NewInt(10) + b.nonces[b.head.Hash()] = 0 + b.publishFn = poolRule(t) + p := newPlanner() + first, err := s.PublishNext(t.Context(), p) // cap 210, tip 10 + require.NoError(t, err) + b.advance(2, 0) + b.head.BaseFee = big.NewInt(base) + b.nonces[b.head.Hash()] = 0 + second, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, first.Hash, second.Hash, "adequate cached bytes are rebroadcast") + require.Equal(t, Known, second.Class) + require.Equal(t, 1, signer.signs, "no signature for a quote the pool must refuse") + require.Len(t, b.published, 2) + }) + } +} + +func TestRisingQuotedTipUsesSmallerTipForAdequacy(t *testing.T) { + s, b, signer := testSender(t) + b.head.BaseFee, b.tip = big.NewInt(100), big.NewInt(100) + b.nonces[b.head.Hash()] = 0 + b.publishFn = poolRule(t) + p := newPlanner() + first, err := s.PublishNext(t.Context(), p) // cap 300, tip 100 + require.NoError(t, err) + b.advance(2, 0) + b.head.BaseFee, b.tip = big.NewInt(196), big.NewInt(105) + b.nonces[b.head.Hash()] = 0 + second, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, first.Hash, second.Hash) + require.Equal(t, Known, second.Class) + require.Equal(t, 1, signer.signs) +} + +func TestChangedIntentPriceRetryKeepsCachedReplacementFloor(t *testing.T) { + for _, refusal := range []string{admissionUnderpricedMessage, replacementUnderpricedMessage} { + t.Run(refusal, func(t *testing.T) { + s, b, signer := testSender(t) + pool := poolRule(t) + b.publishFn = pool + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) // cap 22, tip 2 + require.NoError(t, err) + b.publishFn = func(raw []byte) error { + if len(b.published) == 2 { + return testRPCError{refusal, -32000} // first replacement POST is refused + } + return pool(raw) + } + p.decision, p.eligibility = cancelDecision, Obsolete + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Accepted, report.Class) + retry := publishedTx(t, b, 2) + require.Equal(t, big.NewInt(25), retry.GasFeeCap(), "retry keeps the bumped cap") + require.Equal(t, big.NewInt(25), retry.GasTipCap(), "retry uses that cap as tip") + require.Equal(t, 3, signer.signs) + }) + } +} + +func TestZeroTipCancellationMakesStrictReplacementIncrease(t *testing.T) { + s, b, _ := testSender(t) + b.tip = big.NewInt(0) + b.publishFn = poolRule(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Zero(t, publishedTx(t, b, 0).GasTipCap().Sign()) + p.decision, p.eligibility = cancelDecision, Obsolete + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Accepted, report.Class) + require.True(t, report.Current.Neutral) + require.Equal(t, big.NewInt(1), publishedTx(t, b, 1).GasTipCap()) +} diff --git a/internal/txsender/recovery_test.go b/internal/txsender/recovery_test.go new file mode 100644 index 000000000..20aa0e199 --- /dev/null +++ b/internal/txsender/recovery_test.go @@ -0,0 +1,167 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestRestartWithUnknownPoolBidAndLostReplyReachesPriceRetry(t *testing.T) { + for _, test := range []struct { + name string + fullTip bool + initialBase, recoveryBase int64 + }{ + {"unknown low tip", false, 200, 440}, + {"unknown full tip", true, 200, 440}, + {"quote rises after lost reply", true, 415, 860}, + } { + t.Run(test.name, func(t *testing.T) { + s, b, signer := testSender(t) + b.head.BaseFee, b.tip = big.NewInt(test.initialBase), big.NewInt(20) + b.nonces[b.head.Hash()] = 0 + pool := poolRule(t) + b.publishFn = func(raw []byte) error { + if test.fullTip && len(b.published) == 1 { + return testRPCError{replacementUnderpricedMessage, -32000} + } + return pool(raw) + } + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := publishedTx(t, b, len(b.published)-1) + require.Equal(t, big.NewInt(2*test.initialBase+20), old.GasFeeCap()) + if test.fullTip { + require.Equal(t, old.GasFeeCap(), old.GasTipCap()) + } + // Restart loses local bytes, but the pool still holds the old bid. + s, err = New[int](b, signer, 31337, Policy{}, func(ctx context.Context) error { return ctx.Err() }, testLogger()) + require.NoError(t, err) + b.advance(2, 0) + b.head.BaseFee = big.NewInt(440) + b.nonces[b.head.Hash()] = 0 + lost := true + b.publishFn = func(raw []byte) error { + if lost { + lost = false + return errLostReply // No admission. Retaining bytes is still required. + } + return pool(raw) + } + uncertain, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, errLostReply) + require.Equal(t, Uncertain, uncertain.Class) + lostBytes := append([]byte(nil), s.current.raw...) + // After several blocks, the fresh quote can differ from the retained + // 900/20 bid. Retrying its cap as tip can still be too low. + b.advance(16, 0) + b.head.BaseFee = big.NewInt(test.recoveryBase) + b.nonces[b.head.Hash()] = 0 + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Accepted, report.Class) + require.Equal(t, lostBytes, b.published[len(b.published)-2], "try retained bytes before the one retry") + retry := publishedTx(t, b, len(b.published)-1) + require.Equal(t, big.NewInt(2*test.recoveryBase+20), retry.GasFeeCap()) + require.Equal(t, retry.GasFeeCap(), retry.GasTipCap(), "a low-tip-only step cannot displace an unknown full-tip bid") + require.Greater(t, retry.GasFeeCap().Cmp(b.head.BaseFee), 0, "the accepted bid is includable") + before := signer.signs + repeated, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Known, repeated.Class) + require.Equal(t, report.Hash, repeated.Hash) + require.Equal(t, before, signer.signs, "ordinary polling must not keep increasing a repaired bid") + }) + } +} + +func TestCappedReplacementCanProgressAfterPoolEviction(t *testing.T) { + for _, legacy := range []bool{false, true} { + for _, evicted := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy=%t/evicted=%t", legacy, evicted), func(t *testing.T) { + s, b, _ := testSender(t) + s.policy.Legacy = legacy + b.publishFn = poolRule(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := s.current + s.policy.MaxFee = copyInt(old.cap) + if evicted { + b.publishFn = poolRule(t) // The empty pool cannot be inferred from our cache. + } + p.decision, p.eligibility = cancelDecision, Obsolete + report, err := s.PublishNext(t.Context(), p) + require.True(t, report.Attempted) + require.True(t, report.Canceling) + if evicted { + require.NoError(t, err) + require.Equal(t, Accepted, report.Class) + require.True(t, s.current.neutral) + } else { + require.Error(t, err) + require.Equal(t, ReplacementUnderpriced, report.Class) + require.Same(t, old, s.current, "definite refusal retains the prior usable request") + } + for _, raw := range b.published { + var tx types.Transaction + require.NoError(t, tx.UnmarshalBinary(raw)) + require.LessOrEqual(t, tx.GasFeeCap().Cmp(s.policy.MaxFee), 0) + } + }) + } + } +} + +func TestLegacyReuseRepairsZeroEffectiveTip(t *testing.T) { + s, b, _ := testSender(t) + s.policy.Legacy, b.price, b.publishFn = true, big.NewInt(100), poolRule(t) + p := newPlanner() + first, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.advance(2, 0) + b.head.BaseFee, b.price = big.NewInt(100), big.NewInt(105) + b.nonces[b.head.Hash()] = 0 + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.NotEqual(t, first.Hash, report.Hash) + require.Equal(t, Accepted, report.Class) + require.Equal(t, big.NewInt(110), s.current.cap) + require.Equal(t, s.current.cap, s.current.tip) +} + +func TestLegacyReplacementAfterDynamicQuoteKeepsEnvelopeConsistent(t *testing.T) { + s, b, _ := testSender(t) + b.publishFn = poolRule(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.head.BaseFee = nil + b.nonces[b.head.Hash()] = 0 + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Accepted, report.Class) + require.Equal(t, uint8(types.LegacyTxType), publishedTx(t, b, len(b.published)-1).Type()) + require.Equal(t, s.current.cap, s.current.tip) +} + +func TestEmptyWalletReportsFundingBeforeEstimatorRefusal(t *testing.T) { + s, b, signer := testSender(t) + b.balance.SetInt64(0) + b.estimateErr = testRPCError{unfundedEstimateMessage, -32000} + _, err := s.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, ErrLimit) + require.ErrorContains(t, err, "signer balance") + require.ErrorContains(t, err, "minimum funding before gas estimation") + require.Empty(t, b.estimateBlocks) + require.Zero(t, signer.signs) + require.Empty(t, b.published) +} diff --git a/internal/txsender/replacement_test.go b/internal/txsender/replacement_test.go new file mode 100644 index 000000000..8f5a8195c --- /dev/null +++ b/internal/txsender/replacement_test.go @@ -0,0 +1,320 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// poolRule independently applies geth's default replacement checks. Repeating +// identical bytes is known; both price fields of a replacement must increase. +func poolRule(t *testing.T) func([]byte) error { + t.Helper() + var pending *types.Transaction + return func(raw []byte) error { + tx := new(types.Transaction) + require.NoError(t, tx.UnmarshalBinary(raw)) + if pending != nil { + if tx.Hash() == pending.Hash() { + return testRPCError{knownPublicationMessage, -32000} + } + for _, pair := range [][2]*big.Int{{tx.GasFeeCap(), pending.GasFeeCap()}, {tx.GasTipCap(), pending.GasTipCap()}} { + threshold := new(big.Int).Div(new(big.Int).Mul(pair[1], big.NewInt(110)), big.NewInt(100)) + if pair[0].Cmp(pair[1]) <= 0 || pair[0].Cmp(threshold) < 0 { + return testRPCError{replacementUnderpricedMessage, -32000} + } + } + } + pending = tx + return nil + } +} + +func TestSmallDynamicMarketMovesRebroadcastCachedBytes(t *testing.T) { + for _, base := range []int64{80, 100, 105, 109, 115} { + t.Run(fmt.Sprint(base), func(t *testing.T) { + s, b, signer := testSender(t) + b.head.BaseFee, b.tip = big.NewInt(100), big.NewInt(10) + b.nonces[b.head.Hash()] = 0 + b.publishFn = poolRule(t) + p := newPlanner() + first, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.advance(2, 0) + b.head.BaseFee, b.tip = big.NewInt(base), big.NewInt(11) + b.nonces[b.head.Hash()] = 0 + second, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + if base < 115 { + require.Equal(t, first.Hash, second.Hash) + require.Equal(t, Known, second.Class) + require.Equal(t, 1, signer.signs) + } else { + require.NotEqual(t, first.Hash, second.Hash) + require.Equal(t, Accepted, second.Class) + require.Equal(t, 2, signer.signs) + } + require.Len(t, b.published, 2) + }) + } +} + +func TestCancellationAndUrgentReplacementClearFlatMarketPool(t *testing.T) { + for _, legacy := range []bool{false, true} { + for _, cancel := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy=%t/cancel=%t", legacy, cancel), func(t *testing.T) { + s, b, signer := testSender(t) + s.policy.Legacy, b.publishFn = legacy, poolRule(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + if cancel { + p.decision, p.eligibility = cancelDecision, Obsolete + } else { + p.action = 2 + } + second, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Accepted, second.Class) + require.Equal(t, cancel, second.Current.Neutral) + for range 3 { + repeated, repeatErr := s.PublishNext(t.Context(), p) + require.NoError(t, repeatErr) + require.Equal(t, second.Hash, repeated.Hash, "replacement is not bumped again on every poll") + } + require.Equal(t, 2, signer.signs) + require.Len(t, b.published, 5) + }) + } + } +} + +func TestReplacementCeilingAttemptsFreshQuoteAndPreservesCurrentOnRefusal(t *testing.T) { + s, b, signer := testSender(t) + b.publishFn = poolRule(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := s.current + s.policy.MaxFee = big.NewInt(24) // Current cap22 requires25, including integer rounding. + p.decision, p.eligibility = cancelDecision, Obsolete + _, err = s.PublishNext(t.Context(), p) + require.Error(t, err) + require.NotErrorIs(t, err, ErrLimit, "only the provider can establish that the older copy is still in its pool") + require.Same(t, old, s.current) + require.Len(t, b.published, 3) + require.Equal(t, 3, signer.signs) + for _, raw := range b.published { + var tx types.Transaction + require.NoError(t, tx.UnmarshalBinary(raw)) + require.LessOrEqual(t, tx.GasFeeCap().Cmp(s.policy.MaxFee), 0) + } +} + +func TestCachedFullCapBidDoesNotBlockWhenBaseFeeOutgrowsIt(t *testing.T) { + s, b, signer := testSender(t) + pool := poolRule(t) + b.publishFn = func(raw []byte) error { + if len(b.published) == 1 { + return testRPCError{replacementUnderpricedMessage, -32000} + } + return pool(raw) + } + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := publishedTx(t, b, 1) + require.Equal(t, old.GasFeeCap(), old.GasTipCap()) + b.advance(2, 0) + b.head.BaseFee = big.NewInt(23) // Old22-wei cap no longer covers base fee. + b.nonces[b.head.Hash()] = 0 + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Accepted, report.Class) + replacement := publishedTx(t, b, 2) + require.Equal(t, big.NewInt(48), replacement.GasFeeCap()) + require.Equal(t, big.NewInt(25), replacement.GasTipCap()) + require.Equal(t, 3, signer.signs) + require.Len(t, b.published, 3) +} + +func TestCachedCapClippingQuotedTipGetsOneMinimumReplacement(t *testing.T) { + for _, base := range []int64{21, 22} { + t.Run(fmt.Sprint(base), func(t *testing.T) { + s, b, signer := testSender(t) + b.publishFn = poolRule(t) + p := newPlanner() + first, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.advance(2, 0) + b.head.BaseFee = big.NewInt(base) + b.nonces[b.head.Hash()] = 0 + second, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Accepted, second.Class) + require.NotEqual(t, first.Hash, second.Hash) + tx := publishedTx(t, b, 1) + require.Equal(t, big.NewInt(2*base+2), tx.GasFeeCap()) + require.Equal(t, big.NewInt(3), tx.GasTipCap(), "use the pool minimum rather than paying the full cap") + for range 3 { + repeated, repeatErr := s.PublishNext(t.Context(), p) + require.NoError(t, repeatErr) + require.Equal(t, second.Hash, repeated.Hash) + require.Equal(t, Known, repeated.Class) + } + require.Equal(t, 2, signer.signs, "an adequate replacement is not bumped on later polls") + }) + } +} + +func TestClippedTipReplacementHonorsOperatorCeiling(t *testing.T) { + for _, ceiling := range []int64{24, 25} { + t.Run(fmt.Sprint(ceiling), func(t *testing.T) { + s, b, signer := testSender(t) + s.policy.MaxFee = big.NewInt(ceiling) + b.publishFn = poolRule(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.advance(2, 0) + b.head.BaseFee = big.NewInt(22) + b.nonces[b.head.Hash()] = 0 + for range 3 { + _, publishErr := s.PublishNext(t.Context(), p) + require.NoError(t, publishErr) + require.Equal(t, big.NewInt(ceiling), s.current.cap) + if ceiling == 24 { + require.Equal(t, big.NewInt(24), s.current.tip, "one full-cap retry clears geth's integer threshold") + } else { + require.Equal(t, big.NewInt(3), s.current.tip) + } + } + require.Equal(t, map[int64]int{24: 3, 25: 2}[ceiling], signer.signs) + }) + } +} + +func TestCachedReplacementRefusalWaitsButMixedTransportFailureRemainsAnError(t *testing.T) { + for _, mixed := range []bool{false, true} { + t.Run(fmt.Sprint(mixed), func(t *testing.T) { + s, b, signer := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.publishErr = testRPCError{replacementUnderpricedMessage, -32000} + if mixed { + b.publishErr = errors.Join(b.publishErr, errLostReply) + } + report, err := s.PublishNext(t.Context(), p) + if mixed { + require.ErrorIs(t, err, errLostReply) + require.Equal(t, Uncertain, report.Class) + } else { + require.NoError(t, err) + require.Equal(t, ReplacementUnderpriced, report.Class) + } + require.Equal(t, b.published[0], b.published[1]) + if mixed { + require.Equal(t, 1, signer.signs) + require.Len(t, b.published, 2) + } else { + require.Equal(t, 2, signer.signs, "a definite refusal permits one fresh-quote retry") + require.Len(t, b.published, 3) + } + }) + } +} + +func TestRefusedCachedPriceRetryIsStillAnOrdinaryWait(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := s.current + b.publishFn = func([]byte) error { + if len(b.published) == 2 { + return testRPCError{admissionUnderpricedMessage, -32000} + } + return testRPCError{replacementUnderpricedMessage, -32000} + } + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, ReplacementUnderpriced, report.Class) + require.Same(t, old, s.current) + require.Len(t, b.published, 3) +} + +func TestFinalPublicationGuardChecksCachedAndNewBytes(t *testing.T) { + for _, retained := range []bool{false, true} { + t.Run(fmt.Sprint(retained), func(t *testing.T) { + s, b, signer := testSender(t) + p := newPlanner() + if retained { + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + } + before := len(b.published) + checks := 0 + s.guard = func(context.Context) error { + checks++ + if checks == 3 { + return ErrIneligible + } + return nil + } + report, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, ErrIneligible) + require.False(t, report.Attempted) + require.Len(t, b.published, before) + require.Equal(t, 3, checks) + require.Equal(t, 1, signer.signs) + }) + } +} + +func TestCancellationCannotPublishWhenPostSignRecheckFindsCurrentUseful(t *testing.T) { + s, b, signer := testSender(t) + p := newPlanner() + first, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := s.current + p.decision, p.eligibility = cancelDecision, Obsolete + signer.afterSign = func() { p.eligibility = Useful } + report, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, ErrIneligible) + require.False(t, report.Attempted) + require.Same(t, old, s.current) + require.False(t, report.Current.Neutral) + require.Equal(t, first.Hash, s.current.hash) + require.Len(t, b.published, 1) + require.Equal(t, 2, signer.signs, "eligibility changed while the neutral was being signed") +} + +func TestReadCancellationStaysPureWhileJoinedFailuresRemainVisible(t *testing.T) { + for _, mixed := range []bool{false, true} { + t.Run(fmt.Sprint(mixed), func(t *testing.T) { + s, b, _ := testSender(t) + b.headerErr = fmt.Errorf("in-flight RPC: %w", context.Canceled) + if mixed { + b.headerErr = errors.Join(b.headerErr, errLostReply) + } + _, err := s.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, !mixed, errutil.IsOnlyCancellation(err)) + require.Equal(t, mixed, errors.Is(err, ErrInconclusive)) + if mixed { + require.ErrorIs(t, err, errLostReply) + } + require.Empty(t, b.published) + }) + } +} diff --git a/internal/txsender/reply.go b/internal/txsender/reply.go new file mode 100644 index 000000000..887385193 --- /dev/null +++ b/internal/txsender/reply.go @@ -0,0 +1,128 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "encoding/json" + "errors" + "net" + + "github.com/cartesi/rollups-node/internal/httpclient" + + "github.com/ethereum/go-ethereum/rpc" +) + +type ReplyClass string + +const ( + Accepted ReplyClass = "accepted" + Known ReplyClass = "already-known" + NonceLow ReplyClass = "nonce-too-low" + ReplacementUnderpriced ReplyClass = "replacement-underpriced" + Underpriced ReplyClass = "transaction-underpriced" + BaseFeeLow ReplyClass = "base-fee-too-low" + Unfunded ReplyClass = "insufficient-funds" + GasRejected ReplyClass = "gas-policy-refusal" + FeeRejected ReplyClass = "total-fee-refusal" + ConstructionRejected ReplyClass = "construction-refusal" + PoolFull ReplyClass = "pool-capacity" + Uncertain ReplyClass = "uncertain" +) + +// PublicationError retains the classification and original cause of a failed +// publication. A refusal does not establish nonce consumption or domain success. +type PublicationError struct { + Class ReplyClass + Err error +} + +func (e *PublicationError) Error() string { return e.Err.Error() } +func (e *PublicationError) Unwrap() error { return e.Err } + +// NonceLowOnly permits replanning after a nonce-too-low publication reply. +// Joined transport or application failures must still be reported as errors. +func (e *PublicationError) NonceLowOnly() bool { + return e != nil && e.Class == NonceLow && nonceLowCausesOnly(e.Err) +} + +func nonceLowCausesOnly(err error) bool { return definiteRefusal(err, NonceLow) } + +// ClassifyPublication is valid only for eth_sendRawTransaction replies. It +// never searches transport errors, body data or execution reverts for phrases. +func ClassifyPublication(err error) ReplyClass { + if err == nil { + return Accepted + } + var message string + var code int + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + code = rpcErr.ErrorCode() + message = rpcErr.Error() + } else { + var httpErr rpc.HTTPError + if !errors.As(err, &httpErr) { + return Uncertain + } + var e struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(httpErr.Body, &e) != nil || e.Error == nil || e.Error.Code == 3 { + return Uncertain + } + message, code = e.Error.Message, e.Error.Code + } + switch httpclient.PublicationMessage(code, message) { + case "already known": + return Known + case "nonce too low": + return NonceLow + case "replacement transaction underpriced": + return ReplacementUnderpriced + case "transaction underpriced": + return Underpriced + case "max fee per gas less than block base fee": + return BaseFeeLow + case "insufficient funds": + return Unfunded + case "intrinsic gas too low": + return GasRejected + case "tx fee (redacted) exceeds the configured cap": + return FeeRejected + case "invalid sender": + return ConstructionRejected + case "txpool is full": + return PoolFull + default: + return Uncertain + } +} + +// A joined transport failure can hide an earlier accepted POST. A definitive +// refusal requires every original cause to be the same RPC refusal. +func definiteRefusal(err error, class ReplyClass) bool { + switch cause := err.(type) { + case net.Error: + return false + case interface{ Unwrap() []error }: + children := cause.Unwrap() + if len(children) == 0 { + return false + } + for _, child := range children { + if !definiteRefusal(child, class) { + return false + } + } + return true + case interface{ Unwrap() error }: + if child := cause.Unwrap(); child != nil { + return definiteRefusal(child, class) + } + } + return ClassifyPublication(err) == class +} diff --git a/internal/txsender/reply_test.go b/internal/txsender/reply_test.go new file mode 100644 index 000000000..b1547a9a5 --- /dev/null +++ b/internal/txsender/reply_test.go @@ -0,0 +1,90 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "errors" + "fmt" + "net/url" + "testing" + + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +const unfundedEstimateMessage = "insufficient funds for gas * price + value" + +// Z18: publication classification uses error.message only. Incorrect or +// ambiguous replies may delay progress; they never establish chain progress. +func TestClassifyPublicationZ18(t *testing.T) { + for _, test := range []struct { + message string + want ReplyClass + }{ + {knownPublicationMessage, Known}, {nonceLowMessage, NonceLow}, + {"Known transaction", Known}, + {"Unknown transaction", Uncertain}, + {"Gas price below current base fee", BaseFeeLow}, + {"Gas price below configured minimum gas price", Underpriced}, + {replacementUnderpricedMessage, ReplacementUnderpriced}, + {admissionUnderpricedMessage, Underpriced}, + {"max fee per gas less than block base fee", BaseFeeLow}, + {unfundedEstimateMessage, Unfunded}, + {"intrinsic gas too low", GasRejected}, + {"tx fee (1.0 ether) exceeds the configured cap", FeeRejected}, + {"invalid sender", ConstructionRejected}, {"txpool is full", PoolFull}, + {"execution reverted: nonce too low", Uncertain}, {"something unknown", Uncertain}, + } { + t.Run(test.message, func(t *testing.T) { + err := testRPCError{message: test.message, code: -32000} + require.Equal(t, test.want, ClassifyPublication(fmt.Errorf("wrapped: %w", err))) + }) + } + require.Equal(t, Accepted, ClassifyPublication(nil)) + require.Equal(t, Uncertain, ClassifyPublication(errors.New(nonceLowMessage))) + require.Equal(t, Uncertain, ClassifyPublication(testRPCError{message: knownPublicationMessage, code: 3})) + for _, body := range []string{ + `nonce too low`, + `{"error":{"code":-32000,"message":"unknown","data":"nonce too low"}}`, + `{"error":{"code":3,"message":"nonce too low"}}`, + `{"result":"nonce too low"}`, + `{"error":{"code":-32000,"message":"execution reverted: already known"}}`, + } { + require.Equal(t, Uncertain, ClassifyPublication(rpc.HTTPError{StatusCode: 503, Body: []byte(body)})) + } + err := rpc.HTTPError{StatusCode: 429, Body: []byte(`{"error":{"code":-32000,"message":"already known"}}`)} + require.Equal(t, Known, ClassifyPublication(err)) +} + +func TestPublicationNonceWaitRequiresEveryOriginalCauseToBeANonceReply(t *testing.T) { + nonceReply := testRPCError{message: nonceLowMessage, code: -32000} + transport := errors.New("connection closed during publication") + for _, test := range []struct { + name string + class ReplyClass + cause error + wait bool + }{ + {"RPC reply", NonceLow, nonceReply, true}, + {"wrapped reply", NonceLow, fmt.Errorf("RPC: %w", nonceReply), true}, + {"joined nonce replies", NonceLow, errors.Join(nonceReply, nonceReply), true}, + {"unknown sibling", NonceLow, errors.Join(nonceReply, transport), false}, + {"nested unknown sibling", NonceLow, fmt.Errorf("RPC: %w", errors.Join(nonceReply, transport)), false}, + {"transport", Uncertain, transport, false}, + {"transport wraps RPC reply", NonceLow, &url.Error{Op: "Post", URL: "http://provider", Err: nonceReply}, false}, + {"plain text lookalike", NonceLow, errors.New(nonceReply.message), false}, + {"execution revert", NonceLow, testRPCError{message: nonceReply.message, code: 3}, false}, + {"other reply class", Uncertain, nonceReply, false}, + } { + t.Run(test.name, func(t *testing.T) { + publication := &PublicationError{Class: test.class, Err: test.cause} + require.Equal(t, test.wait, publication.NonceLowOnly()) + require.ErrorIs(t, publication, test.cause) + require.Equal(t, test.cause.Error(), publication.Error()) + }) + } + require.False(t, (&PublicationError{Class: NonceLow}).NonceLowOnly()) + var absent *PublicationError + require.False(t, absent.NonceLowOnly()) +} diff --git a/internal/txsender/rpc.go b/internal/txsender/rpc.go new file mode 100644 index 000000000..027e141b4 --- /dev/null +++ b/internal/txsender/rpc.go @@ -0,0 +1,192 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" +) + +// ProtectedClient adapts the existing protected HTTP client. EIP-1898 reads +// explicitly require canonicality; geth's AtHash helpers do not. +type ProtectedClient struct{ Client *ethclient.Client } + +func blockArg(b BlockRef) rpc.BlockNumberOrHash { + return rpc.BlockNumberOrHashWithHash(b.Hash, true) +} + +// A missing method or explicit rejection of the hash-selector schema is a hard +// capability refusal. Some providers also use invalid-params for missing block +// state, so the code alone cannot identify unsupported EIP-1898 reads. Ambiguous +// failures stay inconclusive. No execution-revert message text is consulted. +func capabilityError(err error) error { + if err == nil { + return nil + } + if errutil.IsOnlyCancellation(err) { + return err + } + var rpcErr rpc.Error + if errors.As(err, &rpcErr) && (rpcErr.ErrorCode() == -32601 || + (rpcErr.ErrorCode() == -32602 && rejectsHashSelector(rpcErr.Error()))) { + return errors.Join(ErrUnsupported, err) + } + return inconclusive(err) +} + +// This allowlist applies only to account reads, whose sole object parameter is +// the block selector. An unknown error may delay progress but cannot relax the +// required hash and canonicality fields or trigger a permanent capability latch. +func rejectsHashSelector(message string) bool { + message = strings.ToLower(message) + if message == "object block argument unsupported" { + return true + } + for _, field := range []string{"requirecanonical", "blockhash"} { + if strings.Contains(message, field) && (strings.Contains(message, "unknown field") || + strings.Contains(message, "unknown parameter") || strings.Contains(message, "not supported") || + strings.Contains(message, "unsupported")) { + return true + } + } + if strings.Contains(message, "invalid type: map, expected a string") || + strings.Contains(message, "invalid type: map, expected a block number") { + return true + } + return strings.Contains(message, "argument 1") && + strings.Contains(message, "cannot unmarshal object into go value of type") && + (strings.Contains(message, "type string") || strings.Contains(message, "type hexutil.uint64") || + strings.Contains(message, "type rpc.blocknumber")) +} + +func (c ProtectedClient) Header(ctx context.Context, tag string) (*types.Header, error) { + var h *types.Header + err := c.Client.Client().CallContext(ctx, &h, "eth_getBlockByNumber", tag, false) + if err != nil { + return nil, err + } + if h == nil || h.Number == nil || !h.Number.IsUint64() { + return nil, ErrInconclusive + } + return h, nil +} + +func (c ProtectedClient) Canonical(ctx context.Context, b BlockRef) (bool, error) { + h, err := c.Header(ctx, hexutil.EncodeUint64(b.Number)) + if err != nil { + return false, err + } + return h.Hash() == b.Hash, nil +} + +func (c ProtectedClient) Nonce(ctx context.Context, a common.Address, b BlockRef) (uint64, error) { + var n hexutil.Uint64 + err := c.Client.Client().CallContext(ctx, &n, "eth_getTransactionCount", a, blockArg(b)) + return uint64(n), capabilityError(err) +} + +func (c ProtectedClient) Balance(ctx context.Context, a common.Address, b BlockRef) (*big.Int, error) { + var n hexutil.Big + err := c.Client.Client().CallContext(ctx, &n, "eth_getBalance", a, blockArg(b)) + return new(big.Int).Set((*big.Int)(&n)), capabilityError(err) +} + +func (c ProtectedClient) Code(ctx context.Context, a common.Address, b BlockRef) ([]byte, error) { + var data hexutil.Bytes + err := c.Client.Client().CallContext(ctx, &data, "eth_getCode", a, blockArg(b)) + return data, capabilityError(err) +} + +func callArg(m ethereum.CallMsg) map[string]any { + a := map[string]any{"from": m.From} + // Binding views and sizing hints leave gas unspecified. Exact transaction + // simulations supply their selected nonzero gas limit explicitly. + if m.Gas != 0 { + a["gas"] = hexutil.Uint64(m.Gas) + } + if m.To != nil { + a["to"] = *m.To + } + if m.Value != nil { + a["value"] = (*hexutil.Big)(m.Value) + } + if m.Data != nil { + a["input"] = hexutil.Bytes(m.Data) + } + if m.GasPrice != nil { + a["gasPrice"] = (*hexutil.Big)(m.GasPrice) + } + if m.GasFeeCap != nil { + a["maxFeePerGas"] = (*hexutil.Big)(m.GasFeeCap) + } + if m.GasTipCap != nil { + a["maxPriorityFeePerGas"] = (*hexutil.Big)(m.GasTipCap) + } + if m.AccessList != nil { + a["accessList"] = m.AccessList + } + return a +} + +func (c ProtectedClient) Call(ctx context.Context, m ethereum.CallMsg, b BlockRef) ([]byte, error) { + var data hexutil.Bytes + err := c.Client.Client().CallContext(ctx, &data, "eth_call", callArg(m), blockArg(b)) + return data, err +} + +func (c ProtectedClient) Estimate(ctx context.Context, m ethereum.CallMsg, b BlockRef) (uint64, error) { + var n hexutil.Uint64 + // Sizing does not authorize a send. Exact calls and fresh canonicality + // checks remain hash-pinned, including after a same-height reorg. + err := c.Client.Client().CallContext(ctx, &n, "eth_estimateGas", callArg(m), hexutil.EncodeUint64(b.Number)) + if err != nil { + return 0, fmt.Errorf("eth_estimateGas at block %d: %w", b.Number, err) + } + return uint64(n), err +} + +func (c ProtectedClient) Tip(ctx context.Context) (*big.Int, error) { + return c.Client.SuggestGasTipCap(ctx) +} +func (c ProtectedClient) Price(ctx context.Context) (*big.Int, error) { + return c.Client.SuggestGasPrice(ctx) +} +func (c ProtectedClient) Publish(ctx context.Context, raw []byte) (common.Hash, error) { + var hash common.Hash + err := c.Client.Client().CallContext(ctx, &hash, "eth_sendRawTransaction", hexutil.Bytes(raw)) + return hash, err +} + +// These methods implement the hash-capable read-binding interface. Numeric +// reads remain available for existing event reconciliation at its selected policy. +func (c ProtectedClient) CallContract(ctx context.Context, m ethereum.CallMsg, n *big.Int) ([]byte, error) { + return c.Client.CallContract(ctx, m, n) +} +func (c ProtectedClient) CodeAt(ctx context.Context, a common.Address, n *big.Int) ([]byte, error) { + return c.Client.CodeAt(ctx, a, n) +} +func (c ProtectedClient) CallContractAtHash(ctx context.Context, m ethereum.CallMsg, h common.Hash) ([]byte, error) { + return c.Call(ctx, m, BlockRef{Hash: h}) +} +func (c ProtectedClient) CodeAtHash(ctx context.Context, a common.Address, h common.Hash) ([]byte, error) { + return c.Code(ctx, a, BlockRef{Hash: h}) +} + +func headerRef(h *types.Header) (BlockRef, error) { + if h == nil || h.Number == nil || !h.Number.IsUint64() { + return BlockRef{}, fmt.Errorf("%w: missing header", ErrInconclusive) + } + return BlockRef{Number: h.Number.Uint64(), Hash: h.Hash()}, nil +} diff --git a/internal/txsender/rpc_test.go b/internal/txsender/rpc_test.go new file mode 100644 index 000000000..310d77bfe --- /dev/null +++ b/internal/txsender/rpc_test.go @@ -0,0 +1,305 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +const testEstimateGasMethod = "eth_estimateGas" + +func TestCapabilityClassificationPreservesPureCancellation(t *testing.T) { + canceled := fmt.Errorf("account RPC stopped: %w", context.Canceled) + err := capabilityError(canceled) + require.True(t, errutil.IsOnlyCancellation(err)) + require.NotErrorIs(t, err, ErrInconclusive) + joined := errors.Join(canceled, errors.New("unrelated read failure")) + err = capabilityError(joined) + require.False(t, errutil.IsOnlyCancellation(err)) + require.ErrorIs(t, err, ErrInconclusive) + require.ErrorContains(t, err, "unrelated read failure") +} + +func TestPreparedNumberEstimateRefusalsRemainVisibleWithoutLatestRetry(t *testing.T) { + for _, test := range []struct { + name, message string + code int + }{ + {"unsupported selector", "blockHash not supported for gas estimation", -32602}, + {"execution revert", "execution reverted: custom error 0x0ef47687", 3}, + } { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, testEstimateGasMethod, request.Method) + require.Len(t, request.Params, 2) + require.JSONEq(t, `"0x7"`, string(request.Params[1])) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + testRPCField: testRPCVersion, "id": request.ID, + "error": map[string]any{testErrorCodeField: test.code, testErrorMessageField: test.message}, + })) + })) + defer server.Close() + client, err := ethutil.DialEthClient(t.Context(), server.URL) + require.NoError(t, err) + defer client.Close() + backend := ProtectedClient{Client: client} + _, err = backend.Estimate(t.Context(), ethereum.CallMsg{}, BlockRef{Number: 7, Hash: common.HexToHash("0x1234")}) + require.ErrorContains(t, err, test.message) + var rpcErr rpc.Error + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, test.code, rpcErr.ErrorCode()) + require.Equal(t, int32(1), requests.Load()) + }) + } +} + +const ( + testCallMethod = "eth_call" + testCodeMethod = "eth_getCode" + testRPCVersion = "2.0" + testRPCField = "jsonrpc" + testResultField = "result" + testErrorCodeField = "code" + testErrorMessageField = "message" +) + +func TestProtectedClientHashReadsUseRequireCanonicalAndExactCallFields(t *testing.T) { + ref := BlockRef{Number: 7, Hash: common.HexToHash("0x1234")} + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + methods = append(methods, request.Method) + require.Len(t, request.Params, 2) + if request.Method == testEstimateGasMethod { + require.JSONEq(t, `"0x7"`, string(request.Params[1]), "estimation only sizes gas at the prepared height") + } else { + var block struct { + Hash common.Hash `json:"blockHash"` + Canonical bool `json:"requireCanonical"` + } + require.NoError(t, json.Unmarshal(request.Params[1], &block)) + require.Equal(t, ref.Hash, block.Hash) + require.True(t, block.Canonical) + } + result := "0x0" + if request.Method == testEstimateGasMethod { + result = "0x5208" + } + if request.Method == testCodeMethod || request.Method == testCallMethod { + result = "0x01" + } + if request.Method == testCallMethod { + var fields map[string]string + require.NoError(t, json.Unmarshal(request.Params[0], &fields)) + require.Equal(t, "0x5208", fields["gas"]) + require.Equal(t, "0x1e", fields["maxFeePerGas"]) + require.Equal(t, "0x2", fields["maxPriorityFeePerGas"]) + require.Equal(t, "0x7", fields["value"]) + require.Equal(t, "0x0102", fields["input"]) + require.NotContains(t, fields, "gasPrice") + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + testRPCField: testRPCVersion, "id": request.ID, testResultField: result, + })) + })) + defer server.Close() + client, err := ethutil.DialEthClient(t.Context(), server.URL) + require.NoError(t, err) + defer client.Close() + backend := ProtectedClient{Client: client} + address := common.HexToAddress("0x100") + _, err = backend.Nonce(t.Context(), address, ref) + require.NoError(t, err) + _, err = backend.Balance(t.Context(), address, ref) + require.NoError(t, err) + _, err = backend.Code(t.Context(), address, ref) + require.NoError(t, err) + _, err = backend.Call(t.Context(), ethereum.CallMsg{ + From: address, To: &address, Gas: 21_000, Value: big.NewInt(7), Data: []byte{1, 2}, + GasFeeCap: big.NewInt(30), GasTipCap: big.NewInt(2), + }, ref) + require.NoError(t, err) + gas, err := backend.Estimate(t.Context(), ethereum.CallMsg{From: address, To: &address}, ref) + require.NoError(t, err) + require.Equal(t, uint64(21_000), gas) + require.Equal(t, []string{"eth_getTransactionCount", "eth_getBalance", testCodeMethod, testCallMethod, testEstimateGasMethod}, methods) +} + +func TestProtectedClientCapabilityFailureRequiresSchemaEvidence(t *testing.T) { + tests := []struct { + name, message string + code int + unsupported bool + }{ + {"missing method", "the method eth_getCode does not exist", -32601, true}, + {"object selector rejected", "object block argument unsupported", -32602, true}, + {"string selector required", "invalid argument 1: json: cannot unmarshal object into Go value of type string", -32602, true}, + {"numeric selector required", + "invalid argument 1: json: cannot unmarshal object into Go value of type rpc.BlockNumber", -32602, true}, + {"canonical field rejected", "unknown field `requireCanonical`", -32602, true}, + {"hash field rejected", "unknown parameter blockHash", -32602, true}, + {"string schema required", "invalid type: map, expected a string", -32602, true}, + {"historical state unavailable", "BlockOutOfRangeError: block height is 6995 but requested was 6931", -32602, false}, + {"unknown hash", "header not found", -32602, false}, + {"pruned state", "missing trie node", -32000, false}, + {"ambiguous invalid parameters", "invalid params", -32602, false}, + {"execution message cannot assert capability", "execution reverted: requireCanonical unsupported", 3, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cause := testRPCError{code: tt.code, message: tt.message} + err := capabilityError(fmt.Errorf("account read: %w", cause)) + require.ErrorIs(t, err, cause, "the original provider cause must remain available") + var rpcErr rpc.Error + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, tt.code, rpcErr.ErrorCode()) + if tt.unsupported { + require.ErrorIs(t, err, ErrUnsupported) + require.NotErrorIs(t, err, ErrInconclusive) + } else { + require.ErrorIs(t, err, ErrInconclusive) + require.NotErrorIs(t, err, ErrUnsupported) + } + }) + } + require.NoError(t, capabilityError(nil)) + cause := errors.New("account read temporarily unavailable") + require.ErrorIs(t, capabilityError(cause), cause) + require.ErrorIs(t, capabilityError(cause), ErrInconclusive) +} + +func TestProtectedClientAccountReadsRecoverFromUnavailableHistoricalState(t *testing.T) { + ref := BlockRef{Number: 6931, Hash: common.HexToHash("0x1234")} + address := common.HexToAddress("0x100") + tests := []struct { + method string + read func(ProtectedClient) error + }{ + {"eth_getTransactionCount", func(c ProtectedClient) error { + _, err := c.Nonce(t.Context(), address, ref) + return err + }}, + {"eth_getBalance", func(c ProtectedClient) error { + _, err := c.Balance(t.Context(), address, ref) + return err + }}, + {testCodeMethod, func(c ProtectedClient) error { + _, err := c.Code(t.Context(), address, ref) + return err + }}, + } + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + var count atomic.Int32 + selectors := make(chan json.RawMessage, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, tt.method, request.Method) + require.Len(t, request.Params, 2) + selectors <- request.Params[1] + response := map[string]any{testRPCField: testRPCVersion, "id": request.ID, testResultField: "0x0"} + if tt.method == testCodeMethod { + response[testResultField] = "0x" + } + if count.Add(1) == 1 { + delete(response, testResultField) + response["error"] = map[string]any{ + "code": -32602, "message": "BlockOutOfRangeError: block height is 6995 but requested was 6931", + } + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(response)) + })) + defer server.Close() + client, err := ethutil.DialEthClient(t.Context(), server.URL) + require.NoError(t, err) + defer client.Close() + backend := ProtectedClient{Client: client} + err = tt.read(backend) + require.ErrorIs(t, err, ErrInconclusive) + require.NotErrorIs(t, err, ErrUnsupported) + require.ErrorContains(t, err, "BlockOutOfRangeError") + require.EqualValues(t, 1, count.Load(), "a failed hash read must not fall back to a numeric or latest read") + require.NoError(t, tt.read(backend), "a recovered historical backend must allow the next protected read") + require.EqualValues(t, 2, count.Load()) + for range 2 { + var selector struct { + Hash common.Hash `json:"blockHash"` + Canonical bool `json:"requireCanonical"` + } + require.NoError(t, json.Unmarshal(<-selectors, &selector)) + require.Equal(t, ref.Hash, selector.Hash) + require.True(t, selector.Canonical) + } + }) + } +} + +func TestReviewProtectedReadBindingOmitsUnspecifiedGas(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, testCallMethod, request.Method) + require.Len(t, request.Params, 2) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(request.Params[0], &fields)) + response := map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": "0x01"} + // Geth supplies its RPC gas default only for an absent field. An explicit + // zero is preserved and fails the intrinsic gas check, even for a view. + if _, gasPresent := fields["gas"]; gasPresent { + delete(response, "result") + response["error"] = map[string]any{"code": -32000, "message": "intrinsic gas too low: have 0, want 21000"} + } + require.NoError(t, json.NewEncoder(w).Encode(response)) + })) + defer server.Close() + client, err := ethutil.DialEthClient(t.Context(), server.URL) + require.NoError(t, err) + defer client.Close() + backend := ProtectedClient{Client: client} + address := common.HexToAddress("0x100") + result, err := backend.CallContractAtHash(t.Context(), ethereum.CallMsg{ + From: address, To: &address, Data: []byte{1}, + }, common.HexToHash("0x1234")) + require.NoError(t, err, "a read binding's unspecified gas must use the protected provider default") + require.Equal(t, []byte{1}, result) +} diff --git a/internal/txsender/sender.go b/internal/txsender/sender.go new file mode 100644 index 000000000..b09aa0d10 --- /dev/null +++ b/internal/txsender/sender.go @@ -0,0 +1,472 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + + "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" +) + +type account struct { + block BlockRef + header *types.Header + nonce uint64 + balance *big.Int +} + +type prepared[A comparable] struct { + action A + neutral bool + request Request + nonce uint64 + block BlockRef + gas uint64 + quote + raw []byte + hash common.Hash +} + +// Sender belongs to one publisher goroutine. Its only transaction state is one +// preferred current-nonce request. Distributed alternatives can still execute. +type Sender[A comparable] struct { + backend Backend + factory ethutil.TransactOptsFactory + chainID uint64 + policy Policy + guard func(context.Context) error + logger *slog.Logger + current *prepared[A] +} + +func New[A comparable](backend Backend, factory ethutil.TransactOptsFactory, chainID uint64, policy Policy, + guard func(context.Context) error, logger *slog.Logger) (*Sender[A], error) { + if backend == nil || factory == nil || chainID == 0 || guard == nil { + return nil, errors.New("sender requires backend, signer, chain identity and publication guard") + } + if err := policy.Validate(); err != nil { + return nil, err + } + if policy.MaxFee != nil { + policy.MaxFee = copyInt(policy.MaxFee) + } + if logger == nil { + logger = slog.Default() + } + return &Sender[A]{backend: backend, factory: factory, chainID: chainID, policy: policy, guard: guard, logger: logger}, nil +} + +func (s *Sender[A]) From() common.Address { return s.factory.From() } + +func (s *Sender[A]) checked(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return s.guard(ctx) +} + +func (s *Sender[A]) account(ctx context.Context) (account, error) { + header, err := s.backend.Header(ctx, latestBlockTag) + if err != nil { + return account{}, inconclusive(err) + } + block, err := headerRef(header) + if err != nil { + return account{}, err + } + nonce, err := s.backend.Nonce(ctx, s.From(), block) + if err != nil { + return account{}, inconclusive(err) + } + balance, err := s.backend.Balance(ctx, s.From(), block) + if err != nil { + return account{}, inconclusive(err) + } + code, err := s.backend.Code(ctx, s.From(), block) + if err != nil { + return account{}, inconclusive(err) + } + if len(code) != 0 { + return account{}, fmt.Errorf("%w: signer code or delegation is outside the supported EOA profile", ErrUnsupported) + } + if balance == nil || balance.Sign() < 0 || balance.BitLen() > 256 || + (header.BaseFee != nil && header.BaseFee.Sign() < 0) { + return account{}, ErrInconclusive + } + if err := s.canonical(ctx, block); err != nil { + return account{}, err + } + return account{block: block, header: types.CopyHeader(header), nonce: nonce, balance: copyInt(balance)}, nil +} + +func (s *Sender[A]) canonical(ctx context.Context, block BlockRef) error { + ok, err := s.backend.Canonical(ctx, block) + if err != nil { + return inconclusive(err) + } + if !ok { + return ErrNonCanonical + } + return nil +} + +func (s *Sender[A]) currentAction() *Current[A] { + if s.current == nil { + return nil + } + return &Current[A]{Action: s.current.action, Neutral: s.current.neutral} +} + +// PublishNext runs one publication decision. A failed read cannot authorize +// cancellation. Latest mined nonce progress retires the cache without receipts. +func (s *Sender[A]) PublishNext(ctx context.Context, planner Planner[A]) (report Report[A], err error) { + defer func() { report.Current = s.currentAction() }() + if planner == nil { + return report, errors.New("publication planner is required") + } + if err := s.checked(ctx); err != nil { + return report, err + } + snap, err := s.account(ctx) + if err != nil { + return report, err + } + report.Block, report.Nonce = snap.block, snap.nonce + if s.current != nil && (s.current.nonce > snap.nonce || s.current.block.Number > snap.block.Number) { + canonical, readErr := s.backend.Canonical(ctx, s.current.block) + if readErr != nil { + return report, inconclusive(readErr) + } + if canonical { + return report, ErrInconclusive // A lagging replica cannot revoke a later canonical account read. + } + } + if s.current != nil && s.current.nonce != snap.nonce { + s.current = nil + } + decision, err := planner.Next(ctx, snap.block, s.currentAction()) + if err != nil { + return report, err + } + if decision.kind == waitDecision || decision.kind == cancelDecision && s.current == nil { + return report, nil + } + neutral := decision.kind == cancelDecision + report.Canceling = neutral + if neutral { + decision.action = s.current.action + decision.request = Request{To: s.From(), Value: new(big.Int), GasLimit: intrinsicGas, + Logger: s.current.request.Logger} + } + request := cloneRequest(decision.request) + if err := validRequest(request); err != nil { + return report, err + } + old := s.current + sameIntent := old != nil && old.action == decision.action && old.neutral == neutral + if old != nil && !old.neutral && !neutral && old.action == decision.action && !sameContent(old.request, request) { + return report, ErrContent + } + q, err := s.fees(ctx, snap) + if err != nil { + return report, err + } + fundingCap := q.cap + if sameIntent && old.legacy == q.legacy { + fundingCap = minimum(q.cap, old.cap) // Adequate held bytes can still be cheaper than the fresh quote. + } + if err := checkFunds(snap.balance, max(uint64(intrinsicGas), request.GasLimit), fundingCap, request.Value); err != nil { + return report, fmt.Errorf("minimum funding before gas estimation: %w", err) + } + sizingQuote := q + if sameIntent && old.legacy == q.legacy && coversQuotedTip(old.quote, q, snap.header.BaseFee) && + !clearsReplacement(q, old.quote) { + sizingQuote = old.quote // Sizing must not demand funding for a quote whose bytes we intend to reuse. + } + candidate, estimate, err := s.prepare(ctx, snap, decision.action, request, neutral, sizingQuote) + if err != nil { + return report, err + } + candidate.quote = q + reusable := sameIntent && old.legacy == q.legacy && old.gas >= max(request.GasLimit, estimate) && + old.gas <= snap.header.GasLimit && coversQuotedTip(old.quote, q, snap.header.BaseFee) + if reusable && !clearsReplacement(q, old.quote) { + retained := *old + retained.request, retained.block = request, snap.block + candidate = &retained + } else if old != nil && !reusable { + candidate.quote = s.replacementFees(q, old.quote) + } + class, err := s.send(ctx, planner, snap, candidate, &report) + if !priceRefusal(class) { + return report, err + } + retryQuote := candidate.quote + if sameIntent && class == ReplacementUnderpriced && definiteRefusal(err, class) { + // A lost reply can leave cached bytes that the pool never admitted. + retryQuote = q + } + retry := s.retryFees(retryQuote) + if retry.cap.Cmp(candidate.cap) != 0 || retry.tip.Cmp(candidate.tip) != 0 { + candidate.quote, candidate.raw = retry, nil + s.requestLogger(request).Debug("Retrying price-refused transaction with the full fresh quote", + "signer", s.From(), "nonce", snap.nonce, "fee_cap", retry.cap, "tip", retry.tip, + "reason", decision.reason) + class, err = s.send(ctx, planner, snap, candidate, &report) + } + if sameIntent && class == ReplacementUnderpriced && definiteRefusal(err, class) { + return report, nil + } + return report, err +} + +func cloneRequest(request Request) Request { + request.Data = append([]byte(nil), request.Data...) + request.Value = copyInt(request.Value) + return request +} + +func sameContent(a, b Request) bool { + return a.To == b.To && a.Value.Cmp(b.Value) == 0 && bytes.Equal(a.Data, b.Data) +} + +func validRequest(request Request) error { + if request.Value.Sign() < 0 || request.Value.BitLen() > 256 { + maximum := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), uint256Bits), big.NewInt(1)) + return limitError("transaction value", request.Value, maximum) + } + if len(request.Data) > defaultCallBytes { + return countLimit("transaction data bytes", len(request.Data), defaultCallBytes) + } + return nil +} + +func (s *Sender[A]) prepare(ctx context.Context, snap account, action A, request Request, + neutral bool, q quote) (*prepared[A], uint64, error) { + ceiling := min(snap.header.GasLimit, uint64(supportedTransactionGasCeiling)) + if request.GasLimit > ceiling { + return nil, 0, gasLimit("transaction gas", request.GasLimit, ceiling) + } + estimate, estimateErr := s.backend.Estimate(ctx, message(s.From(), request, 0, q), snap.block) + var gas uint64 + if estimateErr != nil { + var refusal rpc.Error + if errors.As(estimateErr, &refusal) { + return nil, 0, fmt.Errorf("gas estimate refused: %w", estimateErr) + } + old := s.current + if ctx.Err() != nil || errors.Is(estimateErr, context.Canceled) || + old == nil || old.action != action || old.neutral != neutral || !sameContent(old.request, request) || + old.gas < request.GasLimit || old.gas > ceiling { + return nil, 0, fmt.Errorf("gas estimate unavailable: %w", estimateErr) + } + // An unavailable estimate supplies no new gas requirement. Reuse only + // this request's existing gas; exact simulation still has to succeed. + gas, estimate = old.gas, old.gas + } else { + if estimate > ceiling { + return nil, 0, gasLimit("estimated transaction gas", estimate, ceiling) + } + padding := uint64(0) + if !neutral { + padding = max(estimate/gasPaddingDivisor, uint64(minimumGasPadding)) + if request.Value.Sign() > 0 { + padding = max(estimate, uint64(minimumGasPadding)) + } + } + if padding > ceiling-estimate { + gas = ceiling + } else { + gas = max(request.GasLimit, estimate+padding) + } + } + if gas < intrinsicGas || gas > ceiling { + return nil, 0, gasLimit("transaction gas", gas, ceiling) + } + candidate := &prepared[A]{action: action, neutral: neutral, request: request, nonce: snap.nonce, + block: snap.block, gas: gas, quote: q} + return candidate, estimate, nil +} + +func message(from common.Address, request Request, gas uint64, q quote) ethereum.CallMsg { + m := ethereum.CallMsg{From: from, To: &request.To, Data: request.Data, Value: copyInt(request.Value), Gas: gas} + if q.legacy { + m.GasPrice = copyInt(q.cap) + } else { + m.GasFeeCap, m.GasTipCap = copyInt(q.cap), copyInt(q.tip) + } + return m +} + +func (s *Sender[A]) preflight(ctx context.Context, snap account, candidate *prepared[A]) error { + if candidate.gas > snap.header.GasLimit { + return gasLimit("block gas", candidate.gas, snap.header.GasLimit) + } + if snap.header.BaseFee != nil && candidate.cap.Cmp(snap.header.BaseFee) < 0 { + return limitError("base fee", snap.header.BaseFee, candidate.cap) + } + if err := checkFunds(snap.balance, candidate.gas, candidate.cap, candidate.request.Value); err != nil { + return err + } + result, err := s.backend.Call(ctx, message(s.From(), candidate.request, candidate.gas, candidate.quote), snap.block) + if err != nil { + return err + } + if candidate.request.CheckReturn != nil && !candidate.request.CheckReturn(result) { + return ErrIneligible + } + return nil +} + +func checkFunds(balance *big.Int, gas uint64, feeCap, value *big.Int) error { + cost := new(big.Int).Mul(feeCap, new(big.Int).SetUint64(gas)) + cost.Add(cost, value) + if cost.Cmp(balance) > 0 { + return limitError("signer balance", cost, balance) + } + return nil +} + +func (s *Sender[A]) sameEnvelope(candidate *prepared[A]) bool { + old := s.current + return old != nil && old.action == candidate.action && old.neutral == candidate.neutral && + sameContent(old.request, candidate.request) && old.nonce == candidate.nonce && old.gas == candidate.gas && + old.legacy == candidate.legacy && old.cap.Cmp(candidate.cap) == 0 && old.tip.Cmp(candidate.tip) == 0 +} + +func (s *Sender[A]) sign(ctx context.Context, candidate *prepared[A]) error { + if s.sameEnvelope(candidate) { + candidate.raw, candidate.hash = s.current.raw, s.current.hash + return nil + } + if s.policy.SigningTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, s.policy.SigningTimeout) + defer cancel() + } + opts, err := s.factory.NewTransactOpts(ctx) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if opts == nil || opts.Signer == nil || opts.From != s.From() { + return fmt.Errorf("%w: signer factory returned missing options or a different address", ErrUnsupported) + } + chainID := new(big.Int).SetUint64(s.chainID) + var unsigned *types.Transaction + if candidate.legacy { + unsigned = types.NewTx(&types.LegacyTx{Nonce: candidate.nonce, To: &candidate.request.To, + Value: candidate.request.Value, Data: candidate.request.Data, Gas: candidate.gas, GasPrice: candidate.cap}) + } else { + unsigned = types.NewTx(&types.DynamicFeeTx{ChainID: chainID, Nonce: candidate.nonce, To: &candidate.request.To, + Value: candidate.request.Value, Data: candidate.request.Data, Gas: candidate.gas, + GasFeeCap: candidate.cap, GasTipCap: candidate.tip}) + } + signed, err := opts.Signer(opts.From, unsigned) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if signed == nil { + return ErrContent + } + from, err := types.Sender(types.LatestSignerForChainID(chainID), signed) + if err != nil || from != s.From() || signed.ChainId().Cmp(chainID) != 0 || signed.Type() != unsigned.Type() || + signed.Nonce() != candidate.nonce || signed.To() == nil || *signed.To() != candidate.request.To || + signed.Value().Cmp(candidate.request.Value) != 0 || signed.Gas() != candidate.gas || + !bytes.Equal(signed.Data(), candidate.request.Data) || signed.GasFeeCap().Cmp(candidate.cap) != 0 || + signed.GasTipCap().Cmp(candidate.tip) != 0 { + return ErrContent + } + candidate.raw, err = signed.MarshalBinary() + candidate.hash = signed.Hash() + return err +} + +func (s *Sender[A]) recheck(ctx context.Context, planner Planner[A], original account, candidate *prepared[A]) error { + if err := s.checked(ctx); err != nil { + return err + } + if err := s.canonical(ctx, original.block); err != nil { + return err + } + fresh, err := s.account(ctx) + if err != nil { + return err + } + if fresh.nonce != candidate.nonce || fresh.block.Number < original.block.Number { + return ErrStale + } + eligibility, err := planner.Recheck(ctx, fresh.block, candidate.action) + if err != nil { + return err + } + if eligibility == ProtocolWait { + return ErrInconclusive + } + if candidate.neutral && eligibility != Obsolete || !candidate.neutral && eligibility != Useful { + return ErrIneligible + } + if err := s.preflight(ctx, fresh, candidate); err != nil { + return err + } + return s.checked(ctx) +} + +func (s *Sender[A]) send(ctx context.Context, planner Planner[A], snap account, candidate *prepared[A], + report *Report[A]) (ReplyClass, error) { + if err := s.preflight(ctx, snap, candidate); err != nil { + return "", err + } + if err := s.sign(ctx, candidate); err != nil { + return "", err + } + if err := s.recheck(ctx, planner, snap, candidate); err != nil { + return "", err + } + old := s.current + s.current = candidate // Retain bytes before the possibly ambiguous POST. + report.Attempted, report.Hash = true, candidate.hash + hash, err := s.backend.Publish(ctx, append([]byte(nil), candidate.raw...)) + class := ClassifyPublication(err) + if err != nil && class != Known && class != Uncertain && !definiteRefusal(err, class) { + class = Uncertain + } + if err == nil && hash != candidate.hash { + class, err = Uncertain, errors.New("publication returned a different transaction hash") + } + report.Class = class + if class == Accepted || class == Known { + err = nil + } else if class != Uncertain { + s.current = old + } + s.requestLogger(candidate.request).Info("Transaction publication", "signer", s.From(), "nonce", candidate.nonce, + "tx", candidate.hash, "to", candidate.request.To, "reply", class, "gas", candidate.gas, + "fee_cap", candidate.cap, "tip", candidate.tip, "neutral", candidate.neutral) + if err != nil { + return class, &PublicationError{Class: class, Err: err} + } + return class, nil +} + +func (s *Sender[A]) requestLogger(request Request) *slog.Logger { + if request.Logger != nil { + return request.Logger + } + return s.logger +} diff --git a/internal/txsender/sender_test.go b/internal/txsender/sender_test.go new file mode 100644 index 000000000..937b8add6 --- /dev/null +++ b/internal/txsender/sender_test.go @@ -0,0 +1,412 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestLostReplyRetainsAndRebroadcastsIdenticalBytes(t *testing.T) { + s, b, signer := testSender(t) + p := newPlanner() + b.publishErr = errLostReply + first, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, errLostReply) + require.True(t, first.Attempted) + require.Equal(t, Uncertain, first.Class) + require.Equal(t, &Current[int]{Action: 1}, first.Current) + b.publishErr = testRPCError{knownPublicationMessage, -32000} + second, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, Known, second.Class) + require.Equal(t, first.Hash, second.Hash) + require.Equal(t, b.published[0], b.published[1]) + require.Equal(t, 1, signer.signs) +} + +func TestConsumptionRetiresImmediatelyWithoutFinalizedOrReceiptReads(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.advance(2, 1) + p.action = 2 + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, uint64(1), report.Nonce) + require.Nil(t, p.seen[1]) + require.Equal(t, 2, report.Current.Action) + for _, tag := range b.tags { + require.Equal(t, latestBlockTag, tag) + } +} + +func TestCrashAndReorgReconstructAtCurrentMinedNonce(t *testing.T) { + s, b, f := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + b.advance(2, 1) + p.action = 2 + _, err = s.PublishNext(t.Context(), p) + require.NoError(t, err) + oldHigher := publishedTx(t, b, 1) + // The distributed nonce-1 copy remains outside the cache after this reorg. + b.advance(2, 0) + b.head.Extra = []byte{2} + b.nonces[b.head.Hash()] = 0 + p.action = 1 + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Equal(t, uint64(0), report.Nonce) + require.Equal(t, uint64(1), oldHigher.Nonce()) + require.Nil(t, p.seen[2]) + // Restart loses the remaining cache too; chain and computed work suffice. + restarted, err := New[int](b, f, 31337, Policy{}, func(context.Context) error { return nil }, testLogger()) + require.NoError(t, err) + _, err = restarted.PublishNext(t.Context(), p) + require.NoError(t, err) + require.Nil(t, p.seen[3]) +} + +func TestSigningAcrossForwardBlocksPublishesWithoutQuietChain(t *testing.T) { + s, b, f := testSender(t) + p := newPlanner() + f.afterSign = func() { b.advance(b.head.Number.Uint64()+1, 0) } + p.recheckFn = func(block BlockRef, action int) (Eligibility, error) { + require.Equal(t, uint64(2), block.Number) + require.Equal(t, 1, action) + return Useful, nil + } + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.True(t, report.Attempted) + require.Equal(t, 1, f.signs) + require.Equal(t, uint64(2), b.callBlocks[len(b.callBlocks)-1].Number) +} + +func TestPostSignChangesNeverPublish(t *testing.T) { + for _, test := range []struct { + name string + change func(*Sender[int], *fakeBackend, *fakeSigner, *fakePlanner) + want error + }{ + {"reorg", func(_ *Sender[int], b *fakeBackend, f *fakeSigner, _ *fakePlanner) { + f.afterSign = func() { b.head.Extra = []byte{2}; b.nonces[b.head.Hash()] = 0 } + }, ErrNonCanonical}, + {"nonce consumed", func(_ *Sender[int], b *fakeBackend, f *fakeSigner, _ *fakePlanner) { + f.afterSign = func() { b.advance(2, 1) } + }, ErrStale}, + {"read failed", func(_ *Sender[int], b *fakeBackend, f *fakeSigner, _ *fakePlanner) { + f.afterSign = func() { b.nonceErr = errLostReply } + }, ErrInconclusive}, + {"local eligibility", func(_ *Sender[int], _ *fakeBackend, f *fakeSigner, p *fakePlanner) { + f.afterSign = func() { p.eligibility = Obsolete } + }, ErrIneligible}, + {"publication stopped", func(s *Sender[int], _ *fakeBackend, f *fakeSigner, _ *fakePlanner) { + f.afterSign = func() { s.guard = func(context.Context) error { return ErrIneligible } } + }, ErrIneligible}, + {"balance lost", func(_ *Sender[int], b *fakeBackend, f *fakeSigner, _ *fakePlanner) { + f.afterSign = func() { b.balance.SetUint64(0) } + }, ErrLimit}, + } { + t.Run(test.name, func(t *testing.T) { + s, b, f := testSender(t) + p := newPlanner() + test.change(s, b, f, p) + report, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, test.want) + require.False(t, report.Attempted) + require.Empty(t, b.published) + }) + } +} + +func TestWaitAndFailedEvidenceNeverCancelCurrent(t *testing.T) { + s, b, f := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + p.decision = waitDecision + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.False(t, report.Attempted) + p.nextErr = errLostReply + _, err = s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, errLostReply) + require.Len(t, b.published, 1) + require.Equal(t, 1, f.signs) + require.Equal(t, 1, s.current.action) +} + +func TestPositiveCancellationIsReplaceableByUsefulWork(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + p.decision, p.eligibility = cancelDecision, Obsolete + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.True(t, report.Current.Neutral) + neutral := publishedTx(t, b, 1) + require.Equal(t, s.From(), *neutral.To()) + require.Zero(t, neutral.Value().Sign()) + require.Empty(t, neutral.Data()) + p.decision, p.action, p.eligibility = publishDecision, 2, Useful + report, err = s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.False(t, report.Current.Neutral) + require.Equal(t, 2, report.Current.Action) +} + +func TestFreshCancelIsIdleAndUnknownCancellationCannotPost(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + p.decision = cancelDecision + report, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + require.False(t, report.Attempted) + p.decision = publishDecision + _, err = s.PublishNext(t.Context(), p) + require.NoError(t, err) + p.decision, p.eligibility = cancelDecision, ProtocolWait + _, err = s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, ErrInconclusive) + require.Len(t, b.published, 1) +} + +func TestExactSimulationUsesEveryFieldAndPaddedGas(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + p.request.Value = big.NewInt(77) + b.estimate = 170_396 + b.callFn = func(msg ethereum.CallMsg) ([]byte, error) { + require.Equal(t, s.From(), msg.From) + require.Equal(t, p.request.To, *msg.To) + require.Equal(t, p.request.Data, msg.Data) + require.Equal(t, p.request.Value, msg.Value) + require.Equal(t, uint64(340_792), msg.Gas) + require.Equal(t, big.NewInt(22), msg.GasFeeCap) + require.Equal(t, big.NewInt(2), msg.GasTipCap) + return []byte{1}, nil + } + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) +} + +func TestFalseReturnAndFailedExactCallNeverSign(t *testing.T) { + for _, booleanReturn := range []bool{false, true} { + t.Run(map[bool]string{false: "false ABI return", true: "RPC failure"}[booleanReturn], func(t *testing.T) { + s, b, f := testSender(t) + p := newPlanner() + p.request.CheckReturn = func([]byte) bool { return false } + want := ErrIneligible + if booleanReturn { + b.callFn = func(ethereum.CallMsg) ([]byte, error) { return nil, errLostReply } + want = errLostReply + } + _, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, want) + require.Zero(t, f.signs) + require.Empty(t, b.published) + }) + } +} + +func TestGasSizingRemainsBoundedWithoutInventingAnEstimate(t *testing.T) { + for _, test := range []struct { + name string + explicit uint64 + estimateErr error + gas uint64 + }{ + {"explicit", 800_000, nil, 800_000}, + {"estimate unavailable", 0, errLostReply, 0}, + {"padded estimate", 0, nil, 171_000}, + } { + t.Run(test.name, func(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + p.request.GasLimit, b.estimateErr = test.explicit, test.estimateErr + _, err := s.PublishNext(t.Context(), p) + if test.estimateErr != nil { + require.ErrorIs(t, err, test.estimateErr) + require.Empty(t, b.published) + return + } + require.NoError(t, err) + require.Equal(t, test.gas, publishedTx(t, b, 0).Gas()) + }) + } +} + +func TestCurrentContentIsCopiedAndEqualActionCannotChangeIt(t *testing.T) { + s, _, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + p.request.Data[0] = 99 + p.request.Value.SetInt64(5) + require.Equal(t, []byte{1, 2, 3}, s.current.request.Data) + require.Zero(t, s.current.request.Value.Sign()) + _, err = s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, ErrContent) +} + +func TestPreconditionsRefuseBeforeSigning(t *testing.T) { + for _, test := range []struct { + name string + setup func(*Sender[int], *fakeBackend, *fakePlanner) + want error + }{ + {"unfunded", func(_ *Sender[int], b *fakeBackend, _ *fakePlanner) { b.balance.SetUint64(0) }, ErrLimit}, + {"code signer", func(_ *Sender[int], b *fakeBackend, _ *fakePlanner) { b.code = []byte{1} }, ErrUnsupported}, + {"gas cap", func(_ *Sender[int], _ *fakeBackend, p *fakePlanner) { p.request.GasLimit = 1 << 25 }, ErrLimit}, + {"fee cap", func(s *Sender[int], _ *fakeBackend, _ *fakePlanner) { s.policy.MaxFee = big.NewInt(1) }, ErrLimit}, + {"negative value", func(_ *Sender[int], _ *fakeBackend, p *fakePlanner) { p.request.Value.SetInt64(-1) }, ErrLimit}, + {"owner failure", func(s *Sender[int], _ *fakeBackend, _ *fakePlanner) { + s.guard = func(context.Context) error { return ErrIneligible } + }, ErrIneligible}, + } { + t.Run(test.name, func(t *testing.T) { + s, b, f := testSender(t) + p := newPlanner() + test.setup(s, b, p) + _, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, test.want) + require.Zero(t, f.signs) + require.Empty(t, b.published) + }) + } +} + +func TestCancelledContextNeverSigns(t *testing.T) { + s, b, f := testSender(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := s.PublishNext(ctx, newPlanner()) + require.ErrorIs(t, err, context.Canceled) + require.Zero(t, f.signs) + require.Empty(t, b.published) +} + +func TestSignerCannotChangeSignedFields(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*types.DynamicFeeTx) + }{ + {"nonce", func(tx *types.DynamicFeeTx) { tx.Nonce++ }}, + {"to", func(tx *types.DynamicFeeTx) { other := common.HexToAddress("0x999"); tx.To = &other }}, + {"value", func(tx *types.DynamicFeeTx) { tx.Value = big.NewInt(1) }}, + {"data", func(tx *types.DynamicFeeTx) { tx.Data = []byte{99} }}, + {"gas", func(tx *types.DynamicFeeTx) { tx.Gas++ }}, + {"cap", func(tx *types.DynamicFeeTx) { tx.GasFeeCap = big.NewInt(99) }}, + {"tip", func(tx *types.DynamicFeeTx) { tx.GasTipCap = big.NewInt(3) }}, + {"chain", func(tx *types.DynamicFeeTx) { tx.ChainID = big.NewInt(1) }}, + } { + t.Run(test.name, func(t *testing.T) { + s, b, f := testSender(t) + f.mutate = func(tx *types.Transaction) *types.Transaction { + dynamic := &types.DynamicFeeTx{ChainID: tx.ChainId(), Nonce: tx.Nonce(), To: tx.To(), Value: tx.Value(), + Data: tx.Data(), Gas: tx.Gas(), GasFeeCap: tx.GasFeeCap(), GasTipCap: tx.GasTipCap()} + test.mutate(dynamic) + return types.NewTx(dynamic) + } + _, err := s.PublishNext(t.Context(), newPlanner()) + if test.name == "chain" { + require.Error(t, err, "the signer itself can reject a changed chain before returning a signature") + } else { + require.ErrorIs(t, err, ErrContent) + } + require.Empty(t, b.published) + }) + } +} + +func TestDefiniteRefusalRetainsEarlierUsablePreference(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + old := s.current + p.action = 2 + b.publishErr = testRPCError{replacementUnderpricedMessage, -32000} + report, err := s.PublishNext(t.Context(), p) + require.Error(t, err) + require.Equal(t, ReplacementUnderpriced, report.Class) + require.Same(t, old, s.current) + require.Equal(t, 1, report.Current.Action) + require.Len(t, b.published, 3, "one fresh attempt and one bounded price retry") +} + +func TestUncertainChangedRequestBecomesPreferredWithoutDiscardingNetworkCopy(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + first := append([]byte(nil), b.published[0]...) + p.action = 2 + b.publishErr = errLostReply + report, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, errLostReply) + require.Equal(t, 2, report.Current.Action) + require.Equal(t, first, b.published[0], "the provider can still retain the earlier signed version") +} + +func TestNonceLowReplyDoesNotProveConsumption(t *testing.T) { + s, b, _ := testSender(t) + b.publishErr = testRPCError{nonceLowMessage, -32000} + report, err := s.PublishNext(t.Context(), newPlanner()) + require.Error(t, err) + require.True(t, WaitOnly(err)) + require.Equal(t, uint64(0), report.Nonce) + require.Nil(t, report.Current) + require.ErrorIs(t, err, err.(*PublicationError).Err) + b.publishErr = errors.Join(b.publishErr, errLostReply) + _, err = s.PublishNext(t.Context(), newPlanner()) + require.False(t, WaitOnly(err)) +} + +func TestLaggingCanonicalReplicaCannotDiscardCurrentNonce(t *testing.T) { + for _, sameNonce := range []bool{false, true} { + t.Run(map[bool]string{false: "lower nonce", true: "same nonce"}[sameNonce], func(t *testing.T) { + s, b, _ := testSender(t) + if sameNonce { + b.advance(1, 1) + } + b.advance(2, 1) + _, err := s.PublishNext(t.Context(), newPlanner()) + require.NoError(t, err) + current := s.current + b.head = b.headers[1] + report, err := s.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, ErrInconclusive) + require.False(t, report.Attempted) + require.Same(t, current, s.current) + require.Len(t, b.published, 1) + }) + } +} + +func TestJoinedPublicationTransportFailureRetainsAmbiguousNewPreference(t *testing.T) { + s, b, _ := testSender(t) + p := newPlanner() + _, err := s.PublishNext(t.Context(), p) + require.NoError(t, err) + p.action = 2 + b.publishErr = errors.Join(testRPCError{replacementUnderpricedMessage, -32000}, errLostReply) + report, err := s.PublishNext(t.Context(), p) + require.ErrorIs(t, err, errLostReply) + require.Equal(t, Uncertain, report.Class) + require.Equal(t, 2, report.Current.Action) + require.Len(t, b.published, 2, "ambiguous evidence does not authorize a price retry") +} diff --git a/internal/txsender/shared_wallet_test.go b/internal/txsender/shared_wallet_test.go new file mode 100644 index 000000000..c86a2a173 --- /dev/null +++ b/internal/txsender/shared_wallet_test.go @@ -0,0 +1,69 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/stretchr/testify/require" +) + +// Separate nodes cannot coordinate through their databases. Exercise two real +// senders using the same account and pool, without treating either nonce use as +// proof that the other node's protocol action completed. +func TestAccidentalSharedWalletBoundsConflictsAndReconstructsOutstandingWork(t *testing.T) { + first, backend, signer := testSender(t) + backend.publishFn = poolRule(t) + second, err := New[int](backend, signer, 31337, Policy{}, + func(ctx context.Context) error { return ctx.Err() }, testLogger()) + require.NoError(t, err) + firstPlan, secondPlan := newPlanner(), newPlanner() + secondPlan.action = 2 + secondPlan.request.Data = []byte{4, 5, 6} + + initial, err := first.PublishNext(t.Context(), firstPlan) + require.NoError(t, err) + require.Equal(t, Accepted, initial.Class) + for range 3 { + before := len(backend.published) + conflict, conflictErr := second.PublishNext(t.Context(), secondPlan) + var publicationErr *PublicationError + require.ErrorAs(t, conflictErr, &publicationErr) + require.False(t, HardFailure(conflictErr), "price contention cannot latch a terminal failure") + require.False(t, PassInvalid(conflictErr)) + require.Equal(t, ReplacementUnderpriced, conflict.Class) + require.LessOrEqual(t, len(backend.published)-before, 2, "one pass cannot start an unbounded price auction") + require.Nil(t, conflict.Current, "definite refusals do not establish an admitted transaction") + } + + // The first node's transaction consumes the nonce, without completing the + // second node's action. Reconstruct that same outstanding action at nonce 1. + backend.advance(2, 1) + backend.publishFn = poolRule(t) + recovered, err := second.PublishNext(t.Context(), secondPlan) + require.NoError(t, err) + require.Equal(t, Accepted, recovered.Class) + require.Equal(t, uint64(1), recovered.Nonce) + require.Equal(t, secondPlan.action, recovered.Current.Action) + require.Equal(t, secondPlan.request.Data, publishedTx(t, backend, len(backend.published)-1).Data()) + require.Nil(t, secondPlan.seen[len(secondPlan.seen)-1]) +} + +func TestInvalidSignerFactoryIsUnsupportedWithoutPublishing(t *testing.T) { + sender, backend, signer := testSender(t) + sender.factory = missingSignerFactory{signer} + report, err := sender.PublishNext(t.Context(), newPlanner()) + require.ErrorIs(t, err, ErrUnsupported) + require.True(t, PassInvalid(err)) + require.False(t, report.Attempted) + require.Empty(t, backend.published) +} + +type missingSignerFactory struct{ *fakeSigner } + +func (f missingSignerFactory) NewTransactOpts(context.Context) (*bind.TransactOpts, error) { + return &bind.TransactOpts{From: f.From()}, nil +} diff --git a/internal/txsender/shutdown_test.go b/internal/txsender/shutdown_test.go new file mode 100644 index 000000000..63e239279 --- /dev/null +++ b/internal/txsender/shutdown_test.go @@ -0,0 +1,47 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package txsender + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShutdownOnlyRequiresEveryCauseToBeCancellationOrWait(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + failure := errors.New("database connection failed") + extraWait := errors.New("protocol window is stale") + for _, test := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"pure cancellation", context.Canceled, true}, + {"ordinary wait", ErrInconclusive, true}, + {"named wait", extraWait, true}, + {"wrapped waits and cancellation", fmt.Errorf("pass: %w", errors.Join(ErrInconclusive, ErrLimit, context.Canceled)), true}, + {"nested cancellations", errors.Join(context.Canceled, fmt.Errorf("read: %w", context.Canceled)), true}, + {"cancelled POST", &PublicationError{Class: Uncertain, Err: context.Canceled}, true}, + {"cancelled POST with real failure", &PublicationError{Class: Uncertain, + Err: errors.Join(context.Canceled, failure)}, false}, + {"real failure", failure, false}, + {"mixed failure", errors.Join(ErrInconclusive, context.Canceled, failure), false}, + {"deadline", context.DeadlineExceeded, false}, + {"mixed deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"finality contradiction", errors.Join(context.Canceled, ErrFinality), false}, + {"content contradiction", errors.Join(context.Canceled, ErrContent), false}, + } { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, ShutdownOnly(ctx, test.err, ErrLimit, extraWait)) + require.False(t, ShutdownOnly(t.Context(), test.err, ErrLimit, extraWait), + "cancellation without service shutdown is an operational failure") + }) + } +} diff --git a/internal/txsender/types.go b/internal/txsender/types.go new file mode 100644 index 000000000..f6742b214 --- /dev/null +++ b/internal/txsender/types.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package txsender publishes one current transaction for an exclusively owned signer. +package txsender + +import ( + "context" + "errors" + "log/slog" + "math/big" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type BlockRef struct { + Number uint64 + Hash common.Hash +} + +// Current describes the preferred transaction, not every version still in a pool. +type Current[A comparable] struct { + Action A + Neutral bool +} + +// Request is immutable transaction content supplied by a service. CheckReturn +// must be a pure ABI predicate; it cannot read or write service state. +type Request struct { + To common.Address + Data []byte + Value *big.Int + GasLimit uint64 // minimum allowance; zero uses only the padded estimate + Logger *slog.Logger + CheckReturn func([]byte) bool +} + +type Eligibility uint8 + +const ( + Useful Eligibility = iota + ProtocolWait + Obsolete +) + +type Planner[A comparable] interface { + Next(context.Context, BlockRef, *Current[A]) (Decision[A], error) + // Recheck confirms eligibility for this action and immutable + // request. It cannot select another protocol action or replace its bytes. + Recheck(context.Context, BlockRef, A) (Eligibility, error) +} + +type decisionKind uint8 + +const ( + waitDecision decisionKind = iota + publishDecision + cancelDecision +) + +// Decision values are made with Publish, Wait or Cancel. +type Decision[A comparable] struct { + kind decisionKind + action A + request Request + reason string +} + +func Publish[A comparable](action A, request Request) Decision[A] { + return Decision[A]{kind: publishDecision, action: action, request: request} +} +func Wait[A comparable](reason string) Decision[A] { return Decision[A]{reason: reason} } +func Cancel[A comparable](reason string) Decision[A] { + return Decision[A]{kind: cancelDecision, reason: reason} +} + +type Report[A comparable] struct { + Block BlockRef + Nonce uint64 + Current *Current[A] + Attempted bool + Canceling bool + Hash common.Hash + Class ReplyClass +} + +type Policy struct { + MaxFee *big.Int // nil means no operator price ceiling + Legacy bool + SigningTimeout time.Duration // zero leaves signing bounded by the caller +} + +var ( + ErrInconclusive = errors.New("protected chain view inconclusive") + ErrFinality = errors.New("protected view contradicts saved finality") + ErrNonCanonical = errors.New("protected block is not canonical on this backend") + ErrStale = errors.New("transaction preparation is stale") + ErrLimit = errors.New("transaction policy limit reached") + ErrContent = errors.New("action reused with different transaction content") + ErrIneligible = errors.New("action is no longer eligible") + ErrUnsupported = errors.New("unsupported transaction signer or protected RPC capability") +) + +// Backend never substitutes pending or unpinned account/execution reads. +// Estimate uses the prepared block number for sizing; Call authorizes execution +// at its canonical block hash. +type Backend interface { + Header(context.Context, string) (*types.Header, error) + Canonical(context.Context, BlockRef) (bool, error) + Nonce(context.Context, common.Address, BlockRef) (uint64, error) + Balance(context.Context, common.Address, BlockRef) (*big.Int, error) + Code(context.Context, common.Address, BlockRef) ([]byte, error) + Call(context.Context, ethereum.CallMsg, BlockRef) ([]byte, error) + Estimate(context.Context, ethereum.CallMsg, BlockRef) (uint64, error) + Tip(context.Context) (*big.Int, error) + Price(context.Context) (*big.Int, error) + Publish(context.Context, []byte) (common.Hash, error) +} diff --git a/internal/validator/backoff_admission_test.go b/internal/validator/backoff_admission_test.go new file mode 100644 index 000000000..ee71f1483 --- /dev/null +++ b/internal/validator/backoff_admission_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// A successful admission reload may finish after an existing cooldown expires. +func TestClaimBackoffCooldownExpiresDuringAdmissionRead(t *testing.T) { + s, repo, now, _ := newClaimBackoffFixture(t, time.Minute) + key := claimRetryKey{73, 2} + cause := errors.New("storage unavailable") + repo.store = func(context.Context, claimRetryKey) error { return cause } + _ = backoffTick(t, s, cause) + require.Len(t, repo.stores, 2) + *now = now.Add(30 * time.Second) + repo.read = func(k claimRetryKey) error { + if k == key { + *now = now.Add(31 * time.Second) + } + return nil + } + _ = backoffTick(t, s, cause) + var attempts int + for _, k := range repo.stores { + if k == key { + attempts++ + } + } + require.Equal(t, 2, attempts, "the admission read finished after the cooldown expired") +} + +// A returned cancellation can accompany an independent physical write failure. +func TestClaimBackoffMixedFailureHasCooldownWhileServiceLives(t *testing.T) { + s, repo, _, _ := newClaimBackoffFixture(t, time.Minute) + cause := errors.New("independent storage failure") + repo.store = func(context.Context, claimRetryKey) error { return errors.Join(context.Canceled, cause) } + _ = backoffTick(t, s, cause) + require.Len(t, repo.stores, 2) + _ = backoffTick(t, s, nil) + require.Len(t, repo.stores, 2, "cooling physical failures must not repeat on every poll") +} diff --git a/internal/validator/claim_backoff.go b/internal/validator/claim_backoff.go new file mode 100644 index 000000000..fe2d4e35b --- /dev/null +++ b/internal/validator/claim_backoff.go @@ -0,0 +1,75 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "errors" + "fmt" + "time" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/repository" +) + +const maxClaimRetryDelay = 5 * time.Minute + +type claimRetryKey struct { + applicationID int64 + epochIndex uint64 +} + +type claimRetry struct { + attempts uint64 + delay time.Duration + nextAttempt time.Time +} + +// claimStatusRecorded marks a failure that wrote an application status. +// That status stops the epoch, so the failure starts no cooldown. +type claimStatusRecorded struct{ error } + +func (e *claimStatusRecorded) Unwrap() error { return e.error } + +func (s *Service) claimNow() time.Time { + if s.now != nil { + return s.now() + } + return time.Now() +} + +func (s *Service) claimWaiting(key claimRetryKey) bool { + retry, exists := s.claimRetries[key] + return exists && s.claimNow().Before(retry.nextAttempt) +} + +// failedClaim starts or doubles the epoch's cooldown after an operational +// failure and names the next attempt in the returned error. +func (s *Service) failedClaim(key claimRetryKey, address string, cause error) error { + if !claimFailureNeedsBackoff(cause) { + return cause + } + retry := s.claimRetries[key] + retry.attempts++ + if retry.delay == 0 { + retry.delay = s.claimRetryBase + } else { + retry.delay = min(retry.delay+retry.delay, maxClaimRetryDelay) // Double it. + } + // Read the clock after the failure, including a slow transaction. + retry.nextAttempt = s.claimNow().Add(retry.delay) + if s.claimRetries == nil { + s.claimRetries = make(map[claimRetryKey]claimRetry) + } + s.claimRetries[key] = retry + return fmt.Errorf("application %s epoch %d: claim attempt %d failed, next attempt at %s: %w", + address, key.epochIndex, retry.attempts, retry.nextAttempt.Format(time.RFC3339), cause) +} + +// Recorded status and foreclosure are definitive outcomes from their producers. +// A cancellation branch must not hide an independent operational failure. +func claimFailureNeedsBackoff(err error) bool { + var recorded *claimStatusRecorded + return err != nil && !errors.As(err, &recorded) && !errors.Is(err, repository.ErrEpochForeclosed) && + !errutil.IsOnlyCancellation(err) +} diff --git a/internal/validator/claim_backoff_test.go b/internal/validator/claim_backoff_test.go new file mode 100644 index 000000000..75d5e0e76 --- /dev/null +++ b/internal/validator/claim_backoff_test.go @@ -0,0 +1,583 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// The fake keeps durable epochs separate from mutable completion snapshots. +// A failed publication cannot silently advance the next discovery result. +const ( + claimForeclosedExemption = "foreclosed" + claimRootExemption = "root boundary" + claimStatusExemption = "status" +) + +type claimBackoffRepository struct { + repository.Repository + apps []*model.Application + epochs map[int64][]*model.Epoch + problems map[int64]*model.ApplicationProblem + stores []claimRetryKey + reads []claimRetryKey + statusCalls int + rootCalls int + statusError error + rootError error + read func(claimRetryKey) error + rangeRead func(claimRetryKey) error + store func(context.Context, claimRetryKey) error + count func() (uint64, error) +} + +func (r *claimBackoffRepository) ListApplications( + context.Context, repository.ApplicationFilter, repository.Pagination, bool, +) ([]*model.Application, uint64, error) { + return r.apps, uint64(len(r.apps)), nil +} + +func (r *claimBackoffRepository) app(address string) *model.Application { + for _, app := range r.apps { + if app.IApplicationAddress.Hex() == address { + return app + } + } + panic("unexpected application address") +} + +func (r *claimBackoffRepository) epoch(key claimRetryKey) *model.Epoch { + for _, epoch := range r.epochs[key.applicationID] { + if epoch.Index == key.epochIndex { + return epoch + } + } + panic("unexpected epoch key") +} + +func (r *claimBackoffRepository) ListEpochs( + _ context.Context, address string, filter repository.EpochFilter, _ repository.Pagination, _ bool, +) ([]*model.Epoch, uint64, error) { + if filter.HasTournament != nil { + return nil, 0, nil + } + var epochs []*model.Epoch + for _, epoch := range r.epochs[r.app(address).ID] { + if epoch.Status == model.EpochStatus_InputsProcessed { + snapshot := *epoch + epochs = append(epochs, &snapshot) + } + } + return epochs, uint64(len(epochs)), nil +} + +func (r *claimBackoffRepository) GetEpochCompletionState( + _ context.Context, address string, index uint64, +) (*repository.EpochCompletionState, error) { + app := r.app(address) + key := claimRetryKey{app.ID, index} + r.reads = append(r.reads, key) + if r.read != nil { + if err := r.read(key); err != nil { + return nil, err + } + } + snapshot := *r.epoch(key) + appCopy := *app + return &repository.EpochCompletionState{Application: &appCopy, Epoch: &snapshot, Problem: r.problems[app.ID]}, nil +} + +func (r *claimBackoffRepository) GetEpochOutputRange( + _ context.Context, appID int64, index uint64, +) (repository.EpochOutputRange, error) { + if r.rangeRead != nil { + return repository.EpochOutputRange{}, r.rangeRead(claimRetryKey{appID, index}) + } + return repository.EpochOutputRange{}, nil +} + +func (r *claimBackoffRepository) GetLastInput(_ context.Context, address string, index uint64) (*model.Input, error) { + epoch := r.epoch(claimRetryKey{r.app(address).ID, index}) + return &model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock}, nil +} + +func (r *claimBackoffRepository) GetEpochByVirtualIndex( + _ context.Context, address string, index uint64, +) (*model.Epoch, error) { + for _, epoch := range r.epochs[r.app(address).ID] { + if epoch.VirtualIndex == index { + snapshot := *epoch + return &snapshot, nil + } + } + return nil, nil +} + +func (r *claimBackoffRepository) StoreClaimAndProofs( + ctx context.Context, epoch *model.Epoch, _ repository.ClaimProofWriteFunc, +) error { + key := claimRetryKey{epoch.ApplicationID, epoch.Index} + r.stores = append(r.stores, key) + if r.store != nil { + if err := r.store(ctx, key); err != nil { + return err + } + } + r.epoch(key).Status = model.EpochStatus_ClaimComputed + return nil +} + +func (r *claimBackoffRepository) UpdateApplicationStatus( + _ context.Context, appID int64, status model.ApplicationStatus, reason *string, +) error { + r.statusCalls++ + if r.statusError != nil { + return r.statusError + } + for _, app := range r.apps { + if app.ID == appID { + app.Status, app.Reason = status, reason + } + } + return nil +} + +func (r *claimBackoffRepository) StoreInvalidOutputsRoot(context.Context, int64, uint64, string) error { + r.rootCalls++ + return r.rootError +} + +func (r *claimBackoffRepository) CountEpochStateHashes(context.Context, int64, uint64) (uint64, error) { + return r.count() +} + +func (r *claimBackoffRepository) ListEpochStateHashes( + context.Context, int64, uint64, *uint64, uint64, +) ([]model.EpochStateHash, error) { + return nil, nil +} + +func newClaimBackoffFixture(t *testing.T, poll time.Duration) (*Service, *claimBackoffRepository, *time.Time, *bytes.Buffer) { + t.Helper() + post := merkle.CreatePostContext() + repo := &claimBackoffRepository{epochs: make(map[int64][]*model.Epoch), problems: make(map[int64]*model.ApplicationProblem)} + for _, id := range []int64{73, 91} { + app := &model.Application{ID: id, Name: fmt.Sprintf("app-%d", id), Enabled: true, + IApplicationAddress: common.BigToAddress(big.NewInt(id)), Status: model.ApplicationStatus_OK, + ConsensusType: model.Consensus_Authority, TemplateHash: common.HexToHash("0xcafe")} + repo.apps = append(repo.apps, app) + indexes := []uint64{2, 7} + if id == 91 { + indexes = indexes[:1] + } + for virtual, index := range indexes { + repo.epochs[id] = append(repo.epochs[id], &model.Epoch{ApplicationID: id, Index: index, + VirtualIndex: uint64(virtual), FirstBlock: index * 10, LastBlock: index*10 + 9, + Status: model.EpochStatus_InputsProcessed, MachineHash: &app.TemplateHash, TxBufferDataBlock: &post[merkle.TREE_DEPTH]}) + } + } + now := time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC) + logs := new(bytes.Buffer) + created, err := Create(t.Context(), &CreateInfo{Config: config.ValidatorConfig{ValidatorPollingInterval: poll}, + Repository: repo, Logger: slog.New(slog.NewJSONHandler(logs, nil))}) + require.NoError(t, err) + s := created.(*Service) + s.now = func() time.Time { return now } + return s, repo, &now, logs +} + +func backoffTick(t *testing.T, s *Service, cause error) error { + t.Helper() + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule, "backoff must preserve the polling schedule") + if cause == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, cause) + } + return err +} + +func TestClaimBackoffWaitsForThirdAttemptAndKeepsSiblingProgress(t *testing.T) { + s, repo, now, _ := newClaimBackoffFixture(t, 100*time.Second) + key := claimRetryKey{73, 2} + failure := errors.New("disk full") + repo.store = func(_ context.Context, current claimRetryKey) error { + if current == key { + return failure + } + return nil + } + _ = backoffTick(t, s, failure) + require.Equal(t, []claimRetryKey{{73, 2}, {91, 2}}, repo.stores) + require.Equal(t, model.ApplicationStatus_OK, repo.apps[0].Status) + require.Zero(t, repo.statusCalls) + *now = now.Add(100 * time.Second) + _ = backoffTick(t, s, failure) + require.Equal(t, uint64(2), s.claimRetries[key].attempts) + require.Equal(t, 200*time.Second, s.claimRetries[key].delay) + *now = now.Add(100 * time.Second) + _ = backoffTick(t, s, nil) + require.Len(t, repo.stores, 3, "third poll is still cooling and cannot enter its successor") + *now = now.Add(100 * time.Second) // Exact equality is eligible. + repo.store = nil + _ = backoffTick(t, s, nil) + require.Equal(t, []claimRetryKey{{73, 2}, {91, 2}, {73, 2}, {73, 2}, {73, 7}}, repo.stores) + require.Empty(t, s.claimRetries) +} + +func TestClaimBackoffStartsAtFailureCompletionAndPreservesCoolingReadFailure(t *testing.T) { + s, repo, now, _ := newClaimBackoffFixture(t, 100*time.Second) + key := claimRetryKey{73, 2} + failure := errors.New("slow failed claim") + repo.store = func(_ context.Context, current claimRetryKey) error { + if current == key { + *now = now.Add(201 * time.Second) + return failure + } + return nil + } + start := *now + _ = backoffTick(t, s, failure) + prior := s.claimRetries[key] + require.Equal(t, start.Add(301*time.Second), prior.nextAttempt) + _ = backoffTick(t, s, nil) // The buffered tick at the same completion time. + require.Len(t, repo.stores, 2) + readError := errors.New("cheap coherent read unavailable") + repo.read = func(current claimRetryKey) error { + if current == key { + return readError + } + return nil + } + _ = backoffTick(t, s, readError) + require.Equal(t, prior, s.claimRetries[key], "a cooling read failure is not another claim attempt") + *now = prior.nextAttempt + _ = backoffTick(t, s, readError) + retry := s.claimRetries[key] + require.Equal(t, uint64(2), retry.attempts) + require.Equal(t, 200*time.Second, retry.delay) + require.Equal(t, now.Add(200*time.Second), retry.nextAttempt) +} + +func TestClaimBackoffOperationalClasses(t *testing.T) { + for _, test := range []struct { + name string + cause error + read bool + }{ + {"read", errors.New("read failed"), true}, + {"store", errors.New("store failed"), false}, + {"changed", merkle.ErrOutputsChanged, false}, + {"invariant", merkle.ErrInvariant, false}, + {"operation deadline", context.DeadlineExceeded, false}, + {"joined cancellation", errors.Join(context.Canceled, errors.New("independent write error")), false}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo, now, _ := newClaimBackoffFixture(t, time.Minute) + key := claimRetryKey{73, 2} + fail := func(current claimRetryKey) error { + if current == key { + return test.cause + } + return nil + } + if test.read { + repo.rangeRead = fail + } else { + repo.store = func(_ context.Context, k claimRetryKey) error { return fail(k) } + } + _ = backoffTick(t, s, test.cause) + retry := s.claimRetries[key] + require.Equal(t, uint64(1), retry.attempts) + require.Equal(t, time.Minute, retry.delay) + require.Equal(t, now.Add(time.Minute), retry.nextAttempt) + require.Equal(t, model.ApplicationStatus_OK, repo.apps[0].Status) + require.Zero(t, repo.statusCalls) + require.Equal(t, model.EpochStatus_ClaimComputed, repo.epochs[91][0].Status) + }) + } +} + +func TestClaimBackoffStateHashRecheck(t *testing.T) { + s, repo, _, _ := newClaimBackoffFixture(t, time.Minute) + repo.apps[0].ConsensusType = model.Consensus_PRT + repo.epochs[73][0].InputIndexUpperBound = 1 + failure := errors.New("count recheck failed") + var counts int + repo.count = func() (uint64, error) { + counts++ + if counts == 1 { + return 1, nil + } + return 0, failure + } + _ = backoffTick(t, s, failure) + require.Equal(t, uint64(1), s.claimRetries[claimRetryKey{73, 2}].attempts) + require.Zero(t, repo.statusCalls) +} + +func TestClaimBackoffExemptionsPreserveExistingRetries(t *testing.T) { + for _, exemption := range []string{claimForeclosedExemption, "canceled", claimStatusExemption, claimRootExemption} { + for _, priorFailure := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/prior=%t", exemption, priorFailure), func(t *testing.T) { + s, repo, now, _ := newClaimBackoffFixture(t, time.Minute) + key := claimRetryKey{73, 2} + if priorFailure { + failure := errors.New("first write failed") + repo.store = func(_ context.Context, k claimRetryKey) error { + if k == key { + return failure + } + return nil + } + _ = backoffTick(t, s, failure) + *now = s.claimRetries[key].nextAttempt + } + prior := s.claimRetries[key] + repo.store = nil + var cause error + switch exemption { + case claimForeclosedExemption: + repo.store = func(_ context.Context, k claimRetryKey) error { + if k == key { + repo.epoch(k).Status = model.EpochStatus_ClaimForeclosed + return fmt.Errorf("late: %w", repository.ErrEpochForeclosed) + } + return nil + } + case "canceled": + cause = context.Canceled + repo.store = func(_ context.Context, k claimRetryKey) error { + if k == key { + return fmt.Errorf("write: %w", cause) + } + return nil + } + case claimStatusExemption: + repo.epochs[73][0].MachineHash = nil + case claimRootExemption: + // The successful transaction can keep execution status and only + // record an earlier boundary. app.Status cannot classify it. + repo.apps[0].Status = model.ApplicationStatus_MachineHalted + repo.problems[73] = &model.ApplicationProblem{EpochIndex: 9, Kind: model.ApplicationStatus_MachineHalted, + InputIndex: new(uint64(4))} + repo.epochs[73][0].TxBufferDataBlock = new(common.Hash) + } + if exemption == claimStatusExemption || exemption == claimRootExemption { + reschedule, err := s.Tick(t.Context()) + require.False(t, reschedule) + require.Error(t, err) + } else { + _ = backoffTick(t, s, cause) + } + require.Equal(t, prior, s.claimRetries[key]) + if exemption == claimForeclosedExemption { + require.Equal(t, model.EpochStatus_ClaimComputed, repo.epochs[73][1].Status) + } + if exemption == claimRootExemption { + require.Equal(t, 1, repo.rootCalls) + require.Equal(t, model.ApplicationStatus_MachineHalted, repo.apps[0].Status) + } + }) + } + } +} + +func TestClaimBackoffCapKeysRestartAndExternalCommit(t *testing.T) { + s, repo, now, _ := newClaimBackoffFixture(t, 100*time.Second) + failure := errors.New("permanent disk failure") + key := claimRetryKey{73, 2} + repo.store = func(_ context.Context, k claimRetryKey) error { + if k == key { + return failure + } + return nil + } + for _, expected := range []time.Duration{100, 200, 300, 300, 300} { + _ = backoffTick(t, s, failure) + require.Equal(t, expected*time.Second, s.claimRetries[key].delay) + *now = s.claimRetries[key].nextAttempt + } + other := claimRetryKey{91, 999} + s.claimRetries[other] = claimRetry{attempts: 7, delay: time.Minute, nextAttempt: now.Add(time.Hour)} + repo.store = nil + _ = backoffTick(t, s, nil) + require.Equal(t, uint64(7), s.claimRetries[other].attempts, "success clears only its own key") + // A new service has no volatile retries, even with an old future deadline. + fresh, _, _, _ := newClaimBackoffFixture(t, time.Minute) + fresh.repository = repo + repo.epochs[73][0].Status = model.EpochStatus_InputsProcessed + _ = backoffTick(t, fresh, nil) + require.Empty(t, fresh.claimRetries) + // A late valid commit can remove a cooling epoch from the work set. + s2, repo2, now2, _ := newClaimBackoffFixture(t, time.Minute) + s2.claimRetries = map[claimRetryKey]claimRetry{key: {attempts: 2, delay: time.Minute, nextAttempt: now2.Add(time.Hour)}} + repo2.read = func(k claimRetryKey) error { + if k == key { + repo2.epoch(k).Status = model.EpochStatus_ClaimComputed + } + return nil + } + _ = backoffTick(t, s2, nil) + require.Equal(t, model.EpochStatus_ClaimComputed, repo2.epochs[73][1].Status) + require.Equal(t, uint64(2), s2.claimRetries[key].attempts) +} + +// Create only stores this interface. No method, database, or SQL is called. +type claimCreateRepository struct{ repository.Repository } + +func TestClaimBackoffResolvedPoll(t *testing.T) { + for _, poll := range []time.Duration{0, 100 * time.Second, 5 * time.Minute, 10 * time.Minute} { + t.Run(poll.String(), func(t *testing.T) { + created, err := Create(t.Context(), &CreateInfo{Config: config.ValidatorConfig{ValidatorPollingInterval: poll}, + Logger: slog.New(slog.NewJSONHandler(new(bytes.Buffer), nil)), Repository: &claimCreateRepository{}}) + require.NoError(t, err) + s := created.(*Service) + want := poll + if want == 0 { + want = time.Minute + } + require.Equal(t, min(want, 5*time.Minute), s.claimRetryBase) + _, repo, now, _ := newClaimBackoffFixture(t, poll) + s.repository = repo + s.now = func() time.Time { return *now } + failure := errors.New("first attempt failed") + repo.store = func(_ context.Context, _ claimRetryKey) error { return failure } + _ = backoffTick(t, s, failure) + require.Equal(t, min(want, 5*time.Minute), s.claimRetries[claimRetryKey{73, 2}].delay) + }) + } +} + +func TestClaimBackoffErrorNamesTheRetry(t *testing.T) { + s, repo, now, logs := newClaimBackoffFixture(t, time.Minute) + failures := map[int64]error{73: errors.New("app-A failure"), 91: errors.New("app-B failure")} + repo.store = func(_ context.Context, k claimRetryKey) error { return failures[k.applicationID] } + err := backoffTick(t, s, failures[73]) + require.ErrorIs(t, err, failures[91]) + require.Contains(t, err.Error(), failures[73].Error()) + require.Contains(t, err.Error(), failures[91].Error()) + s.Logger.Error("Tick", "error", err) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + next := now.Add(time.Minute).Format(time.RFC3339) + for i := range 2 { + require.Contains(t, err.Error(), fmt.Sprintf("application %s epoch 2: claim attempt 1 failed, next attempt at %s", + repo.apps[i].IApplicationAddress.String(), next)) + } +} + +func TestClaimBackoffPreservedHealthIsNotRecordedFailure(t *testing.T) { + for _, status := range []model.ApplicationStatus{model.ApplicationStatus_MachineHalted, model.ApplicationStatus_InvalidOutputsRoot} { + t.Run(status.String(), func(t *testing.T) { + s, repo, _, _ := newClaimBackoffFixture(t, time.Minute) + repo.apps[0].Status = status + repo.problems[73] = &model.ApplicationProblem{EpochIndex: 9, Kind: status} + if status.IsExecutionTerminal() { + repo.problems[73].InputIndex = new(uint64(4)) + } + failure := errors.New("operational error before the stop boundary") + repo.rangeRead = func(k claimRetryKey) error { + if k.applicationID == 73 { + return failure + } + return nil + } + _ = backoffTick(t, s, failure) + require.Equal(t, uint64(1), s.claimRetries[claimRetryKey{73, 2}].attempts) + require.Zero(t, repo.statusCalls) + require.Equal(t, status, repo.apps[0].Status) + }) + } +} + +func TestClaimBackoffClassifiesActualStatusWrite(t *testing.T) { + for _, test := range []struct { + name string + status model.ApplicationStatus + writeError error + wantRetry bool + }{ + {"FAILED recorded", model.ApplicationStatus_OK, nil, false}, + {"FAILED write failed", model.ApplicationStatus_OK, errors.New("status failed"), true}, + {"FAILED write canceled while live", model.ApplicationStatus_OK, context.Canceled, true}, + {"execution terminal preserved", model.ApplicationStatus_MachineHalted, nil, true}, + {"root stop preserved", model.ApplicationStatus_InvalidOutputsRoot, nil, true}, + } { + t.Run(test.name, func(t *testing.T) { + s, repo, _, _ := newClaimBackoffFixture(t, time.Minute) + app := repo.apps[0] + app.Status = test.status + repo.statusError = test.writeError + epoch := repo.epochs[73][0] + _, _, err := s.proveCommitment(t.Context(), app, epoch, &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}) + require.ErrorIs(t, err, merkle.ErrInvariant) + if test.writeError != nil { + require.ErrorIs(t, err, test.writeError) + } + _ = s.failedClaim(claimRetryKey{73, 2}, "app", err) + _, retried := s.claimRetries[claimRetryKey{73, 2}] + require.Equal(t, test.wantRetry, retried) + if test.status == model.ApplicationStatus_OK { + require.Equal(t, 1, repo.statusCalls) + } else { + require.Zero(t, repo.statusCalls) + } + }) + } +} + +func TestClaimBackoffFailedStatusPersistenceStillRetries(t *testing.T) { + for _, rootBoundary := range []bool{false, true} { + t.Run(fmt.Sprintf("rootBoundary=%t", rootBoundary), func(t *testing.T) { + s, repo, _, _ := newClaimBackoffFixture(t, time.Minute) + failure := errors.New("status transaction failed") + if rootBoundary { + repo.epochs[73][0].TxBufferDataBlock = new(common.Hash) + repo.rootError = failure + } else { + repo.epochs[73][0].MachineHash = nil + repo.statusError = failure + } + _ = backoffTick(t, s, failure) + require.Equal(t, uint64(1), s.claimRetries[claimRetryKey{73, 2}].attempts) + require.Equal(t, model.ApplicationStatus_OK, repo.apps[0].Status) + }) + } +} + +func TestClaimBackoffServeEmitsSingleError(t *testing.T) { + s, repo, _, logs := newClaimBackoffFixture(t, time.Minute) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: config.ServiceValidator, Logger: s.Logger}, PollInterval: time.Hour, + }, s)) + cause := errors.New("failed transaction") + repo.store = func(_ context.Context, key claimRetryKey) error { + if key.applicationID == 91 { + cancel() + } + return cause + } + require.ErrorIs(t, s.Serve(ctx), context.Canceled) + require.Equal(t, 1, strings.Count(logs.String(), `"level":"ERROR"`)) + require.Equal(t, 2, strings.Count(logs.String(), "claim attempt 1 failed")) +} diff --git a/internal/validator/commitment_memory_test.go b/internal/validator/commitment_memory_test.go new file mode 100644 index 000000000..0c002f02b --- /dev/null +++ b/internal/validator/commitment_memory_test.go @@ -0,0 +1,45 @@ +//go:build !race + +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "os" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildCommitmentMemoryIsBounded(t *testing.T) { + const inputs = 4 + rows := uint64(inputs * 250001) + if os.Getenv("CARTESI_VALIDATOR_LARGE_MEMORY_TEST") == "1" { + rows = 42*1000000 + inputs + } + r, s, app, epoch := newPagedCommitmentFixture(t, rows, inputs) + runtime.GC() + var baseline runtime.MemStats + runtime.ReadMemStats(&baseline) + var peak uint64 + r.observePage = func() { + if r.pageCalls%20 != 0 { + return + } + runtime.GC() + var current runtime.MemStats + runtime.ReadMemStats(¤t) + if current.HeapAlloc > baseline.HeapAlloc { + peak = max(peak, current.HeapAlloc-baseline.HeapAlloc) + } + } + commitment, proof, err := s.buildCommitment(t.Context(), app, epoch) + require.NoError(t, err) + require.NotNil(t, commitment) + require.True(t, proof.VerifyRoot(*commitment)) + t.Logf("peak live heap growth across %d rows: %d bytes", rows, peak) + require.Less(t, peak, uint64(32<<20)) + runtime.KeepAlive(r) +} diff --git a/internal/validator/commitment_proof_test.go b/internal/validator/commitment_proof_test.go new file mode 100644 index 000000000..31f4b58ae --- /dev/null +++ b/internal/validator/commitment_proof_test.go @@ -0,0 +1,102 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "errors" + "io" + "log/slog" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" +) + +func TestCommitmentProofFailureClassification(t *testing.T) { + machineHash := common.HexToHash("0x1234") + dbErr := errors.New("status write failed") + for _, tc := range []struct { + name string + tree *merkle.Tree + status model.ApplicationStatus + errorText string + cause error + writeError error + }{ + { + name: "internal tree invariant", tree: &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}, + status: model.ApplicationStatus_Failed, errorText: "internal invariant violated", cause: merkle.ErrInvariant, + }, + { + name: "invariant with status write failure", tree: &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}, + status: model.ApplicationStatus_Failed, errorText: "internal invariant violated", cause: merkle.ErrInvariant, writeError: dbErr, + }, + { + name: "internal child height mismatch", + tree: &merkle.Tree{ + Height: uint32(model.Log2EpochComputationHashLeafCount), + Subtrees: &merkle.InnerNode{ + LHS: merkle.TreeLeaf(machineHash), RHS: merkle.TreeLeaf(machineHash), + }, + }, + status: model.ApplicationStatus_Failed, errorText: "index out of bounds", cause: merkle.ErrBadInput, + }, + { + name: "stored height mismatch", tree: merkle.TreeLeaf(machineHash).Iterated(model.Log2EpochComputationHashLeafCount - 1), + status: model.ApplicationStatus_Corrupted, errorText: "commitment tree height", + }, + { + name: "stored final hash mismatch", tree: merkle.TreeLeaf(common.Hash{}).Iterated(model.Log2EpochComputationHashLeafCount), + status: model.ApplicationStatus_Corrupted, errorText: "does not match machine hash", + }, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newMockrepo() + app := &model.Application{ID: 42, Name: "proof-app", Status: model.ApplicationStatus_OK} + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, tc.status, mock.Anything). + Return(tc.writeError).Once() + + commitment, proof, err := s.proveCommitment(t.Context(), app, epoch, tc.tree) + + require.ErrorContains(t, err, tc.errorText) + require.Nil(t, commitment) + require.Nil(t, proof) + if tc.cause != nil { + require.ErrorIs(t, err, tc.cause) + } + if tc.writeError != nil { + require.ErrorIs(t, err, tc.writeError) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + } else { + require.Equal(t, tc.status, app.Status) + } + repo.AssertExpectations(t) + }) + } +} + +func TestCommitmentProofMatchesEpoch(t *testing.T) { + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + machineHash := common.HexToHash("0x1234") + epoch := &model.Epoch{Index: 3, MachineHash: &machineHash} + tree := merkle.TreeLeaf(machineHash).Iterated(model.Log2EpochComputationHashLeafCount) + app := &model.Application{Status: model.ApplicationStatus_OK} + + commitment, proof, err := s.proveCommitment(t.Context(), app, epoch, tree) + + require.NoError(t, err) + require.Equal(t, tree.GetRootHash(), *commitment) + require.Equal(t, machineHash, proof.Node) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/internal/validator/commitment_streaming_test.go b/internal/validator/commitment_streaming_test.go new file mode 100644 index 000000000..31acfa44b --- /dev/null +++ b/internal/validator/commitment_streaming_test.go @@ -0,0 +1,423 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/merkle/merkletest" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const countReadError = "count error" + +func expectStateHashRows(repo *Mockrepo, app *model.Application, epoch *model.Epoch, rows []*model.StateHash, total uint64) { + calls := 1 + if uint64(len(rows)) != total { + calls++ + repo.application = app + epoch.Status = model.EpochStatus_InputsProcessed + repo.epochs = []*model.Epoch{epoch} + } + repo.On("CountEpochStateHashes", mock.Anything, app.ID, epoch.Index).Return(total, nil).Times(calls) + page := make([]model.EpochStateHash, len(rows)) + for i, row := range rows { + page[i] = model.EpochStateHash{Index: uint64(i), InputIndex: row.InputIndex, + MachineHash: row.MachineHash, Repetitions: row.Repetitions} + } + repo.On("ListEpochStateHashes", mock.Anything, app.ID, epoch.Index, (*uint64)(nil), stateHashPageSize). + Return(page, nil).Once() +} + +type stateHashPageRepository struct { + *Mockrepo + t *testing.T + appID int64 + epochIndex uint64 + total, inputs uint64 + final common.Hash + countCalls, pageCalls int + cursors []*uint64 + countFn func(context.Context, int) (uint64, error) + pageFn func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) + observePage func() +} + +func (r *stateHashPageRepository) CountEpochStateHashes(ctx context.Context, appID int64, epochIndex uint64) (uint64, error) { + require.Equal(r.t, r.appID, appID) + require.Equal(r.t, r.epochIndex, epochIndex) + r.countCalls++ + if r.countFn != nil { + return r.countFn(ctx, r.countCalls) + } + return r.total, ctx.Err() +} + +func (r *stateHashPageRepository) ListEpochStateHashes( + ctx context.Context, appID int64, epochIndex uint64, after *uint64, limit uint64, +) ([]model.EpochStateHash, error) { + require.Equal(r.t, r.appID, appID) + require.Equal(r.t, r.epochIndex, epochIndex) + require.Equal(r.t, stateHashPageSize, limit) + r.pageCalls++ + if after == nil { + r.cursors = append(r.cursors, nil) + } else { + value := *after + r.cursors = append(r.cursors, &value) + } + if r.pageFn != nil { + return r.pageFn(ctx, after, limit) + } + var start uint64 + if after != nil { + start = (*after-17)/2 + 1 + } + count := min(limit, r.total-start) + page := make([]model.EpochStateHash, count) + for i := range page { + page[i] = generatedStateHash(start+uint64(i), r.total, r.inputs, r.final) + } + if r.observePage != nil { + r.observePage() + } + return page, nil +} + +func generatedStateHash(index, rows, inputs uint64, final common.Hash) model.EpochStateHash { + base, extra := rows/inputs, rows%inputs + wideEnd := (base + 1) * extra + var input, offset, size uint64 + if index < wideEnd { + input, offset, size = index/(base+1), index%(base+1), base+1 + } else { + input, offset, size = extra+(index-wideEnd)/base, (index-wideEnd)%base, base + } + var hash common.Hash + binary.BigEndian.PutUint64(hash[common.HashLength-8:], index+1) + repetitions := uint64(1) + if offset+1 == size { + hash = model.FinalStateEntry(final) + repetitions = model.InputHashCollectionCapacity - (size - 1) + } + return model.EpochStateHash{Index: 17 + 2*index, InputIndex: input, MachineHash: hash, Repetitions: repetitions} +} + +func newPagedCommitmentFixture(t *testing.T, rows, inputs uint64) (*stateHashPageRepository, *Service, *model.Application, *model.Epoch) { + t.Helper() + final := common.HexToHash("0x1234") + app := &model.Application{ + ID: 47, Name: "paged-prt", IApplicationAddress: common.HexToAddress("0x47"), + ConsensusType: model.Consensus_PRT, Enabled: true, Status: model.ApplicationStatus_OK, + TemplateHash: common.HexToHash("0x9876"), + } + epoch := &model.Epoch{ + ApplicationID: app.ID, Index: 83, VirtualIndex: 12, Status: model.EpochStatus_InputsProcessed, + InputIndexLowerBound: 700, InputIndexUpperBound: 700 + inputs, MachineHash: &final, + } + r := &stateHashPageRepository{Mockrepo: newMockrepo(), t: t, appID: app.ID, epochIndex: epoch.Index, + total: rows, inputs: inputs, final: final} + r.application, r.epochs = app, []*model.Epoch{epoch} + s := &Service{repository: r} + s.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + return r, s, app, epoch +} + +func expectedPagedCommitment(t *testing.T, r *stateHashPageRepository) (common.Hash, *merkle.Proof) { + t.Helper() + runs := make([]merkletest.Run, 0, r.total+1) + for index := range r.total { + row := generatedStateHash(index, r.total, r.inputs, r.final) + runs = append(runs, merkletest.Run{Hash: row.MachineHash, Count: row.Repetitions}) + } + runs = append(runs, merkletest.Run{Hash: model.FinalStateEntry(r.final), + Count: (model.MaxAdvanceStatesPerEpoch - r.inputs) << model.Log2InputHashCollectionCapacity}) + const entryHeight = uint32(48) + root := merkletest.Root(runs, entryHeight) + _, upper := merkletest.LastProof(runs, entryHeight) + siblings := make([]common.Hash, 0, 54) + hash := r.final + for range 6 { + siblings = append(siblings, hash) + hash = crypto.Keccak256Hash(hash[:], hash[:]) + } + siblings = append(siblings, upper...) + return root, &merkle.Proof{Pos: new(big.Int).SetUint64((uint64(1) << 54) - 1), Node: r.final, Siblings: siblings} +} + +func TestBuildCommitmentPagesMatchFullBuilder(t *testing.T) { + for _, test := range []struct{ rows, inputs uint64 }{ + {1, 1}, {2, 1}, {stateHashPageSize - 1, 1}, {stateHashPageSize, 1}, + {stateHashPageSize + 1, 1}, {2 * stateHashPageSize, 1}, {2*stateHashPageSize + 7, 1}, {2*stateHashPageSize + 7, 300}, + } { + t.Run(fmt.Sprintf("%d rows %d inputs", test.rows, test.inputs), func(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, test.rows, test.inputs) + wantRoot, wantProof := expectedPagedCommitment(t, r) + commitment, proof, err := s.buildCommitment(t.Context(), app, epoch) + require.NoError(t, err) + require.Equal(t, wantRoot, *commitment) + require.Equal(t, wantProof, proof) + require.True(t, proof.VerifyRoot(*commitment)) + require.Equal(t, 1, r.countCalls) + require.Equal(t, int(test.rows/stateHashPageSize)+1, r.pageCalls) //nolint:gosec // Small fixture counts. + require.Nil(t, r.cursors[0]) + for page := 1; page < len(r.cursors); page++ { + expected := uint64(page)*stateHashPageSize - 1 + require.Equal(t, 17+2*expected, *r.cursors[page]) + } + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func expectPagedCorruption(r *stateHashPageRepository, app *model.Application, text string) { + expectNoStoredProtectionRoots(r.Mockrepo, app) + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.Anything).Return(nil).Once().Run(func(args mock.Arguments) { + require.Contains(r.t, *args.Get(3).(*string), text) + }) +} + +func TestBuildCommitmentLostRowPrecedesAppendError(t *testing.T) { + for _, zeroRepetitions := range []bool{false, true} { + t.Run(fmt.Sprint(zeroRepetitions), func(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, 2, 1) + r.pageFn = func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) { + row := generatedStateHash(1, 2, 1, r.final) + if zeroRepetitions { + row.Repetitions = 0 + } + return []model.EpochStateHash{row}, nil + } + expectPagedCorruption(r, app, "inconsistent number of state hashes") + _, _, err := s.buildCommitment(t.Context(), app, epoch) + require.ErrorContains(t, err, "inconsistent number of state hashes") + require.NotContains(t, err.Error(), "invalid repetitions") + require.Equal(t, 2, r.countCalls) + r.AssertExpectations(t) + }) + } +} + +func TestBuildCommitmentReadErrorsAreOperational(t *testing.T) { + sentinel := errors.New("read unavailable") + for _, test := range []struct { + name string + configure func(*stateHashPageRepository, *model.Epoch) + want string + }{ + {countReadError, func(r *stateHashPageRepository, _ *model.Epoch) { + r.countFn = func(context.Context, int) (uint64, error) { return 0, sentinel } + }, "failed to count state hashes"}, + {"page error", func(r *stateHashPageRepository, _ *model.Epoch) { + r.pageFn = func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) { return nil, sentinel } + }, "failed to list state hashes"}, + {"duplicate index", func(r *stateHashPageRepository, _ *model.Epoch) { + r.pageFn = func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) { + row := generatedStateHash(0, 2, 1, r.final) + return []model.EpochStateHash{row, row}, nil + } + }, "not strictly increasing"}, + {"oversized page", func(r *stateHashPageRepository, _ *model.Epoch) { + r.pageFn = func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) { + return make([]model.EpochStateHash, stateHashPageSize+1), nil + } + }, "above limit"}, + } { + t.Run(test.name, func(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, 2*stateHashPageSize, 1) + test.configure(r, epoch) + _, _, err := s.buildCommitment(t.Context(), app, epoch) + require.ErrorContains(t, err, test.want) + if test.name == countReadError || test.name == "page error" { + require.ErrorIs(t, err, sentinel) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestBuildCommitmentRejectsPageCursorRegression(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, 2*stateHashPageSize, 1) + r.pageFn = func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) { + page := make([]model.EpochStateHash, stateHashPageSize) + for i := range page { + page[i] = generatedStateHash(uint64(i), r.total, r.inputs, r.final) + } + return page, nil + } + _, _, err := s.buildCommitment(t.Context(), app, epoch) + require.ErrorContains(t, err, "not strictly increasing") + require.Equal(t, 2, r.pageCalls) + require.Equal(t, uint64(17+2*(stateHashPageSize-1)), *r.cursors[1]) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestBuildCommitmentStopsUnboundedPagesAtCount(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, stateHashPageSize, 1) + r.pageFn = func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) { + // The malformed provider advances its own index but ignores the supplied cursor. + page := make([]model.EpochStateHash, stateHashPageSize) + start := uint64(r.pageCalls-1) * stateHashPageSize //nolint:gosec // Positive bounded test call count. + for i := range page { + page[i] = model.EpochStateHash{Index: start + uint64(i), Repetitions: 1} + } + return page, nil + } + expectPagedCorruption(r, app, "inconsistent number of state hashes") + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + _, _, err := s.buildCommitment(ctx, app, epoch) + require.ErrorContains(t, err, "inconsistent number of state hashes") + require.Equal(t, 2, r.pageCalls) + r.AssertExpectations(t) +} + +func TestBuildCommitmentRechecksBeforeCorruption(t *testing.T) { + sentinel := errors.New("recheck unavailable") + for _, kind := range []string{ + "count changed", "epoch changed", "application gone", "wrong application", "wrong epoch", countReadError, "epoch error", + } { + t.Run(kind, func(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, 2, 1) + r.On("UpdateApplicationStatus", mock.Anything, app.ID, mock.Anything, mock.Anything).Return(nil).Maybe() + r.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: model.NonTerminalEpochStatuses(), + }, repository.Pagination{}, false).Return([]*model.Epoch(nil), uint64(0), nil).Maybe() + r.pageFn = func(context.Context, *uint64, uint64) ([]model.EpochStateHash, error) { + row := generatedStateHash(1, 2, 1, r.final) + row.Repetitions = 0 + return []model.EpochStateHash{row}, nil + } + r.countFn = func(_ context.Context, call int) (uint64, error) { + if call > 1 && kind == "count changed" { + return 1, nil + } + if call > 1 && kind == countReadError { + return 0, sentinel + } + return 2, nil + } + stateReads := 0 + r.readEpochCompletionState = func(_ context.Context, address string, index uint64) (*repository.EpochCompletionState, error) { + require.Equal(t, app.IApplicationAddress.Hex(), address) + require.Equal(t, epoch.Index, index) + stateReads++ + if kind == "application gone" { + return nil, nil + } + if kind == "epoch error" { + return nil, sentinel + } + currentApp, currentEpoch := *app, *epoch + if kind == "epoch changed" { + currentEpoch.Status = model.EpochStatus_ClaimComputed + } + if kind == "wrong application" { + currentApp.ID++ + } + if kind == "wrong epoch" { + currentEpoch.Index++ + } + return &repository.EpochCompletionState{Application: ¤tApp, Epoch: ¤tEpoch}, nil + } + _, _, err := s.buildCommitment(t.Context(), app, epoch) + require.Error(t, err) + require.NotContains(t, err.Error(), "inconsistent number of state hashes") + require.NotContains(t, err.Error(), "invalid repetitions") + if strings.Contains(kind, "error") { + require.ErrorIs(t, err, sentinel) + } + require.Equal(t, 2, r.countCalls) + if kind == countReadError { + require.Zero(t, stateReads) + } else { + require.Equal(t, 1, stateReads) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } +} + +func TestBuildCommitmentEmptyLaterEpoch(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, 0, 0) + commitment, proof, err := s.buildCommitment(t.Context(), app, epoch) + require.NoError(t, err) + want := merkle.TreeLeaf(*epoch.MachineHash).Iterated(model.Log2EpochComputationHashLeafCount) + require.Equal(t, want.RootHash, *commitment) + require.Equal(t, *epoch.MachineHash, proof.Node) + require.NotEqual(t, app.TemplateHash, proof.Node) + require.Zero(t, r.countCalls) + require.Zero(t, r.pageCalls) +} + +func TestBuildCommitmentCancellationDoesNotWriteStatus(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, 1, 1) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, _, err := s.buildCommitment(ctx, app, epoch) + require.ErrorIs(t, err, context.Canceled) + require.Zero(t, r.pageCalls) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + r.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestValidatorStreamsNonemptyEpochBeforeTerminalBoundary(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, stateHashPageSize+1, 1) + s.pristinePostContext = merkle.CreatePostContext() + s.pristineRootHash = s.pristinePostContext[merkle.TREE_DEPTH] + epoch.TxBufferDataBlock = &s.pristineRootHash + app.Status = model.ApplicationStatus_UnexpectedYield + r.problem = &model.ApplicationProblem{EpochIndex: epoch.Index + 1, Kind: app.Status, + InputIndex: new(epoch.InputIndexUpperBound)} + later := &model.Epoch{ApplicationID: app.ID, Index: epoch.Index + 1, VirtualIndex: epoch.VirtualIndex + 1, + Status: model.EpochStatus_InputsProcessed} + r.On("ListApplications", mock.Anything, validationApplicationsFilter(), repository.Pagination{}, false). + Return([]*model.Application{app}, uint64(1), nil).Once() + r.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch, later}, uint64(2), nil).Once() + expectValidPrefixEpoch(r.Mockrepo, app, epoch, false) + r.On("GetEpochByVirtualIndex", mock.Anything, app.IApplicationAddress.Hex(), epoch.VirtualIndex-1). + Return(&model.Epoch{TxBufferDataBlock: &s.pristineRootHash}, nil).Once() + r.On("StoreClaimAndProofs", mock.Anything, epoch, mock.Anything).Return(nil).Once() + _, err := s.Tick(t.Context()) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Equal(t, model.EpochStatus_InputsProcessed, later.Status) + require.Equal(t, model.ApplicationStatus_UnexpectedYield, app.Status) + require.Equal(t, 2, r.pageCalls) + r.AssertExpectations(t) +} + +func TestOpaqueCommitmentProofNamesBothCauses(t *testing.T) { + r, s, app, epoch := newPagedCommitmentFixture(t, 0, 0) + const height = model.Log2EpochComputationHashLeafCount + tree := merkle.OpaqueTree(common.HexToHash("0xabcd"), 1).Iterated(height - 1) + expectNoStoredProtectionRoots(r.Mockrepo, app) + r.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Failed, mock.Anything).Return(nil).Once() + _, _, err := s.proveCommitment(t.Context(), app, epoch, tree) + require.ErrorIs(t, err, merkle.ErrOpaqueTree) + require.ErrorContains(t, err, "last input fills its span") + require.ErrorContains(t, err, "stored padding row is not the epoch's final entry") + require.Equal(t, model.ApplicationStatus_Failed, app.Status) + r.AssertExpectations(t) +} diff --git a/internal/validator/entry_builder.go b/internal/validator/entry_builder.go new file mode 100644 index 000000000..757496140 --- /dev/null +++ b/internal/validator/entry_builder.go @@ -0,0 +1,47 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" +) + +// entryBuilder maps stored entry roots and epoch padding to protocol leaves. +type entryBuilder struct { + builder merkle.StreamingBuilder + finalEntry *merkle.Tree + appendErr error +} + +func newEntryBuilder(finalHash common.Hash, height uint32) *entryBuilder { + return &entryBuilder{finalEntry: merkle.TreeLeaf(finalHash).Iterated(uint64(height))} +} + +func (b *entryBuilder) appendRow(row model.EpochStateHash) { + if b.appendErr != nil { + return + } + entry := merkle.OpaqueTree(row.MachineHash, b.finalEntry.Height) + if row.MachineHash == b.finalEntry.RootHash { + entry = b.finalEntry + } + b.appendErr = b.builder.AppendRepeated(entry, row.Repetitions) +} + +func (b *entryBuilder) finish(inputCount uint64) (*merkle.Tree, error) { + if b.appendErr != nil { + return nil, b.appendErr + } + // The caller bounds inputCount by the protocol's input capacity. + remainingEntries := (model.MaxAdvanceStatesPerEpoch - inputCount) << model.Log2InputHashCollectionCapacity + if remainingEntries != 0 { + b.appendErr = b.builder.AppendRepeated(b.finalEntry, remainingEntries) + if b.appendErr != nil { + return nil, b.appendErr + } + } + return b.builder.Build() +} diff --git a/internal/validator/entry_builder_test.go b/internal/validator/entry_builder_test.go new file mode 100644 index 000000000..a5b2f814b --- /dev/null +++ b/internal/validator/entry_builder_test.go @@ -0,0 +1,236 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "fmt" + "math/big" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/merkle/merkletest" + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestEntryBuilderMatchesProtocolLeaves(t *testing.T) { + for _, height := range []uint32{0, 6} { + t.Run(fmt.Sprint(height), func(t *testing.T) { + var rows []model.EpochStateHash + var runs []merkletest.Run + var final common.Hash + for input, periodic := range []uint64{0, 1, 63, 64, 65, 127, 128, 191, 0} { + rejected := input == 8 + if !rejected { + final = crypto.Keccak256Hash([]byte(fmt.Sprintf("final-%d", input))) + } + nextRows, nextRuns := syntheticEntryInput(t, periodic, height, final, rejected) + rows = append(rows, nextRows...) + runs = append(runs, nextRuns...) + } + const inputCount = 9 + builder := newEntryBuilder(final, height) + for _, row := range rows { + builder.appendRow(row) + } + tree, err := builder.finish(inputCount) + require.NoError(t, err) + epochPadding := (model.MaxAdvanceStatesPerEpoch - inputCount) * (model.InputHashCollectionCapacity << height) + runs = append(runs, merkletest.Run{Hash: final, Count: epochPadding}) + assertEntryTreeMatchesLeaves(t, tree, runs, 48+height, false) + }) + } +} + +// syntheticEntryInput constructs emulator output from protocol samples directly. +// It hashes each complete bundle and a partial bundle padded with the final hash. +// Bundled output also includes the emulator's final all-final-state entry. +func syntheticEntryInput( + t *testing.T, periodic uint64, height uint32, final common.Hash, rejected bool, +) ([]model.EpochStateHash, []merkletest.Run) { + t.Helper() + samples := make([]common.Hash, periodic) + runs := make([]merkletest.Run, 0, len(samples)+1) + for i := range samples { + samples[i] = crypto.Keccak256Hash([]byte(fmt.Sprintf("sample-%s-%d", final.Hex(), i))) + runs = append(runs, merkletest.Run{Hash: samples[i], Count: 1}) + } + width := uint64(1) << height + var raw [][32]byte + var consumed uint64 + for periodic-consumed >= width { + raw = append(raw, denseEntryHash(samples[consumed:consumed+width])) + consumed += width + } + finalRoot := final + for range height { + finalRoot = crypto.Keccak256Hash(finalRoot[:], finalRoot[:]) + } + if height != 0 { + tail := make([]common.Hash, width) + for i := range tail { + tail[i] = final + } + copy(tail, samples[consumed:]) + raw = append(raw, denseEntryHash(tail)) + } + raw = append(raw, finalRoot) + keptCount := (periodic + width - 1) / width + if rejected { + keptCount = 0 + } + kept := raw[:keptCount] + padding := model.InputHashCollectionCapacity - keptCount + rows := make([]model.EpochStateHash, 0, len(kept)+1) + for _, hash := range kept { + rows = append(rows, model.EpochStateHash{MachineHash: hash, Repetitions: 1}) + } + if padding > 0 { + rows = append(rows, model.EpochStateHash{MachineHash: finalRoot, Repetitions: padding}) + } + span := model.InputHashCollectionCapacity << height + runs = append(runs, merkletest.Run{Hash: final, Count: span - periodic}) + return rows, runs +} + +func denseEntryHash(leaves []common.Hash) common.Hash { + level := append([]common.Hash(nil), leaves...) + for len(level) > 1 { + next := make([]common.Hash, len(level)/2) + for i := range next { + next[i] = crypto.Keccak256Hash(level[2*i][:], level[2*i+1][:]) + } + level = next + } + return level[0] +} + +func assertEntryTreeMatchesLeaves( + t *testing.T, tree *merkle.Tree, runs []merkletest.Run, height uint32, opaqueLast bool, +) { + t.Helper() + require.Equal(t, height, tree.Height) + require.Equal(t, merkletest.Root(runs, height), tree.RootHash) + left, right := merkletest.RootChildren(runs, height) + actualLeft, actualRight, err := tree.Subtrees.Children() + require.NoError(t, err) + require.Equal(t, left, actualLeft.RootHash) + require.Equal(t, right, actualRight.RootHash) + proof, err := tree.ProveLast() + if opaqueLast { + require.ErrorIs(t, err, merkle.ErrOpaqueTree) + return + } + require.NoError(t, err) + node, siblings := merkletest.LastProof(runs, height) + require.Equal(t, node, proof.Node) + require.Equal(t, siblings, proof.Siblings) + expectedPosition := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), uint(height)), big.NewInt(1)) + require.Equal(t, expectedPosition, proof.Pos) + require.True(t, proof.VerifyRoot(tree.RootHash)) + proofLeft, proofRight, err := proof.BuildRootChildren() + require.NoError(t, err) + require.Equal(t, left, proofLeft) + require.Equal(t, right, proofRight) +} + +func TestEntryBuilderEmptyEpochUsesActualFinalHash(t *testing.T) { + final := common.HexToHash("0x1234") + for _, height := range []uint32{0, 6} { + tree, err := newEntryBuilder(final, height).finish(0) + require.NoError(t, err) + leaves := uint64(1) << (48 + height) + assertEntryTreeMatchesLeaves(t, tree, []merkletest.Run{{Hash: final, Count: leaves}}, 48+height, false) + } + require.Equal(t, model.FinalStateEntry(final), merkle.TreeLeaf(final).Iterated(model.Log2StateHashesPerEntry).RootHash) +} + +func TestEntryBuilderFullSpanAndFullEpoch(t *testing.T) { + const height = 6 + const width = uint64(1) << height + a := common.HexToHash("0xaaaa") + b := common.HexToHash("0xbbbb") + final := common.HexToHash("0xcccc") + finalNext := common.HexToHash("0xdddd") + repeatedA := a + repeatedB := b + for range height { + repeatedA = crypto.Keccak256Hash(repeatedA[:], repeatedA[:]) + repeatedB = crypto.Keccak256Hash(repeatedB[:], repeatedB[:]) + } + tail := make([]common.Hash, width) + for i := range tail { + tail[i] = b + } + tail[len(tail)-1] = final + fullSpanRows := []model.EpochStateHash{ + {MachineHash: repeatedB, Repetitions: model.InputHashCollectionCapacity - 1}, + {MachineHash: denseEntryHash(tail), Repetitions: 1}, + } + span := model.InputHashCollectionCapacity * width + fullSpanRuns := []merkletest.Run{{Hash: b, Count: span - 1}, {Hash: final, Count: 1}} + for _, followed := range []bool{false, true} { + name := "full span followed by epoch padding" + if followed { + name = "full span followed by another input" + } + t.Run(name, func(t *testing.T) { + inputCount := uint64(1) + rows := append([]model.EpochStateHash(nil), fullSpanRows...) + runs := append([]merkletest.Run(nil), fullSpanRuns...) + epochFinal := final + if followed { + inputCount++ + epochFinal = finalNext + nextRows, nextRuns := syntheticEntryInput(t, 5, height, finalNext, false) + rows = append(rows, nextRows...) + runs = append(runs, nextRuns...) + } + builder := newEntryBuilder(epochFinal, height) + for _, row := range rows { + builder.appendRow(row) + } + tree, err := builder.finish(inputCount) + require.NoError(t, err) + runs = append(runs, merkletest.Run{Hash: epochFinal, Count: (model.MaxAdvanceStatesPerEpoch - inputCount) * span}) + assertEntryTreeMatchesLeaves(t, tree, runs, 54, false) + }) + } + for _, fullSpan := range []bool{false, true} { + name := "last input has final-state padding" + if fullSpan { + name = "last input fills its span" + } + t.Run(name, func(t *testing.T) { + builder := newEntryBuilder(final, height) + prefixEntries := (model.MaxAdvanceStatesPerEpoch - 1) * model.InputHashCollectionCapacity + builder.appendRow(model.EpochStateHash{MachineHash: repeatedA, Repetitions: prefixEntries}) + rows, lastRuns := fullSpanRows, fullSpanRuns + if !fullSpan { + rows, lastRuns = syntheticEntryInput(t, 5, height, final, false) + } + for _, row := range rows { + builder.appendRow(row) + } + tree, err := builder.finish(model.MaxAdvanceStatesPerEpoch) + require.NoError(t, err) + runs := make([]merkletest.Run, 0, 1+len(lastRuns)) + runs = append(runs, merkletest.Run{Hash: a, Count: prefixEntries * width}) + runs = append(runs, lastRuns...) + assertEntryTreeMatchesLeaves(t, tree, runs, 54, fullSpan) + }) + } +} + +func TestEntryBuilderKeepsFirstAppendError(t *testing.T) { + builder := newEntryBuilder(common.Hash{}, 6) + builder.appendRow(model.EpochStateHash{Repetitions: 0}) + first := builder.appendErr + require.ErrorContains(t, first, "invalid repetitions") + builder.appendRow(model.EpochStateHash{Repetitions: 1}) + _, err := builder.finish(1) + require.ErrorIs(t, err, first) +} diff --git a/internal/validator/invalid_outputs_root_test.go b/internal/validator/invalid_outputs_root_test.go new file mode 100644 index 000000000..350eaf703 --- /dev/null +++ b/internal/validator/invalid_outputs_root_test.go @@ -0,0 +1,158 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "log/slog" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestValidateInvalidOutputsRoot(t *testing.T) { + for _, consensus := range model.ConsensusAllValues { + t.Run(consensus.String(), func(t *testing.T) { + for _, changedOutputs := range []bool{false, true} { + name := "declared root differs" + if changedOutputs { + name = "stored output bytes differ" + } + t.Run(name, func(t *testing.T) { + repo, validator, app, epoch := rootValidationFixture() + app.ConsensusType = consensus + declared := common.Hash{} + var outputs []*model.Output + if changedOutputs { + declared = validator.pristineRootHash + outputs = []*model.Output{{Index: 0, RawData: []byte("stored bytes")}} + } + epoch.TxBufferDataBlock = &declared + input := &model.Input{ + Index: 0, Status: model.InputCompletionStatus_Accepted, + MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock, + } + expectRootValidationReads(repo, app, epoch, input, outputs) + repo.On("StoreInvalidOutputsRoot", mock.Anything, app.ID, epoch.Index, + mock.MatchedBy(func(reason string) bool { + return strings.Contains(reason, "declared="+declared.Hex()) && + strings.Contains(reason, "calculated=") + })).Return(nil).Once() + + err := validator.validateApplication(t.Context(), app) + + require.ErrorContains(t, err, "epoch 0: declared outputs root does not match the root calculated from stored outputs") + // The repository owns the atomic status/boundary write; + // this pre-computation application object is not a cache. + require.Equal(t, model.ApplicationStatus_OK, app.Status) + require.NotContains(t, err.Error(), "guest") + require.NotContains(t, err.Error(), "corrupt") + require.Equal(t, model.InputCompletionStatus_Accepted, input.Status) + require.Equal(t, model.EpochStatus_InputsProcessed, epoch.Status) + repo.AssertNotCalled(t, "StoreClaimAndProofs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "CountEpochStateHashes", mock.Anything, app.ID, epoch.Index) + repo.AssertExpectations(t) + }) + } + }) + } +} + +func TestValidateEmptyFirstEpochInvalidOutputsRoot(t *testing.T) { + repo, validator, app, epoch := rootValidationFixture() + app.ConsensusType = model.Consensus_PRT + zero := common.Hash{} + epoch.TxBufferDataBlock = &zero + expectRootValidationReads(repo, app, epoch, nil, nil) + reason := "epoch 0: declared outputs root does not match the root calculated from stored outputs; declared=" + + zero.Hex() + "; calculated=" + validator.pristineRootHash.Hex() + repo.On("StoreInvalidOutputsRoot", mock.Anything, app.ID, epoch.Index, + reason).Return(nil).Once() + + err := validator.validateApplication(t.Context(), app) + + require.EqualError(t, err, reason) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "StoreClaimAndProofs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +func TestValidateStoredIdentityBeforeOutputsRootRule(t *testing.T) { + for _, tc := range []struct { + name string + empty bool + machineError bool + wantReason string + }{ + {name: "epoch word differs from input", wantReason: "outputs merkle root does not match last input"}, + {name: "epoch machine differs from input", machineError: true, wantReason: "machine hash does not match epoch last input"}, + {name: "empty epoch machine differs from template", empty: true, machineError: true, + wantReason: "machine hash does not match for application template hash"}, + } { + t.Run(tc.name, func(t *testing.T) { + repo, validator, app, epoch := rootValidationFixture() + app.ConsensusType = model.Consensus_PRT + zero := common.Hash{} + epoch.TxBufferDataBlock = &zero // Also fails the calculated-root rule. + input := &model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: &validator.pristineRootHash} + if tc.machineError { + input.TxBufferDataBlock = epoch.TxBufferDataBlock + epoch.MachineHash = new(common.HexToHash("0xbad")) + } + if tc.empty { + input = nil + } + expectRootValidationReads(repo, app, epoch, input, nil) + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, model.ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, tc.wantReason) + })).Return(nil).Once() + + err := validator.validateApplication(t.Context(), app) + + require.ErrorContains(t, err, tc.wantReason) + require.Equal(t, model.ApplicationStatus_Corrupted, app.Status) + repo.AssertNotCalled(t, "StoreClaimAndProofs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func rootValidationFixture() (*Mockrepo, *Service, *model.Application, *model.Epoch) { + repo := newMockrepo() + postContext := merkle.CreatePostContext() + validator := &Service{ + repository: repo, pristinePostContext: postContext, pristineRootHash: postContext[merkle.TREE_DEPTH], + } + validator.Logger = slog.Default() + app := &model.Application{ + ID: 73, Name: "root-validation", Enabled: true, ConsensusType: model.Consensus_Authority, + Status: model.ApplicationStatus_OK, TemplateHash: common.HexToHash("0xcafe"), + } + epoch := &model.Epoch{ + ApplicationID: app.ID, Index: 0, VirtualIndex: 0, FirstBlock: 0, LastBlock: 9, + Status: model.EpochStatus_InputsProcessed, MachineHash: &app.TemplateHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + repo.application = app + return repo, validator, app, epoch +} + +func expectRootValidationReads( + repo *Mockrepo, app *model.Application, epoch *model.Epoch, input *model.Input, outputs []*model.Output, +) { + expectNoStoredProtectionRoots(repo, app) + appAddress := app.IApplicationAddress.String() + // A later epoch must remain unprocessed after the first finding. + epochs := []*model.Epoch{epoch, {ApplicationID: app.ID, Index: 1, VirtualIndex: 1}} + repo.On("ListEpochs", mock.Anything, appAddress, mock.Anything, repository.Pagination{}, false). + Return(epochs, uint64(len(epochs)), nil).Once() + expectEpochOutputs(repo, app, epoch, outputs) + repo.On("GetLastInput", mock.Anything, appAddress, epoch.Index).Return(input, nil).Once() +} diff --git a/internal/validator/outputs.go b/internal/validator/outputs.go new file mode 100644 index 000000000..47bd8118c --- /dev/null +++ b/internal/validator/outputs.go @@ -0,0 +1,161 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "context" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +const outputsChunkHeight = 11 + +// computeMerkleTreeAndProofs reads the epoch's outputs once and returns the +// outputs root. The returned writer reads them again and stores their proofs. +// An epoch without outputs keeps the previous epoch's root and needs no writer. +func (s *Service) computeMerkleTreeAndProofs( + ctx context.Context, app *model.Application, epoch *model.Epoch, +) (root *common.Hash, write repository.ClaimProofWriteFunc, err error) { + start := time.Now() + appAddress := app.IApplicationAddress.String() + // Epoch identity remains unambiguous when inputs straddle a block boundary. + span, err := s.repository.GetEpochOutputRange(ctx, app.ID, epoch.Index) + if err != nil { + return nil, nil, fmt.Errorf("failed to get outputs for epoch %v (%v) of application %v. %w", + epoch.Index, epoch.VirtualIndex, appAddress, err) + } + defer func() { + if err == nil && root != nil { + s.Logger.Info("OutputsMerkleRoot Computed", "application", appAddress, "epoch_index", epoch.Index, + "outputs_merkle_root", *root, "output_count", span.Count, "pass1_elapsed", time.Since(start)) + } + }() + var previousEpoch *model.Epoch + if epoch.VirtualIndex > 0 { + previousEpoch, err = s.repository.GetEpochByVirtualIndex(ctx, appAddress, epoch.VirtualIndex-1) + if err != nil { + return nil, nil, fmt.Errorf("failed to get previous epoch for epoch %v (%v) of application %v. %w", + epoch.Index, epoch.VirtualIndex, appAddress, err) + } + } + if span.Count == 0 { + if epoch.VirtualIndex != 0 && previousEpoch == nil { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "epoch %v (%v): previous epoch with virtual index %v is missing", + epoch.Index, epoch.VirtualIndex, epoch.VirtualIndex-1) + } + if epoch.VirtualIndex == 0 { + return &s.pristineRootHash, nil, nil + } + if previousEpoch.TxBufferDataBlock == nil { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "invalid application state for epoch %v (%v) of application %v. Previous epoch has no claim.", + epoch.Index, epoch.VirtualIndex, appAddress) + } + return previousEpoch.TxBufferDataBlock, nil, nil + } + if span.Last < span.First || span.Last-span.First != span.Count-1 { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "outputs of epoch %v are not contiguous: %v outputs from index %v to %v", + epoch.Index, span.Count, span.First, span.Last) + } + pre := s.pristinePostContext + if span.First != 0 { + previousOutput, err := s.repository.GetOutput(ctx, appAddress, span.First-1) + if err != nil { + return nil, nil, fmt.Errorf("failed to get previous output for epoch %v (%v) of application %v. %w", + epoch.Index, epoch.VirtualIndex, appAddress, err) + } + if previousOutput == nil || previousOutput.Hash == nil || len(previousOutput.OutputHashesSiblings) != merkle.TREE_DEPTH { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "Inconsistent application state (%v). Output (%d) preceding epoch %d is missing or has invalid hash siblings.", + app.Name, span.First-1, epoch.Index) + } + pre = merkle.CreatePreContextFromProof(previousOutput.Index, *previousOutput.Hash, previousOutput.OutputHashesSiblings) + } + builder, err := merkle.NewOutputsTreeBuilder(pre, s.pristinePostContext, span.First, span.Count, outputsChunkHeight) + if err != nil { + return nil, nil, fmt.Errorf("invalid outputs builder: %w: %v", merkle.ErrInvariant, err) + } + scan := func(ctx context.Context, first, last uint64, fn repository.OutputScanFunc) error { + return s.repository.ScanOutputs(ctx, app.ID, first, last, fn) + } + for first, last := range builder.ChunkRanges() { + if err := scanOutputChunk(ctx, first, last, scan, func(hash common.Hash) error { + builder.Append(hash) + return nil + }); err != nil { + return nil, nil, err + } + } + tree := builder.Build() + hash := tree.Root() + return &hash, func(ctx context.Context, writer repository.ClaimProofWriter) error { + return writeOutputProofs(ctx, writer, tree) + }, nil +} + +type outputChunkScan func(context.Context, uint64, uint64, repository.OutputScanFunc) error + +// scanOutputChunk hashes each borrowed row before the scan advances. +func scanOutputChunk( + ctx context.Context, first, last uint64, scan outputChunkScan, appendHash func(common.Hash) error, +) error { + next := first + if err := scan(ctx, first, last, func(index uint64, rawData []byte) error { + if index != next || index > last { + return fmt.Errorf("expected output %d in range %d..%d, got %d: %w", + next, first, last, index, merkle.ErrOutputsChanged) + } + if err := appendHash(crypto.Keccak256Hash(rawData)); err != nil { + return err + } + next++ + return nil + }); err != nil { + return fmt.Errorf("scan outputs %d..%d: %w", first, last, err) + } + if next != last+1 { + return fmt.Errorf("outputs %d..%d ended at %d: %w", first, last, next, merkle.ErrOutputsChanged) + } + return nil +} + +// writeOutputProofs stores one batch of proofs for each chunk of the tree. +func writeOutputProofs(ctx context.Context, writer repository.ClaimProofWriter, tree *merkle.OutputsTree) error { + prover := tree.NewProver() + for first, last := range tree.ChunkRanges() { + var chunk *merkle.ChunkProofs // Each chunk range completes one proof chunk. + err := scanOutputChunk(ctx, first, last, writer.ScanOutputs, func(hash common.Hash) error { + proofs, err := prover.Append(hash) + if proofs != nil { + chunk = proofs + } + return err + }) + if err != nil { + return err + } + if chunk == nil { + return fmt.Errorf("missing proof chunk: %w", merkle.ErrInvariant) + } + proofs := make([]repository.OutputProof, len(chunk.Leaves)) + for index, hash := range chunk.Leaves { + proofs[index] = repository.OutputProof{Index: first + uint64(index), Hash: hash, + Siblings: chunk.Siblings[index*merkle.TREE_DEPTH : (index+1)*merkle.TREE_DEPTH]} + } + // Rows are closed before this call. pgx cannot write during ScanOutputs. + if err := writer.StoreOutputProofs(ctx, proofs); err != nil { + return err + } + } + return nil +} diff --git a/internal/validator/outputs_memory_test.go b/internal/validator/outputs_memory_test.go new file mode 100644 index 000000000..fb2ee0008 --- /dev/null +++ b/internal/validator/outputs_memory_test.go @@ -0,0 +1,116 @@ +//go:build !race + +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "encoding/binary" + "os" + "runtime" + "testing" + + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const outputMemoryMiB = uint64(1 << 20) + +func liveOutputHeap() uint64 { + runtime.GC() + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + return stats.HeapAlloc +} + +func TestOutputsPathMemoryIsBounded(t *testing.T) { + count := uint64(240_000) + if os.Getenv("CARTESI_VALIDATOR_LARGE_OUTPUT_MEMORY_TEST") == "1" { + count = 4_000_000 + } + service, repo, _ := newOutputsFixture(t, 0, count) + repo.keepProofs = false + repo.enforceBatchLimit = false + baseline := liveOutputHeap() + var maximum uint64 + var rowReadings [2]int + batches := 0 + sample := func() { + heap := liveOutputHeap() + if heap > baseline { + maximum = max(maximum, heap-baseline) + } + } + repo.onRow = func(second bool, index uint64, raw []byte) { + pass := 0 + if second { + pass = 1 + } + current := repo.scans[pass][len(repo.scans[pass])-1] + if index == current.first { + sample() + rowReadings[pass]++ + } + runtime.KeepAlive(raw) + } + repo.onBatch = func(proofs []repository.OutputProof) { + sample() + // The matrix, leaves and batch are still live at every measurement. + runtime.KeepAlive(proofs) + batches++ + } + _, write, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + require.NoError(t, err) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), repo.epoch, write)) + final := liveOutputHeap() + finalGrowth := uint64(0) + if final > baseline { + finalGrowth = final - baseline + } + runtime.KeepAlive(write) + runtime.KeepAlive(service) + expected := len(repo.scans[0]) + require.Equal(t, [2]int{expected, expected}, rowReadings) + require.Equal(t, expected, batches) + t.Logf("outputs=%d active_growth=%d final_growth=%d batches=%d readings=%v", + count, maximum, finalGrowth, batches, rowReadings) + // Pure fake measurement: 4,372,216 bytes; ceil(1.5 times growth) is 7 MiB. + assert.LessOrEqual(t, maximum, 7*outputMemoryMiB, "live output batch exceeds its heap bound") + assert.LessOrEqual(t, finalGrowth, 2*outputMemoryMiB, "completed pass retains epoch-sized data") +} + +func TestOutputsPathMemoryLargeOutputs(t *testing.T) { + service, repo, _ := newOutputsFixture(t, 2048-32, 64) + repo.keepProofs = false + repo.raw = func(index uint64) []byte { + raw := make([]byte, 2*outputMemoryMiB) + binary.BigEndian.PutUint64(raw, index) + return raw + } + baseline := liveOutputHeap() + maximum := uint64(0) + readings := [2]int{} + repo.onRow = func(second bool, _ uint64, raw []byte) { + heap := liveOutputHeap() + if heap > baseline { + maximum = max(maximum, heap-baseline) + } + pass := 0 + if second { + pass = 1 + } + readings[pass]++ + runtime.KeepAlive(raw) + } + _, write, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + require.NoError(t, err) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), repo.epoch, write)) + require.Equal(t, [2]int{64, 64}, readings) + require.Len(t, repo.scans[0], 2) + require.Len(t, repo.scans[1], 2) + t.Logf("large_outputs active_growth=%d readings=%v", maximum, readings) + require.LessOrEqual(t, maximum, 16*outputMemoryMiB, "borrowed output rows were retained") + runtime.KeepAlive(write) +} diff --git a/internal/validator/outputs_mock_test.go b/internal/validator/outputs_mock_test.go new file mode 100644 index 000000000..d100f95c0 --- /dev/null +++ b/internal/validator/outputs_mock_test.go @@ -0,0 +1,30 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" +) + +func expectEpochOutputs(repo *Mockrepo, app *model.Application, epoch *model.Epoch, outputs []*model.Output) { + span := repository.EpochOutputRange{Count: uint64(len(outputs))} + if len(outputs) != 0 { + span.First, span.Last = outputs[0].Index, outputs[len(outputs)-1].Index + } + repo.On("GetEpochOutputRange", mock.Anything, app.ID, epoch.Index).Return(span, nil).Once() + if len(outputs) == 0 { + return + } + repo.On("ScanOutputs", mock.Anything, app.ID, mock.Anything, mock.Anything, mock.Anything). + Return(func(fn repository.OutputScanFunc) error { + for _, output := range outputs { + if err := fn(output.Index, output.RawData); err != nil { + return err + } + } + return nil + }, nil).Maybe() +} diff --git a/internal/validator/outputs_scan_test.go b/internal/validator/outputs_scan_test.go new file mode 100644 index 000000000..d09108ba6 --- /dev/null +++ b/internal/validator/outputs_scan_test.go @@ -0,0 +1,38 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/repository" +) + +func TestOutputChunkScanPreservesCallbackError(t *testing.T) { + sentinel := errors.New("builder failed") + err := scanOutputChunk(t.Context(), 0, 0, func(_ context.Context, first, _ uint64, fn repository.OutputScanFunc) error { + return fn(first, []byte("row")) + }, func(common.Hash) error { return sentinel }) + require.ErrorIs(t, err, sentinel) +} + +func TestOutputChunkScanClassifiesMissingAndExtraRows(t *testing.T) { + for _, indexes := range [][]uint64{nil, {0}, {0, 0}, {1}, {0, 1, 2}} { + err := scanOutputChunk(t.Context(), 0, 1, func(_ context.Context, _, _ uint64, fn repository.OutputScanFunc) error { + for _, index := range indexes { + if err := fn(index, []byte("row")); err != nil { + return err + } + } + return nil + }, func(common.Hash) error { return nil }) + require.ErrorIs(t, err, merkle.ErrOutputsChanged) + } +} diff --git a/internal/validator/outputs_test.go b/internal/validator/outputs_test.go new file mode 100644 index 000000000..44e414133 --- /dev/null +++ b/internal/validator/outputs_test.go @@ -0,0 +1,437 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "log/slog" + "slices" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/merkle/merkletest" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type outputScanRange struct{ first, last uint64 } + +type outputsRepository struct { + *Mockrepo + t *testing.T + app *model.Application + epoch *model.Epoch + span repository.EpochOutputRange + raw func(uint64) []byte + scanFault func(bool, uint64, uint64, repository.OutputScanFunc) (bool, error) + onRow func(bool, uint64, []byte) + onBatch func([]repository.OutputProof) + writeErr error + scans [2][]outputScanRange + committed []repository.OutputProof + keepProofs bool + enforceBatchLimit bool + inWrite, inScan bool + storedStatus model.EpochStatus +} + +func (repo *outputsRepository) GetEpochOutputRange( + _ context.Context, appID int64, epochIndex uint64, +) (repository.EpochOutputRange, error) { + require.Equal(repo.t, repo.app.ID, appID) + require.Equal(repo.t, repo.epoch.Index, epochIndex) + return repo.span, nil +} + +func (repo *outputsRepository) GetOutput(ctx context.Context, address string, index uint64) (*model.Output, error) { + require.Equal(repo.t, repo.app.IApplicationAddress.String(), address) + require.Equal(repo.t, repo.span.First-1, index) + return repo.Mockrepo.GetOutput(ctx, address, index) +} + +func (repo *outputsRepository) ScanOutputs( + _ context.Context, appID int64, first, last uint64, fn repository.OutputScanFunc, +) error { + require.Equal(repo.t, repo.app.ID, appID) + if repo.inWrite { + return errors.New("pool scan inside claim transaction") + } + return repo.scan(false, first, last, fn) +} + +func (repo *outputsRepository) scan(second bool, first, last uint64, fn repository.OutputScanFunc) error { + if repo.inScan { + return errors.New("connection busy: nested scan") + } + repo.inScan = true + defer func() { repo.inScan = false }() + pass := 0 + if second { + pass = 1 + } + repo.scans[pass] = append(repo.scans[pass], outputScanRange{first, last}) + if repo.scanFault != nil { + handled, err := repo.scanFault(second, first, last, fn) + if handled || err != nil { + return err + } + } + for index := first; index <= last; index++ { + raw := repo.raw(index) + if err := fn(index, raw); err != nil { + return err + } + if repo.onRow != nil { + repo.onRow(second, index, raw) + } + } + return nil +} + +type outputsWriter struct { + repo *outputsRepository + staged []repository.OutputProof +} + +func (writer *outputsWriter) ScanOutputs( + _ context.Context, first, last uint64, fn repository.OutputScanFunc, +) error { + return writer.repo.scan(true, first, last, fn) +} + +func (writer *outputsWriter) StoreOutputProofs(_ context.Context, proofs []repository.OutputProof) error { + if writer.repo.inScan { + return errors.New("connection busy: proof write during scan") + } + // This literal pins the validator's configured chunk size independently. + if writer.repo.enforceBatchLimit && len(proofs) > 2048 { + return errors.New("proof batch exceeds 2048 outputs") + } + if writer.repo.onBatch != nil { + writer.repo.onBatch(proofs) + } + if writer.repo.writeErr != nil && len(writer.staged) != 0 { + return writer.repo.writeErr + } + if writer.repo.keepProofs { + for _, proof := range proofs { + proof.Siblings = slices.Clone(proof.Siblings) + writer.staged = append(writer.staged, proof) + } + } + return nil +} + +func (repo *outputsRepository) StoreClaimAndProofs( + ctx context.Context, epoch *model.Epoch, write repository.ClaimProofWriteFunc, +) error { + require.Equal(repo.t, repo.app.ID, epoch.ApplicationID) + require.Equal(repo.t, repo.epoch.Index, epoch.Index) + if repo.inWrite { + return errors.New("nested claim write") + } + repo.inWrite = true + defer func() { repo.inWrite = false }() + writer := &outputsWriter{repo: repo} + if write != nil { + if err := write(ctx, writer); err != nil { + return err + } + } + repo.committed = writer.staged + repo.storedStatus = model.EpochStatus_ClaimComputed + return nil +} + +func generatedOutput(index uint64) []byte { + raw := make([]byte, 132) + binary.BigEndian.PutUint64(raw, index) + return raw +} + +func newOutputsFixture(t *testing.T, begin, count uint64) (*Service, *outputsRepository, []common.Hash) { + t.Helper() + post := merkle.CreatePostContext() + app := &model.Application{ID: 73, Name: "streaming-output-fixture", Enabled: true, Status: model.ApplicationStatus_OK, + ConsensusType: model.Consensus_Authority, IApplicationAddress: common.HexToAddress("0x1234")} + epoch := &model.Epoch{ApplicationID: app.ID, Index: 5, VirtualIndex: 2, + Status: model.EpochStatus_InputsProcessed, MachineHash: new(common.HexToHash("0x5678"))} + repo := &outputsRepository{Mockrepo: newMockrepo(), t: t, app: app, epoch: epoch, + span: repository.EpochOutputRange{Count: count, First: begin}, raw: generatedOutput, + keepProofs: true, enforceBatchLimit: true, storedStatus: model.EpochStatus_InputsProcessed} + if count > 0 { + repo.span.Last = begin + count - 1 + } + repo.application = app + pre := post + previousRoot := post[merkle.TREE_DEPTH] + if begin != 0 { + hash := crypto.Keccak256Hash([]byte("previous output")) + siblings := make([]common.Hash, merkle.TREE_DEPTH) + for i := range siblings { + siblings[i] = crypto.Keccak256Hash([]byte{byte(i), 42}) + } + previous := &model.Output{Index: begin - 1, Hash: &hash, OutputHashesSiblings: siblings} + repo.On("GetOutput", mock.Anything, app.IApplicationAddress.String(), begin-1).Return(previous, nil).Once() + pre = merkle.CreatePreContextFromProof(previous.Index, hash, siblings) + previousRoot = merkletest.ComputeRootHashFromProof(previous.Index, hash, siblings) + } + previous := &model.Epoch{ApplicationID: app.ID, Index: 3, VirtualIndex: 1, TxBufferDataBlock: &previousRoot} + repo.On("GetEpochByVirtualIndex", mock.Anything, app.IApplicationAddress.String(), uint64(1)).Return(previous, nil).Once() + service := &Service{repository: repo, pristinePostContext: post, pristineRootHash: post[merkle.TREE_DEPTH]} + service.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + return service, repo, pre +} + +func recursiveOutputsOracle(t *testing.T, repo *outputsRepository, pre, post []common.Hash) (common.Hash, []repository.OutputProof) { + t.Helper() + leaves := make([]common.Hash, repo.span.Count) + for i := range leaves { + leaves[i] = crypto.Keccak256Hash(repo.raw(repo.span.First + uint64(i))) + } + siblings, err := merkletest.ComputeSiblingsMatrix(pre, leaves, post, repo.span.First) + require.NoError(t, err) + proofs := make([]repository.OutputProof, len(leaves)) + for i, leaf := range leaves { + proofs[i] = repository.OutputProof{Index: repo.span.First + uint64(i), Hash: leaf, + Siblings: slices.Clone(siblings[i*merkle.TREE_DEPTH : (i+1)*merkle.TREE_DEPTH])} + } + return merkletest.ComputeRootHashFromProof(repo.span.First, leaves[0], proofs[0].Siblings), proofs +} + +func TestOutputsTwoPassesMatchRecursiveOracle(t *testing.T) { + const page = uint64(2048) + type testRange struct{ begin, count uint64 } + cases := make([]testRange, 0, 32) + cases = append(cases, []testRange{{0, 1}, {0, 2}, {0, page - 1}, {0, page}, {0, page + 1}, {0, 2*page + 7}, + {1, page + 9}, {page - 1, page + 9}, {3*page + 5, page + 9}, {1<<40 + 3, page + 9}, + {1<<63 - page - 9, page + 9}, {1<<63 - 1, 1}}...) + for _, test := range cases { + t.Run(fmt.Sprintf("%d_%d", test.begin, test.count), func(t *testing.T) { + service, repo, pre := newOutputsFixture(t, test.begin, test.count) + expectedRoot, expectedProofs := recursiveOutputsOracle(t, repo, pre, service.pristinePostContext) + root, write, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + require.NoError(t, err) + require.Equal(t, expectedRoot, *root) + require.NotNil(t, write) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), repo.epoch, write)) + assertOutputProofs(t, expectedProofs, repo.committed) + require.Equal(t, repo.scans[0], repo.scans[1]) + expectedChunks := (test.begin+test.count-1)/page - test.begin/page + 1 + require.Equal(t, expectedChunks, uint64(len(repo.scans[0]))) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestOutputsFences(t *testing.T) { + const scanError = "scan error" + const secondScanError = "second scan error" + const storeError = "store error" + for _, fault := range []string{ + "lost first", "lost second", "changed second", "reordered first", "reordered second", + "extra first", "extra second", scanError, secondScanError, storeError, + } { + t.Run(fault, func(t *testing.T) { + service, repo, _ := newOutputsFixture(t, 0, 4103) + sentinel := errors.New("storage failed") + repo.scanFault = func(second bool, first, last uint64, fn repository.OutputScanFunc) (bool, error) { + switch { + case fault == scanError || fault == secondScanError && second: + return true, sentinel + case fault == "lost first" && !second, fault == "lost second" && second: + for index := first; index < last; index++ { + if err := fn(index, repo.raw(index)); err != nil { + return true, err + } + } + return true, nil + case fault == "changed second" && second: + for index := first; index <= last; index++ { + raw := repo.raw(index) + raw[0] ^= 1 + if err := fn(index, raw); err != nil { + return true, err + } + } + return true, nil + case fault == "reordered first" && !second, fault == "reordered second" && second: + if last != repo.span.Last { + return false, nil + } + for index := first; index <= last; index++ { + swapped := index + if index == first { + swapped = first + 1 + } + if index == first+1 { + swapped = first + } + // Keep payload order unchanged so only the row-index check detects this fault. + if err := fn(swapped, repo.raw(index)); err != nil { + return true, err + } + } + return true, nil + case fault == "extra first" && !second, fault == "extra second" && second: + if last != repo.span.Last { + return false, nil + } + for index := first; index <= last; index++ { + if err := fn(index, repo.raw(index)); err != nil { + return true, err + } + } + return true, fn(last+1, repo.raw(last+1)) + } + return false, nil + } + if fault == storeError { + repo.writeErr = sentinel + } + _, write, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + if err == nil { + err = repo.StoreClaimAndProofs(t.Context(), repo.epoch, write) + } + if fault == scanError || fault == secondScanError || fault == storeError { + require.ErrorIs(t, err, sentinel) + } else { + require.ErrorIs(t, err, merkle.ErrOutputsChanged) + } + require.Empty(t, repo.committed) + require.Equal(t, model.EpochStatus_InputsProcessed, repo.storedStatus) + require.Equal(t, model.ApplicationStatus_OK, repo.app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } + for _, span := range []repository.EpochOutputRange{{Count: 2, First: 0, Last: 2}, {Count: 1, First: 2, Last: 1}} { + t.Run(fmt.Sprint(span), func(t *testing.T) { + service, repo, _ := newOutputsFixture(t, 0, 1) + repo.span = span + reason := fmt.Sprintf("outputs of epoch 5 are not contiguous: %d outputs from index %d to %d", + span.Count, span.First, span.Last) + repo.On("UpdateApplicationStatus", mock.Anything, repo.app.ID, model.ApplicationStatus_Corrupted, &reason).Return(nil).Once() + _, write, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + require.ErrorContains(t, err, reason) + require.Nil(t, write) + require.Empty(t, repo.scans[0]) + repo.AssertExpectations(t) + }) + } + t.Run("invalid tree range is a code invariant", func(t *testing.T) { + service, repo, _ := newOutputsFixture(t, 1<<63-1, 2) + _, _, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + require.ErrorIs(t, err, merkle.ErrInvariant) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestOutputsValidateApplicationWritesProofs(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(fmt.Sprint(fail), func(t *testing.T) { + service, repo, pre := newOutputsFixture(t, 0, 4103) + root, proofs := recursiveOutputsOracle(t, repo, pre, service.pristinePostContext) + repo.epoch.TxBufferDataBlock = &root + repo.On("ListEpochs", mock.Anything, repo.app.IApplicationAddress.String(), mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{repo.epoch}, uint64(1), nil).Once() + repo.On("GetLastInput", mock.Anything, repo.app.IApplicationAddress.String(), repo.epoch.Index). + Return(&model.Input{MachineHash: repo.epoch.MachineHash, TxBufferDataBlock: &root}, nil).Once() + sentinel := errors.New("second batch failed") + if fail { + repo.writeErr = sentinel + } + err := service.validateApplication(t.Context(), repo.app) + if fail { + require.ErrorIs(t, err, sentinel) + require.Empty(t, repo.committed) + require.Equal(t, model.EpochStatus_InputsProcessed, repo.storedStatus) + } else { + require.NoError(t, err) + assertOutputProofs(t, proofs, repo.committed) + require.Equal(t, model.EpochStatus_ClaimComputed, repo.storedStatus) + } + require.Equal(t, model.ApplicationStatus_OK, repo.app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestOutputsBorrowedRowsAreHashedInsideCallback(t *testing.T) { + service, repo, pre := newOutputsFixture(t, 0, 4103) + expectedRoot, expectedProofs := recursiveOutputsOracle(t, repo, pre, service.pristinePostContext) + repo.scanFault = func(_ bool, first, last uint64, fn repository.OutputScanFunc) (bool, error) { + var borrowed [132]byte + for index := first; index <= last; index++ { + binary.BigEndian.PutUint64(borrowed[:], index) + if err := fn(index, borrowed[:]); err != nil { + return true, err + } + clear(borrowed[:]) + } + return true, nil + } + root, write, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + require.NoError(t, err) + require.Equal(t, expectedRoot, *root) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), repo.epoch, write)) + assertOutputProofs(t, expectedProofs, repo.committed) +} + +// Compare bounded elements so a proof regression cannot dump an entire epoch. +func assertOutputProofs(t *testing.T, want, got []repository.OutputProof) { + t.Helper() + require.Len(t, got, len(want)) + for i := range want { + require.Equal(t, want[i].Index, got[i].Index, "proof %d index", i) + require.Equal(t, want[i].Hash, got[i].Hash, "proof %d hash", i) + require.Len(t, got[i].Siblings, len(want[i].Siblings), "proof %d sibling count", i) + for j := range want[i].Siblings { + require.Equal(t, want[i].Siblings[j], got[i].Siblings[j], "proof %d sibling %d", i, j) + } + } +} + +func TestOutputsWriterScanPreservesFullPayloadAndProofIdentity(t *testing.T) { + service, repo, _ := newOutputsFixture(t, 0, 3) + repo.raw = func(index uint64) []byte { + raw := generatedOutput(index) + raw[97], raw[131] = byte(index%3+11), byte(index%3+91) + return raw + } + leaves := make([]common.Hash, 3) + for i := range leaves { + leaves[i] = crypto.Keccak256Hash(repo.raw(uint64(i))) + } + history := merkletest.NewOutputHistory(leaves) + var rows [2]int + repo.onRow = func(second bool, _ uint64, _ []byte) { + if second { + require.True(t, repo.inWrite) + rows[1]++ + } else { + require.False(t, repo.inWrite) + rows[0]++ + } + } + root, write, err := service.computeMerkleTreeAndProofs(t.Context(), repo.app, repo.epoch) + require.NoError(t, err) + require.Equal(t, history.Root(), *root) + require.NoError(t, repo.StoreClaimAndProofs(t.Context(), repo.epoch, write)) + require.Equal(t, [2]int{3, 3}, rows) + want := make([]repository.OutputProof, len(leaves)) + for i, hash := range leaves { + want[i] = repository.OutputProof{Index: uint64(i), Hash: hash, Siblings: history.Proof(uint64(i))} + } + assertOutputProofs(t, want, repo.committed) +} diff --git a/internal/validator/prefix_test.go b/internal/validator/prefix_test.go new file mode 100644 index 000000000..9203b3507 --- /dev/null +++ b/internal/validator/prefix_test.go @@ -0,0 +1,201 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// Reproduce the ordering that used to lose E: execution has already stopped in +// P before the validator first discovers the complete earlier epoch E. +func TestValidatorCompletesEpochBeforeTerminalBoundary(t *testing.T) { + for _, consensus := range model.ConsensusAllValues { + for _, status := range []model.ApplicationStatus{ + model.ApplicationStatus_GuestException, model.ApplicationStatus_MachineHalted, + model.ApplicationStatus_McycleOverflow, model.ApplicationStatus_UnexpectedYield, + model.ApplicationStatus_InvalidOutputsRoot, + } { + t.Run(consensus.String()+"/"+status.String(), func(t *testing.T) { + repo, validator, app, epoch := rootValidationFixture() + app.Status = status + app.ConsensusType = consensus + // A nonempty Authority/Quorum epoch; the PRT first epoch can + // be empty and uses its template as the pre-computation state. + app.TemplateHash = *epoch.MachineHash + repo.problem = &model.ApplicationProblem{EpochIndex: 1, Kind: status} + if status.IsExecutionTerminal() { + repo.problem.InputIndex = new(uint64(1)) + } + epochs := []*model.Epoch{epoch, + {ApplicationID: app.ID, Index: 1, VirtualIndex: 1, Status: model.EpochStatus_InputsProcessed}, + {ApplicationID: app.ID, Index: 2, VirtualIndex: 2, Status: model.EpochStatus_InputsProcessed}, + } + repo.On("ListApplications", mock.Anything, validationApplicationsFilter(), repository.Pagination{}, false). + Return([]*model.Application{app}, uint64(1), nil).Once() + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.String(), mock.Anything, repository.Pagination{}, false). + Return(epochs, uint64(len(epochs)), nil).Once() + expectValidPrefixEpoch(repo, app, epoch, consensus == model.Consensus_PRT) + repo.On("StoreClaimAndProofs", mock.Anything, epoch, mock.Anything).Return(nil).Once() + + _, err := validator.Tick(t.Context()) + + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + require.Equal(t, model.EpochStatus_InputsProcessed, epochs[1].Status) + require.Equal(t, model.EpochStatus_InputsProcessed, epochs[2].Status) + require.Equal(t, status, app.Status) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 1) + repo.AssertExpectations(t) + }) + } + } +} + +func expectValidPrefixEpoch(repo *Mockrepo, app *model.Application, epoch *model.Epoch, empty bool) { + address := app.IApplicationAddress.String() + expectEpochOutputs(repo, app, epoch, []*model.Output{}) + var input *model.Input + if !empty { + input = &model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock} + } + repo.On("GetLastInput", mock.Anything, address, epoch.Index).Return(input, nil).Once() +} + +func TestValidatorReloadsPrefixBeforeComputation(t *testing.T) { + for _, test := range []struct { + name string + status model.ApplicationStatus + problem *model.ApplicationProblem + disabled bool + missingEpoch bool + readErr error + wantErr string + wantClaim bool + }{ + {name: "runtime stopped after discovery", status: model.ApplicationStatus_MachineHalted, wantClaim: true, + problem: &model.ApplicationProblem{EpochIndex: 2, Kind: model.ApplicationStatus_MachineHalted, InputIndex: new(uint64(2))}}, + {name: "root finding after discovery", status: model.ApplicationStatus_InvalidOutputsRoot, wantClaim: true, + problem: &model.ApplicationProblem{EpochIndex: 2, Kind: model.ApplicationStatus_InvalidOutputsRoot}}, + {name: "failed after discovery", status: model.ApplicationStatus_Failed}, + {name: "diverged after discovery", status: model.ApplicationStatus_Diverged}, + {name: "corrupted after discovery", status: model.ApplicationStatus_Corrupted}, + {name: "disabled after discovery", status: model.ApplicationStatus_OK, disabled: true}, + {name: "cut tightened to this epoch", status: model.ApplicationStatus_MachineHalted, + problem: &model.ApplicationProblem{EpochIndex: 0, Kind: model.ApplicationStatus_InvalidOutputsRoot}}, + {name: "terminal without source", status: model.ApplicationStatus_MachineHalted, wantErr: "without an epoch stop boundary"}, + {name: "epoch missing", status: model.ApplicationStatus_OK, missingEpoch: true, wantErr: "source is unavailable"}, + {name: "read failed", readErr: errors.New("snapshot unavailable"), wantErr: "snapshot unavailable"}, + } { + t.Run(test.name, func(t *testing.T) { + repo, validator, app, epoch := rootValidationFixture() + current := *app + current.Status = test.status + current.Enabled = !test.disabled + state := &repository.EpochCompletionState{Application: ¤t, Epoch: epoch, Problem: test.problem} + if test.missingEpoch { + state.Epoch = nil + } + repo.readEpochCompletionState = func(context.Context, string, uint64) (*repository.EpochCompletionState, error) { + return state, test.readErr + } + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.String(), mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + + if test.wantClaim { + expectValidPrefixEpoch(repo, ¤t, epoch, false) + repo.On("StoreClaimAndProofs", mock.Anything, epoch, mock.Anything).Return(nil).Once() + } + err := validator.validateApplication(t.Context(), app) + + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, model.ApplicationStatus_OK, app.Status, "discovery is not the current source of truth") + if test.wantClaim { + require.Equal(t, model.EpochStatus_ClaimComputed, epoch.Status) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 1) + } else { + repo.AssertNotCalled(t, "GetEpochOutputRange", mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "ScanOutputs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "StoreClaimAndProofs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertNotCalled(t, "StoreClaimAndProofs", mock.Anything, mock.Anything, mock.Anything) + } + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} + +func TestValidatorEarlierRootFindingTightensRuntimeStop(t *testing.T) { + repo, validator, app, epoch := rootValidationFixture() + app.Status = model.ApplicationStatus_MachineHalted + repo.problem = &model.ApplicationProblem{EpochIndex: 2, Kind: app.Status, InputIndex: new(uint64(2))} + // The epoch and last input agree with each other, but their declared word + // fails the independently computed outputs root rule. + epoch.TxBufferDataBlock = &app.TemplateHash + input := &model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock} + expectRootValidationReads(repo, app, epoch, input, nil) + repo.On("StoreInvalidOutputsRoot", mock.Anything, app.ID, epoch.Index, mock.Anything). + Return(nil).Once() + + err := validator.validateApplication(t.Context(), app) + + require.ErrorContains(t, err, "declared outputs root does not match") + require.Equal(t, model.ApplicationStatus_MachineHalted, app.Status) + repo.AssertNotCalled(t, "StoreClaimAndProofs", mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) +} + +// Two applications sharing epoch indices must still get separate completion +// views. A single-application fake can hide a lookup that ignores its address. +func TestValidatorCompletesSeparateApplicationPrefixes(t *testing.T) { + repo, svc, first, firstEpoch := rootValidationFixture() + first.Status = model.ApplicationStatus_MachineHalted + second := *first + second.ID++ + second.Name = "other-prefix" + second.IApplicationAddress = common.HexToAddress("0x2") + secondEpoch := *firstEpoch + secondEpoch.ApplicationID = second.ID + repo.problem = &model.ApplicationProblem{EpochIndex: 1, Kind: first.Status, InputIndex: new(uint64(1))} + repo.On("ListApplications", mock.Anything, + mock.MatchedBy(func(filter repository.ApplicationFilter) bool { + if filter.Enabled == nil || !*filter.Enabled { + return false + } + for _, status := range filter.Statuses { + if status == model.ApplicationStatus_MachineHalted { + return true + } + } + return false + }), repository.Pagination{}, false). + Return([]*model.Application{first, &second}, uint64(2), nil).Once() + for _, pair := range []struct { + app *model.Application + epoch *model.Epoch + }{{first, firstEpoch}, {&second, &secondEpoch}} { + repo.On("ListEpochs", mock.Anything, pair.app.IApplicationAddress.Hex(), mock.Anything, repository.Pagination{}, false). + Return([]*model.Epoch{pair.epoch}, uint64(1), nil).Once() + expectValidPrefixEpoch(repo, pair.app, pair.epoch, false) + repo.On("StoreClaimAndProofs", mock.Anything, pair.epoch, mock.Anything).Return(nil).Once() + } + _, err := svc.Tick(t.Context()) + require.NoError(t, err) + require.Equal(t, model.EpochStatus_ClaimComputed, firstEpoch.Status) + require.Equal(t, model.EpochStatus_ClaimComputed, secondEpoch.Status) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 2) + repo.AssertExpectations(t) +} diff --git a/internal/validator/protection_test.go b/internal/validator/protection_test.go new file mode 100644 index 000000000..450535914 --- /dev/null +++ b/internal/validator/protection_test.go @@ -0,0 +1,113 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "errors" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func expectProtectionRoots(repo *Mockrepo, app *model.Application) { + var epochs []*model.Epoch + for index := uint64(0); index < 3; index++ { + root := common.Address{byte(index + 1)} + epochs = append(epochs, &model.Epoch{ApplicationID: app.ID, Index: index, + LastBlock: 100 + index, TournamentAddress: &root, Status: model.EpochStatus_Closed}) + if index > 0 { + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), root.Hex()).Return(&model.Tournament{ + ApplicationID: app.ID, EpochIndex: index, Address: root, StartInstant: 100 + index, Allowance: 50, + }, nil).Once() + } + } + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: model.NonTerminalEpochStatuses(), + }, repository.Pagination{}, false).Return(epochs, uint64(len(epochs)), nil).Once() +} + +func TestValidatorInvalidRootProtectionStartsAtCommittedCutoff(t *testing.T) { + for _, storeFailure := range []bool{false, true} { + t.Run(map[bool]string{false: "committed", true: "write failed"}[storeFailure], func(t *testing.T) { + repo, s, app, epoch := rootValidationFixture() + app.ConsensusType = model.Consensus_PRT + epoch.Index = 1 + zero := common.Hash{} + epoch.TxBufferDataBlock = &zero + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), + repository.EpochFilter{Status: []model.EpochStatus{model.EpochStatus_InputsProcessed}}, + repository.Pagination{}, false). + Return([]*model.Epoch{epoch}, uint64(1), nil).Once() + expectEpochOutputs(repo, app, epoch, nil) + repo.On("GetLastInput", mock.Anything, app.IApplicationAddress.Hex(), epoch.Index). + Return(&model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock}, nil).Once() + var storeErr error + if storeFailure { + storeErr = errors.New("atomic root finding was not saved") + } else { + expectProtectionRoots(repo, app) + } + repo.On("StoreInvalidOutputsRoot", mock.Anything, app.ID, epoch.Index, mock.Anything).Return(storeErr).Once() + err := s.validateApplication(t.Context(), app) + require.Error(t, err) + if storeFailure { + require.ErrorIs(t, err, storeErr) + require.NotContains(t, output.String(), "PRT protection stopped") + } else { + require.Contains(t, output.String(), "join_close_block=151") + require.Contains(t, output.String(), "join_close_block=152") + require.NotContains(t, output.String(), "join_close_block=150") + repo.AssertNumberOfCalls(t, "GetTournament", 2) + } + repo.AssertExpectations(t) + }) + } +} + +func TestValidatorIntegrityAndProofFailureProtection(t *testing.T) { + for _, proofFailure := range []bool{false, true} { + t.Run(map[bool]string{false: "corruption", true: "proof failure"}[proofFailure], func(t *testing.T) { + repo, s, app, epoch := rootValidationFixture() + app.ConsensusType = model.Consensus_PRT + var output bytes.Buffer + s.Logger = slog.New(slog.NewTextHandler(&output, nil)) + root := common.HexToAddress("0x777") + obligation := &model.Epoch{ApplicationID: app.ID, Index: 3, LastBlock: 100, + TournamentAddress: &root, Status: model.EpochStatus_Closed} + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: model.NonTerminalEpochStatuses(), + }, repository.Pagination{}, false).Return([]*model.Epoch{obligation}, uint64(1), nil).Once() + repo.On("GetTournament", mock.Anything, app.IApplicationAddress.Hex(), root.Hex()).Return(&model.Tournament{ + ApplicationID: app.ID, EpochIndex: 3, Address: root, StartInstant: 100, Allowance: 50, + }, nil).Once() + status := model.ApplicationStatus_Corrupted + if proofFailure { + status = model.ApplicationStatus_Failed + } + writeErr := errors.New("status write failed") + repo.On("UpdateApplicationStatus", mock.Anything, app.ID, status, mock.Anything).Return(writeErr).Once() + var err error + if proofFailure { + _, _, err = s.proveCommitment(t.Context(), app, epoch, + &merkle.Tree{Height: uint32(model.Log2EpochComputationHashLeafCount)}) + } else { + err = s.setApplicationCorrupted(t.Context(), app, "stored root missing") + } + require.ErrorIs(t, err, writeErr) + require.Contains(t, output.String(), "join_close_block=150") + require.Contains(t, output.String(), "epoch_index=3") + require.Equal(t, model.ApplicationStatus_OK, app.Status) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/validator/publication_test.go b/internal/validator/publication_test.go new file mode 100644 index 000000000..fb1e31e0f --- /dev/null +++ b/internal/validator/publication_test.go @@ -0,0 +1,88 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestClaimPublicationOutcome(t *testing.T) { + for _, test := range []struct { + name string + storeErr error + obsolete bool + }{ + {name: "published"}, + {name: "foreclosed during publication", storeErr: fmt.Errorf("state changed: %w", repository.ErrEpochForeclosed), obsolete: true}, + {name: "unexpected state", storeErr: repository.ErrNoUpdate}, + {name: "database failure", storeErr: errors.New("database write failed")}, + } { + t.Run(test.name, func(t *testing.T) { + repo := newMockrepo() + var output bytes.Buffer + postContext := merkle.CreatePostContext() + s := &Service{ + repository: repo, pristinePostContext: postContext, pristineRootHash: postContext[merkle.TREE_DEPTH], + } + s.Logger = slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug})) + // Keep the application snapshot stale. The repository outcome, not + // this object's foreclosure field, must decide whether work is obsolete. + app := &Application{ID: 7, Name: "publication-app", ConsensusType: Consensus_PRT, + Status: ApplicationStatus_OK, Enabled: true, TemplateHash: s.pristineRootHash} + epochs := []*Epoch{ + {ApplicationID: app.ID, Index: 0, VirtualIndex: 0, LastBlock: 9, Status: EpochStatus_InputsProcessed, + MachineHash: &s.pristineRootHash, TxBufferDataBlock: &s.pristineRootHash}, + {ApplicationID: app.ID, Index: 1, VirtualIndex: 1, FirstBlock: 10, LastBlock: 19, Status: EpochStatus_InputsProcessed, + MachineHash: &s.pristineRootHash, TxBufferDataBlock: &s.pristineRootHash}, + } + repo.application = app + appAddress := app.IApplicationAddress.String() + repo.On("ListEpochs", mock.Anything, appAddress, mock.Anything, repository.Pagination{}, false). + Return(epochs, uint64(len(epochs)), nil).Once() + wantContinued := test.obsolete || test.storeErr == nil + for i, epoch := range epochs { + if i > 0 && !wantContinued { + break + } + expectEpochOutputs(repo, app, epoch, []*Output{}) + repo.On("GetLastInput", mock.Anything, appAddress, epoch.Index).Return((*Input)(nil), nil).Once() + storeErr := test.storeErr + if i > 0 { + storeErr = nil + repo.On("GetEpochByVirtualIndex", mock.Anything, appAddress, epoch.VirtualIndex-1).Return(epochs[0], nil).Twice() + } + repo.On("StoreClaimAndProofs", mock.Anything, epoch, mock.Anything).Return(storeErr).Once() + } + + err := s.validateApplication(t.Context(), app) + + if wantContinued { + require.NoError(t, err) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 2) + } else { + require.ErrorIs(t, err, test.storeErr) + repo.AssertNumberOfCalls(t, "StoreClaimAndProofs", 1) + } + const obsoleteMessage = "Epoch was foreclosed before claim publication; discarding obsolete claim and proofs" + if test.obsolete { + require.Contains(t, output.String(), "level=INFO msg=\""+obsoleteMessage+"\"") + } else { + require.NotContains(t, output.String(), obsoleteMessage) + } + require.Equal(t, ApplicationStatus_OK, app.Status) + repo.AssertNotCalled(t, "UpdateApplicationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + repo.AssertExpectations(t) + }) + } +} diff --git a/internal/validator/shutdown_test.go b/internal/validator/shutdown_test.go new file mode 100644 index 000000000..ce413fc52 --- /dev/null +++ b/internal/validator/shutdown_test.go @@ -0,0 +1,191 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/merkle" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" +) + +func TestValidatorTickShutdownErrors(t *testing.T) { + dbErr := errors.New("database unavailable") + for _, operation := range []string{"applications", "epochs", "outputs", "claim write"} { + for _, stopping := range []bool{false, true} { + for _, test := range []struct { + name string + cause error + cancellationOnly bool + }{ + {"cancellation", context.Canceled, true}, + {"wrapped cancellation", fmt.Errorf("query: %w", context.Canceled), true}, + {"joined cancellations", errors.Join(context.Canceled, fmt.Errorf("query: %w", context.Canceled)), true}, + {"database query failure", dbErr, false}, + {"deadline", context.DeadlineExceeded, false}, + {"mixed deadline", errors.Join(context.Canceled, context.DeadlineExceeded), false}, + {"mixed database failure", errors.Join(context.Canceled, dbErr), false}, + {"nested database failure", fmt.Errorf("query: %w", errors.Join(context.Canceled, dbErr)), false}, + } { + t.Run(fmt.Sprintf("%s/stopping=%t/%s", operation, stopping, test.name), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s, logs := validatorShutdownFixture(ctx, t, operation, test.cause, func(mock.Arguments) { + if stopping { + cancel() + } + }) + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if stopping && test.cancellationOnly { + require.NoError(t, err) + require.NotContains(t, logs.String(), "level=ERROR") + require.NotContains(t, logs.String(), "level=WARN") + } else { + require.ErrorIs(t, err, test.cause, "preserve all causes, not just one joined error") + } + }) + } + } + } +} + +func TestValidatorClaimWriteShutdownLogs(t *testing.T) { + dbErr := errors.New("database write failed") + for _, test := range []struct { + name string + cause error + wantLog bool + }{ + {"canceled write", fmt.Errorf("write: %w", context.Canceled), false}, + {"completed write", nil, false}, + {"database failure", dbErr, true}, + {"mixed failure", errors.Join(context.Canceled, dbErr), true}, + {"deadline", context.DeadlineExceeded, true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + s, logs := validatorShutdownFixture(ctx, t, "claim write", test.cause, func(mock.Arguments) { cancel() }) + require.ErrorIs(t, s.Serve(ctx), context.Canceled) + if test.wantLog { + require.Contains(t, logs.String(), "level=ERROR msg=Tick") + if errors.Is(test.cause, dbErr) { + require.Contains(t, logs.String(), dbErr.Error()) + } else { + require.Contains(t, logs.String(), test.cause.Error()) + } + } else { + require.NotContains(t, logs.String(), "level=ERROR") + } + }) + } +} + +// Cancel from the failing operation, after Tick has started. No status-write +// expectation is installed: an I/O failure must not change application health. +func validatorShutdownFixture( + ctx context.Context, t *testing.T, operation string, cause error, hook func(mock.Arguments), +) (*Service, *bytes.Buffer) { + t.Helper() + repo := newMockrepo() + postContext := merkle.CreatePostContext() + s := &Service{repository: repo, pristinePostContext: postContext, pristineRootHash: postContext[merkle.TREE_DEPTH]} + logs := new(bytes.Buffer) + require.NoError(t, service.InitTickServiceTemplate(&s.TickServiceTemplate, &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{Name: "validator", Logger: slog.New(slog.NewTextHandler(logs, nil))}, + }, s)) + app := &model.Application{ID: 1, Name: "validator-app", Enabled: true, Status: model.ApplicationStatus_OK} + t.Cleanup(func() { + repo.AssertExpectations(t) + require.Equal(t, model.ApplicationStatus_OK, app.Status) + }) + apps := repo.On("ListApplications", ctx, mock.Anything, mock.Anything, false).Once() + if operation == "applications" { + apps.Run(hook).Return([]*model.Application(nil), uint64(0), cause) + return s, logs + } + apps.Return([]*model.Application{app}, uint64(1), nil) + epochs := repo.On("ListEpochs", ctx, app.IApplicationAddress.Hex(), mock.Anything, mock.Anything, false).Once() + if operation == "epochs" { + epochs.Run(hook).Return([]*model.Epoch(nil), uint64(0), cause) + return s, logs + } + epoch := &model.Epoch{ApplicationID: app.ID, Status: model.EpochStatus_InputsProcessed, + MachineHash: new(common.HexToHash("0x123")), TxBufferDataBlock: &s.pristineRootHash} + epochs.Return([]*model.Epoch{epoch}, uint64(1), nil) + outputs := repo.On("GetEpochOutputRange", ctx, app.ID, epoch.Index).Once() + if operation == "outputs" { + outputs.Run(hook).Return(repository.EpochOutputRange{}, cause) + return s, logs + } + outputs.Return(repository.EpochOutputRange{}, nil) + repo.On("GetLastInput", ctx, app.IApplicationAddress.Hex(), epoch.Index). + Return(&model.Input{MachineHash: epoch.MachineHash, TxBufferDataBlock: epoch.TxBufferDataBlock}, nil).Once() + repo.On("StoreClaimAndProofs", ctx, epoch, mock.Anything).Run(hook).Return(cause).Once() + return s, logs +} + +func TestValidatorTickShutdownRetainsEarlierError(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(new(bytes.Buffer), nil)) + apps := []*model.Application{ + {IApplicationAddress: common.HexToAddress("0x1")}, + {IApplicationAddress: common.HexToAddress("0x2")}, + {IApplicationAddress: common.HexToAddress("0x3")}, + } + repo.On("ListApplications", ctx, mock.Anything, mock.Anything, false).Return(apps, uint64(len(apps)), nil).Once() + firstErr := errors.New("first application database read failed") + repo.On("ListEpochs", ctx, apps[0].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false). + Return([]*model.Epoch(nil), uint64(0), firstErr).Once() + repo.On("ListEpochs", ctx, apps[1].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false). + Run(func(mock.Arguments) { cancel() }).Return([]*model.Epoch(nil), uint64(0), context.Canceled).Once() + + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + require.ErrorIs(t, err, firstErr) + repo.AssertNotCalled(t, "ListEpochs", ctx, apps[2].IApplicationAddress.Hex(), mock.Anything, mock.Anything, false) + repo.AssertExpectations(t) +} + +func TestValidatorTickDoneContext(t *testing.T) { + for _, deadline := range []bool{false, true} { + t.Run(fmt.Sprintf("deadline=%t", deadline), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + if deadline { + cancel() + ctx, cancel = context.WithDeadline(t.Context(), time.Time{}) + } + defer cancel() + cancel() + repo := newMockrepo() + s := &Service{repository: repo} + s.Logger = slog.New(slog.NewTextHandler(new(bytes.Buffer), nil)) + reschedule, err := s.Tick(ctx) + require.False(t, reschedule) + if deadline { + require.ErrorIs(t, err, context.DeadlineExceeded) + } else { + require.NoError(t, err) + } + require.Empty(t, repo.Calls, "a stopped tick must not query the database") + }) + } +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 000000000..19780664f --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,577 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package validator provides components to create epoch claims and update +// rollups outputs with their proofs. +package validator + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/cartesi/rollups-node/internal/appstatus" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/merkle" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/prtprotection" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" +) + +type Service struct { + service.TickServiceTemplate + + repository ValidatorRepository + + // cached constants + pristineRootHash common.Hash + pristinePostContext []common.Hash + + // Tick owns this volatile state. A new service starts without retries. + claimRetries map[claimRetryKey]claimRetry + claimRetryBase time.Duration + now func() time.Time +} + +type CreateInfo struct { + Config config.ValidatorConfig + Logger *slog.Logger + Repository repository.Repository +} + +func Create(ctx context.Context, c *CreateInfo) (service.SupervisedService, error) { + var err error + if err = ctx.Err(); err != nil { + return nil, err // This returns context.Canceled or context.DeadlineExceeded. + } + + s := &Service{} + tickCfg := &service.TickServiceConfigs{ + BaseConfigs: service.BaseConfigs{ + Name: config.ServiceValidator, + Logger: c.Logger, + LogLevel: c.Config.LogLevel, + LogColor: c.Config.LogColor, + }, + PollInterval: c.Config.ValidatorPollingInterval, + } + err = service.InitTickServiceTemplate(&s.TickServiceTemplate, tickCfg, s) + if err != nil { + return nil, err + } + + s.claimRetryBase = min(tickCfg.PollInterval, maxClaimRetryDelay) + s.repository = c.Repository + if s.repository == nil { + return nil, fmt.Errorf("repository on validator service Create is nil") + } + + s.pristinePostContext = merkle.CreatePostContext() + s.pristineRootHash = s.pristinePostContext[merkle.TREE_DEPTH] + + s.Logger.Info("Created", "config", c.Config) + + return s, nil +} + +// Tick executes the Validator main logic of producing claims and/or proofs +// for processed epochs of all applications with a valid epoch prefix. +func (s *Service) Tick(ctx context.Context) (reschedule bool, err error) { + defer func() { + if errors.Is(ctx.Err(), context.Canceled) && errutil.IsOnlyCancellation(err) { + s.Logger.Debug("Tick interrupted by shutdown", "error", err) + err = nil + } + }() + if err := ctx.Err(); err != nil { + return false, err + } + apps, _, err := getAllRunningApplications(ctx, s.repository) + if err != nil { + return false, fmt.Errorf("failed to get running applications. %w", err) + } + + // validate each application + errs := []error{} + for idx := range apps { + if err := ctx.Err(); err != nil { + return false, errors.Join(append(errs, err)...) + } + if err := s.validateApplication(ctx, apps[idx]); err != nil { + errs = append(errs, err) + } + } + return false, errors.Join(errs...) +} + +type ValidatorRepository interface { + prtprotection.Repository + ListApplications( + ctx context.Context, f repository.ApplicationFilter, p repository.Pagination, descending bool, + ) ([]*Application, uint64, error) + UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error + GetEpochOutputRange(ctx context.Context, appID int64, epochIndex uint64) (repository.EpochOutputRange, error) + ScanOutputs(ctx context.Context, appID int64, first, last uint64, fn repository.OutputScanFunc) error + GetOutput(ctx context.Context, nameOrAddress string, outputIndex uint64) (*Output, error) + ListEpochs( + ctx context.Context, nameOrAddress string, f repository.EpochFilter, p repository.Pagination, descending bool, + ) ([]*Epoch, uint64, error) + GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) // FIXME migrate to list + GetEpochByVirtualIndex(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) + GetEpochCompletionState(ctx context.Context, nameOrAddress string, index uint64) (*repository.EpochCompletionState, error) + StoreClaimAndProofs(ctx context.Context, epoch *Epoch, write repository.ClaimProofWriteFunc) error + StoreInvalidOutputsRoot(ctx context.Context, appID int64, epochIndex uint64, reason string) error + CountEpochStateHashes(ctx context.Context, appID int64, epochIndex uint64) (uint64, error) + ListEpochStateHashes(ctx context.Context, appID int64, epochIndex uint64, afterIndex *uint64, limit uint64) ([]EpochStateHash, error) +} + +func getAllRunningApplications(ctx context.Context, er ValidatorRepository) ([]*Application, uint64, error) { + return er.ListApplications(ctx, validationApplicationsFilter(), repository.Pagination{}, false) +} + +func validationApplicationsFilter() repository.ApplicationFilter { + return repository.ApplicationFilter{ + Enabled: new(true), + Statuses: []ApplicationStatus{ + ApplicationStatus_OK, + ApplicationStatus_MachineHalted, + ApplicationStatus_GuestException, + ApplicationStatus_McycleOverflow, + ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, + }, + } +} + +func getProcessedEpochs(ctx context.Context, er ValidatorRepository, address string) ([]*Epoch, uint64, error) { + f := repository.EpochFilter{Status: []EpochStatus{EpochStatus_InputsProcessed}} + return er.ListEpochs(ctx, address, f, repository.Pagination{}, false) +} + +func (s *Service) setApplicationCorrupted(ctx context.Context, app *Application, reasonFmt string, args ...any) error { + reason := fmt.Sprintf(reasonFmt, args...) + before := app.Status + err := appstatus.SetCorrupted(ctx, s.Logger, s.repository, app, reason) + if app.Status != before { // appstatus changes app only after the write succeeds. + err = &claimStatusRecorded{err} + } + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, 0, errors.New(reason)) + return err +} + +// validateApplication calculates, validates and stores the claim and/or proofs +// for each processed epoch of the application. +// Epochs are iterated in ascending virtual-index order as returned by getProcessedEpochs. +func (s *Service) validateApplication(ctx context.Context, app *Application) (err error) { + var retryKey *claimRetryKey // The epoch in progress, unless it is cooling. + defer func() { + if err != nil && retryKey != nil { + err = s.failedClaim(*retryKey, app.IApplicationAddress.String(), err) + } + }() + s.Logger.Debug("Starting validation", "application", app.Name) + appAddress := app.IApplicationAddress.String() + processedEpochs, _, err := getProcessedEpochs(ctx, s.repository, appAddress) + if err != nil { + return fmt.Errorf( + "failed to get processed epochs of application %v. %w", + app.IApplicationAddress, err, + ) + } + + for _, epoch := range processedEpochs { + if err := ctx.Err(); err != nil { + return err + } + key := claimRetryKey{applicationID: app.ID, epochIndex: epoch.Index} + // A cooling epoch still reloads its state, so a failed read stays + // visible, but that read does not extend the cooldown. + waiting := s.claimWaiting(key) + retryKey = nil + if !waiting { + retryKey = &key + } + // The application list and epoch list are discovery only. Reload the + // application, epoch and stop boundary together before computing work. + state, err := s.repository.GetEpochCompletionState(ctx, appAddress, epoch.Index) + if err != nil { + return fmt.Errorf("failed to read validation state for application %s epoch %d: %w", appAddress, epoch.Index, err) + } + if state == nil || state.Application == nil || state.Application.ID != app.ID || !state.Application.Enabled { + return nil + } + allowed, err := state.AllowsEpochCompletion() + if err != nil { + return fmt.Errorf("inconclusive validation state for application %s epoch %d: %w", appAddress, epoch.Index, err) + } + if !allowed { + return nil + } + app, epoch = state.Application, state.Epoch + if epoch.Status != EpochStatus_InputsProcessed { + continue + } + if app.ForecloseBlock != 0 && epoch.LastBlock >= app.ForecloseBlock { + s.Logger.Info("Skipping foreclosed epoch that cannot be accepted", + "application", appAddress, + "epoch_index", epoch.Index, + "last_block", epoch.LastBlock, + "foreclose_block", app.ForecloseBlock, + ) + continue + } + if s.claimWaiting(key) { + return nil + } + retryKey = &key + s.Logger.Debug("Started calculating outputs merkle root", + "application", appAddress, + "epoch_index", epoch.Index, + "last_block", epoch.LastBlock, + ) + + if epoch.MachineHash == nil { + return s.setApplicationCorrupted(ctx, app, + "epoch %v (%v) has no machine hash", epoch.Index, epoch.VirtualIndex) + } + if epoch.TxBufferDataBlock == nil { + return s.setApplicationCorrupted(ctx, app, + "epoch %v (%v) has no outputs merkle root", epoch.Index, epoch.VirtualIndex) + } + + merkleRoot, write, err := s.computeMerkleTreeAndProofs(ctx, app, epoch) + if err != nil { + // Tick owns operational error reporting. The returned error already + // identifies the failed operation, application, and epoch. + return err + } + + input, err := s.repository.GetLastInput(ctx, appAddress, epoch.Index) + if err != nil { + return fmt.Errorf( + "failed to get the last Input for epoch %v of application %v. %w", + epoch.Index, appAddress, err, + ) + } + + if input != nil { + if input.TxBufferDataBlock == nil { + return s.setApplicationCorrupted(ctx, app, + "inconsistent state: epoch %v last input (%v) outputs merkle root is not defined", + epoch.Index, input.Index) + } + + // These are two stored views of the same post-input state. Check + // their identity before classifying the outputs-root rule below. + if *epoch.TxBufferDataBlock != *input.TxBufferDataBlock { + return s.setApplicationCorrupted(ctx, app, + "epoch %v outputs merkle root does not match last input %v merkle root. Expected: %v, Got %v", + epoch.Index, input.Index, *input.TxBufferDataBlock, *epoch.TxBufferDataBlock) + } + + if input.MachineHash == nil { + return s.setApplicationCorrupted(ctx, app, + "inconsistent state: epoch %v last input (%v) machine hash is not defined", + epoch.Index, input.Index) + } + if *epoch.MachineHash != *input.MachineHash { + return s.setApplicationCorrupted(ctx, app, + "epoch %v machine hash does not match epoch last input (%v) machine hash. Expected: %v, Got %v", + epoch.Index, input.Index, *input.MachineHash, *epoch.MachineHash) + } + } else { // empty epochs + // DaveConsensus can have empty epochs. Authority and Quorum can't. + if !app.IsDaveConsensus() { + return s.setApplicationCorrupted(ctx, app, + "epoch %v (%v) has no inputs while not DaveConsensus", epoch.Index, epoch.VirtualIndex) + } + + if epoch.VirtualIndex > 0 { + previousEpoch, err := s.repository.GetEpochByVirtualIndex(ctx, appAddress, epoch.VirtualIndex-1) + if err != nil { + return fmt.Errorf( + "failed to get previous epoch for epoch %v (%v) of application %v. %w", + epoch.Index, epoch.VirtualIndex, appAddress, err, + ) + } + + // nil is a valid result from GetEpochByVirtualIndex. + // However it indicates some kind of corruption when combined with the epoch.VirtualIndex > 0 check. + if previousEpoch == nil { + return s.setApplicationCorrupted(ctx, app, + "epoch %v (%v) has no previous epoch", epoch.Index, epoch.VirtualIndex) + } + if previousEpoch.MachineHash == nil { + return s.setApplicationCorrupted(ctx, app, + "previous epoch %v (%v) machine hash is not defined", previousEpoch.Index, previousEpoch.VirtualIndex) + } + if *epoch.MachineHash != *previousEpoch.MachineHash { + return s.setApplicationCorrupted(ctx, app, + "epoch %v machine hash does not match previous epoch %v machine hash. Expected: %v, Got %v", + epoch.Index, previousEpoch.Index, *previousEpoch.MachineHash, *epoch.MachineHash) + } + if previousEpoch.TxBufferDataBlock == nil { + return s.setApplicationCorrupted(ctx, app, + "previous epoch %v (%v) outputs merkle root is not defined", previousEpoch.Index, previousEpoch.VirtualIndex) + } + if *epoch.TxBufferDataBlock != *previousEpoch.TxBufferDataBlock { + return s.setApplicationCorrupted(ctx, app, + "epoch %v outputs merkle root does not match previous epoch %v one. Expected: %v, Got %v", + epoch.Index, previousEpoch.Index, *previousEpoch.TxBufferDataBlock, *epoch.TxBufferDataBlock) + } + } else { // first epoch + if *epoch.MachineHash != app.TemplateHash { + return s.setApplicationCorrupted(ctx, app, + "epoch %v machine hash does not match for application template hash. Expected: %v, Got %v", + epoch.Index, app.TemplateHash, *epoch.MachineHash) + } + } + } + + // The guest supplies the cumulative outputs root. Compare its proved + // word with the root calculated from stored outputs only after checking + // the stored state identities. This also checks the pristine root of an + // empty first epoch; a mismatch alone does not establish its cause. + if *epoch.TxBufferDataBlock != *merkleRoot { + reason := appstatus.NormalizeReason(fmt.Sprintf( + "epoch %v: declared outputs root does not match the root calculated from stored outputs; declared=%s; calculated=%s", + epoch.Index, epoch.TxBufferDataBlock.Hex(), merkleRoot.Hex())) + if err := s.repository.StoreInvalidOutputsRoot(ctx, app.ID, epoch.Index, reason); err != nil { + return fmt.Errorf("recording invalid outputs root for application %s epoch %d: %w", appAddress, epoch.Index, err) + } + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, epoch.Index, errors.New(reason)) + s.Logger.Error("Invalid outputs root recorded", "application", app.Name, "address", appAddress, + "epoch_index", epoch.Index, "reason", reason) + return &claimStatusRecorded{errors.New(reason)} + } + + if app.IsDaveConsensus() { + commitment, proof, err := s.buildCommitment(ctx, app, epoch) + if err != nil { + return fmt.Errorf("failed to compute commitment for epoch %v (%v) of application %v. %w", + epoch.Index, epoch.VirtualIndex, appAddress, err, + ) + } + epoch.Commitment = commitment + epoch.CommitmentProof = proof.Siblings + } + + epoch.Status = EpochStatus_ClaimComputed + + // Store each proof batch after its scan closes, on the same connection. + transactionStart := time.Now() + err = s.repository.StoreClaimAndProofs(ctx, epoch, write) + if err != nil { + if errors.Is(err, repository.ErrEpochForeclosed) { + s.Logger.Info("Epoch was foreclosed before claim publication; discarding obsolete claim and proofs", + "application", appAddress, "epoch_index", epoch.Index) + continue + } + return fmt.Errorf( + "failed to store claim and proofs for epoch %v of application %v. %w", + epoch.Index, appAddress, err, + ) + } + delete(s.claimRetries, key) + retryKey = nil + s.Logger.Info("Claim and output proofs stored", "application", appAddress, "epoch_index", epoch.Index, + "transaction_elapsed", time.Since(transactionStart)) + + } + + if len(processedEpochs) == 0 { + s.Logger.Debug("no processed epochs to validate", + "app", app.IApplicationAddress, + ) + } + + return nil +} + +func (s *Service) buildCommitment(ctx context.Context, app *Application, epoch *Epoch) (*common.Hash, *merkle.Proof, error) { + if app == nil || epoch == nil { + return nil, nil, fmt.Errorf("application or epoch is nil") + } + if !app.IsDaveConsensus() { + return nil, nil, nil + } + started := time.Now() + s.Logger.Debug("DaveConsensus: Building commitment for epoch", + "application", app.Name, + "epoch", epoch.Index) + + if epoch.InputIndexLowerBound > epoch.InputIndexUpperBound { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "invalid epoch %v (%v): lower bound (%v) > upper bound (%v)", + epoch.Index, epoch.VirtualIndex, epoch.InputIndexLowerBound, epoch.InputIndexUpperBound) + } + if epoch.MachineHash == nil { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "epoch %v (%v) machine hash is not defined", epoch.Index, epoch.VirtualIndex) + } + inputCount := epoch.InputIndexUpperBound - epoch.InputIndexLowerBound + if inputCount > MaxAdvanceStatesPerEpoch { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "input count is too large for epoch %v of application %v: max %v, got %v", + epoch.Index, app.Name, MaxAdvanceStatesPerEpoch, inputCount) + } + + builder := newEntryBuilder(*epoch.MachineHash, uint32(Log2StateHashesPerEntry)) + var rowsRead uint64 + if inputCount > 0 { + total, err := s.repository.CountEpochStateHashes(ctx, app.ID, epoch.Index) + if err != nil { + return nil, nil, fmt.Errorf("failed to count state hashes for epoch %d of application %s: %w", epoch.Index, app.Name, err) + } + if total < inputCount { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "not enough state hashes for epoch %d of application %s: expected at least %d, got %d", + epoch.Index, app.Name, inputCount, total) + } + rowsRead, err = s.appendEpochStateHashes(ctx, app, epoch, builder, total) + if err != nil { + return nil, nil, fmt.Errorf("failed to list state hashes for epoch %d of application %s: %w", epoch.Index, app.Name, err) + } + if rowsRead != total { + if err := s.recheckStateHashRead(ctx, app, epoch, total); err != nil { + return nil, nil, err + } + return nil, nil, s.setApplicationCorrupted(ctx, app, + "inconsistent number of state hashes for epoch %d of application %s: expected %d, got %d", + epoch.Index, app.Name, total, rowsRead) + } + } + + epochCommitmentTree, err := builder.finish(inputCount) + if err != nil { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "failed to build commitment for epoch %d of application %s with error: %v", epoch.Index, app.Name, err) + } + commitment, proof, err := s.proveCommitment(ctx, app, epoch, epochCommitmentTree) + if err != nil { + return nil, nil, err + } + s.Logger.Info("DaveConsensus epoch commitment built", + "application", app.Name, "epoch", epoch.Index, "commitment", commitment.String(), + "state_hash_rows", rowsRead, "elapsed", time.Since(started)) + return commitment, proof, nil +} + +const stateHashPageSize uint64 = 10000 + +func (s *Service) appendEpochStateHashes( + ctx context.Context, app *Application, epoch *Epoch, builder *entryBuilder, total uint64, +) (uint64, error) { + var read uint64 + var after *uint64 + for { + if err := ctx.Err(); err != nil { + return read, err + } + page, err := s.repository.ListEpochStateHashes(ctx, app.ID, epoch.Index, after, stateHashPageSize) + if err != nil { + return read, err + } + if uint64(len(page)) > stateHashPageSize { + return read, fmt.Errorf("state hash page has %d rows, above limit %d", len(page), stateHashPageSize) + } + read += uint64(len(page)) + if read > total { + return read, nil + } + var last uint64 + hasPrevious := after != nil + if hasPrevious { + last = *after + } + for _, row := range page { + if hasPrevious && row.Index <= last { + return read, fmt.Errorf("state hash page is not strictly increasing: index %d follows %d", row.Index, last) + } + last = row.Index + hasPrevious = true + builder.appendRow(row) + } + if uint64(len(page)) < stateHashPageSize { + return read, nil + } + // Copy the cursor value, so no row keeps its page alive. + cursor := last + after = &cursor + } +} + +func (s *Service) recheckStateHashRead(ctx context.Context, app *Application, epoch *Epoch, expected uint64) error { + count, err := s.repository.CountEpochStateHashes(ctx, app.ID, epoch.Index) + if err != nil { + return fmt.Errorf("failed to recheck state hash count for epoch %d of application %s: %w", epoch.Index, app.Name, err) + } + state, err := s.repository.GetEpochCompletionState(ctx, app.IApplicationAddress.Hex(), epoch.Index) + if err != nil { + return fmt.Errorf("failed to recheck epoch %d of application %s after state hash read: %w", epoch.Index, app.Name, err) + } + if count != expected { + return fmt.Errorf("state hash count changed for epoch %d of application %s during read: expected %d, got %d", + epoch.Index, app.Name, expected, count) + } + if state == nil || state.Application == nil || state.Application.ID != app.ID { + return fmt.Errorf("application %s disappeared or changed identity during state hash read for epoch %d", app.Name, epoch.Index) + } + if state.Epoch == nil || state.Epoch.ApplicationID != app.ID || state.Epoch.Index != epoch.Index || + state.Epoch.Status != EpochStatus_InputsProcessed { + return fmt.Errorf("epoch %d of application %s disappeared or changed during state hash read", epoch.Index, app.Name) + } + return nil +} + +// proveCommitment checks the completed tree and proves its final machine state. +// Invalid stored dimensions or hashes are corruption. A broken internal tree +// invariant is a computation failure, not evidence that persisted data is wrong. +func (s *Service) proveCommitment( + ctx context.Context, + app *Application, + epoch *Epoch, + epochCommitmentTree *merkle.Tree, +) (*common.Hash, *merkle.Proof, error) { + // The protocol leaf height combines Log2MaxAdvanceStatesPerEpoch and Log2InputComputationHashLeafCount. + const expectedHeight = Log2EpochComputationHashLeafCount + if uint64(epochCommitmentTree.Height) != expectedHeight { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "epoch %v commitment tree height %v, expected %v — state hash repetitions are inconsistent", + epoch.Index, epochCommitmentTree.Height, expectedHeight) + } + + commitment := epochCommitmentTree.GetRootHash() + proof, err := epochCommitmentTree.ProveLast() + if err != nil { + // ProveLast derives its own valid index. Any traversal failure in this + // newly built tree is an internal error, even an out-of-range child index. + failure := fmt.Errorf("failed to retrieve commitment proof for epoch %d of application %s: %w", epoch.Index, app.Name, err) + if errors.Is(err, merkle.ErrOpaqueTree) { + failure = fmt.Errorf("%w: the epoch's last leaf lies inside a stored bundle: the last input fills its span, "+ + "which needs a bundle opening that this node does not support, "+ + "or its stored padding row is not the epoch's final entry", failure) + } + before := app.Status + statusErr := appstatus.SetFailed(ctx, s.Logger, s.repository, app, failure.Error()) + prtprotection.ReportStop(ctx, s.Logger, s.repository, app, 0, failure) + if app.Status != before { // appstatus changes app only after the write succeeds. + return nil, nil, &claimStatusRecorded{failure} + } + return nil, nil, errors.Join(failure, statusErr) + } + // PRT reconstructs the root children from (epoch.MachineHash, proof). + // The tree's last leaf must therefore be the epoch's final machine hash. + if proof.Node != *epoch.MachineHash { + return nil, nil, s.setApplicationCorrupted(ctx, app, + "epoch %v commitment last leaf %v does not match machine hash %v", + epoch.Index, proof.Node, *epoch.MachineHash) + } + return &commitment, proof, nil +} diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go new file mode 100644 index 000000000..804733e5a --- /dev/null +++ b/internal/validator/validator_test.go @@ -0,0 +1,1183 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package validator + +import ( + "context" + "fmt" + "github.com/cartesi/rollups-node/internal/merkle/merkletest" + "strings" + "testing" + + "github.com/cartesi/rollups-node/internal/merkle" + . "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" + "github.com/cartesi/rollups-node/pkg/service" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +const dummyApplicationName = "dummy-application-name" + +type ValidatorSuite struct { + suite.Suite +} + +func TestValidatorSuite(t *testing.T) { + suite.Run(t, new(ValidatorSuite)) +} + +var ( + validator *Service + repo *Mockrepo + dummyEpochs []Epoch + dummyOutputs []Output +) + +func expectCorrupted(app *Application, reasonSubstring string) { + // Subtests intentionally reuse application fixtures. Reset health so each + // case independently exercises its own corruption transition rather than + // inheriting the previous subtest's now-idempotent terminal status. + app.Status = ApplicationStatus_OK + app.Reason = nil + expectNoStoredProtectionRoots(repo, app) + repo.On("UpdateApplicationStatus", + mock.Anything, app.ID, ApplicationStatus_Corrupted, + mock.MatchedBy(func(reason *string) bool { + return reason != nil && strings.Contains(*reason, reasonSubstring) + }), + ).Return(nil).Once() +} + +func (s *ValidatorSuite) SetupSubTest() { + repo = newMockrepo() + postContext := merkle.CreatePostContext() + validator = &Service{ + repository: repo, + pristinePostContext: postContext, + pristineRootHash: postContext[merkle.TREE_DEPTH], + } + serviceArgs := &service.BaseConfigs{Name: "validator"} + service.InitServiceTemplate(&validator.BaseTemplate, serviceArgs) + dummyTxBufferDataBlock := common.HexToHash("0x0a162946e56158bac0673e6dd3bdfdc1e4a0e7744a120fdb640050c8d7abe1c6") + dummyEpochs = []Epoch{ + {Index: 0, VirtualIndex: 0, FirstBlock: 0, LastBlock: 9, Status: EpochStatus_InputsProcessed, + TxBufferDataBlock: &dummyTxBufferDataBlock, MachineHash: &validator.pristineRootHash}, + {Index: 1, VirtualIndex: 1, FirstBlock: 10, LastBlock: 19, Status: EpochStatus_InputsProcessed}, + {Index: 2, VirtualIndex: 2, FirstBlock: 20, LastBlock: 29, Status: EpochStatus_InputsProcessed}, + {Index: 3, VirtualIndex: 3, FirstBlock: 30, LastBlock: 39, Status: EpochStatus_InputsProcessed}, + } + hash := common.HexToHash("0x68b9914c8b694e0037fc9ae85670b5de94fa9d1adb10b3a037c3b170e14ee50d") + dummyOutputs = []Output{ + { + Index: 0, + RawData: common.Hash{}.Bytes(), + Hash: &hash, + OutputHashesSiblings: []common.Hash{ + common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"), + common.HexToHash("0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5"), + common.HexToHash("0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30"), + common.HexToHash("0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85"), + common.HexToHash("0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344"), + common.HexToHash("0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d"), + common.HexToHash("0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968"), + common.HexToHash("0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83"), + common.HexToHash("0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af"), + common.HexToHash("0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0"), + common.HexToHash("0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5"), + common.HexToHash("0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892"), + common.HexToHash("0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c"), + common.HexToHash("0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb"), + common.HexToHash("0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc"), + common.HexToHash("0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2"), + common.HexToHash("0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f"), + common.HexToHash("0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a"), + common.HexToHash("0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0"), + common.HexToHash("0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0"), + common.HexToHash("0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2"), + common.HexToHash("0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9"), + common.HexToHash("0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377"), + common.HexToHash("0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652"), + common.HexToHash("0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef"), + common.HexToHash("0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d"), + common.HexToHash("0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0"), + common.HexToHash("0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e"), + common.HexToHash("0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e"), + common.HexToHash("0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322"), + common.HexToHash("0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735"), + common.HexToHash("0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9"), + common.HexToHash("0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757"), + common.HexToHash("0xbf558bebd2ceec7f3c5dce04a4782f88c2c6036ae78ee206d0bc5289d20461a2"), + common.HexToHash("0xe21908c2968c0699040a6fd866a577a99a9d2ec88745c815fd4a472c789244da"), + common.HexToHash("0xae824d72ddc272aab68a8c3022e36f10454437c1886f3ff9927b64f232df414f"), + common.HexToHash("0x27e429a4bef3083bc31a671d046ea5c1f5b8c3094d72868d9dfdc12c7334ac5f"), + common.HexToHash("0x743cc5c365a9a6a15c1f240ac25880c7a9d1de290696cb766074a1d83d927816"), + common.HexToHash("0x4adcf616c3bfabf63999a01966c998b7bb572774035a63ead49da73b5987f347"), + common.HexToHash("0x75786645d0c5dd7c04a2f8a75dcae085213652f5bce3ea8b9b9bedd1cab3c5e9"), + common.HexToHash("0xb88b152c9b8a7b79637d35911848b0c41e7cc7cca2ab4fe9a15f9c38bb4bb939"), + common.HexToHash("0x0c4e2d8ce834ffd7a6cd85d7113d4521abb857774845c4291e6f6d010d97e318"), + common.HexToHash("0x5bc799d83e3bb31501b3da786680df30fbc18eb41cbce611e8c0e9c72f69571c"), + common.HexToHash("0xa10d3ef857d04d9c03ead7c6317d797a090fa1271ad9c7addfbcb412e9643d4f"), + common.HexToHash("0xb33b1809c42623f474055fa9400a2027a7a885c8dfa4efe20666b4ee27d7529c"), + common.HexToHash("0x134d7f28d53f175f6bf4b62faa2110d5b76f0f770c15e628181c1fcc18f970a9"), + common.HexToHash("0xc34d24b2fc8c50ca9c07a7156ef4e5ff4bdf002eda0b11c1d359d0b59a546807"), + common.HexToHash("0x04dbb9db631457879b27e0dfdbe50158fd9cf9b4cf77605c4ac4c95bd65fc9f6"), + common.HexToHash("0xf9295a686647cb999090819cda700820c282c613cedcd218540bbc6f37b01c65"), + common.HexToHash("0x67c4a1ea624f092a3a5cca2d6f0f0db231972fce627f0ecca0dee60f17551c5f"), + common.HexToHash("0x8fdaeb5ab560b2ceb781cdb339361a0fbee1b9dffad59115138c8d6a70dda9cc"), + common.HexToHash("0xc1bf0bbdd7fee15764845db875f6432559ff8dbc9055324431bc34e5b93d15da"), + common.HexToHash("0x307317849eccd90c0c7b98870b9317c15a5959dcfb84c76dcc908c4fe6ba9212"), + common.HexToHash("0x6339bf06e458f6646df5e83ba7c3d35bc263b3222c8e9040068847749ca8e8f9"), + common.HexToHash("0x5045e4342aeb521eb3a5587ec268ed3aa6faf32b62b0bc41a9d549521f406fc3"), + common.HexToHash("0x08601d83cdd34b5f7b8df63e7b9a16519d35473d0b89c317beed3d3d9424b253"), + common.HexToHash("0x84e35c5d92171376cae5c86300822d729cd3a8479583bef09527027dba5f1126"), + common.HexToHash("0x3c5cbbeb3834b7a5c1cba9aa5fee0c95ec3f17a33ec3d8047fff799187f5ae20"), + common.HexToHash("0x40bbe913c226c34c9fbe4389dd728984257a816892b3cae3e43191dd291f0eb5"), + common.HexToHash("0x14af5385bcbb1e4738bbae8106046e6e2fca42875aa5c000c582587742bcc748"), + common.HexToHash("0x72f29656803c2f4be177b1b8dd2a5137892b080b022100fde4e96d93ef8c96ff"), + common.HexToHash("0xd06f27061c734d7825b46865d00aa900e5cc3a3672080e527171e1171aa5038a"), + common.HexToHash("0x28203985b5f2d87709171678169739f957d2745f4bfa5cc91e2b4bd9bf483b40"), + }, + }, + } +} + +func (s *ValidatorSuite) TearDownSubTest() { + repo = nil + validator = nil +} + +func (s *ValidatorSuite) TestCreateClaimAndProofSuccess() { + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + } + + s.Run("FirstEpochNoOutputs", func() { + ctx := context.Background() + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{}) + + claimHash, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[0]) + s.NoError(err) + claimHashRef, _, err := merkletest.CreateProofs(nil, merkle.TREE_DEPTH) + s.NoError(err) + s.NotNil(claimHash) + s.Equal(claimHashRef, *claimHash) + repo.AssertExpectations(s.T()) + }) + + s.Run("FirstEpochOneOutput", func() { + ctx := context.Background() + output := Output{ + RawData: common.Hash{}.Bytes(), + } + + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{&output}) + + claimHash, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[0]) + s.NoError(err) + s.NotNil(claimHash) + repo.AssertExpectations(s.T()) + }) + + s.Run("SecondEpochNoOutputs", func() { + ctx := context.Background() + expectEpochOutputs(repo, &app, &dummyEpochs[1], []*Output{}) + + repo.On("GetEpochByVirtualIndex", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&dummyEpochs[0], nil).Once() + + claimHash, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[1]) + s.NoError(err) + s.Equal(dummyEpochs[0].TxBufferDataBlock, claimHash) + repo.AssertExpectations(s.T()) + }) + + s.Run("SecondEpochTwoOutputs", func() { + ctx := context.Background() + newOutput0 := Output{ + Index: 1, + RawData: common.Hash{}.Bytes(), + } + newOutput1 := Output{ + Index: 2, + RawData: common.Hash{}.Bytes(), + } + expectEpochOutputs(repo, &app, &dummyEpochs[1], []*Output{&newOutput0, &newOutput1}) + + repo.On("GetEpochByVirtualIndex", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&dummyEpochs[0], nil).Once() + + repo.On("GetOutput", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&dummyOutputs[0], nil).Once() + + _, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[1]) + s.NoError(err) + repo.AssertExpectations(s.T()) + }) +} + +func (s *ValidatorSuite) TestCreateClaimAndProofFailures() { + ctx := context.Background() + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + } + xerror := fmt.Errorf("Error") + + // Fail because the epoch range read failed + s.Run("EpochOutputRangeFailure", func() { + repo.On("GetEpochOutputRange", mock.Anything, app.ID, dummyEpochs[0].Index).Return(repository.EpochOutputRange{}, xerror).Once() + + _, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[0]) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + // Fail because GetEpochByVirtualIndex failed + s.Run("GetEpochByVirtualIndex", func() { + expectEpochOutputs(repo, &app, &dummyEpochs[1], []*Output{}) + + repo.On("GetEpochByVirtualIndex", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&dummyEpochs[0], xerror).Once() + + _, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[1]) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + // Fail because somehow the previous epoch does not have a claim hash yet. + s.Run("InvalidPreviousEpoch", func() { + expectEpochOutputs(repo, &app, &dummyEpochs[1], []*Output{}) + + invalidEpoch := dummyEpochs[0] + invalidEpoch.TxBufferDataBlock = nil + repo.On("GetEpochByVirtualIndex", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&invalidEpoch, nil).Once() + + expectCorrupted(&app, "Previous epoch has no claim") + + _, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[1]) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + // Fail because GetOutput (the preceding-output lookup) failed + s.Run("GetOutputFailure", func() { + expectEpochOutputs(repo, &app, &dummyEpochs[1], []*Output{{Index: 1}}) + + repo.On("GetEpochByVirtualIndex", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&dummyEpochs[0], nil).Once() + + repo.On("GetOutput", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&Output{}, xerror).Once() + + _, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[1]) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + // Fail because the preceding output has no hash + s.Run("InvalidPreviousOutputFailure", func() { + expectEpochOutputs(repo, &app, &dummyEpochs[1], []*Output{{Index: 1}}) + + repo.On("GetEpochByVirtualIndex", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&dummyEpochs[0], nil).Once() + + repo.On("GetOutput", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&Output{}, nil).Once() + + expectCorrupted(&app, "Output (0) preceding epoch 1 is missing or has invalid hash siblings") + + _, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[1]) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + // Fail because the preceding output is missing entirely + s.Run("MissingPreviousOutputFailure", func() { + expectEpochOutputs(repo, &app, &dummyEpochs[1], []*Output{{Index: 1}}) + + repo.On("GetEpochByVirtualIndex", + mock.Anything, mock.Anything, mock.Anything, + ).Return(&dummyEpochs[0], nil).Once() + + repo.On("GetOutput", + mock.Anything, mock.Anything, mock.Anything, + ).Return((*Output)(nil), nil).Once() + + expectCorrupted(&app, "Output (0) preceding epoch 1 is missing or has invalid hash siblings") + + _, _, err := validator.computeMerkleTreeAndProofs(ctx, &app, &dummyEpochs[1]) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + +} + +func (s *ValidatorSuite) TestValidateApplicationSuccess() { + ctx := context.Background() + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + } + s.Run("NoEpoch", func() { + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return(([]*Epoch)(nil), uint64(0), nil).Once() + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NoError(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("FirstEpochNoOutputs", func() { + input := Input{ + EpochApplicationID: app.ID, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{}) + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), dummyEpochs[0].Index, + ).Return(&input, nil).Once() + + repo.On("StoreClaimAndProofs", + mock.Anything, mock.Anything, mock.Anything, + ).Return(nil).Once() + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NoError(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("SkipsForeclosedEpochAtOrAfterForecloseBlock", func() { + foreclosedApp := app + foreclosedApp.ForecloseBlock = 9 + unacceptableEpoch := dummyEpochs[0] + unacceptableEpoch.LastBlock = foreclosedApp.ForecloseBlock + + repo.On("ListEpochs", + mock.Anything, foreclosedApp.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&unacceptableEpoch}, uint64(1), nil).Once() + + repo.application = &foreclosedApp + err := validator.validateApplication(ctx, &foreclosedApp) + s.NoError(err) + repo.AssertExpectations(s.T()) + }) +} + +func (s *ValidatorSuite) TestValidationApplicationsFilterSelectsValidPrefixes() { + s.Run("Filter", func() { + repo.On("ListApplications", + mock.Anything, + mock.MatchedBy(func(f repository.ApplicationFilter) bool { + return f.Enabled != nil && *f.Enabled && + s.ElementsMatch([]ApplicationStatus{ + ApplicationStatus_OK, ApplicationStatus_MachineHalted, ApplicationStatus_GuestException, + ApplicationStatus_McycleOverflow, ApplicationStatus_UnexpectedYield, + ApplicationStatus_InvalidOutputsRoot, + }, f.Statuses) + }), + repository.Pagination{}, + false, + ).Return([]*Application{}, uint64(0), nil).Once() + + _, _, err := getAllRunningApplications(context.Background(), repo) + s.NoError(err) + repo.AssertExpectations(s.T()) + }) +} + +func (s *ValidatorSuite) TestValidateApplicationFailure() { + ctx := context.Background() + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + } + xerror := fmt.Errorf("Error") + + s.Run("getProcessedEpochsFailure", func() { + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{}, uint64(0), xerror).Once() + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("computeMerkleTreeAndProofsFailure", func() { + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + repo.On("GetEpochOutputRange", mock.Anything, app.ID, dummyEpochs[0].Index).Return(repository.EpochOutputRange{}, xerror).Once() + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("GetLastInputFailure", func() { + input := Input{ + EpochApplicationID: app.ID, + TxBufferDataBlock: &validator.pristineRootHash, + } + + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{}) + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), dummyEpochs[0].Index, + ).Return(&input, xerror).Once() + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("InvalidInputFailure", func() { + input := Input{ + EpochApplicationID: app.ID, + TxBufferDataBlock: nil, // <- this is invalid + } + + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{}) + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), dummyEpochs[0].Index, + ).Return(&input, nil).Once() + + expectCorrupted(&app, "outputs merkle root is not defined") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("NilInputMachineHash", func() { + input := Input{ + EpochApplicationID: app.ID, + TxBufferDataBlock: &validator.pristineRootHash, + MachineHash: nil, // <- trigger nil guard + } + + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{}) + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), dummyEpochs[0].Index, + ).Return(&input, nil).Once() + + expectCorrupted(&app, "machine hash is not defined") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("ClaimMismatch", func() { + invalidClaim := common.Hash{} + input := Input{ + EpochApplicationID: app.ID, + TxBufferDataBlock: &invalidClaim, + } + + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{}) + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), dummyEpochs[0].Index, + ).Return(&input, nil).Once() + + expectCorrupted(&app, "outputs merkle root does not match last input") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("StoreClaimAndProofsFailure", func() { + app.Status = ApplicationStatus_OK + app.Reason = nil + input := Input{ + EpochApplicationID: app.ID, + TxBufferDataBlock: &validator.pristineRootHash, + MachineHash: &validator.pristineRootHash, + } + + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &dummyEpochs[0], []*Output{}) + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), dummyEpochs[0].Index, + ).Return(&input, nil).Once() + + repo.On("StoreClaimAndProofs", + mock.Anything, mock.Anything, mock.Anything, + ).Return(xerror).Once() + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.ErrorIs(err, xerror) + repo.AssertExpectations(s.T()) + }) + + // --- nil pointer access guards added in fix/validator-hardening --- + + s.Run("NilEpochMachineHash", func() { + epoch := Epoch{Status: EpochStatus_InputsProcessed, + Index: 0, + VirtualIndex: 0, + FirstBlock: 0, + LastBlock: 9, + TxBufferDataBlock: &validator.pristineRootHash, + MachineHash: nil, // <- nil triggers the new guard + } + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&epoch}, uint64(1), nil).Once() + + expectCorrupted(&app, "has no machine hash") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("NilEpochTxBufferDataBlock", func() { + epoch := Epoch{Status: EpochStatus_InputsProcessed, + Index: 0, + VirtualIndex: 0, + FirstBlock: 0, + LastBlock: 9, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: nil, // <- nil triggers the new guard + } + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&epoch}, uint64(1), nil).Once() + + expectCorrupted(&app, "has no outputs merkle root") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("EmptyEpochInNonDaveConsensusApp", func() { + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + ConsensusType: Consensus_Authority, // non-DaveConsensus + } + epoch := Epoch{Status: EpochStatus_InputsProcessed, + Index: 0, + VirtualIndex: 0, + FirstBlock: 0, + LastBlock: 9, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: dummyEpochs[0].TxBufferDataBlock, + } + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&epoch}, uint64(1), nil).Once() + + // computeMerkleTreeAndProofs: no outputs, no previous epoch -> pristine claim + expectEpochOutputs(repo, &app, &epoch, []*Output{}) + + // empty epoch (GetLastInput returns nil) + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), epoch.Index, + ).Return((*Input)(nil), nil).Once() + + expectCorrupted(&app, "has no inputs while not DaveConsensus") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("EmptyEpochPreviousEpochIsNil", func() { + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + ConsensusType: Consensus_PRT, // DaveConsensus so !IsDaveConsensus guard passes + } + epoch := Epoch{Status: EpochStatus_InputsProcessed, + Index: 1, + VirtualIndex: 1, + FirstBlock: 10, + LastBlock: 19, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: dummyEpochs[0].TxBufferDataBlock, + } + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&epoch}, uint64(1), nil).Once() + + // computeMerkleTreeAndProofs: no outputs -> looks up previous epoch + expectEpochOutputs(repo, &app, &epoch, []*Output{}) + + // 1st GetEpochByVirtualIndex (inside computeMerkleTreeAndProofs) succeeds + repo.On("GetEpochByVirtualIndex", + mock.Anything, app.IApplicationAddress.String(), uint64(0), + ).Return(&dummyEpochs[0], nil).Once() + + // empty epoch (GetLastInput returns nil) + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), epoch.Index, + ).Return((*Input)(nil), nil).Once() + + // 2nd GetEpochByVirtualIndex (inside validateApplication else branch) returns nil + repo.On("GetEpochByVirtualIndex", + mock.Anything, app.IApplicationAddress.String(), uint64(0), + ).Return((*Epoch)(nil), nil).Once() + + expectCorrupted(&app, "has no previous epoch") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("EmptyEpochPreviousEpochMachineHashNil", func() { + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + ConsensusType: Consensus_PRT, + } + epoch := Epoch{Status: EpochStatus_InputsProcessed, + Index: 1, + VirtualIndex: 1, + FirstBlock: 10, + LastBlock: 19, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: dummyEpochs[0].TxBufferDataBlock, + } + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&epoch}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &epoch, []*Output{}) + + // 1st GetEpochByVirtualIndex (inside computeMerkleTreeAndProofs) succeeds + repo.On("GetEpochByVirtualIndex", + mock.Anything, app.IApplicationAddress.String(), uint64(0), + ).Return(&dummyEpochs[0], nil).Once() + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), epoch.Index, + ).Return((*Input)(nil), nil).Once() + + // 2nd GetEpochByVirtualIndex: returns epoch with nil MachineHash + prev := Epoch{ + Index: 0, + VirtualIndex: 0, + MachineHash: nil, // <- nil triggers the guard + TxBufferDataBlock: &validator.pristineRootHash, + } + repo.On("GetEpochByVirtualIndex", + mock.Anything, app.IApplicationAddress.String(), uint64(0), + ).Return(&prev, nil).Once() + + expectCorrupted(&app, "machine hash is not defined") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("EmptyEpochPreviousEpochTxBufferDataBlockNil", func() { + app := Application{ + Name: dummyApplicationName, Enabled: true, Status: ApplicationStatus_OK, + ConsensusType: Consensus_PRT, + } + epoch := Epoch{Status: EpochStatus_InputsProcessed, + Index: 1, + VirtualIndex: 1, + FirstBlock: 10, + LastBlock: 19, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: dummyEpochs[0].TxBufferDataBlock, + } + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&epoch}, uint64(1), nil).Once() + + expectEpochOutputs(repo, &app, &epoch, []*Output{}) + + // 1st GetEpochByVirtualIndex (inside computeMerkleTreeAndProofs) succeeds + repo.On("GetEpochByVirtualIndex", + mock.Anything, app.IApplicationAddress.String(), uint64(0), + ).Return(&dummyEpochs[0], nil).Once() + + repo.On("GetLastInput", + mock.Anything, app.IApplicationAddress.String(), epoch.Index, + ).Return((*Input)(nil), nil).Once() + + // 2nd GetEpochByVirtualIndex: MachineHash matches but TxBufferDataBlock is nil + prev := Epoch{ + Index: 0, + VirtualIndex: 0, + MachineHash: &validator.pristineRootHash, // matches epoch.MachineHash + TxBufferDataBlock: nil, // <- nil triggers the guard + } + repo.On("GetEpochByVirtualIndex", + mock.Anything, app.IApplicationAddress.String(), uint64(0), + ).Return(&prev, nil).Once() + + expectCorrupted(&app, "outputs merkle root is not defined") + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.NotNil(err) + repo.AssertExpectations(s.T()) + }) + + s.Run("ContextCancellation", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + repo.On("ListEpochs", + mock.Anything, app.IApplicationAddress.String(), mock.Anything, mock.Anything, false, + ).Return([]*Epoch{&dummyEpochs[0]}, uint64(1), nil).Once() + + repo.application = &app + err := validator.validateApplication(ctx, &app) + s.ErrorIs(err, context.Canceled) + repo.AssertExpectations(s.T()) + }) +} + +// cover the integer overflow guards. +func (s *ValidatorSuite) TestBuildCommitment() { + ctx := context.Background() + const testAppName = "test-app" + + s.Run("ValidEpochWithInputs", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 5, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + // 5 inputs, each with one state hash covering the full + // entries-per-input count so the + // total entry count remains a power of two. + entriesPerInput := InputHashCollectionCapacity + stateHashes := make([]*StateHash, 5) + for i := range 5 { + stateHashes[i] = &StateHash{ + MachineHash: FinalStateEntry(validator.pristineRootHash), + Repetitions: entriesPerInput, + } + } + + expectStateHashRows(repo, app, epoch, stateHashes, uint64(5)) + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.NoError(err) + s.NotNil(commitment) + s.NotNil(proof) + repo.AssertExpectations(s.T()) + }) + + s.Run("ValidEmptyEpoch", func() { + // Equal bounds → inputCount==0, no state hashes queried, + // entire epoch is filled with pristine machine-hash entries. + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 7, + InputIndexUpperBound: 7, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + // No CountEpochStateHashes or ListEpochStateHashes call expected (inputCount==0 branch is skipped) + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.NoError(err) + s.NotNil(commitment) + s.NotNil(proof) + repo.AssertExpectations(s.T()) + }) + + s.Run("LowerBoundExceedsUpperBound", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 5, + InputIndexUpperBound: 3, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + expectCorrupted(app, "lower bound") + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.Error(err) + s.Nil(commitment) + s.Nil(proof) + s.Contains(err.Error(), "lower bound") + s.Contains(err.Error(), "upper bound") + repo.AssertExpectations(s.T()) + }) + + s.Run("InputCountExceedsMax", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + // MaxAdvanceStatesPerEpoch = 1 << 24 = 16_777_216 + // Set bounds so inputCount == MaxAdvanceStatesPerEpoch + 1 + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 0, + InputIndexUpperBound: MaxAdvanceStatesPerEpoch + 1, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + expectCorrupted(app, "input count is too large") + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.Error(err) + s.Nil(commitment) + s.Nil(proof) + s.Contains(err.Error(), "input count is too large") + repo.AssertExpectations(s.T()) + }) + + s.Run("NotEnoughStateHashes", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 2, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + repo.On("CountEpochStateHashes", mock.Anything, app.ID, epoch.Index).Return(uint64(1), nil).Once() + expectCorrupted(app, "not enough state hashes") + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.ErrorContains(err, "not enough state hashes") + s.Nil(commitment) + s.Nil(proof) + repo.AssertExpectations(s.T()) + }) + + s.Run("InconsistentStateHashCount", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + expectStateHashRows(repo, app, epoch, []*StateHash{{MachineHash: validator.pristineRootHash, Repetitions: 1}}, uint64(2)) + expectCorrupted(app, "inconsistent number of state hashes") + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.ErrorContains(err, "inconsistent number of state hashes") + s.Nil(commitment) + s.Nil(proof) + repo.AssertExpectations(s.T()) + }) + + s.Run("StateHashAppendFailure", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + + expectStateHashRows(repo, app, epoch, []*StateHash{{MachineHash: validator.pristineRootHash, Repetitions: 0}}, uint64(1)) + expectCorrupted(app, "failed to build commitment") + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.ErrorContains(err, "failed to build commitment") + s.Nil(commitment) + s.Nil(proof) + repo.AssertExpectations(s.T()) + }) + + s.Run("BuildFailure", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 2, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + stateHashes := []*StateHash{ + {MachineHash: validator.pristineRootHash, Repetitions: 1}, + {MachineHash: validator.pristineRootHash, Repetitions: 1}, + } + + expectStateHashRows(repo, app, epoch, stateHashes, uint64(len(stateHashes))) + expectCorrupted(app, "failed to build commitment") + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.ErrorContains(err, "failed to build commitment") + s.Nil(commitment) + s.Nil(proof) + repo.AssertExpectations(s.T()) + }) + + s.Run("CommitmentHeightMismatch", func() { + app := &Application{ + Name: testAppName, + ConsensusType: Consensus_PRT, + } + epoch := &Epoch{ + Index: 0, + VirtualIndex: 0, + InputIndexLowerBound: 0, + InputIndexUpperBound: 1, + MachineHash: &validator.pristineRootHash, + TxBufferDataBlock: &validator.pristineRootHash, + } + repetitions := (uint64(1) << (Log2MaxAdvanceStatesPerEpoch + Log2InputHashCollectionCapacity)) + InputHashCollectionCapacity + + expectStateHashRows(repo, app, epoch, []*StateHash{{MachineHash: validator.pristineRootHash, Repetitions: repetitions}}, uint64(1)) + expectCorrupted(app, "commitment tree height") + + commitment, proof, err := validator.buildCommitment(ctx, app, epoch) + s.ErrorContains(err, "commitment tree height") + s.Nil(commitment) + s.Nil(proof) + repo.AssertExpectations(s.T()) + }) +} + +type Mockrepo struct { + mock.Mock + application *Application + applications []*Application + epochs []*Epoch + problem *ApplicationProblem + readEpochCompletionState func(context.Context, string, uint64) (*repository.EpochCompletionState, error) +} + +func newMockrepo() *Mockrepo { + return new(Mockrepo) +} + +func (m *Mockrepo) ListApplications( + ctx context.Context, + f repository.ApplicationFilter, + pagination repository.Pagination, + descending bool, +) ([]*Application, uint64, error) { + args := m.Called(ctx, f, pagination, descending) + apps := args.Get(0).([]*Application) + m.applications = apps + if len(apps) == 1 { + m.application = apps[0] + } + return apps, args.Get(1).(uint64), args.Error(2) +} + +func (m *Mockrepo) GetEpochOutputRange( + ctx context.Context, appID int64, epochIndex uint64, +) (repository.EpochOutputRange, error) { + args := m.Called(ctx, appID, epochIndex) + return args.Get(0).(repository.EpochOutputRange), args.Error(1) +} + +func (m *Mockrepo) ScanOutputs( + ctx context.Context, appID int64, first, last uint64, fn repository.OutputScanFunc, +) error { + args := m.Called(ctx, appID, first, last, fn) + if err := args.Error(1); err != nil { + return err + } + scan := args.Get(0).(func(repository.OutputScanFunc) error) + return scan(fn) +} + +func (m *Mockrepo) GetOutput( + ctx context.Context, + nameOrAddress string, + outputIndex uint64, +) (*Output, error) { + args := m.Called(ctx, nameOrAddress, outputIndex) + return args.Get(0).(*Output), args.Error(1) +} + +func (m *Mockrepo) ListEpochs( + ctx context.Context, + nameOrAddress string, + f repository.EpochFilter, + p repository.Pagination, + descending bool, +) ([]*Epoch, uint64, error) { + args := m.Called(ctx, nameOrAddress, f, p, descending) + m.epochs = args.Get(0).([]*Epoch) + return m.epochs, args.Get(1).(uint64), args.Error(2) +} + +func (m *Mockrepo) GetLastInput(ctx context.Context, appAddress string, epochIndex uint64) (*Input, error) { + args := m.Called(ctx, appAddress, epochIndex) + return args.Get(0).(*Input), args.Error(1) +} + +func (m *Mockrepo) GetEpochByVirtualIndex(ctx context.Context, nameOrAddress string, index uint64) (*Epoch, error) { + args := m.Called(ctx, nameOrAddress, index) + return args.Get(0).(*Epoch), args.Error(1) +} + +func (m *Mockrepo) StoreClaimAndProofs(ctx context.Context, epoch *Epoch, write repository.ClaimProofWriteFunc) error { + args := m.Called(ctx, epoch, write) + return args.Error(0) +} + +func (m *Mockrepo) CountEpochStateHashes(ctx context.Context, appID int64, epochIndex uint64) (uint64, error) { + args := m.Called(ctx, appID, epochIndex) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *Mockrepo) ListEpochStateHashes( + ctx context.Context, appID int64, epochIndex uint64, afterIndex *uint64, limit uint64, +) ([]EpochStateHash, error) { + args := m.Called(ctx, appID, epochIndex, afterIndex, limit) + return args.Get(0).([]EpochStateHash), args.Error(1) +} + +func (m *Mockrepo) UpdateApplicationStatus(ctx context.Context, appID int64, status ApplicationStatus, reason *string) error { + args := m.Called(ctx, appID, status, reason) + return args.Error(0) +} + +func (m *Mockrepo) StoreInvalidOutputsRoot(ctx context.Context, appID int64, epochIndex uint64, reason string) error { + args := m.Called(ctx, appID, epochIndex, reason) + return args.Error(0) +} + +func (m *Mockrepo) GetEpochCompletionState(ctx context.Context, name string, index uint64) (*repository.EpochCompletionState, error) { + if m.readEpochCompletionState != nil { + return m.readEpochCompletionState(ctx, name, index) + } + app := m.application + if m.applications != nil { + app = nil + for _, candidate := range m.applications { + if name == candidate.Name || name == candidate.IApplicationAddress.Hex() { + app = candidate + break + } + } + } + state := &repository.EpochCompletionState{} + if app == nil || name != app.Name && name != app.IApplicationAddress.Hex() { + return state, nil + } + state.Application, state.Problem = app, m.problem + for _, epoch := range m.epochs { + if epoch.Index == index && epoch.ApplicationID == app.ID { + state.Epoch = epoch + break + } + } + return state, nil +} + +func (m *Mockrepo) GetTournament(ctx context.Context, name, address string) (*Tournament, error) { + args := m.Called(ctx, name, address) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Tournament), args.Error(1) +} + +func expectNoStoredProtectionRoots(repo *Mockrepo, app *Application) { + if !app.IsDaveConsensus() { + return + } + repo.On("ListEpochs", mock.Anything, app.IApplicationAddress.Hex(), repository.EpochFilter{ + HasTournament: new(true), Status: NonTerminalEpochStatuses(), + }, repository.Pagination{}, false).Return([]*Epoch(nil), uint64(0), nil).Once() +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 000000000..6b47bde8f --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,10 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package version + +var ( + // Should be overridden during the final release build with ldflags + // to contain the actual version number + BuildVersion = "devel" +) diff --git a/licenses/GPL-3.0.txt b/licenses/GPL-3.0.txt new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/licenses/GPL-3.0.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/licenses/LGPL-3.0.txt b/licenses/LGPL-3.0.txt new file mode 100644 index 000000000..0a041280b --- /dev/null +++ b/licenses/LGPL-3.0.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/offchain/.gitignore b/offchain/.gitignore deleted file mode 100644 index eb5a316cb..000000000 --- a/offchain/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target diff --git a/offchain/.rustfmt.toml b/offchain/.rustfmt.toml deleted file mode 100644 index 0c2ca5c32..000000000 --- a/offchain/.rustfmt.toml +++ /dev/null @@ -1,2 +0,0 @@ -edition = "2018" -max_width = 80 diff --git a/offchain/Cargo.lock b/offchain/Cargo.lock deleted file mode 100644 index f23b33966..000000000 --- a/offchain/Cargo.lock +++ /dev/null @@ -1,6569 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "Inflector" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" -dependencies = [ - "lazy_static", - "regex", -] - -[[package]] -name = "actix-codec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" -dependencies = [ - "bitflags 2.5.0", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-cors" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331" -dependencies = [ - "actix-utils", - "actix-web", - "derive_more", - "futures-util", - "log 0.4.21", - "once_cell", - "smallvec", -] - -[[package]] -name = "actix-http" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d223b13fd481fc0d1f83bb12659ae774d9e3601814c68a0bc539731698cca743" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "ahash", - "base64 0.21.7", - "bitflags 2.5.0", - "brotli", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "flate2", - "futures-core", - "h2 0.3.25", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.8.5", - "sha1", - "smallvec", - "tokio", - "tokio-util", - "tracing", - "zstd 0.13.1", -] - -[[package]] -name = "actix-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" -dependencies = [ - "quote", - "syn 2.0.55", -] - -[[package]] -name = "actix-router" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22475596539443685426b6bdadb926ad0ecaefdfc5fb05e5e3441f15463c511" -dependencies = [ - "bytestring", - "http 0.2.12", - "regex", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28f32d40287d3f402ae0028a9d54bef51af15c8769492826a69d28f81893151d" -dependencies = [ - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb13e7eef0423ea6eab0e59f6c72e7cb46d33691ad56a726b3cd07ddec2c2d4" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" -dependencies = [ - "futures-core", - "paste", - "pin-project-lite", -] - -[[package]] -name = "actix-tls" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4cce60a2f2b477bc72e5cde0af1812a6e82d8fd85b5570a5dcf2a5bf2c5be5f" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "http 0.2.12", - "http 1.1.0", - "impl-more", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a6556ddebb638c2358714d853257ed226ece6023ef9364f23f0c70737ea984" -dependencies = [ - "actix-codec", - "actix-http", - "actix-macros", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-utils", - "actix-web-codegen", - "ahash", - "bytes", - "bytestring", - "cfg-if", - "cookie", - "derive_more", - "encoding_rs", - "futures-core", - "futures-util", - "itoa", - "language-tags", - "log 0.4.21", - "mime", - "once_cell", - "pin-project-lite", - "regex", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2", - "time", - "url", -] - -[[package]] -name = "actix-web-codegen" -version = "4.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1f50ebbb30eca122b188319a4398b3f7bb4a8cdf50ecfb73bfc6a3c3ce54f5" -dependencies = [ - "actix-router", - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "addr2line" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "advance-runner" -version = "1.5.1" -dependencies = [ - "backoff", - "clap", - "env_logger", - "grpc-interfaces", - "hex", - "http-health-check", - "log 1.5.1", - "rand 0.8.5", - "rollups-events", - "sha3", - "snafu 0.8.2", - "tempfile", - "test-fixtures", - "test-log", - "testcontainers", - "tokio", - "tonic", - "tracing", - "tracing-subscriber", - "uuid 1.8.0", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "getrandom 0.2.12", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "0.6.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" - -[[package]] -name = "anstyle-parse" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" -dependencies = [ - "anstyle", - "windows-sys 0.52.0", -] - -[[package]] -name = "anyhow" -version = "1.0.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" - -[[package]] -name = "arc-swap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" - -[[package]] -name = "arrayvec" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" - -[[package]] -name = "ascii" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" - -[[package]] -name = "ascii-canvas" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" -dependencies = [ - "term", -] - -[[package]] -name = "async-recursion" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30c5ef0ede93efbf733c1a727f3b6b5a1060bbedd5600183e66f6e4be4af0ec5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "async-stream" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "async-trait" -version = "0.1.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - -[[package]] -name = "authority-claimer" -version = "1.5.1" -dependencies = [ - "async-trait", - "backoff", - "clap", - "contracts", - "eth-tx-manager", - "ethabi", - "ethers", - "ethers-signers", - "http-server", - "log 1.5.1", - "redacted", - "rollups-events", - "rusoto_core", - "rusoto_kms", - "rusoto_sts", - "serde", - "serde_json", - "serial_test", - "snafu 0.8.2", - "test-fixtures", - "testcontainers", - "tokio", - "tracing", - "tracing-test", - "types", - "url", -] - -[[package]] -name = "auto_impl" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7862e21c893d65a1650125d157eaeec691439379a1cee17ee49031b79236ada4" -dependencies = [ - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "auto_impl" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c87f3f15e7794432337fc718554eaa4dc8f04c9677a950ffe366f20a162ae42" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "autocfg" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" - -[[package]] -name = "awc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c09cc97310b926f01621faee652f3d1b0962545a3cec6c9ac07def9ea36c2c" -dependencies = [ - "actix-codec", - "actix-http", - "actix-rt", - "actix-service", - "actix-tls", - "actix-utils", - "base64 0.21.7", - "bytes", - "cfg-if", - "cookie", - "derive_more", - "futures-core", - "futures-util", - "h2 0.3.25", - "http 0.2.12", - "itoa", - "log 0.4.21", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.8.5", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", -] - -[[package]] -name = "axum" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" -dependencies = [ - "async-trait", - "axum-core 0.3.4", - "bitflags 1.3.2", - "bytes", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.28", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper 0.1.2", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" -dependencies = [ - "async-trait", - "axum-core 0.4.3", - "bytes", - "futures-util", - "http 1.1.0", - "http-body 1.0.0", - "http-body-util", - "hyper 1.2.0", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper 1.0.0", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "mime", - "rustversion", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.1.0", - "http-body 1.0.0", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper 0.1.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "backoff" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" -dependencies = [ - "futures-core", - "getrandom 0.2.12", - "instant", - "pin-project-lite", - "rand 0.8.5", - "tokio", -] - -[[package]] -name = "backtrace" -version = "0.3.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - -[[package]] -name = "base16ct" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" - -[[package]] -name = "base58" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5024ee8015f02155eee35c711107ddd9a9bf3cb689cf2a9089c97e79b6e1ae83" - -[[package]] -name = "base58check" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee2fe4c9a0c84515f136aaae2466744a721af6d63339c18689d9e995d74d99b" -dependencies = [ - "base58", - "sha2 0.8.2", -] - -[[package]] -name = "base64" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" - -[[package]] -name = "base64ct" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" - -[[package]] -name = "bech32" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dabbe35f96fb9507f7330793dc490461b2962659ac5d427181e451a623751d1" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" - -[[package]] -name = "bitmaps" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" -dependencies = [ - "typenum", -] - -[[package]] -name = "bitvec" -version = "0.17.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41262f11d771fd4a61aa3ce019fca363b4b6c282fca9da2a31186d3965a47a5c" -dependencies = [ - "either", - "radium 0.3.0", -] - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium 0.7.0", - "tap", - "wyz", -] - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "block-buffer" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" -dependencies = [ - "block-padding", - "byte-tools", - "byteorder", - "generic-array 0.12.4", -] - -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "block-padding" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" -dependencies = [ - "byte-tools", -] - -[[package]] -name = "bollard-stubs" -version = "1.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2f2e73fffe9455141e170fb9c1feb0ac521ec7e7dcd47a7cab72a658490fb8" -dependencies = [ - "chrono", - "serde", - "serde_with", -] - -[[package]] -name = "brotli" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bs58" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" - -[[package]] -name = "bson" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0aa578035b938855a710ba58d43cfb4d435f3619f99236fb35922a574d6cb1" -dependencies = [ - "base64 0.13.1", - "chrono", - "hex", - "lazy_static", - "linked-hash-map", - "rand 0.7.3", - "serde", - "serde_json", - "uuid 0.8.2", -] - -[[package]] -name = "built" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d17f4d6e4dc36d1a02fbedc2753a096848e7c1b0772f7654eab8e2c927dd53" -dependencies = [ - "git2", -] - -[[package]] -name = "bumpalo" -version = "3.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" - -[[package]] -name = "byte-slice-cast" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ac9f8b63eca6fd385229b3675f6cc0dc5c8a5c8a54a59d4f52ffd670d87b0c" - -[[package]] -name = "byte-tools" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" -dependencies = [ - "serde", -] - -[[package]] -name = "bytestring" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d80203ea6b29df88012294f62733de21cfeab47f17b41af3a38bc30a03ee72" -dependencies = [ - "bytes", -] - -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.11+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "camino" -version = "1.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-platform" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "cc" -version = "1.0.90" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" -dependencies = [ - "jobserver", - "libc", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-targets 0.52.4", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "clap" -version = "4.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim 0.11.0", -] - -[[package]] -name = "clap_derive" -version = "4.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "clap_lex" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" - -[[package]] -name = "coins-bip32" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634c509653de24b439672164bbf56f5f582a2ab0e313d3b0f6af0b7345cf2560" -dependencies = [ - "bincode", - "bs58", - "coins-core", - "digest 0.10.7", - "getrandom 0.2.12", - "hmac 0.12.1", - "k256", - "lazy_static", - "serde", - "sha2 0.10.8", - "thiserror", -] - -[[package]] -name = "coins-bip39" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a11892bcac83b4c6e95ab84b5b06c76d9d70ad73548dd07418269c5c7977171" -dependencies = [ - "bitvec 0.17.4", - "coins-bip32", - "getrandom 0.2.12", - "hex", - "hmac 0.12.1", - "pbkdf2", - "rand 0.8.5", - "sha2 0.10.8", - "thiserror", -] - -[[package]] -name = "coins-core" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c94090a6663f224feae66ab01e41a2555a8296ee07b5f20dab8888bdefc9f617" -dependencies = [ - "base58check", - "base64 0.12.3", - "bech32", - "blake2", - "digest 0.10.7", - "generic-array 0.14.7", - "hex", - "ripemd", - "serde", - "serde_derive", - "sha2 0.10.8", - "sha3", - "thiserror", -] - -[[package]] -name = "colorchoice" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" - -[[package]] -name = "combine" -version = "3.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" -dependencies = [ - "ascii", - "byteorder", - "either", - "memchr", - "unreachable", -] - -[[package]] -name = "combine" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" -dependencies = [ - "bytes", - "futures-core", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - -[[package]] -name = "contracts" -version = "1.5.1" -dependencies = [ - "eth-state-fold-types", - "snafu 0.8.2", - "tempfile", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cookie" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" - -[[package]] -name = "cpufeatures" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" -dependencies = [ - "libc", -] - -[[package]] -name = "crc16" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" - -[[package]] -name = "crc32fast" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" - -[[package]] -name = "crunchy" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" - -[[package]] -name = "crypto-bigint" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" -dependencies = [ - "generic-array 0.14.7", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array 0.14.7", - "typenum", -] - -[[package]] -name = "crypto-mac" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" -dependencies = [ - "generic-array 0.14.7", - "subtle", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "darling" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn 1.0.109", -] - -[[package]] -name = "darling_macro" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" -dependencies = [ - "darling_core", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.3", - "lock_api", - "once_cell", - "parking_lot_core 0.9.9", -] - -[[package]] -name = "der" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "0.99.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 1.0.109", -] - -[[package]] -name = "derive_utils" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532b4c15dccee12c7044f1fcad956e98410860b22231e44a3b827464797ca7bf" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "diesel" -version = "2.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559" -dependencies = [ - "bitflags 2.5.0", - "byteorder", - "diesel_derives", - "itoa", - "pq-sys", - "r2d2", -] - -[[package]] -name = "diesel_derives" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d02eecb814ae714ffe61ddc2db2dd03e6c49a42e269b5001355500d431cce0c" -dependencies = [ - "diesel_table_macro_syntax", - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "diesel_migrations" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6036b3f0120c5961381b570ee20a02432d7e2d27ea60de9578799cf9156914ac" -dependencies = [ - "diesel", - "migrations_internals", - "migrations_macros", -] - -[[package]] -name = "diesel_table_macro_syntax" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" -dependencies = [ - "syn 2.0.55", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "digest" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" -dependencies = [ - "generic-array 0.12.4", -] - -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "dispatcher" -version = "1.5.1" -dependencies = [ - "async-trait", - "backoff", - "clap", - "eth-state-client-lib", - "eth-state-fold-types", - "futures", - "http-server", - "im", - "log 1.5.1", - "rand 0.8.5", - "redis", - "rollups-events", - "serial_test", - "snafu 0.8.2", - "test-fixtures", - "testcontainers", - "tokio", - "tokio-stream", - "tonic", - "tracing", - "tracing-test", - "types", -] - -[[package]] -name = "doc-comment" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" - -[[package]] -name = "downcast" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - -[[package]] -name = "dtoa" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" - -[[package]] -name = "dunce" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" - -[[package]] -name = "ecdsa" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" -dependencies = [ - "der", - "elliptic-curve", - "rfc6979", - "signature", -] - -[[package]] -name = "either" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" - -[[package]] -name = "elliptic-curve" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" -dependencies = [ - "base16ct", - "crypto-bigint", - "der", - "digest 0.10.7", - "ff", - "generic-array 0.14.7", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "ena" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533630cf40e9caa44bd91aadc88a75d75a4c3a12b4cfde353cbed41daa1e1f1" -dependencies = [ - "log 0.4.21", -] - -[[package]] -name = "encoding_rs" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "env_filter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea" -dependencies = [ - "log 0.4.21", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "humantime", - "log 0.4.21", -] - -[[package]] -name = "equivalent" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" - -[[package]] -name = "errno" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "eth-block-history" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca058dc4a095167fc361462a1361f6cfa91dd9431d034ed989218ed5f71acf9" -dependencies = [ - "async-stream", - "clap", - "eth-state-fold-types", - "snafu 0.7.5", - "tokio", - "tokio-stream", - "tracing", -] - -[[package]] -name = "eth-keystore" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fda3bf123be441da5260717e0661c25a2fd9cb2b2c1d20bf2e05580047158ab" -dependencies = [ - "aes", - "ctr", - "digest 0.10.7", - "hex", - "hmac 0.12.1", - "pbkdf2", - "rand 0.8.5", - "scrypt", - "serde", - "serde_json", - "sha2 0.10.8", - "sha3", - "thiserror", - "uuid 0.8.2", -] - -[[package]] -name = "eth-state-client-lib" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40b8c77c0518287f68c2d5fbe9f291aa0787404345fc92796c7ec3ec4ee3a3b" -dependencies = [ - "async-trait", - "clap", - "eth-state-fold-types", - "eth-state-server-common", - "serde", - "serde_json", - "snafu 0.7.5", - "tokio", - "tokio-stream", - "tonic", -] - -[[package]] -name = "eth-state-fold" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0f1ac68e0b96efc11f7a9f60450dbfb14e987edd35553fd64989b57bfa53813" -dependencies = [ - "async-recursion", - "async-trait", - "clap", - "eth-block-history", - "eth-state-fold-types", - "futures", - "snafu 0.7.5", - "tokio", -] - -[[package]] -name = "eth-state-fold-types" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5194f4eca643c2654aedd175fd99b0d21a5bac8b49f72681aaa33e4f792d5c49" -dependencies = [ - "anyhow", - "ethabi", - "ethers", - "proc-macro2", - "quote", - "serde", - "serde_json", - "snafu 0.7.5", - "toml 0.5.11", -] - -[[package]] -name = "eth-state-server-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589cfcf2bb4c3531e7e6bc08e9d00ba874773b3111445c02ab220692db3e3725" -dependencies = [ - "anyhow", - "eth-state-fold-types", - "prost", - "serde", - "serde_json", - "snafu 0.7.5", - "tonic", - "tonic-build", -] - -[[package]] -name = "eth-state-server-lib" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f0141b54e46461538cec27aa45757a679cab0bd0e7c26fa27bfc3b7001488f1" -dependencies = [ - "clap", - "eth-block-history", - "eth-state-fold", - "eth-state-fold-types", - "eth-state-server-common", - "futures", - "serde", - "serde_json", - "snafu 0.7.5", - "tokio", - "tokio-stream", - "tonic", - "tonic-health", - "tracing", -] - -[[package]] -name = "eth-tx-manager" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9a5d4164843249a9d8cbf976a60b93ba5a855ad738ded32173fe3d7860b5f2" -dependencies = [ - "async-recursion", - "async-trait", - "clap", - "ethers", - "reqwest 0.11.24", - "serde", - "serde_json", - "thiserror", - "tokio", - "tracing", -] - -[[package]] -name = "ethabi" -version = "18.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7413c5f74cc903ea37386a8965a936cbeb334bd270862fdece542c1b2dcbc898" -dependencies = [ - "ethereum-types", - "hex", - "once_cell", - "regex", - "serde", - "serde_json", - "sha3", - "thiserror", - "uint", -] - -[[package]] -name = "ethbloom" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c22d4b5885b6aa2fe5e8b9329fb8d232bf739e434e6b87347c63bdd00c120f60" -dependencies = [ - "crunchy", - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "scale-info", - "tiny-keccak", -] - -[[package]] -name = "ethereum-types" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee" -dependencies = [ - "ethbloom", - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "primitive-types", - "scale-info", - "uint", -] - -[[package]] -name = "ethers" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f26f9d8d80da18ca72aca51804c65eb2153093af3bec74fd5ce32aa0c1f665" -dependencies = [ - "ethers-addressbook", - "ethers-contract", - "ethers-core", - "ethers-etherscan", - "ethers-middleware", - "ethers-providers", - "ethers-signers", - "ethers-solc", -] - -[[package]] -name = "ethers-addressbook" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4be54dd2260945d784e06ccdeb5ad573e8f1541838cee13a1ab885485eaa0b" -dependencies = [ - "ethers-core", - "once_cell", - "serde", - "serde_json", -] - -[[package]] -name = "ethers-contract" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c3c3e119a89f0a9a1e539e7faecea815f74ddcf7c90d0b00d1f524db2fdc9c" -dependencies = [ - "ethers-contract-abigen", - "ethers-contract-derive", - "ethers-core", - "ethers-providers", - "futures-util", - "hex", - "once_cell", - "pin-project", - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "ethers-contract-abigen" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4e5ad46aede34901f71afdb7bb555710ed9613d88d644245c657dc371aa228" -dependencies = [ - "Inflector", - "cfg-if", - "dunce", - "ethers-core", - "eyre", - "getrandom 0.2.12", - "hex", - "proc-macro2", - "quote", - "regex", - "reqwest 0.11.24", - "serde", - "serde_json", - "syn 1.0.109", - "toml 0.5.11", - "url", - "walkdir", -] - -[[package]] -name = "ethers-contract-derive" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f192e8e4cf2b038318aae01e94e7644e0659a76219e94bcd3203df744341d61f" -dependencies = [ - "ethers-contract-abigen", - "ethers-core", - "hex", - "proc-macro2", - "quote", - "serde_json", - "syn 1.0.109", -] - -[[package]] -name = "ethers-core" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade3e9c97727343984e1ceada4fdab11142d2ee3472d2c67027d56b1251d4f15" -dependencies = [ - "arrayvec", - "bytes", - "cargo_metadata", - "chrono", - "convert_case 0.6.0", - "elliptic-curve", - "ethabi", - "generic-array 0.14.7", - "hex", - "k256", - "once_cell", - "open-fastrlp", - "proc-macro2", - "rand 0.8.5", - "rlp", - "rlp-derive", - "serde", - "serde_json", - "strum", - "syn 1.0.109", - "thiserror", - "tiny-keccak", - "unicode-xid", -] - -[[package]] -name = "ethers-etherscan" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9713f525348e5dde025d09b0a4217429f8074e8ff22c886263cc191e87d8216" -dependencies = [ - "ethers-core", - "getrandom 0.2.12", - "reqwest 0.11.24", - "semver", - "serde", - "serde-aux", - "serde_json", - "thiserror", - "tracing", -] - -[[package]] -name = "ethers-middleware" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e71df7391b0a9a51208ffb5c7f2d068900e99d6b3128d3a4849d138f194778b7" -dependencies = [ - "async-trait", - "auto_impl 0.5.0", - "ethers-contract", - "ethers-core", - "ethers-etherscan", - "ethers-providers", - "ethers-signers", - "futures-locks", - "futures-util", - "instant", - "reqwest 0.11.24", - "serde", - "serde_json", - "thiserror", - "tokio", - "tracing", - "tracing-futures", - "url", -] - -[[package]] -name = "ethers-providers" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a9e0597aa6b2fdc810ff58bc95e4eeaa2c219b3e615ed025106ecb027407d8" -dependencies = [ - "async-trait", - "auto_impl 1.2.0", - "base64 0.13.1", - "ethers-core", - "futures-channel", - "futures-core", - "futures-timer", - "futures-util", - "getrandom 0.2.12", - "hashers", - "hex", - "http 0.2.12", - "once_cell", - "parking_lot 0.11.2", - "pin-project", - "reqwest 0.11.24", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-tungstenite", - "tracing", - "tracing-futures", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-timer", - "web-sys", - "ws_stream_wasm", -] - -[[package]] -name = "ethers-signers" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f41ced186867f64773db2e55ffdd92959e094072a1d09a5e5e831d443204f98" -dependencies = [ - "async-trait", - "coins-bip32", - "coins-bip39", - "elliptic-curve", - "eth-keystore", - "ethers-core", - "hex", - "rand 0.8.5", - "rusoto_core", - "rusoto_kms", - "sha2 0.10.8", - "spki", - "thiserror", - "tracing", -] - -[[package]] -name = "ethers-solc" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbe9c0a6d296c57191e5f8a613a3b5e816812c28f4a28d6178a17c21db903d77" -dependencies = [ - "cfg-if", - "dunce", - "ethers-core", - "getrandom 0.2.12", - "glob", - "hex", - "home", - "md-5 0.10.6", - "num_cpus", - "once_cell", - "path-slash", - "rayon", - "regex", - "semver", - "serde", - "serde_json", - "solang-parser", - "svm-rs", - "thiserror", - "tiny-keccak", - "tokio", - "tracing", - "walkdir", - "yansi", -] - -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - -[[package]] -name = "fake-simd" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" - -[[package]] -name = "fastrand" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" - -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fixed-hash" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" -dependencies = [ - "byteorder", - "rand 0.8.5", - "rustc-hex", - "static_assertions", -] - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "flate2" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fragile" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" - -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" - -[[package]] -name = "futures-enum" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3422d14de7903a52e9dbc10ae05a7e14445ec61890100e098754e120b2bd7b1e" -dependencies = [ - "derive_utils", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "futures-executor" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" - -[[package]] -name = "futures-locks" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45ec6fe3675af967e67c5536c0b9d44e34e6c52f86bedc4ea49c5317b8e94d06" -dependencies = [ - "futures-channel", - "futures-task", -] - -[[package]] -name = "futures-macro" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "futures-sink" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" - -[[package]] -name = "futures-task" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - -[[package]] -name = "futures-util" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "generic-array" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" -dependencies = [ - "typenum", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "gimli" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" - -[[package]] -name = "git2" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" -dependencies = [ - "bitflags 2.5.0", - "libc", - "libgit2-sys", - "log 0.4.21", - "url", -] - -[[package]] -name = "glob" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" - -[[package]] -name = "graphql-parser" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1abd4ce5247dfc04a03ccde70f87a048458c9356c7e41d21ad8c407b3dde6f2" -dependencies = [ - "combine 3.8.1", - "thiserror", -] - -[[package]] -name = "graphql-server" -version = "1.5.1" -dependencies = [ - "actix-cors", - "actix-web", - "awc", - "clap", - "hex", - "http-health-check", - "juniper", - "log 1.5.1", - "rollups-data", - "serde", - "serde_json", - "serial_test", - "snafu 0.8.2", - "test-fixtures", - "testcontainers", - "tokio", - "tracing", -] - -[[package]] -name = "group" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "grpc-interfaces" -version = "1.5.1" -dependencies = [ - "prost", - "tonic", - "tonic-build", -] - -[[package]] -name = "h2" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fbd2820c5e49886948654ab546d0688ff24530286bdcf8fca3cefb16d4618eb" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.2.6", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51ee2dd2e4f378392eeff5d51618cd9a63166a2513846bbc55f21cfacd9199d4" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 1.1.0", - "indexmap 2.2.6", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" - -[[package]] -name = "hashers" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bca93b15ea5a746f220e56587f71e73c6165eab783df9e26590069953e3c30" -dependencies = [ - "fxhash", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hmac" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" -dependencies = [ - "crypto-mac", - "digest 0.9.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "home" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "host-runner" -version = "1.5.1" -dependencies = [ - "actix-web", - "async-trait", - "byteorder", - "clap", - "ethabi", - "futures-util", - "grpc-interfaces", - "hex", - "http-health-check", - "log 1.5.1", - "mockall", - "rand 0.8.5", - "reqwest 0.12.2", - "rollups-http-client", - "serde", - "serial_test", - "sha3", - "snafu 0.8.2", - "tokio", - "tonic", - "tonic-health", - "tracing", - "tracing-test", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" -dependencies = [ - "bytes", - "http 1.1.0", -] - -[[package]] -name = "http-body-util" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" -dependencies = [ - "bytes", - "futures-core", - "http 1.1.0", - "http-body 1.0.0", - "pin-project-lite", -] - -[[package]] -name = "http-health-check" -version = "1.5.1" -dependencies = [ - "axum 0.7.5", - "snafu 0.8.2", - "tokio", - "tracing", -] - -[[package]] -name = "http-server" -version = "1.5.1" -dependencies = [ - "axum 0.7.5", - "clap", - "hyper 0.14.28", - "prometheus-client", - "tokio", - "tracing", -] - -[[package]] -name = "httparse" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - -[[package]] -name = "hyper" -version = "0.14.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.25", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "h2 0.4.3", - "http 1.1.0", - "http-body 1.0.0", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.28", - "rustls 0.21.10", - "tokio", - "tokio-rustls 0.24.1", -] - -[[package]] -name = "hyper-timeout" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" -dependencies = [ - "hyper 0.14.28", - "pin-project-lite", - "tokio", - "tokio-io-timeout", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.28", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.2.0", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http 1.1.0", - "http-body 1.0.0", - "hyper 1.2.0", - "pin-project-lite", - "socket2", - "tokio", - "tower", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "im" -version = "15.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" -dependencies = [ - "bitmaps", - "rand_core 0.6.4", - "rand_xoshiro", - "serde", - "sized-chunks", - "typenum", - "version_check", -] - -[[package]] -name = "impl-codec" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" -dependencies = [ - "parity-scale-codec", -] - -[[package]] -name = "impl-more" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206ca75c9c03ba3d4ace2460e57b189f39f43de612c2f85836e65c929701bb2d" - -[[package]] -name = "impl-rlp" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" -dependencies = [ - "rlp", -] - -[[package]] -name = "impl-serde" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" -dependencies = [ - "serde", -] - -[[package]] -name = "impl-trait-for-tuples" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d7a9f6330b71fea57921c9b61c47ee6e84f72d394754eff6163ae67e7395eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "indenter" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" - -[[package]] -name = "indexer" -version = "1.5.1" -dependencies = [ - "backoff", - "clap", - "env_logger", - "http-health-check", - "log 1.5.1", - "rand 0.8.5", - "rollups-data", - "rollups-events", - "serial_test", - "snafu 0.8.2", - "test-fixtures", - "test-log", - "testcontainers", - "tokio", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" -dependencies = [ - "equivalent", - "hashbrown 0.14.3", -] - -[[package]] -name = "inout" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "inspect-server" -version = "1.5.1" -dependencies = [ - "actix-cors", - "actix-web", - "clap", - "futures", - "grpc-interfaces", - "hex", - "http-health-check", - "log 1.5.1", - "reqwest 0.12.2", - "serde", - "serial_test", - "snafu 0.8.2", - "tokio", - "toml 0.8.12", - "tonic", - "tracing", - "tracing-actix-web", - "uuid 1.8.0", -] - -[[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "ipnet" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" - -[[package]] -name = "is-terminal" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" - -[[package]] -name = "jobserver" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6" -dependencies = [ - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "json" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" - -[[package]] -name = "juniper" -version = "0.15.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875dca5a0c08b1521e1bb0ed940e9955a9f38971008aaa2a9f64a2ac6b59e1b5" -dependencies = [ - "async-trait", - "bson", - "chrono", - "fnv", - "futures", - "futures-enum", - "graphql-parser", - "indexmap 1.9.3", - "juniper_codegen", - "serde", - "smartstring", - "static_assertions", - "url", - "uuid 0.8.2", -] - -[[package]] -name = "juniper_codegen" -version = "0.15.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee97671061ad50301ba077d054d295e01d31a1868fbd07902db651f987e71db" -dependencies = [ - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "k256" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c1e0b51e7ec0a97369623508396067a486bd0cbed95a2659a4b863d28cfc8b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "sha2 0.10.8", - "sha3", -] - -[[package]] -name = "keccak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" -dependencies = [ - "cpufeatures", -] - -[[package]] -name = "lalrpop" -version = "0.19.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a1cbf952127589f2851ab2046af368fd20645491bb4b376f04b7f94d7a9837b" -dependencies = [ - "ascii-canvas", - "bit-set", - "diff", - "ena", - "is-terminal", - "itertools", - "lalrpop-util", - "petgraph", - "regex", - "regex-syntax 0.6.29", - "string_cache", - "term", - "tiny-keccak", - "unicode-xid", -] - -[[package]] -name = "lalrpop-util" -version = "0.19.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3c48237b9604c5a4702de6b824e02006c3214327564636aef27c1028a8fa0ed" -dependencies = [ - "regex", -] - -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "libc" -version = "0.2.153" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" - -[[package]] -name = "libgit2-sys" -version = "0.16.2+1.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - -[[package]] -name = "libredox" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" -dependencies = [ - "bitflags 2.5.0", - "libc", - "redox_syscall 0.4.1", -] - -[[package]] -name = "libz-sys" -version = "1.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linux-raw-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" - -[[package]] -name = "local-channel" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - -[[package]] -name = "local-waker" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" - -[[package]] -name = "lock_api" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" - -[[package]] -name = "log" -version = "1.5.1" -dependencies = [ - "built", - "clap", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata 0.1.10", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "md-5" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" -dependencies = [ - "block-buffer 0.9.0", - "digest 0.9.0", - "opaque-debug 0.3.1", -] - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - -[[package]] -name = "memchr" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" - -[[package]] -name = "migrations_internals" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f23f71580015254b020e856feac3df5878c2c7a8812297edd6c0a485ac9dada" -dependencies = [ - "serde", - "toml 0.7.8", -] - -[[package]] -name = "migrations_macros" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cce3325ac70e67bbab5bd837a31cae01f1a6db64e0e744a33cb03a543469ef08" -dependencies = [ - "migrations_internals", - "proc-macro2", - "quote", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" -dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log 0.4.21", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - -[[package]] -name = "mockall" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" -dependencies = [ - "cfg-if", - "downcast", - "fragile", - "lazy_static", - "mockall_derive", - "predicates", - "predicates-tree", -] - -[[package]] -name = "mockall_derive" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "multimap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" - -[[package]] -name = "mutually_exclusive_features" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d02c0b00610773bb7fc61d85e13d86c7858cbdf00e1a120bfc41bc055dbaa0e" - -[[package]] -name = "native-tls" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" -dependencies = [ - "lazy_static", - "libc", - "log 0.4.21", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-traits" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" - -[[package]] -name = "opaque-debug" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "open-fastrlp" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786393f80485445794f6043fd3138854dd109cc6c4bd1a6383db304c9ce9b9ce" -dependencies = [ - "arrayvec", - "auto_impl 1.2.0", - "bytes", - "ethereum-types", - "open-fastrlp-derive", -] - -[[package]] -name = "open-fastrlp-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "003b2be5c6c53c1cfeb0a238b8a1c3915cd410feb684457a36c10038f764bb1c" -dependencies = [ - "bytes", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "openssl" -version = "0.10.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" -dependencies = [ - "bitflags 2.5.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - -[[package]] -name = "openssl-sys" -version = "0.9.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "parity-scale-codec" -version = "3.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881331e34fa842a2fb61cc2db9643a8fedc615e47cfcc52597d1af0db9a7e8fe" -dependencies = [ - "arrayvec", - "bitvec 1.0.1", - "byte-slice-cast", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "serde", -] - -[[package]] -name = "parity-scale-codec-derive" -version = "3.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be30eaf4b0a9fba5336683b38de57bb86d179a35862ba6bfcf57625d006bde5b" -dependencies = [ - "proc-macro-crate 2.0.0", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core 0.9.9", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.4.1", - "smallvec", - "windows-targets 0.48.5", -] - -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "paste" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" - -[[package]] -name = "path-slash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" - -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", - "password-hash", - "sha2 0.10.8", -] - -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "petgraph" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" -dependencies = [ - "fixedbitset", - "indexmap 2.2.6", -] - -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros", - "phf_shared", - "proc-macro-hack", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkcs8" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - -[[package]] -name = "pq-sys" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c0052426df997c0cbd30789eb44ca097e3541717a7b8fa36b1c464ee7edebd" -dependencies = [ - "vcpkg", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "predicates" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" -dependencies = [ - "anstyle", - "predicates-core", -] - -[[package]] -name = "predicates-core" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" - -[[package]] -name = "predicates-tree" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" -dependencies = [ - "predicates-core", - "termtree", -] - -[[package]] -name = "prettyplease" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" -dependencies = [ - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "primitive-types" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" -dependencies = [ - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "scale-info", - "uint", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" -dependencies = [ - "toml_edit 0.20.7", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prometheus-client" -version = "0.22.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ca959da22a332509f2a73ae9e5f23f9dcfc31fd3a54d71f159495bd5909baa" -dependencies = [ - "dtoa", - "itoa", - "parking_lot 0.12.1", - "prometheus-client-derive-encode", -] - -[[package]] -name = "prometheus-client-derive-encode" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "prost" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" -dependencies = [ - "bytes", - "heck 0.4.1", - "itertools", - "lazy_static", - "log 0.4.21", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 1.0.109", - "tempfile", - "which", -] - -[[package]] -name = "prost-derive" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "prost-types" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" -dependencies = [ - "prost", -] - -[[package]] -name = "quote" -version = "1.0.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log 0.4.21", - "parking_lot 0.12.1", - "scheduled-thread-pool", -] - -[[package]] -name = "radium" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def50a86306165861203e7f84ecffbbdfdea79f0e51039b33de1e952358c47ac" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.12", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rayon" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redacted" -version = "1.5.1" -dependencies = [ - "url", -] - -[[package]] -name = "redis" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d64e978fd98a0e6b105d066ba4889a7301fca65aeac850a877d8797343feeb" -dependencies = [ - "arc-swap", - "async-trait", - "bytes", - "combine 4.6.6", - "crc16", - "futures", - "futures-util", - "itoa", - "log 0.4.21", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rand 0.8.5", - "ryu", - "sha1_smol", - "socket2", - "tokio", - "tokio-native-tls", - "tokio-retry", - "tokio-util", - "url", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_users" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" -dependencies = [ - "getrandom 0.2.12", - "libredox", - "thiserror", -] - -[[package]] -name = "regex" -version = "1.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.6", - "regex-syntax 0.8.3", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.3", -] - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" - -[[package]] -name = "reqwest" -version = "0.11.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.25", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.28", - "hyper-rustls", - "hyper-tls 0.5.0", - "ipnet", - "js-sys", - "log 0.4.21", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls 0.21.10", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tokio-rustls 0.24.1", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 0.25.4", - "winreg", -] - -[[package]] -name = "reqwest" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d66674f2b6fb864665eea7a3c1ac4e3dfacd2fda83cf6f935a612e01b0e3338" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.4.3", - "http 1.1.0", - "http-body 1.0.0", - "http-body-util", - "hyper 1.2.0", - "hyper-tls 0.6.0", - "hyper-util", - "ipnet", - "js-sys", - "log 0.4.21", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - -[[package]] -name = "rfc6979" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" -dependencies = [ - "crypto-bigint", - "hmac 0.12.1", - "zeroize", -] - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - -[[package]] -name = "ring" -version = "0.17.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.12", - "libc", - "spin 0.9.8", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "ripemd" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "rlp" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" -dependencies = [ - "bytes", - "rustc-hex", -] - -[[package]] -name = "rlp-derive" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33d7b2abe0c340d8797fe2907d3f20d3b5ea5908683618bfe80df7f621f672a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "rollups-data" -version = "1.5.1" -dependencies = [ - "backoff", - "base64 0.22.0", - "clap", - "diesel", - "diesel_migrations", - "env_logger", - "redacted", - "serial_test", - "snafu 0.8.2", - "tempfile", - "test-fixtures", - "test-log", - "testcontainers", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "rollups-events" -version = "1.5.1" -dependencies = [ - "backoff", - "base64 0.22.0", - "clap", - "env_logger", - "hex", - "prometheus-client", - "redacted", - "redis", - "regex", - "serde", - "serde_json", - "snafu 0.8.2", - "test-log", - "testcontainers", - "tokio", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "rollups-http-client" -version = "1.5.1" -dependencies = [ - "hyper 0.14.28", - "serde", - "serde_json", - "tracing", -] - -[[package]] -name = "rusoto_core" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db30db44ea73551326269adcf7a2169428a054f14faf9e1768f2163494f2fa2" -dependencies = [ - "async-trait", - "base64 0.13.1", - "bytes", - "crc32fast", - "futures", - "http 0.2.12", - "hyper 0.14.28", - "hyper-tls 0.5.0", - "lazy_static", - "log 0.4.21", - "rusoto_credential", - "rusoto_signature", - "rustc_version", - "serde", - "serde_json", - "tokio", - "xml-rs", -] - -[[package]] -name = "rusoto_credential" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee0a6c13db5aad6047b6a44ef023dbbc21a056b6dab5be3b79ce4283d5c02d05" -dependencies = [ - "async-trait", - "chrono", - "dirs-next", - "futures", - "hyper 0.14.28", - "serde", - "serde_json", - "shlex", - "tokio", - "zeroize", -] - -[[package]] -name = "rusoto_kms" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1fc19cfcfd9f6b2f96e36d5b0dddda9004d2cbfc2d17543e3b9f10cc38fce8" -dependencies = [ - "async-trait", - "bytes", - "futures", - "rusoto_core", - "serde", - "serde_json", -] - -[[package]] -name = "rusoto_signature" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ae95491c8b4847931e291b151127eccd6ff8ca13f33603eb3d0035ecb05272" -dependencies = [ - "base64 0.13.1", - "bytes", - "chrono", - "digest 0.9.0", - "futures", - "hex", - "hmac 0.11.0", - "http 0.2.12", - "hyper 0.14.28", - "log 0.4.21", - "md-5 0.9.1", - "percent-encoding", - "pin-project-lite", - "rusoto_credential", - "rustc_version", - "serde", - "sha2 0.9.9", - "tokio", -] - -[[package]] -name = "rusoto_sts" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1643f49aa67cb7cb895ebac5a2ff3f991c6dbdc58ad98b28158cd5706aecd1d" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "rusoto_core", - "serde_urlencoded", - "xml-rs", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" - -[[package]] -name = "rustc-hex" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" - -[[package]] -name = "rustc_version" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "0.38.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" -dependencies = [ - "bitflags 2.5.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log 0.4.21", - "ring 0.16.20", - "sct", - "webpki", -] - -[[package]] -name = "rustls" -version = "0.21.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" -dependencies = [ - "log 0.4.21", - "ring 0.17.8", - "rustls-webpki", - "sct", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" - -[[package]] -name = "ryu" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" - -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scale-info" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788745a868b0e751750388f4e6546eb921ef714a4317fa6954f7cde114eb2eb7" -dependencies = [ - "cfg-if", - "derive_more", - "parity-scale-codec", - "scale-info-derive", -] - -[[package]] -name = "scale-info-derive" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc2f4e8bc344b9fc3d5f74f72c2e55bfc38d28dc2ebc69c194a3df424e4d9ac" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "schannel" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot 0.12.1", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scrypt" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" -dependencies = [ - "hmac 0.12.1", - "pbkdf2", - "salsa20", - "sha2 0.10.8", -] - -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", -] - -[[package]] -name = "sec1" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" -dependencies = [ - "base16ct", - "der", - "generic-array 0.14.7", - "pkcs8", - "subtle", - "zeroize", -] - -[[package]] -name = "security-framework" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" -dependencies = [ - "serde", -] - -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - -[[package]] -name = "serde" -version = "1.0.197" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde-aux" -version = "4.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d2e8bfba469d06512e11e3311d4d051a4a387a5b42d010404fecf3200321c95" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "serde_derive" -version = "1.0.197" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "serde_json" -version = "1.0.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" -dependencies = [ - "indexmap 2.2.6", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" -dependencies = [ - "itoa", - "serde", -] - -[[package]] -name = "serde_spanned" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" -dependencies = [ - "serde", - "serde_with_macros", -] - -[[package]] -name = "serde_with_macros" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "serial_test" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ad9342b3aaca7cb43c45c097dd008d4907070394bd0751a0aa8817e5a018d" -dependencies = [ - "dashmap", - "futures", - "lazy_static", - "log 0.4.21", - "parking_lot 0.12.1", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93fb4adc70021ac1b47f7d45e8cc4169baaa7ea58483bc5b721d19a26202212" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "sha-1" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha1_smol" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" - -[[package]] -name = "sha2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a256f46ea78a0c0d9ff00077504903ac881a1dafdc20da66545699e7776b3e69" -dependencies = [ - "block-buffer 0.7.3", - "digest 0.8.1", - "fake-simd", - "opaque-debug 0.2.3", -] - -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug 0.3.1", -] - -[[package]] -name = "sha2" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha3" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" -dependencies = [ - "digest 0.10.7", - "keccak", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" -dependencies = [ - "libc", -] - -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "sized-chunks" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" -dependencies = [ - "bitmaps", - "typenum", -] - -[[package]] -name = "slab" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - -[[package]] -name = "snafu" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6" -dependencies = [ - "doc-comment", - "snafu-derive 0.7.5", -] - -[[package]] -name = "snafu" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75976f4748ab44f6e5332102be424e7c2dc18daeaf7e725f2040c3ebb133512e" -dependencies = [ - "snafu-derive 0.8.2", -] - -[[package]] -name = "snafu-derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "snafu-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b19911debfb8c2fb1107bc6cb2d61868aaf53a988449213959bb1b5b1ed95f" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "socket2" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "solang-parser" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac8ac4bfef383f368bd9bb045107a501cd9cd0b64ad1983e1b7e839d6a44ecad" -dependencies = [ - "itertools", - "lalrpop", - "lalrpop-util", - "phf", - "unicode-xid", -] - -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "state-server" -version = "1.5.1" -dependencies = [ - "clap", - "eth-block-history", - "eth-state-fold", - "eth-state-fold-types", - "eth-state-server-lib", - "log 1.5.1", - "serde", - "snafu 0.8.2", - "tokio", - "tonic", - "tracing", - "types", - "url", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "string_cache" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" -dependencies = [ - "new_debug_unreachable", - "once_cell", - "parking_lot 0.12.1", - "phf_shared", - "precomputed-hash", -] - -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - -[[package]] -name = "strsim" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" - -[[package]] -name = "strum" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", -] - -[[package]] -name = "subtle" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" - -[[package]] -name = "svm-rs" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a04fc4f5cd35c700153b233f5575ccb3237e0f941fa5049d9e98254d10bf2fe" -dependencies = [ - "fs2", - "hex", - "home", - "once_cell", - "reqwest 0.11.24", - "semver", - "serde", - "serde_json", - "sha2 0.10.8", - "thiserror", - "url", - "zip", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "002a1b3dbf967edfafc32655d0f377ab0bb7b994aa1d32c8cc7e9b8bf3ebb8f0" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "sync_wrapper" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384595c11a4e2969895cad5a8c4029115f5ab956a9e5ef4de79d11a426e5f20c" - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tempfile" -version = "3.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" -dependencies = [ - "cfg-if", - "fastrand", - "rustix", - "windows-sys 0.52.0", -] - -[[package]] -name = "term" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" -dependencies = [ - "dirs-next", - "rustversion", - "winapi", -] - -[[package]] -name = "termtree" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" - -[[package]] -name = "test-fixtures" -version = "1.5.1" -dependencies = [ - "anyhow", - "backoff", - "grpc-interfaces", - "hyper 0.14.28", - "json", - "rollups-data", - "rollups-events", - "tempfile", - "testcontainers", - "tokio", - "tonic", - "tracing", - "users", -] - -[[package]] -name = "test-log" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b319995299c65d522680decf80f2c108d85b861d81dfe340a10d16cee29d9e6" -dependencies = [ - "env_logger", - "test-log-macros", - "tracing-subscriber", -] - -[[package]] -name = "test-log-macros" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8f546451eaa38373f549093fe9fd05e7d2bade739e2ddf834b9968621d60107" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "testcontainers" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e2b1567ca8a2b819ea7b28c92be35d9f76fb9edb214321dcc86eb96023d1f87" -dependencies = [ - "bollard-stubs", - "futures", - "hex", - "hmac 0.12.1", - "log 0.4.21", - "rand 0.8.5", - "serde", - "serde_json", - "sha2 0.10.8", -] - -[[package]] -name = "thiserror" -version = "1.0.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "thread_local" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" -dependencies = [ - "cfg-if", - "once_cell", -] - -[[package]] -name = "time" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" - -[[package]] -name = "time-macros" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - -[[package]] -name = "tinyvec" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "num_cpus", - "parking_lot 0.12.1", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-io-timeout" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" -dependencies = [ - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-macros" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-retry" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" -dependencies = [ - "pin-project", - "rand 0.8.5", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" -dependencies = [ - "rustls 0.20.9", - "tokio", - "webpki", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.10", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" -dependencies = [ - "futures-util", - "log 0.4.21", - "rustls 0.20.9", - "tokio", - "tokio-rustls 0.23.4", - "tungstenite", - "webpki", - "webpki-roots 0.22.6", -] - -[[package]] -name = "tokio-util" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", - "tracing", -] - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "toml" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit 0.19.15", -] - -[[package]] -name = "toml" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit 0.22.9", -] - -[[package]] -name = "toml_datetime" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.2.6", - "serde", - "serde_spanned", - "toml_datetime", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" -dependencies = [ - "indexmap 2.2.6", - "toml_datetime", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.22.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4" -dependencies = [ - "indexmap 2.2.6", - "serde", - "serde_spanned", - "toml_datetime", - "winnow 0.6.5", -] - -[[package]] -name = "tonic" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" -dependencies = [ - "async-trait", - "axum 0.6.20", - "base64 0.21.7", - "bytes", - "futures-core", - "futures-util", - "h2 0.3.25", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.28", - "hyper-timeout", - "percent-encoding", - "pin-project", - "prost", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-build" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6fdaae4c2c638bb70fe42803a26fbd6fc6ac8c72f5c59f67ecc2a2dcabf4b07" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "tonic-health" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080964d45894b90273d2b1dd755fdd114560db8636bb41cea615213c45043c4d" -dependencies = [ - "async-stream", - "prost", - "tokio", - "tokio-stream", - "tonic", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.5", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" - -[[package]] -name = "tower-service" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" - -[[package]] -name = "tracing" -version = "0.1.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" -dependencies = [ - "log 0.4.21", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-actix-web" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa069bd1503dd526ee793bb3fce408895136c95fc86d2edb2acf1c646d7f0684" -dependencies = [ - "actix-web", - "mutually_exclusive_features", - "pin-project", - "tracing", - "uuid 1.8.0", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "tracing-core" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-futures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" -dependencies = [ - "pin-project", - "tracing", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log 0.4.21", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "tracing-test" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a2c0ff408fe918a94c428a3f2ad04e4afd5c95bbc08fcf868eff750c15728a4" -dependencies = [ - "lazy_static", - "tracing-core", - "tracing-subscriber", - "tracing-test-macro", -] - -[[package]] -name = "tracing-test-macro" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258bc1c4f8e2e73a977812ab339d503e6feeb92700f6d07a6de4d321522d5c08" -dependencies = [ - "lazy_static", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tungstenite" -version = "0.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" -dependencies = [ - "base64 0.13.1", - "byteorder", - "bytes", - "http 0.2.12", - "httparse", - "log 0.4.21", - "rand 0.8.5", - "rustls 0.20.9", - "sha-1", - "thiserror", - "url", - "utf-8", - "webpki", -] - -[[package]] -name = "typenum" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" - -[[package]] -name = "types" -version = "1.5.1" -dependencies = [ - "anyhow", - "async-trait", - "clap", - "contracts", - "eth-state-fold", - "eth-state-fold-types", - "im", - "rollups-events", - "serde", - "serde_json", - "snafu 0.8.2", -] - -[[package]] -name = "uint" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" -dependencies = [ - "byteorder", - "crunchy", - "hex", - "static_assertions", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" - -[[package]] -name = "unicode-ident" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - -[[package]] -name = "unicode-normalization" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" - -[[package]] -name = "unicode-xid" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" - -[[package]] -name = "unreachable" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" -dependencies = [ - "void", -] - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[package]] -name = "users" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" -dependencies = [ - "libc", - "log 0.4.21", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" - -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.12", - "serde", -] - -[[package]] -name = "uuid" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" -dependencies = [ - "getrandom 0.2.12", -] - -[[package]] -name = "valuable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasm-bindgen" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" -dependencies = [ - "bumpalo", - "log 0.4.21", - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.55", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" - -[[package]] -name = "wasm-timer" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" -dependencies = [ - "futures", - "js-sys", - "parking_lot 0.11.2", - "pin-utils", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", -] - -[[package]] -name = "webpki-roots" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" -dependencies = [ - "webpki", -] - -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.4", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.4", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" -dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dffa400e67ed5a4dd237983829e66475f0a4a26938c4b04c21baede6262215b8" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "ws_stream_wasm" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7999f5f4217fe3818726b66257a4475f71e74ffd190776ad053fa159e50737f5" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log 0.4.21", - "pharos", - "rustc_version", - "send_wrapper", - "thiserror", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "xml-rs" -version = "0.8.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" - -[[package]] -name = "yansi" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" - -[[package]] -name = "zerocopy" -version = "0.7.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "zeroize" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" - -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac 0.12.1", - "pbkdf2", - "sha1", - "time", - "zstd 0.11.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a" -dependencies = [ - "zstd-safe 7.1.0", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-safe" -version = "7.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd99b45c6bc03a018c8b8a86025678c87e55526064e38f9df301989dce7ec0a" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.10+zstd.1.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/offchain/Cargo.toml b/offchain/Cargo.toml deleted file mode 100644 index b930fd0b4..000000000 --- a/offchain/Cargo.toml +++ /dev/null @@ -1,96 +0,0 @@ -[workspace] -resolver = "2" -members = [ - "advance-runner", - "authority-claimer", - "contracts", - "data", - "dispatcher", - "graphql-server", - "grpc-interfaces", - "host-runner", - "http-health-check", - "http-server", - "indexer", - "inspect-server", - "log", - "redacted", - "rollups-events", - "rollups-http-client", - "state-server", - "test-fixtures", - "types", -] - -[workspace.package] -version = "1.5.1" -license = "Apache-2.0" -edition = "2021" - -# This list is sorted alphabetically. -[workspace.dependencies] -actix-cors = "0.7" -actix-web = "4.5" -anyhow = "1.0" -async-trait = "0.1" -awc = "3.4" -axum = "0.7" -backoff = "0.4" -base64 = "0.22" -built = "0.7" -byteorder = "1.5" -clap = "4.5" -diesel = "2.1" -diesel_migrations = "2.1" -env_logger = "0.11" -ethabi = "18.0" -eth-block-history = "0.9" -eth-state-client-lib = "0.9" -eth-state-fold-types = "0.9" -eth-state-fold = "0.9" -eth-state-server-lib = "0.9" -eth-tx-manager = "0.10" -ethers = "1.0" -ethers-signers = "1.0" -futures = "0.3" -futures-util = "0.3" -hex = "0.4" -hyper = "0.14" -im = "15" -json = "0.12" -juniper = "0.15" -log = "0.4" -mockall = "0.12" -prometheus-client = "0.22" -prost = "0.11" -rand = "0.8" -redis = "0.25" -regex = "1" -reqwest = "0.12" -rusoto_core = "0.48" -rusoto_kms = "0.48" -rusoto_sts = "0.48" -serde = "1" -serde_json = "1" -serial_test = "3.0" -sha3 = "0.10" -snafu = "0.8" -tempfile = "3.10" -testcontainers = "0.14" -test-log = "0.2" -tokio = "1" -tokio-stream = "0.1" -toml = "0.8" -tonic = "0.9" -tonic-build = "0.9" -tonic-health = "0.9" -tracing = "0.1" -tracing-actix-web = "0.7" -tracing-subscriber = "0.3" -tracing-test = "0.2" -url = "2" -users = "0.11" -uuid = "1.8" - -[profile.release] -strip = true # Automatically strip symbols from the binary. diff --git a/offchain/advance-runner/Cargo.toml b/offchain/advance-runner/Cargo.toml deleted file mode 100644 index 52cf7c038..000000000 --- a/offchain/advance-runner/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "advance-runner" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-advance-runner" -path = "src/main.rs" - -[dependencies] -grpc-interfaces = { path = "../grpc-interfaces" } -http-health-check = { path = "../http-health-check" } -log = { path = "../log" } -rollups-events = { path = "../rollups-events" } - -backoff = { workspace = true, features = ["tokio"] } -clap = { workspace = true, features = ["derive", "env"] } -hex.workspace = true -sha3 = { workspace = true, features = ["std"] } -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -tonic.workspace = true -tracing.workspace = true -uuid = { workspace = true, features = ["v4"] } - -[dev-dependencies] -test-fixtures = { path = "../test-fixtures" } - -env_logger.workspace = true -rand.workspace = true -tempfile.workspace = true -test-log = { workspace = true, features = ["trace"] } -testcontainers.workspace = true -tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/offchain/advance-runner/README.md b/offchain/advance-runner/README.md deleted file mode 100644 index 7e226210f..000000000 --- a/offchain/advance-runner/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Advance Runner - -This service consumes rollups input events from the broker and uses them to advance the server-manager state. -When the epoch finishes, the advance-runner gets the claim from the server-manager and produces the rollups claim event. diff --git a/offchain/advance-runner/src/broker.rs b/offchain/advance-runner/src/broker.rs deleted file mode 100644 index d871e1179..000000000 --- a/offchain/advance-runner/src/broker.rs +++ /dev/null @@ -1,404 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use rollups_events::{ - Broker, BrokerConfig, BrokerError, DAppMetadata, RollupsClaim, - RollupsClaimsStream, RollupsInput, RollupsInputsStream, RollupsOutput, - RollupsOutputsStream, INITIAL_ID, -}; -use snafu::{ResultExt, Snafu}; - -#[derive(Debug, Snafu)] -pub enum BrokerFacadeError { - #[snafu(display("broker internal error"))] - BrokerInternalError { source: BrokerError }, - - #[snafu(display( - "expected first_index from claim to be {}, but got {}", - expected, - got - ))] - InvalidIndexes { expected: u128, got: u128 }, - - #[snafu(display("failed to consume input event"))] - ConsumeError { source: BrokerError }, - - #[snafu(display( - "failed to find finish epoch input event epoch={}", - epoch - ))] - FindFinishEpochInputError { epoch: u64 }, - - #[snafu(display("processed event not found in broker"))] - ProcessedEventNotFound {}, - - #[snafu(display( - "parent id doesn't match expected={} got={}", - expected, - got - ))] - ParentIdMismatchError { expected: String, got: String }, -} - -pub type Result = std::result::Result; - -pub struct BrokerFacade { - client: Broker, - inputs_stream: RollupsInputsStream, - outputs_stream: RollupsOutputsStream, - claims_stream: RollupsClaimsStream, - reader_mode: bool, - last_id: String, -} - -impl BrokerFacade { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn new( - config: BrokerConfig, - dapp_metadata: DAppMetadata, - reader_mode: bool, - ) -> Result { - tracing::trace!(?config, "connecting to broker"); - let client = Broker::new(config).await.context(BrokerInternalSnafu)?; - let inputs_stream = RollupsInputsStream::new(&dapp_metadata); - let outputs_stream = RollupsOutputsStream::new(&dapp_metadata); - let claims_stream = RollupsClaimsStream::new(&dapp_metadata); - Ok(Self { - client, - inputs_stream, - outputs_stream, - claims_stream, - reader_mode, - last_id: INITIAL_ID.to_owned(), - }) - } - - /// Consume rollups input event - #[tracing::instrument(level = "trace", skip_all)] - pub async fn consume_input(&mut self) -> Result { - tracing::trace!(self.last_id, "consuming rollups input event"); - let event = self - .client - .consume_blocking(&self.inputs_stream, &self.last_id) - .await - .context(BrokerInternalSnafu)?; - if event.payload.parent_id != self.last_id { - Err(BrokerFacadeError::ParentIdMismatchError { - expected: self.last_id.to_owned(), - got: event.payload.parent_id, - }) - } else { - self.last_id = event.id; - Ok(event.payload) - } - } - - /// Produce the rollups claim if it isn't in the stream yet - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce_rollups_claim( - &mut self, - rollups_claim: RollupsClaim, - ) -> Result<()> { - if self.reader_mode { - return Ok(()); - } - - tracing::trace!(rollups_claim.epoch_index, - ?rollups_claim.epoch_hash, - "producing rollups claim for stream {:?}", - self.claims_stream, - ); - - let last_claim_event = self - .client - .peek_latest(&self.claims_stream) - .await - .context(BrokerInternalSnafu)?; - - let should_enqueue_claim = match last_claim_event { - Some(event) => { - let last_claim = event.payload; - tracing::trace!( - ?last_claim, - "got last claim from broker stream" - ); - let should_enqueue_claim = - rollups_claim.epoch_index > last_claim.epoch_index; - - // If this happens, then something is wrong with the dispatcher. - let invalid_indexes = - rollups_claim.first_index != last_claim.last_index + 1; - if should_enqueue_claim && invalid_indexes { - tracing::debug!("rollups_claim.first_index = {}, last_claim.last_index = {}", - rollups_claim.first_index, last_claim.last_index); - return Err(BrokerFacadeError::InvalidIndexes { - expected: last_claim.last_index + 1, - got: rollups_claim.first_index, - }); - }; - - should_enqueue_claim - } - None => { - tracing::trace!("no claims in the stream"); - true - } - }; - - if should_enqueue_claim { - self.client - .produce(&self.claims_stream, rollups_claim) - .await - .context(BrokerInternalSnafu)?; - } - - Ok(()) - } - - /// Produce outputs to the rollups-outputs stream - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce_outputs( - &mut self, - outputs: Vec, - ) -> Result<()> { - tracing::trace!(?outputs, "producing rollups outputs"); - - for output in outputs { - self.client - .produce(&self.outputs_stream, output) - .await - .context(BrokerInternalSnafu)?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use backoff::ExponentialBackoff; - use rollups_events::{ - Address, DAppMetadata, Hash, InputMetadata, Payload, - RollupsAdvanceStateInput, RollupsData, ADDRESS_SIZE, HASH_SIZE, - }; - use test_fixtures::BrokerFixture; - use testcontainers::clients::Cli; - - struct TestState<'d> { - fixture: BrokerFixture<'d>, - facade: BrokerFacade, - } - - impl TestState<'_> { - async fn setup(docker: &Cli) -> TestState<'_> { - let fixture = BrokerFixture::setup(docker).await; - let backoff = ExponentialBackoff::default(); - let dapp_metadata = DAppMetadata { - chain_id: fixture.chain_id(), - dapp_address: fixture.dapp_address().to_owned(), - }; - let config = BrokerConfig { - redis_endpoint: fixture.redis_endpoint().to_owned(), - consume_timeout: 10, - backoff, - }; - let facade = BrokerFacade::new(config, dapp_metadata, false) - .await - .expect("failed to create broker facade"); - TestState { fixture, facade } - } - } - - #[test_log::test(tokio::test)] - async fn test_it_consumes_inputs() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - let inputs = vec![ - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - epoch_index: 0, - input_index: 0, - ..Default::default() - }, - payload: Payload::new(vec![0, 0]), - tx_hash: Hash::default(), - }), - RollupsData::FinishEpoch {}, - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - epoch_index: 1, - input_index: 1, - ..Default::default() - }, - payload: Payload::new(vec![1, 1]), - tx_hash: Hash::default(), - }), - ]; - let mut ids = Vec::new(); - for input in inputs.iter() { - ids.push(state.fixture.produce_input_event(input.clone()).await); - } - assert_eq!( - state.facade.consume_input().await.unwrap(), - RollupsInput { - parent_id: INITIAL_ID.to_owned(), - epoch_index: 0, - inputs_sent_count: 1, - data: inputs[0].clone(), - }, - ); - assert_eq!( - state.facade.consume_input().await.unwrap(), - RollupsInput { - parent_id: ids[0].clone(), - epoch_index: 0, - inputs_sent_count: 1, - data: inputs[1].clone(), - }, - ); - assert_eq!( - state.facade.consume_input().await.unwrap(), - RollupsInput { - parent_id: ids[1].clone(), - epoch_index: 1, - inputs_sent_count: 2, - data: inputs[2].clone(), - }, - ); - } - - #[test_log::test(tokio::test)] - async fn test_it_does_not_produce_claim_when_it_was_already_produced() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - let rollups_claim = RollupsClaim { - dapp_address: Address::new([0xa0; ADDRESS_SIZE]), - epoch_index: 0, - epoch_hash: Hash::new([0xb0; HASH_SIZE]), - first_index: 0, - last_index: 6, - }; - state - .fixture - .produce_rollups_claim(rollups_claim.clone()) - .await; - state - .facade - .produce_rollups_claim(rollups_claim.clone()) - .await - .unwrap(); - assert_eq!( - state.fixture.consume_all_claims().await, - vec![rollups_claim] - ); - } - - #[test_log::test(tokio::test)] - async fn test_it_produces_claims() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - let rollups_claim0 = RollupsClaim { - dapp_address: Address::new([0xa0; ADDRESS_SIZE]), - epoch_index: 0, - epoch_hash: Hash::new([0xb0; HASH_SIZE]), - first_index: 0, - last_index: 0, - }; - let rollups_claim1 = RollupsClaim { - dapp_address: Address::new([0xa1; ADDRESS_SIZE]), - epoch_index: 1, - epoch_hash: Hash::new([0xb1; HASH_SIZE]), - first_index: 1, - last_index: 1, - }; - state - .facade - .produce_rollups_claim(rollups_claim0.clone()) - .await - .unwrap(); - state - .facade - .produce_rollups_claim(rollups_claim1.clone()) - .await - .unwrap(); - assert_eq!( - state.fixture.consume_all_claims().await, - vec![rollups_claim0, rollups_claim1] - ); - } - - #[test_log::test(tokio::test)] - async fn test_invalid_indexes_overlapping() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - let rollups_claim1 = RollupsClaim { - dapp_address: Address::new([0xa0; ADDRESS_SIZE]), - epoch_index: 0, - epoch_hash: Hash::new([0xb0; HASH_SIZE]), - first_index: 0, - last_index: 6, - }; - let rollups_claim2 = RollupsClaim { - dapp_address: Address::new([0xa0; ADDRESS_SIZE]), - epoch_index: 1, - epoch_hash: Hash::new([0xb0; HASH_SIZE]), - first_index: 6, - last_index: 7, - }; - state - .fixture - .produce_rollups_claim(rollups_claim1.clone()) - .await; - let result = state - .facade - .produce_rollups_claim(rollups_claim2.clone()) - .await; - assert!(result.is_err()); - assert_eq!( - BrokerFacadeError::InvalidIndexes { - expected: 7, - got: 6 - } - .to_string(), - result.unwrap_err().to_string() - ) - } - - #[test_log::test(tokio::test)] - async fn test_invalid_indexes_nonsequential() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - let rollups_claim1 = RollupsClaim { - dapp_address: Address::new([0xa0; ADDRESS_SIZE]), - epoch_index: 0, - epoch_hash: Hash::new([0xb0; HASH_SIZE]), - first_index: 0, - last_index: 6, - }; - let rollups_claim2 = RollupsClaim { - dapp_address: Address::new([0xa0; ADDRESS_SIZE]), - epoch_index: 1, - epoch_hash: Hash::new([0xb0; HASH_SIZE]), - first_index: 11, - last_index: 14, - }; - state - .fixture - .produce_rollups_claim(rollups_claim1.clone()) - .await; - let result = state - .facade - .produce_rollups_claim(rollups_claim2.clone()) - .await; - assert!(result.is_err()); - assert_eq!( - BrokerFacadeError::InvalidIndexes { - expected: 7, - got: 11 - } - .to_string(), - result.unwrap_err().to_string() - ) - } -} diff --git a/offchain/advance-runner/src/config.rs b/offchain/advance-runner/src/config.rs deleted file mode 100644 index 3d16e4f4f..000000000 --- a/offchain/advance-runner/src/config.rs +++ /dev/null @@ -1,85 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; -use std::time::Duration; - -use crate::server_manager::ServerManagerCLIConfig; -pub use crate::server_manager::ServerManagerConfig; -use log::{LogConfig, LogEnvCliConfig}; -pub use rollups_events::{ - BrokerCLIConfig, BrokerConfig, DAppMetadata, DAppMetadataCLIConfig, -}; - -#[derive(Debug, Clone)] -pub struct AdvanceRunnerConfig { - pub server_manager_config: ServerManagerConfig, - pub broker_config: BrokerConfig, - pub dapp_metadata: DAppMetadata, - pub log_config: LogConfig, - pub backoff_max_elapsed_duration: Duration, - pub healthcheck_port: u16, - pub reader_mode: bool, -} - -impl AdvanceRunnerConfig { - pub fn parse() -> Self { - let cli_config = CLIConfig::parse(); - let broker_config = cli_config.broker_cli_config.into(); - let dapp_metadata: DAppMetadata = - cli_config.dapp_metadata_cli_config.into(); - let server_manager_config = - ServerManagerConfig::parse_from_cli(cli_config.sm_cli_config); - - let log_config = LogConfig::initialize(cli_config.log_cli_config); - - let backoff_max_elapsed_duration = - Duration::from_millis(cli_config.backoff_max_elapsed_duration); - - let healthcheck_port = cli_config.healthcheck_port; - - let reader_mode = cli_config.reader_mode; - - Self { - server_manager_config, - broker_config, - dapp_metadata, - log_config, - backoff_max_elapsed_duration, - healthcheck_port, - reader_mode, - } - } -} - -#[derive(Parser)] -#[command(name = "advance_runner_config")] -#[command(about = "Configuration for advance-runner")] -struct CLIConfig { - #[command(flatten)] - sm_cli_config: ServerManagerCLIConfig, - - #[command(flatten)] - broker_cli_config: BrokerCLIConfig, - - #[command(flatten)] - dapp_metadata_cli_config: DAppMetadataCLIConfig, - - #[command(flatten)] - pub log_cli_config: LogEnvCliConfig, - - /// The max elapsed time for backoff in ms - #[arg(long, env, default_value = "120000")] - backoff_max_elapsed_duration: u64, - - /// Port of health check - #[arg( - long, - env = "ADVANCE_RUNNER_HEALTHCHECK_PORT", - default_value_t = 8080 - )] - pub healthcheck_port: u16, - - #[arg(long, env)] - reader_mode: bool, -} diff --git a/offchain/advance-runner/src/error.rs b/offchain/advance-runner/src/error.rs deleted file mode 100644 index e8d0cd4e6..000000000 --- a/offchain/advance-runner/src/error.rs +++ /dev/null @@ -1,26 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::Snafu; - -use crate::{broker, runner, server_manager}; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum AdvanceRunnerError { - #[snafu(display("health check error"))] - HealthCheckError { - source: http_health_check::HealthCheckError, - }, - - #[snafu(display("server manager error"))] - ServerManagerError { - source: server_manager::ServerManagerError, - }, - - #[snafu(display("broker error"))] - BrokerError { source: broker::BrokerFacadeError }, - - #[snafu(display("runner error"))] - RunnerError { source: runner::RunnerError }, -} diff --git a/offchain/advance-runner/src/lib.rs b/offchain/advance-runner/src/lib.rs deleted file mode 100644 index b1a2366d9..000000000 --- a/offchain/advance-runner/src/lib.rs +++ /dev/null @@ -1,66 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::ExponentialBackoffBuilder; -use broker::BrokerFacade; -use config::AdvanceRunnerConfig; -use runner::Runner; -use server_manager::ServerManagerFacade; -use snafu::ResultExt; - -pub use broker::BrokerFacadeError; -pub use error::AdvanceRunnerError; -pub use runner::RunnerError; - -mod broker; -pub mod config; -mod error; -pub mod runner; -mod server_manager; - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn run( - config: AdvanceRunnerConfig, -) -> Result<(), AdvanceRunnerError> { - let health_handle = http_health_check::start(config.healthcheck_port); - let advance_runner_handle = start_advance_runner(config); - tokio::select! { - ret = health_handle => { - ret.context(error::HealthCheckSnafu) - } - ret = advance_runner_handle => { - ret - } - } -} - -#[tracing::instrument(level = "trace", skip_all)] -async fn start_advance_runner( - config: AdvanceRunnerConfig, -) -> Result<(), AdvanceRunnerError> { - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(config.backoff_max_elapsed_duration)) - .build(); - - let server_manager = ServerManagerFacade::new( - config.dapp_metadata.dapp_address.clone(), - config.server_manager_config, - backoff, - ) - .await - .context(error::ServerManagerSnafu)?; - tracing::trace!("connected to the server-manager"); - - let broker = BrokerFacade::new( - config.broker_config, - config.dapp_metadata, - config.reader_mode, - ) - .await - .context(error::BrokerSnafu)?; - tracing::trace!("connected the broker"); - - Runner::start(server_manager, broker) - .await - .context(error::RunnerSnafu) -} diff --git a/offchain/advance-runner/src/main.rs b/offchain/advance-runner/src/main.rs deleted file mode 100644 index 60826dd3f..000000000 --- a/offchain/advance-runner/src/main.rs +++ /dev/null @@ -1,15 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use advance_runner::config::AdvanceRunnerConfig; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = AdvanceRunnerConfig::parse(); - - log::configure(&config.log_config); - - log::log_service_start(&config, "Advance Runner"); - - advance_runner::run(config).await.map_err(|e| e.into()) -} diff --git a/offchain/advance-runner/src/runner.rs b/offchain/advance-runner/src/runner.rs deleted file mode 100644 index f2a72d88c..000000000 --- a/offchain/advance-runner/src/runner.rs +++ /dev/null @@ -1,150 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use rollups_events::{InputMetadata, RollupsData}; -use snafu::{ResultExt, Snafu}; - -use crate::broker::{BrokerFacade, BrokerFacadeError}; -use crate::server_manager::{ServerManagerError, ServerManagerFacade}; - -#[derive(Debug, Snafu)] -pub enum RunnerError { - #[snafu(display("failed to send advance-state input to server-manager"))] - AdvanceError { source: ServerManagerError }, - - #[snafu(display("failed to finish epoch in server-manager"))] - FinishEpochError { source: ServerManagerError }, - - #[snafu(display("failed to get epoch claim from server-manager"))] - GetEpochClaimError { source: ServerManagerError }, - - #[snafu(display("failed to find finish epoch input event"))] - FindFinishEpochInputError { source: BrokerFacadeError }, - - #[snafu(display("failed to consume input from broker"))] - ConsumeInputError { source: BrokerFacadeError }, - - #[snafu(display("failed to get whether claim was produced"))] - PeekClaimError { source: BrokerFacadeError }, - - #[snafu(display("failed to produce claim in broker"))] - ProduceClaimError { source: BrokerFacadeError }, - - #[snafu(display("failed to produce outputs in broker"))] - ProduceOutputsError { source: BrokerFacadeError }, -} - -type Result = std::result::Result; - -pub struct Runner { - server_manager: ServerManagerFacade, - broker: BrokerFacade, -} - -impl Runner { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn start( - server_manager: ServerManagerFacade, - broker: BrokerFacade, - ) -> Result<()> { - let mut runner = Self { - server_manager, - broker, - }; - - tracing::info!("starting runner main loop"); - loop { - let event = runner - .broker - .consume_input() - .await - .context(ConsumeInputSnafu)?; - tracing::info!(?event, "consumed input event"); - - match event.data { - RollupsData::AdvanceStateInput(input) => { - runner - .handle_advance( - event.epoch_index, - event.inputs_sent_count, - input.metadata, - input.payload.into_inner(), - ) - .await?; - } - RollupsData::FinishEpoch {} => { - runner.handle_finish(event.epoch_index).await?; - } - } - tracing::info!("waiting for the next input event"); - } - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn handle_advance( - &mut self, - epoch_index: u64, - inputs_sent_count: u64, - input_metadata: InputMetadata, - input_payload: Vec, - ) -> Result<()> { - tracing::trace!("handling advance state"); - - let input_index = inputs_sent_count - 1; - let outputs = self - .server_manager - .advance_state( - epoch_index, - input_index, - input_metadata, - input_payload, - ) - .await - .context(AdvanceSnafu)?; - tracing::trace!("advance state sent to server-manager"); - - self.broker - .produce_outputs(outputs) - .await - .context(ProduceOutputsSnafu)?; - tracing::trace!("produced outputs in broker"); - - Ok(()) - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn handle_finish(&mut self, epoch_index: u64) -> Result<()> { - tracing::trace!("handling finish"); - - let result = self.server_manager.finish_epoch(epoch_index).await; - tracing::trace!("finished epoch in server-manager"); - - match result { - Ok((rollups_claim, proofs)) => { - self.broker - .produce_outputs(proofs) - .await - .context(ProduceOutputsSnafu)?; - tracing::trace!("produced outputs in broker stream"); - - let claim = rollups_claim.clone(); - self.broker - .produce_rollups_claim(rollups_claim) - .await - .context(ProduceClaimSnafu)?; - tracing::info!( - "produced epoch claim in broker stream: {:?}", - claim - ); - } - Err(source) => { - if let ServerManagerError::EmptyEpochError { .. } = source { - tracing::warn!("{}", source) - } else { - return Err(RunnerError::FinishEpochError { source }); - } - } - } - Ok(()) - } -} diff --git a/offchain/advance-runner/src/server_manager/claim.rs b/offchain/advance-runner/src/server_manager/claim.rs deleted file mode 100644 index a9d958213..000000000 --- a/offchain/advance-runner/src/server_manager/claim.rs +++ /dev/null @@ -1,45 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use rollups_events::{Hash, HASH_SIZE}; -use sha3::{Digest, Keccak256}; - -pub fn compute_epoch_hash( - machine_state_hash: &Hash, - vouchers_metadata_hash: &Hash, - notices_metadata_hash: &Hash, -) -> Hash { - let mut hasher = Keccak256::new(); - hasher.update(machine_state_hash.inner()); - hasher.update(vouchers_metadata_hash.inner()); - hasher.update(notices_metadata_hash.inner()); - let data: [u8; HASH_SIZE] = hasher.finalize().into(); - Hash::new(data) -} - -#[cfg(test)] -mod tests { - use super::{compute_epoch_hash, Hash}; - - #[test_log::test] - fn test_claim_hash() { - let hash = Hash::new( - hex::decode( - "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - ) - .unwrap() - .try_into() - .unwrap() - ); - let claim = compute_epoch_hash(&hash, &hash, &hash); - let expected = Hash::new( - hex::decode( - "8590bbc3ea43e28e8624fb1a2d59aaca701a5517e08511c4a14d9037de6f6086", - ) - .unwrap() - .try_into() - .unwrap() - ); - assert_eq!(expected, claim); - } -} diff --git a/offchain/advance-runner/src/server_manager/config.rs b/offchain/advance-runner/src/server_manager/config.rs deleted file mode 100644 index 642e7daea..000000000 --- a/offchain/advance-runner/src/server_manager/config.rs +++ /dev/null @@ -1,149 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; - -use grpc_interfaces::cartesi_machine::{ - ConcurrencyConfig, MachineRuntimeConfig, -}; -use grpc_interfaces::cartesi_server_manager::{CyclesConfig, DeadlineConfig}; - -#[derive(Debug, Clone)] -pub struct ServerManagerConfig { - pub server_manager_endpoint: String, - pub machine_snapshot_path: String, - pub max_decoding_message_size: usize, - pub session_id: String, - pub pending_inputs_sleep_duration: u64, - pub pending_inputs_max_retries: u64, - pub runtime_config: MachineRuntimeConfig, - pub deadline_config: DeadlineConfig, - pub cycles_config: CyclesConfig, -} - -impl ServerManagerConfig { - pub fn parse_from_cli(cli_config: ServerManagerCLIConfig) -> Self { - let runtime_config = MachineRuntimeConfig { - concurrency: Some(ConcurrencyConfig { - update_merkle_tree: cli_config - .sm_concurrency_update_merkle_tree, - }), - }; - - let deadline_config = DeadlineConfig { - checkin: cli_config.sm_deadline_checkin, - advance_state: cli_config.sm_deadline_advance_state, - advance_state_increment: cli_config - .sm_deadline_advance_state_increment, - inspect_state: cli_config.sm_deadline_inspect_state, - inspect_state_increment: cli_config - .sm_deadline_inspect_state_increment, - machine: cli_config.sm_deadline_machine, - store: cli_config.sm_deadline_store, - fast: cli_config.sm_deadline_fast, - }; - - let cycles_config = CyclesConfig { - max_advance_state: cli_config.sm_cycles_max_advance_state, - advance_state_increment: cli_config - .sm_cycles_advance_state_increment, - max_inspect_state: cli_config.sm_cycles_max_inspect_state, - inspect_state_increment: cli_config - .sm_cycles_inspect_state_increment, - }; - - Self { - server_manager_endpoint: cli_config.server_manager_endpoint, - machine_snapshot_path: cli_config.machine_snapshot_path, - max_decoding_message_size: cli_config.max_decoding_message_size, - session_id: cli_config.session_id, - pending_inputs_sleep_duration: cli_config - .sm_pending_inputs_sleep_duration, - pending_inputs_max_retries: cli_config - .sm_pending_inputs_max_retries, - runtime_config, - deadline_config, - cycles_config, - } - } -} - -#[derive(Debug, Parser)] -#[command(name = "server_manager_config")] -pub struct ServerManagerCLIConfig { - /// Server-manager gRPC endpoint - #[arg(long, env, default_value = "http://127.0.0.1:5001")] - pub server_manager_endpoint: String, - - /// Path to the machine snapshot - #[arg(long, env, default_value = "")] - pub machine_snapshot_path: String, - - /// Maximum size of a decoded message - #[arg(long, env, default_value_t = 100 * 1024 * 1024)] - pub max_decoding_message_size: usize, - - /// Server-manager session id - #[arg(long, env, default_value = "default_rollups_id")] - pub session_id: String, - - /// Sleep duration while polling for server-manager pending inputs (in millis) - #[arg(long, env, default_value_t = 1000)] - pub sm_pending_inputs_sleep_duration: u64, - - /// Max number of retries while polling server-manager for pending inputs - #[arg(long, env, default_value_t = 600)] - pub sm_pending_inputs_max_retries: u64, - - /// Defines the number of threads to use while calculating the merkle tree - #[arg(long, env, default_value_t = 0)] - pub sm_concurrency_update_merkle_tree: u64, - - /// Deadline for receiving checkin from spawned machine server - #[arg(long, env, default_value_t = 5 * 1000)] - pub sm_deadline_checkin: u64, - - /// Deadline for advancing the state - #[arg(long, env, default_value_t = 1000 * 60 * 3)] - pub sm_deadline_advance_state: u64, - - /// Deadline for each increment when advancing state - #[arg(long, env, default_value_t = 1000 * 10)] - pub sm_deadline_advance_state_increment: u64, - - /// Deadline for inspecting state - #[arg(long, env, default_value_t = 1000 * 60 * 3)] - pub sm_deadline_inspect_state: u64, - - /// Deadline for each increment when inspecting state - #[arg(long, env, default_value_t = 1000 * 10)] - pub sm_deadline_inspect_state_increment: u64, - - /// Deadline for instantiating a machine - #[arg(long, env, default_value_t = 1000 * 60 * 5)] - pub sm_deadline_machine: u64, - - /// Deadline for storing a machine - #[arg(long, env, default_value_t = 1000 * 60 * 3)] - pub sm_deadline_store: u64, - - /// Deadline for quick machine server tasks - #[arg(long, env, default_value_t = 1000 * 5)] - pub sm_deadline_fast: u64, - - /// Maximum number of cycles that processing the input in an AdvanceState can take - #[arg(long, env, default_value_t = u64::MAX >> 2)] - pub sm_cycles_max_advance_state: u64, - - /// Number of cycles in each increment to processing an input - #[arg(long, env, default_value_t = 1 << 22)] - pub sm_cycles_advance_state_increment: u64, - - /// Maximum number of cycles that processing the query in an InspectState can take - #[arg(long, env, default_value_t = u64::MAX >> 2)] - pub sm_cycles_max_inspect_state: u64, - - /// Number of cycles in each increment to processing a query - #[arg(long, env, default_value_t = 1 << 22)] - pub sm_cycles_inspect_state_increment: u64, -} diff --git a/offchain/advance-runner/src/server_manager/conversions.rs b/offchain/advance-runner/src/server_manager/conversions.rs deleted file mode 100644 index bd05231ec..000000000 --- a/offchain/advance-runner/src/server_manager/conversions.rs +++ /dev/null @@ -1,135 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//! This module contains functions to convert from gRPC types to -//! rollups-events types -use grpc_interfaces::cartesi_machine::Hash; -use grpc_interfaces::cartesi_server_manager::{ - Address, CompletionStatus, OutputEnum, OutputValidityProof, Proof, -}; -use rollups_events::{ - Address as RollupsAddress, Hash as RollupsHash, Payload, - RollupsCompletionStatus, RollupsOutputEnum, RollupsOutputValidityProof, - RollupsProof, ADDRESS_SIZE, HASH_SIZE, -}; - -use super::error::ServerManagerError; - -/// Try to get the field from an option, otherwise return an error -macro_rules! get_field { - ($field: expr) => { - match $field { - Some(value) => value, - None => { - return Err( - super::error::ServerManagerError::MissingFieldError { - name: stringify!($field).to_owned(), - }, - ); - } - } - }; -} - -// Export the get_field macro for other modules to use -pub(super) use get_field; - -/// Convert gRPC completion status to broker equivalent -pub fn convert_completion_status( - status: CompletionStatus, -) -> RollupsCompletionStatus { - match status { - CompletionStatus::Accepted => RollupsCompletionStatus::Accepted, - CompletionStatus::Rejected => RollupsCompletionStatus::Rejected, - CompletionStatus::Exception => RollupsCompletionStatus::Exception, - CompletionStatus::MachineHalted => { - RollupsCompletionStatus::MachineHalted - } - CompletionStatus::CycleLimitExceeded => { - RollupsCompletionStatus::CycleLimitExceeded - } - CompletionStatus::TimeLimitExceeded => { - RollupsCompletionStatus::TimeLimitExceeded - } - CompletionStatus::PayloadLengthLimitExceeded => { - RollupsCompletionStatus::PayloadLengthLimitExceeded - } - } -} - -/// Convert gRPC hash to broker equivalent -pub fn convert_hash(hash: Hash) -> Result { - hash.data.try_into().map(RollupsHash::new).map_err(|data| { - ServerManagerError::WrongArraySizeError { - name: "hash".to_owned(), - expected: HASH_SIZE, - got: data.len(), - } - }) -} - -/// Convert gRPC address to broker equivalent -pub fn convert_address( - address: Address, -) -> Result { - address - .data - .try_into() - .map(RollupsAddress::new) - .map_err(|data| ServerManagerError::WrongArraySizeError { - name: "address".to_owned(), - expected: ADDRESS_SIZE, - got: data.len(), - }) -} - -/// Convert from gRPC proof to broker equivalent -pub fn convert_proof(proof: Proof) -> Result { - let output_enum = match proof.output_enum() { - OutputEnum::Voucher => RollupsOutputEnum::Voucher, - OutputEnum::Notice => RollupsOutputEnum::Notice, - }; - let validity = convert_validity(get_field!(proof.validity))?; - let context = Payload::new(proof.context); - Ok(RollupsProof { - input_index: proof.input_index, - output_index: proof.output_index, - output_enum, - validity, - context, - }) -} - -/// Convert from gRPC output validity proof to broker equivalent -fn convert_validity( - validity: OutputValidityProof, -) -> Result { - let output_hashes_root_hash = - convert_hash(get_field!(validity.output_hashes_root_hash))?; - let vouchers_epoch_root_hash = - convert_hash(get_field!(validity.vouchers_epoch_root_hash))?; - let notices_epoch_root_hash = - convert_hash(get_field!(validity.notices_epoch_root_hash))?; - let machine_state_hash = - convert_hash(get_field!(validity.machine_state_hash))?; - let output_hash_in_output_hashes_siblings = validity - .output_hash_in_output_hashes_siblings - .into_iter() - .map(convert_hash) - .collect::, ServerManagerError>>()?; - let output_hashes_in_epoch_siblings = validity - .output_hashes_in_epoch_siblings - .into_iter() - .map(convert_hash) - .collect::, ServerManagerError>>()?; - Ok(RollupsOutputValidityProof { - input_index_within_epoch: validity.input_index_within_epoch, - output_index_within_input: validity.output_index_within_input, - output_hashes_root_hash, - vouchers_epoch_root_hash, - notices_epoch_root_hash, - machine_state_hash, - output_hash_in_output_hashes_siblings, - output_hashes_in_epoch_siblings, - }) -} diff --git a/offchain/advance-runner/src/server_manager/error.rs b/offchain/advance-runner/src/server_manager/error.rs deleted file mode 100644 index d3b5bdf68..000000000 --- a/offchain/advance-runner/src/server_manager/error.rs +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::Snafu; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(super)))] -#[allow(clippy::enum_variant_names)] -pub enum ServerManagerError { - #[snafu(display("failed to connect to server-manager"))] - ConnectionError { source: tonic::transport::Error }, - - #[snafu(display( - "failed to call {} with request-id {}", - method, - request_id - ))] - MethodCallError { - method: String, - request_id: String, - source: tonic::Status, - }, - - #[snafu(display("maximum number of retries exceeded"))] - PendingInputsExceededError {}, - - #[snafu(display("missing field {}", name))] - MissingFieldError { name: String }, - - #[snafu(display( - "array of wrong size for {} type, expected {} but got {}", - name, - expected, - got - ))] - WrongArraySizeError { - name: String, - expected: usize, - got: usize, - }, - - #[snafu(display("missing processed input in get epoch status"))] - MissingProcessedInputError {}, - - #[snafu(display( - "invalid last processed input index, expected {} but got {}", - expected, - got - ))] - InvalidProcessedInputError { expected: u64, got: u64 }, - - #[snafu(display( - "can't generate claim for epoch {} because it has no inputs", - epoch_index - ))] - EmptyEpochError { epoch_index: u64 }, -} diff --git a/offchain/advance-runner/src/server_manager/facade.rs b/offchain/advance-runner/src/server_manager/facade.rs deleted file mode 100644 index ce03dc7ba..000000000 --- a/offchain/advance-runner/src/server_manager/facade.rs +++ /dev/null @@ -1,456 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::{future::retry, Error, ExponentialBackoff}; -use rollups_events::{ - InputMetadata as RollupsInputMetadata, Payload, RollupsAdvanceResult, - RollupsClaim, RollupsNotice, RollupsOutput, RollupsReport, RollupsVoucher, -}; -use snafu::{OptionExt, ResultExt}; -use tonic::{transport::Channel, Request}; -use uuid::Uuid; - -use grpc_interfaces::cartesi_machine::Void; -use grpc_interfaces::cartesi_server_manager::server_manager_client::ServerManagerClient; -use grpc_interfaces::cartesi_server_manager::{ - processed_input::ProcessedInputOneOf, Address, AdvanceStateRequest, - EndSessionRequest, FinishEpochRequest, GetEpochStatusRequest, - GetSessionStatusRequest, InputMetadata, ProcessedInput, - StartSessionRequest, -}; - -use super::claim::compute_epoch_hash; -use super::config::ServerManagerConfig; -use super::conversions::{ - convert_address, convert_completion_status, convert_hash, convert_proof, - get_field, -}; -use super::error::{ - ConnectionSnafu, EmptyEpochSnafu, InvalidProcessedInputSnafu, - ServerManagerError, -}; - -/// Call the grpc method passing an unique request-id and with retry -macro_rules! grpc_call { - ($self: ident, $method: ident, $request: expr) => { - retry($self.backoff.clone(), || async { - let request_id = Uuid::new_v4().to_string(); - let request = $request; - - tracing::trace!( - request_id, - method = stringify!($method), - ?request, - "calling grpc" - ); - - let mut grpc_request = Request::new(request); - grpc_request - .metadata_mut() - .insert("request-id", request_id.parse().unwrap()); - - let response = $self.client.clone().$method(grpc_request).await; - - tracing::trace!( - request_id, - method = stringify!($method), - ?response, - "got grpc response", - ); - - response.map(|v| v.into_inner()).map_err(|status| { - let err_type = match status.code() { - tonic::Code::InvalidArgument => Error::Permanent, - tonic::Code::NotFound => Error::Permanent, - tonic::Code::AlreadyExists => Error::Permanent, - tonic::Code::FailedPrecondition => Error::Permanent, - tonic::Code::OutOfRange => Error::Permanent, - tonic::Code::Unimplemented => Error::Permanent, - tonic::Code::DataLoss => Error::Permanent, - _ => Error::transient, - }; - err_type(ServerManagerError::MethodCallError { - source: status, - method: stringify!($method).to_owned(), - request_id, - }) - }) - }) - .await - }; -} - -pub type Result = std::result::Result; - -pub struct ServerManagerFacade { - dapp_address: rollups_events::Address, - client: ServerManagerClient, - config: ServerManagerConfig, - backoff: ExponentialBackoff, -} - -impl ServerManagerFacade { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn new( - dapp_address: rollups_events::Address, - config: ServerManagerConfig, - backoff: ExponentialBackoff, - ) -> Result { - tracing::trace!(?config, "connecting to server manager"); - - let client = retry(backoff.clone(), || async { - ServerManagerClient::connect(config.server_manager_endpoint.clone()) - .await - .map_err(Error::transient) - }) - .await - .context(ConnectionSnafu)? - .max_decoding_message_size(config.max_decoding_message_size); - - let mut sm_facade = Self { - dapp_address, - client, - config, - backoff, - }; - - // If session exists, delete it before creating new one - let response = grpc_call!(sm_facade, get_status, Void {})?; - if response.session_id.contains(&sm_facade.config.session_id) { - tracing::warn!("deleting previous server-manager session"); - let session_status = grpc_call!( - sm_facade, - get_session_status, - GetSessionStatusRequest { - session_id: sm_facade.config.session_id.clone(), - } - )?; - let active_epoch_index = session_status.active_epoch_index; - let processed_input_count_within_epoch = sm_facade - .wait_for_pending_inputs(active_epoch_index) - .await? - .len() - as u64; - grpc_call!( - sm_facade, - finish_epoch, - FinishEpochRequest { - session_id: sm_facade.config.session_id.clone(), - active_epoch_index, - processed_input_count_within_epoch, - storage_directory: "".to_string(), - } - )?; - grpc_call!( - sm_facade, - end_session, - EndSessionRequest { - session_id: sm_facade.config.session_id.clone(), - } - )?; - } - - tracing::trace!("starting server-manager session"); - - grpc_call!(sm_facade, start_session, { - StartSessionRequest { - session_id: sm_facade.config.session_id.clone(), - machine_directory: sm_facade - .config - .machine_snapshot_path - .clone(), - runtime: Some(sm_facade.config.runtime_config.clone()), - active_epoch_index: 0, - processed_input_count: 0, - server_cycles: Some(sm_facade.config.cycles_config.clone()), - server_deadline: Some(sm_facade.config.deadline_config.clone()), - } - })?; - - Ok(sm_facade) - } - - #[tracing::instrument(level = "trace", skip_all)] - pub async fn advance_state( - &mut self, - active_epoch_index: u64, - current_input_index: u64, - input_metadata: RollupsInputMetadata, - input_payload: Vec, - ) -> Result> { - tracing::trace!("sending advance-state input to server-manager"); - - let response = grpc_call!(self, advance_state, { - let input_metadata = InputMetadata { - msg_sender: Some(Address { - data: (*input_metadata.msg_sender.inner()).into(), - }), - block_number: input_metadata.block_number, - timestamp: input_metadata.timestamp, - epoch_index: input_metadata.epoch_index, - input_index: input_metadata.input_index, - }; - AdvanceStateRequest { - session_id: self.config.session_id.to_owned(), - active_epoch_index, - current_input_index, - input_metadata: Some(input_metadata), - input_payload: input_payload.clone(), - } - }); - - self.handle_advance_state_error(response).await?; - - tracing::trace!("waiting until the input is processed"); - - let processed_input = self - .wait_for_pending_inputs(active_epoch_index) - .await? - .pop() - .ok_or(ServerManagerError::MissingProcessedInputError {})?; - snafu::ensure!( - processed_input.input_index == current_input_index, - InvalidProcessedInputSnafu { - expected: current_input_index, - got: processed_input.input_index, - } - ); - - tracing::trace!("getting outputs"); - - let mut outputs = vec![]; - - let status = convert_completion_status(processed_input.status()); - let result = RollupsAdvanceResult { - input_index: current_input_index, - status, - }; - outputs.push(RollupsOutput::AdvanceResult(result)); - - for (index, report) in processed_input.reports.into_iter().enumerate() { - let report = RollupsReport { - index: index as u64, - input_index: current_input_index, - payload: Payload::new(report.payload), - }; - outputs.push(RollupsOutput::Report(report)); - } - - if let Some(one_of) = processed_input.processed_input_one_of { - match one_of { - ProcessedInputOneOf::AcceptedData(data) => { - for (index, voucher) in - data.vouchers.into_iter().enumerate() - { - let destination = - convert_address(get_field!(voucher.destination))?; - let voucher = RollupsVoucher { - index: index as u64, - input_index: current_input_index, - payload: Payload::new(voucher.payload), - destination, - }; - outputs.push(RollupsOutput::Voucher(voucher)); - } - for (index, notice) in data.notices.into_iter().enumerate() - { - let notice = RollupsNotice { - index: index as u64, - input_index: current_input_index, - payload: Payload::new(notice.payload), - }; - outputs.push(RollupsOutput::Notice(notice)); - } - } - _ => { - tracing::trace!("ignoring input not accepted"); - } - } - } - - tracing::trace!(?outputs, "got outputs from epoch status"); - - Ok(outputs) - } - - /// Handle response to advance-state request - #[tracing::instrument(level = "trace", skip_all)] - async fn handle_advance_state_error( - &mut self, - response: Result, - ) -> Result<()> { - if response.is_ok() { - return Ok(()); - } - - // Capture server-manager error for advance-state request - // to try to get the reason for a possible tainted session error. - let err = response.unwrap_err(); - - let err: ServerManagerError = match err { - ServerManagerError::MethodCallError { - method, - request_id, - source, - } => { - // Original error message to be reported by default. - let mut message = source.message().to_string(); - // The server-manager signals with code Dataloss when the session has been previously tainted. - if source.code() == tonic::Code::DataLoss { - // Recover the tainted session reason from the session's status. - let status_response = grpc_call!( - self, - get_session_status, - GetSessionStatusRequest { - session_id: self.config.session_id.clone(), - } - ); - - if status_response.is_err() { - match status_response.unwrap_err() { - ServerManagerError::MethodCallError { - method: _, - request_id: _, - source, - } => { - tracing::error!( - "get-session-status error: {:?}", - source.message() - ); - } - _ => (), - } - } else { - let status_response = status_response.unwrap(); - let taint_status = status_response.taint_status.clone(); - if let Some(taint_status) = taint_status { - message = format!( - "Server manager session was tainted: {} ({})", - taint_status.error_code, - taint_status.error_message - ); - tracing::error!(message); - } - } - } - ServerManagerError::MethodCallError { - method, - request_id, - source: tonic::Status::new(source.code(), message), - } - } - _ => err, - }; - Err(err) - } - - /// Send a finish-epoch request to the server-manager - /// Return the epoch claim and the proofs - #[tracing::instrument(level = "trace", skip_all)] - pub async fn finish_epoch( - &mut self, - epoch_index: u64, - ) -> Result<(RollupsClaim, Vec)> { - tracing::trace!(epoch_index, "sending finish epoch"); - - // Wait for pending inputs before sending a finish request - let processed_inputs = - self.wait_for_pending_inputs(epoch_index).await?; - let processed_input_count_within_epoch = processed_inputs.len() as u64; - - let response = grpc_call!(self, finish_epoch, { - FinishEpochRequest { - session_id: self.config.session_id.to_owned(), - active_epoch_index: epoch_index, - processed_input_count_within_epoch, - storage_directory: "".to_owned(), - } - })?; - - // Only try to get first and last after making the finish epoch request - let (first_input, last_input) = processed_inputs - .first() - .zip(processed_inputs.last()) - .context(EmptyEpochSnafu { epoch_index })?; - - let vouchers_metadata_hash = - convert_hash(get_field!(response.vouchers_epoch_root_hash))?; - let notices_metadata_hash = - convert_hash(get_field!(response.notices_epoch_root_hash))?; - let machine_state_hash = - convert_hash(get_field!(response.machine_hash))?; - let epoch_hash = compute_epoch_hash( - &vouchers_metadata_hash, - ¬ices_metadata_hash, - &machine_state_hash, - ); - tracing::trace!(?epoch_hash, "computed epoch hash"); - - let mut proofs = vec![]; - for proof in response.proofs { - let proof = convert_proof(proof)?; - proofs.push(RollupsOutput::Proof(proof)); - } - tracing::trace!(?proofs, "got proofs"); - - let rollups_claim = RollupsClaim { - dapp_address: self.dapp_address.clone(), - epoch_index, - epoch_hash, - first_index: first_input.input_index as u128, - last_index: last_input.input_index as u128, - }; - - Ok((rollups_claim, proofs)) - } - - /// Wait until the server-manager processes all pending inputs - /// Return the list of processed inputs for the given epoch - #[tracing::instrument(level = "trace", skip_all)] - async fn wait_for_pending_inputs( - &mut self, - epoch_index: u64, - ) -> Result> { - tracing::trace!(epoch_index, "waiting for pending inputs"); - - for _ in 0..self.config.pending_inputs_max_retries { - let response = grpc_call!(self, get_epoch_status, { - GetEpochStatusRequest { - session_id: self.config.session_id.to_owned(), - epoch_index, - } - })?; - if response.pending_input_count > 0 { - let duration = std::time::Duration::from_millis( - self.config.pending_inputs_sleep_duration, - ); - tracing::debug!( - "server-manager has {} pending inputs; sleeping for {} ms", - response.pending_input_count, - duration.as_millis(), - ); - tokio::time::sleep(duration).await; - } else { - return Ok(response.processed_inputs); - } - } - - tracing::warn!( - "the number of retries while waiting for pending inputs exceeded" - ); - - Err(ServerManagerError::PendingInputsExceededError {}) - } -} - -#[tracing::instrument(level = "trace", skip_all)] -fn get_advance_state_error_message(err: &ServerManagerError) -> Option { - match err { - ServerManagerError::MethodCallError { - method: _, - request_id: _, - source, - } => Some(source.message().to_string()), - _ => None, - } -} diff --git a/offchain/advance-runner/src/server_manager/mod.rs b/offchain/advance-runner/src/server_manager/mod.rs deleted file mode 100644 index 6018b4465..000000000 --- a/offchain/advance-runner/src/server_manager/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod claim; -mod config; -mod conversions; -mod error; -mod facade; - -pub use config::{ServerManagerCLIConfig, ServerManagerConfig}; -pub use error::ServerManagerError; -pub use facade::ServerManagerFacade; diff --git a/offchain/advance-runner/tests/fixtures/mod.rs b/offchain/advance-runner/tests/fixtures/mod.rs deleted file mode 100644 index a5f68af10..000000000 --- a/offchain/advance-runner/tests/fixtures/mod.rs +++ /dev/null @@ -1,136 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use advance_runner::config::{ - AdvanceRunnerConfig, BrokerConfig, DAppMetadata, ServerManagerConfig, -}; -use advance_runner::AdvanceRunnerError; -use grpc_interfaces::cartesi_machine::{ - ConcurrencyConfig, MachineRuntimeConfig, -}; -use grpc_interfaces::cartesi_server_manager::{CyclesConfig, DeadlineConfig}; -use log::LogConfig; -use rollups_events::{Address, BrokerEndpoint}; -use std::cell::RefCell; -use std::time::Duration; -use tokio::task::JoinHandle; - -pub struct AdvanceRunnerFixture { - config: AdvanceRunnerConfig, - handler: RefCell>>>, -} - -impl AdvanceRunnerFixture { - pub async fn setup( - server_manager_endpoint: String, - session_id: String, - redis_endpoint: BrokerEndpoint, - chain_id: u64, - dapp_address: Address, - snapshot_dir: Option, - ) -> Self { - let runtime_config = MachineRuntimeConfig { - concurrency: Some(ConcurrencyConfig { - update_merkle_tree: 0, - }), - }; - - let deadline_config = DeadlineConfig { - checkin: 1000 * 5, - advance_state: 1000 * 60 * 3, - advance_state_increment: 1000 * 10, - inspect_state: 1000 * 60 * 3, - inspect_state_increment: 1000 * 10, - machine: 1000 * 60 * 5, - store: 1000 * 60 * 3, - fast: 1000 * 5, - }; - - let cycles_config = CyclesConfig { - max_advance_state: u64::MAX >> 2, - advance_state_increment: 1 << 22, - max_inspect_state: u64::MAX >> 2, - inspect_state_increment: 1 << 22, - }; - - let server_manager_config = ServerManagerConfig { - server_manager_endpoint, - machine_snapshot_path: snapshot_dir.unwrap_or("".to_owned()), - max_decoding_message_size: 100 * 1024 * 1024, - session_id, - pending_inputs_sleep_duration: 1000, - pending_inputs_max_retries: 10, - runtime_config, - deadline_config, - cycles_config, - }; - - let dapp_metadata = DAppMetadata { - chain_id, - dapp_address: dapp_address.clone(), - }; - - let broker_config = BrokerConfig { - redis_endpoint, - consume_timeout: 100, - backoff: Default::default(), - }; - - let backoff_max_elapsed_duration = Duration::from_millis(1); - - let config = AdvanceRunnerConfig { - server_manager_config, - broker_config, - dapp_metadata, - backoff_max_elapsed_duration, - healthcheck_port: 0, - log_config: LogConfig::default(), - reader_mode: false, - }; - let handler = RefCell::new(Some(start_advance_runner(config.clone()))); - Self { config, handler } - } - - /// Wait until the advance runner exists with an error - #[tracing::instrument(level = "trace", skip_all)] - pub async fn wait_err(&self) -> AdvanceRunnerError { - tracing::trace!("waiting for advance runner error"); - let handler = self.handler.replace(None); - handler - .expect("handler not found") - .await - .expect("failed to wait for handler") - .expect_err("advance runner should exit with an error") - } - - /// Abort the current advance runner, wait it to finish and start another one - #[tracing::instrument(level = "trace", skip_all)] - pub async fn restart(&self) { - tracing::trace!("restartin advance runner"); - let handler = self.handler.replace(None).expect("handler not found"); - handler.abort(); - handler - .await - .expect_err("advance runner finished before abort"); - let new_handler = start_advance_runner(self.config.clone()); - self.handler.replace(Some(new_handler)); - } -} - -fn start_advance_runner( - config: AdvanceRunnerConfig, -) -> JoinHandle> { - tokio::spawn(async move { - let output = advance_runner::run(config).await; - tracing::error!(?output, "advance_runner exited"); - output - }) -} - -impl Drop for AdvanceRunnerFixture { - fn drop(&mut self) { - if let Some(handler) = self.handler.borrow().as_ref() { - handler.abort(); - } - } -} diff --git a/offchain/advance-runner/tests/host_integration.rs b/offchain/advance-runner/tests/host_integration.rs deleted file mode 100644 index 2604c3078..000000000 --- a/offchain/advance-runner/tests/host_integration.rs +++ /dev/null @@ -1,297 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use fixtures::AdvanceRunnerFixture; -use rand::Rng; -use rollups_events::{ - Hash, InputMetadata, Payload, RollupsAdvanceStateInput, RollupsClaim, - RollupsData, RollupsInput, INITIAL_ID, -}; -use test_fixtures::{BrokerFixture, EchoDAppFixture, HostServerManagerFixture}; -use testcontainers::clients::Cli; - -mod fixtures; - -struct TestState<'d> { - broker: BrokerFixture<'d>, - server_manager: HostServerManagerFixture<'d>, - advance_runner: AdvanceRunnerFixture, -} - -impl TestState<'_> { - async fn setup(docker: &Cli) -> TestState<'_> { - let broker = BrokerFixture::setup(docker).await; - let server_manager = HostServerManagerFixture::setup(docker).await; - - let endpoint = server_manager.http_endpoint().to_owned(); - tokio::spawn(async move { - if let Err(e) = EchoDAppFixture::start_echo_dapp(endpoint).await { - tracing::error!("error running echo dapp thread: {:?}", e); - } - }); - - let advance_runner = AdvanceRunnerFixture::setup( - server_manager.grpc_endpoint().to_owned(), - server_manager.session_id().to_owned(), - broker.redis_endpoint().to_owned(), - broker.chain_id(), - broker.dapp_address().to_owned(), - None, - ) - .await; - - TestState { - broker, - server_manager, - advance_runner, - } - } -} - -fn generate_payload() -> Payload { - let len = rand::thread_rng().gen_range(100..200); - let data: Vec = (0..len).map(|_| rand::thread_rng().gen()).collect(); - Payload::new(data) -} - -#[test_log::test(tokio::test)] -async fn start_server_manager_session() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("checking whether advance_runner created session"); - state.server_manager.assert_session_ready().await; -} - -#[test_log::test(tokio::test)] -async fn send_inputs_to_server_manager() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: usize = 3; - tracing::info!("producing {} inputs", N); - - let payloads: Vec<_> = (0..N).map(|_| generate_payload()).collect(); - for (i, payload) in payloads.iter().enumerate() { - let data = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: i as u64, - ..Default::default() - }, - payload: payload.clone().into(), - tx_hash: Hash::default(), - }); - state.broker.produce_input_event(data).await; - } - - tracing::info!("waiting until the inputs are processed"); - state.server_manager.assert_session_ready().await; - state - .server_manager - .assert_epoch_status_payloads(0, &payloads) - .await; -} - -#[test_log::test(tokio::test)] -async fn advance_runner_fails_when_inputs_has_wrong_epoch() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("producing input with wrong epoch index"); - let data = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 0, - ..Default::default() - }, - payload: Default::default(), - tx_hash: Hash::default(), - }); - let input = RollupsInput { - parent_id: INITIAL_ID.to_owned(), - epoch_index: 1, - inputs_sent_count: 1, - data, - }; - state.broker.produce_raw_input_event(input).await; - - tracing::info!("waiting for the advance_runner to exit with error"); - let err = state.advance_runner.wait_err().await; - assert!(format!("{:?}", err).contains("incorrect active epoch index")); -} - -#[test_log::test(tokio::test)] -async fn advance_runner_fails_when_inputs_has_wrong_parent_id() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("producing input with wrong parent id"); - let data = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 0, - ..Default::default() - }, - payload: Default::default(), - tx_hash: Hash::default(), - }); - let input = RollupsInput { - parent_id: "invalid".to_owned(), - epoch_index: 0, - inputs_sent_count: 1, - data, - }; - state.broker.produce_raw_input_event(input).await; - - tracing::info!("waiting for the advance_runner to exit with error"); - let err = state.advance_runner.wait_err().await; - assert!(matches!( - err, - advance_runner::AdvanceRunnerError::RunnerError { - source: advance_runner::RunnerError::ConsumeInputError { - source: advance_runner::BrokerFacadeError::ParentIdMismatchError { - expected, - got, - } - } - } if expected == "0".to_owned() && got == "invalid".to_owned() - )); -} - -#[test_log::test(tokio::test)] -async fn advance_runner_generates_claim_after_finishing_epoch() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: usize = 3; - tracing::info!("producing {} finish epoch events", N); - for i in 0..N { - let advance = - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: i as u64, - ..Default::default() - }, - payload: generate_payload(), - tx_hash: Hash::default(), - }); - let finish = RollupsData::FinishEpoch {}; - state.broker.produce_input_event(advance).await; - state.broker.produce_input_event(finish).await; - } - - tracing::info!("waiting until the expected claims are generated"); - state.server_manager.assert_session_ready().await; - let claims = state.broker.consume_n_claims(N).await; - // We don't verify the claim hash because it is not the resposability of the - // advance_runner and because it changes every time we update the Cartesi Machine. - assert_eq!(claims.len(), N); -} - -#[test_log::test(tokio::test)] -async fn advance_runner_finishes_epoch_when_the_previous_epoch_has_inputs() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("finishing epochs with no inputs"); - state - .broker - .produce_input_event(RollupsData::FinishEpoch {}) - .await; - state - .broker - .produce_input_event(RollupsData::FinishEpoch {}) - .await; - - tracing::info!("waiting until second epoch is finished"); - state.server_manager.assert_epoch_finished(1).await; - - tracing::info!("checking it didn't produce claims"); - let claims = state.broker.consume_all_claims().await; - assert_eq!(claims.len(), 0); -} - -/// Send an input, an finish epoch, and another input. -/// After the second input is processed by the server-manager we know -/// for sure that the advance_runner finished processing the finish epoch. -/// We can't simply wait for the epoch to be finished because the advance_runner -/// still does tasks after that. -async fn finish_epoch_and_wait_for_next_input(state: &TestState<'_>) { - tracing::info!("producing input, finish, and another input"); - let payload = generate_payload(); - let inputs = vec![ - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 0, - ..Default::default() - }, - payload: Default::default(), - tx_hash: Hash::default(), - }), - RollupsData::FinishEpoch {}, - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 1, - ..Default::default() - }, - payload: payload.clone(), - tx_hash: Hash::default(), - }), - ]; - for input in inputs { - state.broker.produce_input_event(input).await; - } - - tracing::info!("waiting until second input is processed"); - state.server_manager.assert_session_ready().await; - state.server_manager.assert_epoch_status(0, 1).await; - state - .server_manager - .assert_epoch_status_payloads(1, &vec![payload]) - .await; -} - -#[test_log::test(tokio::test)] -async fn advance_runner_sends_inputs_after_finishing_epoch() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - finish_epoch_and_wait_for_next_input(&state).await; -} - -#[test_log::test(tokio::test)] -async fn advance_runner_does_not_generate_duplicate_claim() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("producing claim"); - let claim = RollupsClaim::default(); - state.broker.produce_rollups_claim(claim.clone()).await; - - finish_epoch_and_wait_for_next_input(&state).await; - - tracing::info!("getting all claims"); - let produced_claims = state.broker.consume_all_claims().await; - assert_eq!(produced_claims.len(), 1); - assert_eq!(produced_claims[0], claim); -} - -#[test_log::test(tokio::test)] -async fn advance_runner_restore_session_after_restart() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - finish_epoch_and_wait_for_next_input(&state).await; - - tracing::info!("restarting advance_runner"); - state.advance_runner.restart().await; - - tracing::info!("producing another input and checking"); - let input = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 2, - ..Default::default() - }, - payload: generate_payload(), - tx_hash: Hash::default(), - }); - state.broker.produce_input_event(input).await; - state.server_manager.assert_epoch_status(0, 1).await; -} diff --git a/offchain/advance-runner/tests/server_integration.rs b/offchain/advance-runner/tests/server_integration.rs deleted file mode 100644 index 0ef18d97f..000000000 --- a/offchain/advance-runner/tests/server_integration.rs +++ /dev/null @@ -1,296 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use fixtures::AdvanceRunnerFixture; -use rand::Rng; -use rollups_events::{ - Hash, InputMetadata, Payload, RollupsAdvanceStateInput, RollupsClaim, - RollupsData, RollupsInput, INITIAL_ID, -}; -use test_fixtures::{ - BrokerFixture, MachineSnapshotsFixture, ServerManagerFixture, -}; -use testcontainers::clients::Cli; - -mod fixtures; - -struct TestState<'d> { - _snapshots: MachineSnapshotsFixture, - broker: BrokerFixture<'d>, - server_manager: ServerManagerFixture<'d>, - advance_runner: AdvanceRunnerFixture, -} - -impl TestState<'_> { - async fn setup(docker: &Cli) -> TestState<'_> { - let snapshots = MachineSnapshotsFixture::setup(); - let broker = BrokerFixture::setup(docker).await; - let server_manager = - ServerManagerFixture::setup(docker, &snapshots.path()).await; - let advance_runner = AdvanceRunnerFixture::setup( - server_manager.endpoint().to_owned(), - server_manager.session_id().to_owned(), - broker.redis_endpoint().to_owned(), - broker.chain_id(), - broker.dapp_address().to_owned(), - Some(snapshots.path().to_string_lossy().to_string()), - ) - .await; - TestState { - _snapshots: snapshots, - broker, - server_manager, - advance_runner, - } - } -} - -fn generate_payload() -> Payload { - let len = rand::thread_rng().gen_range(100..200); - let data: Vec = (0..len).map(|_| rand::thread_rng().gen()).collect(); - Payload::new(data) -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_starts_server_manager_session() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("checking whether advance_runner created session"); - state.server_manager.assert_session_ready().await; -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_sends_inputs_to_server_manager() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: usize = 3; - tracing::info!("producing {} inputs", N); - let payloads: Vec<_> = (0..N).map(|_| generate_payload()).collect(); - for (i, payload) in payloads.iter().enumerate() { - let data = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: i as u64, - ..Default::default() - }, - payload: payload.clone().into(), - tx_hash: Hash::default(), - }); - state.broker.produce_input_event(data).await; - } - - tracing::info!("waiting until the inputs are processed"); - state.server_manager.assert_session_ready().await; - state - .server_manager - .assert_epoch_status_payloads(0, &payloads) - .await; -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_fails_when_inputs_has_wrong_epoch() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("producing input with wrong epoch index"); - let data = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 0, - ..Default::default() - }, - payload: Default::default(), - tx_hash: Hash::default(), - }); - let input = RollupsInput { - parent_id: INITIAL_ID.to_owned(), - epoch_index: 1, - inputs_sent_count: 1, - data, - }; - state.broker.produce_raw_input_event(input).await; - - tracing::info!("waiting for the advance_runner to exit with error"); - let err = state.advance_runner.wait_err().await; - assert!(format!("{:?}", err).contains("incorrect active epoch index")); -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_fails_when_inputs_has_wrong_parent_id() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("producing input with wrong parent id"); - let data = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 0, - ..Default::default() - }, - payload: Default::default(), - tx_hash: Hash::default(), - }); - let input = RollupsInput { - parent_id: "invalid".to_owned(), - epoch_index: 0, - inputs_sent_count: 1, - data, - }; - state.broker.produce_raw_input_event(input).await; - - tracing::info!("waiting for the advance_runner to exit with error"); - let err = state.advance_runner.wait_err().await; - assert!(matches!( - err, - advance_runner::AdvanceRunnerError::RunnerError { - source: advance_runner::RunnerError::ConsumeInputError { - source: advance_runner::BrokerFacadeError::ParentIdMismatchError { - expected, - got, - } - } - } if expected == "0".to_owned() && got == "invalid".to_owned() - )); -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_generates_claim_after_finishing_epoch() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: usize = 3; - tracing::info!("producing {} finish epoch events", N); - for i in 0..N { - let advance = - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: i as u64, - ..Default::default() - }, - payload: generate_payload(), - tx_hash: Hash::default(), - }); - let finish = RollupsData::FinishEpoch {}; - state.broker.produce_input_event(advance).await; - state.broker.produce_input_event(finish).await; - } - - tracing::info!("waiting until the expected claims are generated"); - state.server_manager.assert_session_ready().await; - let claims = state.broker.consume_n_claims(N).await; - // We don't verify the claim hash because it is not the resposability of the - // advance_runner and because it changes every time we update the Cartesi Machine. - assert_eq!(claims.len(), N); -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_finishes_epoch_when_it_has_no_inputs() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("finishing epochs with no inputs"); - state - .broker - .produce_input_event(RollupsData::FinishEpoch {}) - .await; - state - .broker - .produce_input_event(RollupsData::FinishEpoch {}) - .await; - - tracing::info!("waiting until second epoch is finished"); - state.server_manager.assert_epoch_finished(1).await; - - tracing::info!("checking it didn't produce claims"); - let claims = state.broker.consume_all_claims().await; - assert_eq!(claims.len(), 0); -} - -/// Send an input, an finish epoch, and another input. -/// After the second input is processed by the server-manager we know -/// for sure that the advance_runner finished processing the finish epoch. -/// We can't simply wait for the epoch to be finished because the advance_runner -/// still does tasks after that. -async fn finish_epoch_and_wait_for_next_input(state: &TestState<'_>) { - tracing::info!("producing input, finish, and another input"); - let payload = generate_payload(); - let inputs = vec![ - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 0, - ..Default::default() - }, - payload: Default::default(), - tx_hash: Hash::default(), - }), - RollupsData::FinishEpoch {}, - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 1, - ..Default::default() - }, - payload: payload.clone(), - tx_hash: Hash::default(), - }), - ]; - for input in inputs { - state.broker.produce_input_event(input).await; - } - - tracing::info!("waiting until second input is processed"); - state.server_manager.assert_session_ready().await; - state.server_manager.assert_epoch_status(0, 1).await; - state - .server_manager - .assert_epoch_status_payloads(1, &vec![payload]) - .await; -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_sends_inputs_after_finishing_epoch() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - finish_epoch_and_wait_for_next_input(&state).await; -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_does_not_generate_duplicate_claim() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("producing claim"); - let rollups_claim = RollupsClaim::default(); - state - .broker - .produce_rollups_claim(rollups_claim.clone()) - .await; - - finish_epoch_and_wait_for_next_input(&state).await; - - tracing::info!("getting all claims"); - let produced_claims = state.broker.consume_all_claims().await; - assert_eq!(produced_claims.len(), 1); - assert_eq!(produced_claims[0].epoch_hash, rollups_claim.epoch_hash); -} - -#[test_log::test(tokio::test)] -async fn test_advance_runner_restore_session_after_restart() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - finish_epoch_and_wait_for_next_input(&state).await; - - tracing::info!("restarting advance_runner"); - state.advance_runner.restart().await; - - tracing::info!("producing another input and checking"); - let input = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index: 2, - ..Default::default() - }, - payload: generate_payload(), - tx_hash: Hash::default(), - }); - state.broker.produce_input_event(input).await; - state.server_manager.assert_epoch_status(1, 2).await; -} diff --git a/offchain/authority-claimer/Cargo.toml b/offchain/authority-claimer/Cargo.toml deleted file mode 100644 index 0b589d166..000000000 --- a/offchain/authority-claimer/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "authority-claimer" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-authority-claimer" -path = "src/main.rs" -test = false - -[dependencies] -contracts = { path = "../contracts" } -http-server = { path = "../http-server" } -log = { path = "../log" } -rollups-events = { path = "../rollups-events" } -types = { path = "../types" } -redacted = { path = "../redacted" } - -async-trait.workspace = true -clap = { workspace = true, features = ["derive", "env"] } -eth-tx-manager.workspace = true -ethabi.workspace = true -ethers-signers = { workspace = true, features = ["aws"] } -ethers.workspace = true -rusoto_core.workspace = true -rusoto_kms.workspace = true -rusoto_sts.workspace = true -serde.workspace = true -serde_json.workspace = true -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -tracing.workspace = true -url.workspace = true - -[dev-dependencies] -test-fixtures = { path = "../test-fixtures" } - -backoff = { workspace = true, features = ["tokio"] } -serial_test.workspace = true -testcontainers.workspace = true -tracing-test = { workspace = true, features = ["no-env-filter"] } diff --git a/offchain/authority-claimer/README.md b/offchain/authority-claimer/README.md deleted file mode 100644 index 665231cfa..000000000 --- a/offchain/authority-claimer/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Authority Claimer - -This service submits rollups claims consumed from the broker to the blockchain using the [tx-manager crate](https://github.com/cartesi/tx-manager). -It runs at the end of every epoch, when new claims are inserted on the broker. - -### Multi-dapp Mode - -(This is an **experimental** feature! Don't try it unless you know exactly what you are doing!) - -The `authority-claimer` can be configured to run in "multidapp mode". -To do so, the `DAPP_CONTRACT_ADDRESS` environment variable must be left unset. -This will force the claimer to instantiate a `MultidappBrokerListener` instead of a `DefaultBrokerListener`. - -In multidapp mode, the claimer reads claims from the broker for multiple applications. -All dapps must share the same History contract and the same chain ID. - -Instead of using evironment variables, - the claimer will get the list of application addresses from Redis, - through the `experimental-dapp-addresses-config` key. -This key holds a [Redis Set](https://redis.io/docs/latest/develop/data-types/sets/) value. -You must use commands such as [`SADD`](https://redis.io/docs/latest/commands/sadd/) and [`SREM`]https://redis.io/docs/latest/commands/srem/() to manipulate the set of addresses. - -Application addresses must be encoded as hex strings. -The prefix `0x` is ignored by the `authority-claimer` when loading applications adresses. -However, it's advised to use it as a matter of convention. -Example address value: `"0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"`. - -> [!NOTE] -> Duplicate addresses as well as malformed addresses are detected and logged. - -> [!TIP] -> Application addresses are case insensitive even though Redis values are not. -> So, even though `"0x00000000000000000000000000000000deadbeef"` and `"0x00000000000000000000000000000000DeadBeef"` may belong to the same Redis Set, they are considered to be the same application address and one of them will be identified as a duplicate. - -You may update the contents of `experimental-dapp-addresses-config` at any time, - the claimer will adjust accordingly. -The set of addresses may be emptied at any time as well, - the claimer will wait until an application address is added to the set before resuming operations. diff --git a/offchain/authority-claimer/src/checker.rs b/offchain/authority-claimer/src/checker.rs deleted file mode 100644 index 7479a8b0d..000000000 --- a/offchain/authority-claimer/src/checker.rs +++ /dev/null @@ -1,213 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use contracts::history::{Claim, History}; -use ethers::{ - self, - contract::ContractError, - providers::{ - Http, HttpRateLimitRetryPolicy, Middleware, Provider, RetryClient, - }, - types::H160, -}; -use rollups_events::{Address, RollupsClaim}; -use snafu::{ensure, ResultExt, Snafu}; -use std::sync::Arc; -use std::{collections::HashMap, fmt::Debug}; -use tracing::trace; -use url::{ParseError, Url}; - -const MAX_RETRIES: u32 = 10; -const INITIAL_BACKOFF: u64 = 1000; - -/// The `DuplicateChecker` checks if a given claim was already submitted to the blockchain. -#[async_trait] -pub trait DuplicateChecker: Debug { - type Error: snafu::Error + 'static; - - async fn is_duplicated_rollups_claim( - &mut self, - rollups_claim: &RollupsClaim, - ) -> Result; -} - -// ------------------------------------------------------------------------------------------------ -// DefaultDuplicateChecker -// ------------------------------------------------------------------------------------------------ - -#[derive(Debug)] -pub struct DefaultDuplicateChecker { - provider: Arc>>, - history: History>>, - claims: HashMap>, - confirmations: usize, - next_block_to_read: u64, -} - -#[derive(Debug, Snafu)] -pub enum DuplicateCheckerError { - #[snafu(display("failed to call contract"))] - ContractError { - source: ContractError>>, - }, - - #[snafu(display("failed to call provider"))] - ProviderError { - source: ethers::providers::ProviderError, - }, - - #[snafu(display("parser error"))] - ParseError { source: ParseError }, - - #[snafu(display( - "Depth of `{}` higher than latest block `{}`", - depth, - latest - ))] - DepthTooHigh { depth: u64, latest: u64 }, - - #[snafu(display( - "Claim mismatch; blockchain expects [{}, ?], but got claim with [{}, {}]", - expected_first_index, - claim_first_index, - claim_last_index - ))] - ClaimMismatch { - expected_first_index: u128, - claim_first_index: u128, - claim_last_index: u128, - }, -} - -impl DefaultDuplicateChecker { - pub async fn new( - http_endpoint: String, - history_address: Address, - confirmations: usize, - genesis_block: u64, - ) -> Result { - let http = Http::new(Url::parse(&http_endpoint).context(ParseSnafu)?); - let retry_client = RetryClient::new( - http, - Box::new(HttpRateLimitRetryPolicy), - MAX_RETRIES, - INITIAL_BACKOFF, - ); - let provider = Arc::new(Provider::new(retry_client)); - let history = History::new( - H160(history_address.inner().to_owned()), - provider.clone(), - ); - let mut checker = Self { - provider, - history, - claims: HashMap::new(), - confirmations, - next_block_to_read: genesis_block, - }; - checker.update_claims().await?; // to allow failure during instantiation - Ok(checker) - } -} - -#[async_trait] -impl DuplicateChecker for DefaultDuplicateChecker { - type Error = DuplicateCheckerError; - - async fn is_duplicated_rollups_claim( - &mut self, - rollups_claim: &RollupsClaim, - ) -> Result { - self.update_claims().await?; - let expected_first_index = self - .claims // HashMap => DappAddress to Vec - .get(&rollups_claim.dapp_address) // Gets a Option> - .map(|claims| claims.last()) // Maps to Option> - .flatten() // Back to only one Option - .map(|claim| claim.last_index + 1) // Maps to a number - .unwrap_or(0); // If None, unwrap to 0 - tracing::debug!("checking duplicate claim: expected_first_index={}, rollups_claim={:?}", - expected_first_index, rollups_claim); - if rollups_claim.first_index == expected_first_index { - // This claim is the one the blockchain expects, so it is not considered a duplicate. - Ok(false) - } else if rollups_claim.last_index < expected_first_index { - // This claim is already on the blockchain. - Ok(true) - } else { - // This claim is not on the blockchain, but it isn't the one the blockchain expects. - // If this happens, there is a bug on the dispatcher. - Err(DuplicateCheckerError::ClaimMismatch { - expected_first_index, - claim_first_index: rollups_claim.first_index, - claim_last_index: rollups_claim.last_index, - }) - } - } -} - -impl DefaultDuplicateChecker { - async fn update_claims(&mut self) -> Result<(), DuplicateCheckerError> { - let depth = self.confirmations as u64; - - let latest = self - .provider - .get_block_number() - .await - .context(ProviderSnafu)? - .as_u64(); - - ensure!(depth <= latest, DepthTooHighSnafu { depth, latest }); - let latest = latest - depth; - - if latest < self.next_block_to_read { - trace!( - "nothing to read; next block is {}, but current block is {}", - self.next_block_to_read, - latest - ); - return Ok(()); - } - - let new_claims: Vec<(Address, Claim)> = self - .history - .new_claim_to_history_filter() - .from_block(self.next_block_to_read) - .to_block(latest) - .query() - .await - .context(ContractSnafu)? - .into_iter() - .map(|e| (Address::new(e.dapp.into()), e.claim)) - .collect(); - trace!( - "read new claims {:?} from block {} to {}", - new_claims, - self.next_block_to_read, - latest - ); - self.append_claims(new_claims); - - self.next_block_to_read = latest + 1; - - Ok(()) - } - - // Appends new claims to the [Address => Vec] hashmap cache. - fn append_claims(&mut self, new_claims: Vec<(Address, Claim)>) { - if new_claims.is_empty() { - return; - } - for (dapp_address, new_claim) in new_claims { - match self.claims.get_mut(&dapp_address) { - Some(old_claims) => { - old_claims.push(new_claim); - } - None => { - self.claims.insert(dapp_address, vec![new_claim]); - } - } - } - } -} diff --git a/offchain/authority-claimer/src/claimer.rs b/offchain/authority-claimer/src/claimer.rs deleted file mode 100644 index 2119c3ed5..000000000 --- a/offchain/authority-claimer/src/claimer.rs +++ /dev/null @@ -1,119 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use snafu::ResultExt; -use std::fmt::Debug; -use tracing::{debug, info}; - -use rollups_events::Address; - -use crate::{ - checker::DuplicateChecker, listener::BrokerListener, - sender::TransactionSender, -}; - -/// The `Claimer` starts an event loop that waits for claim messages -/// from the broker, and then sends the claims to the blockchain. It checks to -/// see if the claim is duplicated before sending. -/// -/// It uses three injected traits, `BrokerListener`, `DuplicateChecker`, and -/// `TransactionSender`, to, respectivelly, listen for messages, check for -/// duplicated claims, and send claims to the blockchain. -#[async_trait] -pub trait Claimer: Sized + Debug { - type Error: snafu::Error + 'static; - - async fn start(mut self) -> Result<(), Self::Error>; -} - -#[derive(Debug, snafu::Snafu)] -pub enum ClaimerError< - B: BrokerListener, - D: DuplicateChecker, - T: TransactionSender, -> { - #[snafu(display("invalid app address {:?}", app_address))] - InvalidAppAddress { app_address: Address }, - - #[snafu(display("broker listener error"))] - BrokerListenerError { source: B::Error }, - - #[snafu(display("duplicated claim error"))] - DuplicatedClaimError { source: D::Error }, - - #[snafu(display("transaction sender error"))] - TransactionSenderError { source: T::Error }, -} - -// ------------------------------------------------------------------------------------------------ -// DefaultClaimer -// ------------------------------------------------------------------------------------------------ - -/// The `DefaultClaimer` must be injected with a -/// `BrokerListener`, a `DuplicateChecker` and a `TransactionSender`. -#[derive(Debug)] -pub struct DefaultClaimer< - B: BrokerListener, - D: DuplicateChecker, - T: TransactionSender, -> { - broker_listener: B, - duplicate_checker: D, - transaction_sender: T, -} - -impl - DefaultClaimer -{ - pub fn new( - broker_listener: B, - duplicate_checker: D, - transaction_sender: T, - ) -> Self { - Self { - broker_listener, - duplicate_checker, - transaction_sender, - } - } -} - -#[async_trait] -impl Claimer for DefaultClaimer -where - B: BrokerListener + Send + Sync + 'static, - D: DuplicateChecker + Send + Sync + 'static, - T: TransactionSender + Send + 'static, -{ - type Error = ClaimerError; - - async fn start(mut self) -> Result<(), Self::Error> { - debug!("Starting the authority claimer loop"); - loop { - let rollups_claim = self - .broker_listener - .listen() - .await - .context(BrokerListenerSnafu)?; - info!("Received claim from the broker: {:?}", rollups_claim); - - let is_duplicated_rollups_claim = self - .duplicate_checker - .is_duplicated_rollups_claim(&rollups_claim) - .await - .context(DuplicatedClaimSnafu)?; - if is_duplicated_rollups_claim { - info!("Duplicate claim detected: {:?}", rollups_claim); - continue; - } - - info!("Sending a new rollups claim transaction"); - self.transaction_sender = self - .transaction_sender - .send_rollups_claim_transaction(rollups_claim) - .await - .context(TransactionSenderSnafu)? - } - } -} diff --git a/offchain/authority-claimer/src/config/cli.rs b/offchain/authority-claimer/src/config/cli.rs deleted file mode 100644 index db1979aeb..000000000 --- a/offchain/authority-claimer/src/config/cli.rs +++ /dev/null @@ -1,184 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::{command, Parser}; -use eth_tx_manager::{ - config::{TxEnvCLIConfig as TxManagerCLIConfig, TxManagerConfig}, - Priority, -}; -use ethers::utils::hex; -use log::{LogConfig, LogEnvCliConfig}; -use redacted::Redacted; -use rollups_events::{Address, BrokerCLIConfig, BrokerConfig}; -use rusoto_core::Region; -use snafu::ResultExt; -use std::{fs, str::FromStr}; - -use crate::config::{ - error::{ - AuthorityClaimerConfigError, ContractsSnafu, InvalidRegionSnafu, - MnemonicFileSnafu, TxManagerSnafu, TxSigningConfigError, - TxSigningSnafu, - }, - AuthorityClaimerConfig, ContractsConfig, TxSigningConfig, -}; - -use super::contracts::ContractsCLIConfig; - -// ------------------------------------------------------------------------------------------------ -// AuthorityClaimerCLI -// ------------------------------------------------------------------------------------------------ - -#[derive(Parser)] -#[command(name = "authority_claimer_config")] -#[command(about = "Configuration for authority-claimer")] -pub(crate) struct AuthorityClaimerCLI { - #[command(flatten)] - pub tx_manager_config: TxManagerCLIConfig, - - #[command(flatten)] - pub tx_signing_config: TxSigningCLIConfig, - - #[command(flatten)] - pub broker_config: BrokerCLIConfig, - - #[command(flatten)] - pub log_config: LogEnvCliConfig, - - #[command(flatten)] - pub contracts_config: ContractsCLIConfig, - - /// Address of rollups dapp - #[arg(long, env)] - pub dapp_contract_address: Option, - - /// Genesis block for reading blockchain events - #[arg(long, env, default_value_t = 1)] - pub genesis_block: u64, -} - -impl TryFrom for AuthorityClaimerConfig { - type Error = AuthorityClaimerConfigError; - - fn try_from(cli_config: AuthorityClaimerCLI) -> Result { - let tx_manager_config = - TxManagerConfig::initialize(cli_config.tx_manager_config) - .context(TxManagerSnafu)?; - - let tx_signing_config = - TxSigningConfig::try_from(cli_config.tx_signing_config) - .context(TxSigningSnafu)?; - - let broker_config = BrokerConfig::from(cli_config.broker_config); - - let log_config = LogConfig::initialize(cli_config.log_config); - - let contracts_config = - ContractsConfig::try_from(cli_config.contracts_config) - .context(ContractsSnafu)?; - - let dapp_contract_address: Option
= cli_config - .dapp_contract_address - .map(|raw_dapp_contract_address| { - let address: [u8; 20] = - hex::decode(&raw_dapp_contract_address[2..]) - .expect("Dapp json parse error") - .try_into() - .expect("Dapp address with wrong size"); - address.into() - }); - - Ok(AuthorityClaimerConfig { - tx_manager_config, - tx_signing_config, - tx_manager_priority: Priority::Normal, - broker_config, - log_config, - contracts_config, - dapp_address: dapp_contract_address, - genesis_block: cli_config.genesis_block, - }) - } -} - -// ------------------------------------------------------------------------------------------------ -// TxSigningCLIConfig -// ------------------------------------------------------------------------------------------------ - -#[derive(Debug, Parser)] -#[command(name = "tx_signing_config")] -pub(crate) struct TxSigningCLIConfig { - /// Signer private key, overrides `tx_signing_private_key_file`, `tx_signing_mnemonic` , `tx_signing_mnemonic_file` and `tx_signing_aws_kms_*` - #[arg(long, env)] - tx_signing_private_key: Option, - - /// Signer private key file, overrides `tx_signing_mnemonic` , `tx_signing_mnemonic_file` and `tx_signing_aws_kms_*` - #[arg(long, env)] - tx_signing_private_key_file: Option, - - /// Signer mnemonic, overrides `tx_signing_mnemonic_file` and `tx_signing_aws_kms_*` - #[arg(long, env)] - tx_signing_mnemonic: Option, - - /// Signer mnemonic file path, overrides `tx_signing_aws_kms_*` - #[arg(long, env)] - tx_signing_mnemonic_file: Option, - - /// Mnemonic account index - #[arg(long, env)] - tx_signing_mnemonic_account_index: Option, - - /// AWS KMS signer key-id - #[arg(long, env)] - tx_signing_aws_kms_key_id: Option, - - /// AWS KMS signer region - #[arg(long, env)] - tx_signing_aws_kms_region: Option, -} - -impl TryFrom for TxSigningConfig { - type Error = TxSigningConfigError; - - fn try_from(cli: TxSigningCLIConfig) -> Result { - let account_index = cli.tx_signing_mnemonic_account_index; - if let Some(private_key) = cli.tx_signing_private_key { - Ok(TxSigningConfig::PrivateKey { - private_key: Redacted::new(private_key), - }) - } else if let Some(path) = cli.tx_signing_private_key_file { - let private_key = fs::read_to_string(path.clone()) - .context(MnemonicFileSnafu { path })? - .trim() - .to_string(); - Ok(TxSigningConfig::PrivateKey { - private_key: Redacted::new(private_key), - }) - } else if let Some(mnemonic) = cli.tx_signing_mnemonic { - Ok(TxSigningConfig::Mnemonic { - mnemonic: Redacted::new(mnemonic), - account_index, - }) - } else if let Some(path) = cli.tx_signing_mnemonic_file { - let mnemonic = fs::read_to_string(path.clone()) - .context(MnemonicFileSnafu { path })? - .trim() - .to_string(); - Ok(TxSigningConfig::Mnemonic { - mnemonic: Redacted::new(mnemonic), - account_index, - }) - } else { - match (cli.tx_signing_aws_kms_key_id, cli.tx_signing_aws_kms_region) - { - (None, _) => Err(TxSigningConfigError::AuthConfigMissing), - (Some(_), None) => Err(TxSigningConfigError::MissingRegion), - (Some(key_id), Some(region)) => { - let region = Region::from_str(®ion) - .context(InvalidRegionSnafu)?; - Ok(TxSigningConfig::Aws { key_id, region }) - } - } - } - } -} diff --git a/offchain/authority-claimer/src/config/contracts.rs b/offchain/authority-claimer/src/config/contracts.rs deleted file mode 100644 index e9221f21c..000000000 --- a/offchain/authority-claimer/src/config/contracts.rs +++ /dev/null @@ -1,114 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; -use rollups_events::Address; -use serde::de::DeserializeOwned; -use snafu::{ResultExt, Snafu}; -use std::{fs::File, io::BufReader, path::PathBuf}; -use types::blockchain_config::RollupsDeployment; - -#[derive(Clone, Debug)] -pub struct ContractsConfig { - pub history_address: Address, - pub authority_address: Address, -} - -#[derive(Debug, Parser)] -#[command(name = "blockchain_config")] -pub struct ContractsCLIConfig { - /// History contract address - #[arg(long, env)] - pub history_address: Option, - - /// Authority contract address - #[arg(long, env)] - pub authority_address: Option, - - /// Path to file with deployment json of the rollups - #[arg(long, env)] - pub rollups_deployment_file: Option, -} - -impl TryFrom for ContractsConfig { - type Error = ContractsConfigError; - - fn try_from(cli: ContractsCLIConfig) -> Result { - // try to get the values from the environment values - let mut history_address = cli - .history_address - .map(deserialize::
) - .transpose()?; - let mut authority_address = cli - .authority_address - .map(deserialize::
) - .transpose()?; - - // read file and replace values if they are not set - if let Some(file) = cli - .rollups_deployment_file - .map(read::) - .transpose()? - { - history_address = history_address.or(file - .contracts - .history - .map(|c| c.address) - .flatten()); - authority_address = authority_address.or(file - .contracts - .authority - .map(|c| c.address) - .flatten()); - } - - Ok(ContractsConfig { - history_address: history_address - .ok_or(ContractsConfigError::MissingHistoryContractConfig)?, - authority_address: authority_address - .ok_or(ContractsConfigError::MissingAuthorityContractConfig)?, - }) - } -} - -#[derive(Debug, Snafu)] -pub enum ContractsConfigError { - #[snafu(display("Json deserialize error"))] - JsonDeserializeError { source: serde_json::Error }, - - #[snafu(display("Json read error ({})", path.display()))] - JsonReadError { - path: PathBuf, - source: serde_json::Error, - }, - - #[snafu(display("Read file error ({})", path.display()))] - ReadFileError { - path: PathBuf, - source: std::io::Error, - }, - - #[snafu(display("Missing History contract configuration"))] - MissingHistoryContractConfig, - - #[snafu(display("Missing Authority contract configuration"))] - MissingAuthorityContractConfig, -} - -// ------------------------------------------------------------------------------------------------ -// Auxiliary -// ------------------------------------------------------------------------------------------------ - -fn read(path: PathBuf) -> Result { - let file = - File::open(&path).context(ReadFileSnafu { path: path.clone() })?; - let reader = BufReader::new(file); - serde_json::from_reader(reader).context(JsonReadSnafu { path }) -} - -fn deserialize( - s: String, -) -> Result { - serde_json::from_value(serde_json::Value::String(s)) - .context(JsonDeserializeSnafu) -} diff --git a/offchain/authority-claimer/src/config/error.rs b/offchain/authority-claimer/src/config/error.rs deleted file mode 100644 index e40411670..000000000 --- a/offchain/authority-claimer/src/config/error.rs +++ /dev/null @@ -1,40 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use eth_tx_manager::config::Error as TxManagerConfigError; -use rusoto_core::region::ParseRegionError; -use snafu::Snafu; - -use super::ContractsConfigError; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum AuthorityClaimerConfigError { - #[snafu(display("TxManager configuration error"))] - TxManagerError { source: TxManagerConfigError }, - - #[snafu(display("TxSigning configuration error"))] - TxSigningError { source: TxSigningConfigError }, - - #[snafu(display("Contracts configuration error"))] - ContractsError { source: ContractsConfigError }, -} - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum TxSigningConfigError { - #[snafu(display("Missing auth configuration"))] - AuthConfigMissing, - - #[snafu(display("Could not read mnemonic file at path `{}`", path,))] - MnemonicFileError { - path: String, - source: std::io::Error, - }, - - #[snafu(display("Missing AWS region"))] - MissingRegion, - - #[snafu(display("Invalid AWS region"))] - InvalidRegion { source: ParseRegionError }, -} diff --git a/offchain/authority-claimer/src/config/mod.rs b/offchain/authority-claimer/src/config/mod.rs deleted file mode 100644 index 175163188..000000000 --- a/offchain/authority-claimer/src/config/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod cli; -mod contracts; -mod error; - -pub use contracts::{ContractsConfig, ContractsConfigError}; -pub use error::{AuthorityClaimerConfigError, TxSigningConfigError}; - -use cli::AuthorityClaimerCLI; -use eth_tx_manager::{config::TxManagerConfig, Priority}; -use http_server::HttpServerConfig; -use log::LogConfig; -use redacted::Redacted; -use rollups_events::{Address, BrokerConfig}; -use rusoto_core::Region; - -#[derive(Debug, Clone)] -pub struct Config { - pub authority_claimer_config: AuthorityClaimerConfig, - pub http_server_config: HttpServerConfig, -} - -#[derive(Debug, Clone)] -pub struct AuthorityClaimerConfig { - pub tx_manager_config: TxManagerConfig, - pub tx_signing_config: TxSigningConfig, - pub tx_manager_priority: Priority, - pub broker_config: BrokerConfig, - pub log_config: LogConfig, - pub contracts_config: ContractsConfig, - pub dapp_address: Option
, - pub genesis_block: u64, -} - -#[derive(Debug, Clone)] -pub enum TxSigningConfig { - PrivateKey { - private_key: Redacted, - }, - - Mnemonic { - mnemonic: Redacted, - account_index: Option, - }, - - Aws { - key_id: String, - region: Region, - }, -} - -impl Config { - pub fn new() -> Result { - let (http_server_config, authority_claimer_cli) = - HttpServerConfig::parse::("authority_claimer"); - let authority_claimer_config = authority_claimer_cli.try_into()?; - Ok(Self { - authority_claimer_config, - http_server_config, - }) - } -} diff --git a/offchain/authority-claimer/src/lib.rs b/offchain/authority-claimer/src/lib.rs deleted file mode 100644 index 3e6571517..000000000 --- a/offchain/authority-claimer/src/lib.rs +++ /dev/null @@ -1,89 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod checker; -pub mod claimer; -pub mod config; -pub mod listener; -pub mod metrics; -pub mod sender; -pub mod signer; - -use config::Config; -use listener::{BrokerListener, MultidappBrokerListener}; -use snafu::Error; -use tracing::{info, trace}; - -use crate::{ - checker::DefaultDuplicateChecker, - claimer::{Claimer, DefaultClaimer}, - listener::DefaultBrokerListener, - metrics::AuthorityClaimerMetrics, - sender::DefaultTransactionSender, -}; - -pub async fn run(config: Config) -> Result<(), Box> { - let metrics = AuthorityClaimerMetrics::new(); - let dapp_address = config.authority_claimer_config.dapp_address.clone(); - if let Some(dapp_address) = dapp_address { - info!("Creating the default broker listener"); - let broker_listener = DefaultBrokerListener::new( - config.authority_claimer_config.broker_config.clone(), - config.authority_claimer_config.tx_manager_config.chain_id, - dapp_address, - ) - .await?; - _run(metrics, config, broker_listener).await - } else { - info!("Creating the multidapp broker listener"); - let broker_listener = MultidappBrokerListener::new( - config.authority_claimer_config.broker_config.clone(), - config.authority_claimer_config.tx_manager_config.chain_id, - ) - .await?; - _run(metrics, config, broker_listener).await - } -} - -async fn _run( - metrics: AuthorityClaimerMetrics, - config: Config, - broker_listener: B, -) -> Result<(), Box> { - let http_server_handle = - http_server::start(config.http_server_config, metrics.clone().into()); - let config = config.authority_claimer_config; - - let chain_id = config.tx_manager_config.chain_id; - - trace!("Creating the duplicate checker"); - let duplicate_checker = DefaultDuplicateChecker::new( - config.tx_manager_config.provider_http_endpoint.clone(), - config.contracts_config.history_address.clone(), - config.tx_manager_config.default_confirmations, - config.genesis_block, - ) - .await?; - - trace!("Creating the transaction sender"); - let transaction_sender = - DefaultTransactionSender::new(config.clone(), chain_id, metrics) - .await?; - - // Creating the claimer. - let claimer = DefaultClaimer::new( - broker_listener, - duplicate_checker, - transaction_sender, - ); - - let claimer_handle = claimer.start(); - - // Starting the HTTP server and the claimer loop. - tokio::select! { - ret = http_server_handle => { ret? } - ret = claimer_handle => { ret? } - }; - - unreachable!() -} diff --git a/offchain/authority-claimer/src/listener.rs b/offchain/authority-claimer/src/listener.rs deleted file mode 100644 index 9ab334d0f..000000000 --- a/offchain/authority-claimer/src/listener.rs +++ /dev/null @@ -1,805 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use rollups_events::{ - Address, Broker, BrokerConfig, BrokerError, DAppMetadata, RollupsClaim, - RollupsClaimsStream, INITIAL_ID, -}; -use snafu::ResultExt; -use std::{collections::HashMap, collections::HashSet, fmt::Debug}; - -/// The `BrokerListener` listens for new claims from the broker. -#[async_trait] -pub trait BrokerListener: Debug { - type Error: snafu::Error + 'static; - - async fn listen(&mut self) -> Result; -} - -#[derive(Debug, snafu::Snafu)] -pub enum BrokerListenerError { - #[snafu(display("broker error"))] - BrokerError { source: BrokerError }, -} - -// ------------------------------------------------------------------------------------------------ -// DefaultBrokerListener -// ------------------------------------------------------------------------------------------------ - -/// The `DefaultBrokerListener` only listens for claims from one dapp. -#[derive(Debug)] -pub struct DefaultBrokerListener { - broker: Broker, - stream: RollupsClaimsStream, - last_claim_id: String, -} - -impl DefaultBrokerListener { - pub async fn new( - broker_config: BrokerConfig, - chain_id: u64, - dapp_address: Address, - ) -> Result { - tracing::info!("Connecting to the broker ({:?})", broker_config); - let broker = Broker::new(broker_config).await?; - let dapp_metadata = DAppMetadata { - chain_id, - dapp_address, - }; - let stream = RollupsClaimsStream::new(&dapp_metadata); - let last_claim_id = INITIAL_ID.to_string(); - Ok(Self { - broker, - stream, - last_claim_id, - }) - } -} - -#[async_trait] -impl BrokerListener for DefaultBrokerListener { - type Error = BrokerListenerError; - - async fn listen(&mut self) -> Result { - tracing::trace!("Waiting for claim with id {}", self.last_claim_id); - let event = self - .broker - .consume_blocking(&self.stream, &self.last_claim_id) - .await - .context(BrokerSnafu)?; - - self.last_claim_id = event.id; - - Ok(event.payload) - } -} - -// ------------------------------------------------------------------------------------------------ -// MultidappBrokerListener -// ------------------------------------------------------------------------------------------------ - -/// The `MultidappBrokerListener` listens for claims from multiple dapps. -/// It updates its internal list of dapps by consuming from redis' DappsStream. -#[derive(Debug)] -pub struct MultidappBrokerListener { - broker: Broker, - streams: HashMap, // stream => last-claim-id - buffer: HashMap, - chain_id: u64, -} - -impl MultidappBrokerListener { - pub async fn new( - broker_config: BrokerConfig, - chain_id: u64, - ) -> Result { - tracing::info!( - "Connecting to the broker ({:?}) on multidapp mode", - broker_config - ); - let broker = Broker::new(broker_config).await?; - let streams = HashMap::new(); - let buffer = HashMap::new(); - Ok(Self { - broker, - streams, - buffer, - chain_id, - }) - } -} - -impl MultidappBrokerListener { - /// Reads addresses from the DappStream and - /// converts them to the stream to last-consumed-id map. - async fn update_streams(&mut self) -> Result<(), BrokerListenerError> { - let initial_id = INITIAL_ID.to_string(); - - // Gets the dapps from the broker. - let dapps = self.broker.get_dapps().await.context(BrokerSnafu)?; - assert!(!dapps.is_empty()); - { - // Logging if the dapps changed. - let old_dapps: HashSet
= self - .streams - .iter() - .map(|(stream, _)| stream.dapp_address.clone()) - .collect(); - let new_dapps = HashSet::from_iter(dapps.clone()); - if old_dapps != new_dapps { - tracing::info!( - "Updated list of dapp addresses from key \"{}\": {:?}", - rollups_events::DAPPS_KEY, - new_dapps - ); - } - } - - // Converts dapps to streams. - let streams: Vec<_> = dapps - .into_iter() - .map(|dapp_address| { - RollupsClaimsStream::new(&DAppMetadata { - chain_id: self.chain_id, - dapp_address, - }) - }) - .collect(); - - // Removes obsolete dapps from the buffer, if any. - for key in self.buffer.clone().keys() { - if !streams.contains(key) { - self.buffer.remove(key); - } - } - - // Adds the last consumed ids. - let streams: Vec<_> = streams - .into_iter() - .map(|stream| { - let id = self.streams.get(&stream).unwrap_or(&initial_id); - (stream, id.to_string()) - }) - .collect(); - - self.streams = HashMap::from_iter(streams); - Ok(()) - } - - // Returns true if it succeeded in filling the buffer and false otherwise. - async fn fill_buffer(&mut self) -> Result { - let streams_and_events = self - .broker - .consume_blocking_from_multiple_streams(self.streams.clone()) - .await; - if let Err(BrokerError::FailedToConsume) = streams_and_events { - return Ok(false); - } - - let streams_and_events = streams_and_events.context(BrokerSnafu)?; - for (stream, event) in streams_and_events { - // Updates the last-consumed-id from the stream. - let replaced = self.streams.insert(stream.clone(), event.id); - assert!(replaced.is_some()); - - let replaced = self.buffer.insert(stream, event.payload); - assert!(replaced.is_none()); - } - - Ok(true) - } -} - -#[async_trait] -impl BrokerListener for MultidappBrokerListener { - type Error = BrokerListenerError; - - async fn listen(&mut self) -> Result { - self.update_streams().await?; - - tracing::trace!("Waiting for a claim"); - if self.buffer.is_empty() { - loop { - if self.fill_buffer().await? { - break; - } else { - self.update_streams().await?; - } - } - } - - let buffer = self.buffer.clone(); - let (stream, rollups_claim) = buffer.into_iter().next().unwrap(); - self.buffer.remove(&stream); - Ok(rollups_claim) - } -} - -// ------------------------------------------------------------------------------------------------ -// Tests -// ------------------------------------------------------------------------------------------------ - -#[cfg(test)] -mod tests { - use std::{collections::HashMap, time::Duration}; - use testcontainers::clients::Cli; - - use test_fixtures::{broker::ClaimerMultidappBrokerFixture, BrokerFixture}; - - use backoff::ExponentialBackoffBuilder; - use rollups_events::{ - Address, BrokerConfig, BrokerEndpoint, BrokerError, RedactedUrl, - RollupsClaim, RollupsClaimsStream, Url, - }; - - use crate::listener::BrokerListener; - - use super::{DefaultBrokerListener, MultidappBrokerListener}; - - // -------------------------------------------------------------------------------------------- - // Broker Mock - // -------------------------------------------------------------------------------------------- - - fn config(redis_endpoint: BrokerEndpoint) -> BrokerConfig { - BrokerConfig { - redis_endpoint, - consume_timeout: 300000, - backoff: ExponentialBackoffBuilder::new() - .with_initial_interval(Duration::from_millis(1000)) - .with_max_elapsed_time(Some(Duration::from_millis(3000))) - .build(), - } - } - - // -------------------------------------------------------------------------------------------- - // DefaultListener Tests - // -------------------------------------------------------------------------------------------- - - async fn setup_default_broker_listener( - docker: &Cli, - should_fail: bool, - ) -> Result<(BrokerFixture, DefaultBrokerListener), BrokerError> { - let fixture = BrokerFixture::setup(docker).await; - let redis_endpoint = if should_fail { - BrokerEndpoint::Single(RedactedUrl::new( - Url::parse("https://invalid.com").unwrap(), - )) - } else { - fixture.redis_endpoint().clone() - }; - let broker = DefaultBrokerListener::new( - config(redis_endpoint), - fixture.chain_id(), - fixture.dapp_address().clone(), - ) - .await?; - Ok((fixture, broker)) - } - - async fn default_produce_claims( - fixture: &BrokerFixture<'_>, - n: usize, - epoch_index_start: usize, - ) -> Vec { - let mut rollups_claims = Vec::new(); - for i in 0..n { - let mut rollups_claim = RollupsClaim::default(); - rollups_claim.epoch_index = (i + epoch_index_start) as u64; - fixture.produce_rollups_claim(rollups_claim.clone()).await; - rollups_claims.push(rollups_claim); - } - rollups_claims - } - - /// The last claim should trigger an `EndError` error. - async fn default_produce_last_claim( - fixture: &BrokerFixture<'_>, - epoch_index: usize, - ) -> Vec { - default_produce_claims(fixture, 1, epoch_index).await - } - - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn instantiate_new_default_broker_listener_ok() { - let docker = Cli::default(); - let _ = setup_default_broker_listener(&docker, false).await; - } - - #[tokio::test] - async fn instantiate_new_default_broker_listener_error() { - let docker = Cli::default(); - let result = setup_default_broker_listener(&docker, true).await; - assert!(result.is_err(), "setup didn't fail as it should"); - let error = result.err().unwrap().to_string(); - assert_eq!(error, "error connecting to Redis"); - } - - #[tokio::test] - async fn start_default_broker_listener_with_one_claim_enqueued() { - let docker = Cli::default(); - let (fixture, mut broker_listener) = - setup_default_broker_listener(&docker, false).await.unwrap(); - let n = 5; - default_produce_claims(&fixture, n, 0).await; - default_produce_last_claim(&fixture, n).await; - let result = broker_listener.listen().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn start_default_broker_listener_with_claims_enqueued() { - let docker = Cli::default(); - let (fixture, mut broker_listener) = - setup_default_broker_listener(&docker, false).await.unwrap(); - default_produce_last_claim(&fixture, 0).await; - let claim = broker_listener.listen().await; - assert!(claim.is_ok()); - } - - #[tokio::test] - async fn start_default_broker_listener_listener_with_no_claims_enqueued() { - let docker = Cli::default(); - let (fixture, mut broker_listener) = - setup_default_broker_listener(&docker, false).await.unwrap(); - let n = 7; - - let broker_listener_thread = tokio::spawn(async move { - println!("Spawned the broker-listener thread."); - let claim = broker_listener.listen().await; - assert!(claim.is_ok()); - }); - - println!("Going to sleep for 1 second."); - tokio::time::sleep(Duration::from_secs(1)).await; - - let x = 2; - println!("Creating {} claims.", x); - default_produce_claims(&fixture, x, 0).await; - - println!("Going to sleep for 2 seconds."); - tokio::time::sleep(Duration::from_secs(2)).await; - - let y = 5; - println!("Creating {} claims.", y); - default_produce_claims(&fixture, y, x).await; - - assert_eq!(x + y, n); - default_produce_last_claim(&fixture, n).await; - - broker_listener_thread.await.unwrap(); - } - - // -------------------------------------------------------------------------------------------- - // MultidappListener Tests - // -------------------------------------------------------------------------------------------- - - async fn setup_multidapp_listener( - docker: &Cli, - should_fail: bool, - ) -> Result< - ( - ClaimerMultidappBrokerFixture, - MultidappBrokerListener, - Vec
, - ), - BrokerError, - > { - let chain_id: u64 = 0; - let dapp_addresses: Vec
= vec![ - [3; 20].into(), // - [5; 20].into(), // - [10; 20].into(), // - ]; - let dapps: Vec<_> = dapp_addresses - .clone() - .into_iter() - .map(|dapp_address| (chain_id, dapp_address)) - .collect(); - - let fixture = - ClaimerMultidappBrokerFixture::setup(docker, dapps.clone()).await; - fixture.dapps_set(dapp_addresses.clone()).await; - - let redis_endpoint = if should_fail { - BrokerEndpoint::Single(RedactedUrl::new( - Url::parse("https://invalid.com").unwrap(), - )) - } else { - fixture.redis_endpoint().clone() - }; - - let listener = - MultidappBrokerListener::new(config(redis_endpoint), chain_id) - .await?; - Ok((fixture, listener, dapp_addresses)) - } - - // For each index in indexes, this function produces a claim - // with rollups_claim.dapp_address = dapps[index] - // and rollups_claim.epoch_index = epochs[index]. - // It then increments epochs[index]. - async fn multidapp_produce_claims( - fixture: &ClaimerMultidappBrokerFixture<'_>, - epochs: &mut Vec, - dapps: &Vec
, - indexes: &Vec, - ) { - for &index in indexes { - let epoch = *epochs.get(index).unwrap(); - - let mut rollups_claim = RollupsClaim::default(); - rollups_claim.dapp_address = dapps.get(index).unwrap().clone(); - rollups_claim.epoch_index = epoch; - fixture.produce_rollups_claim(rollups_claim.clone()).await; - - epochs[index] = epoch + 1; - } - } - - // Asserts that listener.listen() will return indexes.len() claims, - // and that for each index in indexes - // there is an unique claim for which claim.dapp_address = dapps[index]. - async fn assert_listen( - listener: &mut MultidappBrokerListener, - dapps: &Vec
, - indexes: &Vec, - ) { - let mut dapps: Vec<_> = indexes - .iter() - .map(|&index| dapps.get(index).unwrap().clone()) - .collect(); - for _ in indexes.clone() { - println!("--- Listening..."); - let result = listener.listen().await; - assert!(result.is_ok(), "{:?}", result.unwrap_err()); - let dapp = result.unwrap().dapp_address; - - let index = dapps.iter().position(|expected| *expected == dapp); - assert!(index.is_some()); - println!("--- Listened for a claim from {:?}", dapp); - dapps.remove(index.unwrap()); - } - assert!(dapps.is_empty()); - } - - fn streams_to_vec( - streams: &HashMap, - ) -> Vec
{ - streams - .keys() - .into_iter() - .map(|stream| stream.dapp_address.clone()) - .collect::>() - } - - fn assert_eq_vec(mut v1: Vec
, mut v2: Vec
) { - assert_eq!(v1.len(), v2.len()); - while !v1.is_empty() { - let e1 = v1.pop().unwrap(); - let e2 = v2.pop().unwrap(); - assert_eq!(e1, e2); - } - } - - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn instantiate_multidapp_broker_listener_ok() { - let docker = Cli::default(); - let _ = setup_multidapp_listener(&docker, false).await; - } - - #[tokio::test] - async fn instantiate_multidapp_broker_listener_error() { - let docker = Cli::default(); - let result = setup_multidapp_listener(&docker, true).await; - assert!(result.is_err(), "setup didn't fail as it should"); - let error = result.err().unwrap().to_string(); - assert_eq!(error, "error connecting to Redis"); - } - - #[tokio::test] - async fn multidapp_listen_with_no_dapps() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - fixture.dapps_set(vec![]).await; - let mut epochs = vec![0; dapps.len()]; - let indexes = vec![0, 1, 2]; - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &indexes).await; - - let thread = tokio::spawn(async move { - let _ = listener.listen().await; - unreachable!(); - }); - let result = tokio::time::timeout(Duration::from_secs(3), thread).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn multidapp_listen_with_one_dapp() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - fixture.dapps_set(vec![dapps.get(0).unwrap().clone()]).await; - let mut epochs = vec![0; dapps.len()]; - let indexes = vec![2, 1, 1, 2, 0]; - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &indexes).await; - assert_listen(&mut listener, &dapps, &vec![0]).await; - } - - #[tokio::test] - async fn multidapp_listen_with_changing_dapps() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - - let first_batch_dapps = vec![ - dapps.get(0).unwrap().clone(), // - ]; - let second_batch_dapps = vec![ - dapps.get(0).unwrap().clone(), // - dapps.get(1).unwrap().clone(), // - ]; - let third_batch_dapps = vec![ - dapps.get(0).unwrap().clone(), // - dapps.get(1).unwrap().clone(), // - dapps.get(2).unwrap().clone(), // - ]; - let fourth_batch_dapps = vec![ - dapps.get(2).unwrap().clone(), // - ]; - - let mut epochs = vec![0; dapps.len()]; - let first_batch = vec![0, 0]; - let second_batch = vec![1, 0]; - let third_batch = vec![2, 1, 0]; - let fourth_batch = vec![2]; - - { - println!("=== Producing the first batch of claims."); - multidapp_produce_claims( - &fixture, - &mut epochs, - &dapps, - &first_batch, - ) - .await; - println!("=== Epochs: {:?}", epochs); - - println!("--- Setting dapps..."); - fixture.dapps_set(first_batch_dapps.clone()).await; - assert_listen(&mut listener, &dapps, &first_batch).await; - let mut dapps = streams_to_vec(&listener.streams); - dapps.sort(); - println!("--- Current dapps: {:?}", dapps); - assert_eq_vec(first_batch_dapps, dapps); - println!("--- All good with the first batch!"); - } - - { - println!("=== Producing the second batch of claims."); - multidapp_produce_claims( - &fixture, - &mut epochs, - &dapps, - &second_batch, - ) - .await; - println!("=== Epochs: {:?}", epochs); - - println!("--- Setting dapps..."); - fixture.dapps_set(second_batch_dapps.clone()).await; - assert_listen(&mut listener, &dapps, &second_batch).await; - let mut dapps = streams_to_vec(&listener.streams); - dapps.sort(); - println!("--- Current dapps: {:?}", dapps); - assert_eq_vec(second_batch_dapps, dapps); - println!("--- All good with the second batch!"); - } - - { - println!("=== Producing the third batch of claims."); - multidapp_produce_claims( - &fixture, - &mut epochs, - &dapps, - &third_batch, - ) - .await; - println!("=== Epochs: {:?}", epochs); - - println!("--- Setting dapps..."); - fixture.dapps_set(third_batch_dapps.clone()).await; - assert_listen(&mut listener, &dapps, &third_batch).await; - let mut dapps = streams_to_vec(&listener.streams); - dapps.sort(); - println!("--- Current dapps: {:?}", dapps); - assert_eq_vec(third_batch_dapps, dapps); - println!("--- All good with the third batch!"); - } - - { - println!("=== Producing the fourth batch of claims."); - multidapp_produce_claims( - &fixture, - &mut epochs, - &dapps, - &fourth_batch, - ) - .await; - println!("=== Epochs: {:?}", epochs); - - println!("--- Setting dapps..."); - fixture.dapps_set(fourth_batch_dapps.clone()).await; - assert_listen(&mut listener, &dapps, &fourth_batch).await; - let mut dapps = streams_to_vec(&listener.streams); - dapps.sort(); - println!("--- Current dapps: {:?}", dapps); - assert_eq_vec(fourth_batch_dapps, dapps); - println!("--- All good with the fourth batch!"); - } - } - - #[tokio::test] - async fn multidapp_listen_with_one_claim_enqueued() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - - let mut epochs = vec![0; dapps.len()]; - let index = 0; - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &vec![index]) - .await; - - let result = listener.listen().await; - assert!(result.is_ok(), "{:?}", result); - - let expected_dapp = dapps.get(index).unwrap().clone(); - let actual_dapp = result.unwrap().dapp_address; - assert_eq!(expected_dapp, actual_dapp); - } - - #[tokio::test] - async fn multidapp_listen_with_multiple_claims_enqueued() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - - let mut epochs = vec![0; dapps.len()]; - let indexes = vec![2, 1, 1, 2]; - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &indexes).await; - assert_listen(&mut listener, &dapps, &indexes).await; - } - - #[tokio::test] - async fn multidapp_listen_with_one_claim_for_each_dapp_enqueued() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - - let mut epochs = vec![0; dapps.len()]; - let indexes = vec![2, 1, 0]; - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &indexes).await; - assert_listen(&mut listener, &dapps, &indexes).await; - } - - #[tokio::test] - async fn multidapp_listen_with_no_claims_enqueued() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - - let mut epochs = vec![0; dapps.len()]; - let first_batch = vec![0, 1, 2, 0]; - let second_batch = vec![2, 1, 0, 0, 2, 1]; - - let broker_listener_thread = { - let _dapps = dapps.clone(); - let _first_batch = first_batch.clone(); - let _second_batch = second_batch.clone(); - tokio::spawn(async move { - println!("Spawned the broker-listener thread."); - assert_listen(&mut listener, &_dapps, &_first_batch).await; - println!("All good with the first batch!"); - assert_listen(&mut listener, &_dapps, &_second_batch).await; - println!("All good with the second batch!"); - }) - }; - - println!("Going to sleep for 1 second."); - tokio::time::sleep(Duration::from_secs(1)).await; - - println!("Producing the first batch of claims."); - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &first_batch) - .await; - println!("Epochs: {:?}", epochs); - - println!("Going to sleep for 2 seconds."); - tokio::time::sleep(Duration::from_secs(2)).await; - - println!("Producing the second batch of claims."); - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &second_batch) - .await; - println!("Epochs: {:?}", epochs); - - broker_listener_thread.await.unwrap(); - } - - #[tokio::test] - async fn multidapp_listen_buffer_order() { - let docker = Cli::default(); - let (fixture, mut listener, dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - - let mut epochs = vec![0; dapps.len()]; - let indexes = vec![1, 1, 1, 1, 1, 2, 1, 2, 0]; - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &indexes).await; - - let mut buffers = vec![ - vec![0, 1, 2], // - vec![1, 2], - vec![1], - vec![1], - vec![1], - vec![1], - ]; - - for buffer in buffers.iter_mut() { - for _ in 0..buffer.len() { - println!("Buffer: {:?}", buffer); - let result = listener.listen().await; - assert!(result.is_ok(), "{:?}", result.unwrap_err()); - let dapp_address = result.unwrap().dapp_address; - let index = dapps - .iter() - .position(|address| *address == dapp_address) - .unwrap(); - let index = buffer.iter().position(|i| *i == index).unwrap(); - buffer.remove(index); - } - assert!(buffer.is_empty()); - println!("Emptied one of the buffers"); - } - } - - #[tokio::test] - async fn multidapp_listen_buffer_change() { - let docker = Cli::default(); - let (fixture, mut listener, mut dapps) = - setup_multidapp_listener(&docker, false).await.unwrap(); - - let mut epochs = vec![0; dapps.len()]; - let indexes = vec![2, 2, 2, 0, 1, 0]; - multidapp_produce_claims(&fixture, &mut epochs, &dapps, &indexes).await; - - // Removes the last dapp. - assert!(dapps.pop().is_some()); - fixture.dapps_set(dapps.clone()).await; - - let mut buffers = vec![ - vec![0, 1], // - vec![0], - ]; - - for buffer in buffers.iter_mut() { - for _ in 0..buffer.len() { - println!("Buffer: {:?}", buffer); - let result = listener.listen().await; - assert!(result.is_ok(), "{:?}", result.unwrap_err()); - let dapp_address = result.unwrap().dapp_address; - let index = dapps - .iter() - .position(|address| *address == dapp_address) - .unwrap(); - let index = buffer.iter().position(|i| *i == index).unwrap(); - buffer.remove(index); - } - assert!(buffer.is_empty()); - println!("Emptied one of the buffers"); - } - } -} diff --git a/offchain/authority-claimer/src/main.rs b/offchain/authority-claimer/src/main.rs deleted file mode 100644 index 0185876db..000000000 --- a/offchain/authority-claimer/src/main.rs +++ /dev/null @@ -1,19 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use authority_claimer::config::Config; -use std::error::Error; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Getting the configuration. - let config: Config = Config::new().map_err(Box::new)?; - - // Setting up the logging environment. - log::configure(&config.authority_claimer_config.log_config); - - //Log Service info - log::log_service_start(&config, "Authority Claimer"); - - authority_claimer::run(config).await -} diff --git a/offchain/authority-claimer/src/metrics.rs b/offchain/authority-claimer/src/metrics.rs deleted file mode 100644 index 3ffd67a66..000000000 --- a/offchain/authority-claimer/src/metrics.rs +++ /dev/null @@ -1,34 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use http_server::{CounterRef, FamilyRef, Registry}; -use rollups_events::DAppMetadata; - -const METRICS_PREFIX: &str = "cartesi_rollups_authority_claimer"; - -fn prefixed_metrics(name: &str) -> String { - format!("{}_{}", METRICS_PREFIX, name) -} - -#[derive(Debug, Clone, Default)] -pub struct AuthorityClaimerMetrics { - pub claims_sent: FamilyRef, -} - -impl AuthorityClaimerMetrics { - pub fn new() -> Self { - Self::default() - } -} - -impl From for Registry { - fn from(metrics: AuthorityClaimerMetrics) -> Self { - let mut registry = Registry::default(); - registry.register( - prefixed_metrics("claims_sent"), - "Counts the number of claims sent", - metrics.claims_sent, - ); - registry - } -} diff --git a/offchain/authority-claimer/src/sender.rs b/offchain/authority-claimer/src/sender.rs deleted file mode 100644 index 70997f921..000000000 --- a/offchain/authority-claimer/src/sender.rs +++ /dev/null @@ -1,267 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use contracts::{authority::Authority, history::Claim}; -use eth_tx_manager::{ - database::FileSystemDatabase as Database, - gas_oracle::DefaultGasOracle as GasOracle, - manager::Configuration, - time::DefaultTime as Time, - transaction::{Priority, Transaction, Value}, - Chain, -}; -use ethabi::Token; -use ethers::{ - self, - abi::AbiEncode, - middleware::SignerMiddleware, - providers::{ - Http, HttpRateLimitRetryPolicy, MockProvider, Provider, RetryClient, - }, - signers::Signer, - types::{Bytes, NameOrAddress, H160}, -}; -use rollups_events::{DAppMetadata, RollupsClaim}; -use snafu::{OptionExt, ResultExt, Snafu}; -use std::fmt::Debug; -use std::sync::Arc; -use tracing::{info, trace}; -use url::{ParseError, Url}; - -use crate::{ - config::AuthorityClaimerConfig, - metrics::AuthorityClaimerMetrics, - signer::{ConditionalSigner, ConditionalSignerError}, -}; - -/// The `TransactionSender` sends claims to the blockchain. -/// -/// It should wait for N blockchain confirmations. -#[async_trait] -pub trait TransactionSender: Sized + Debug { - type Error: snafu::Error + 'static; - - /// The `send_rollups_claim_transaction` function consumes the - /// `TransactionSender` object and then returns it to avoid - /// that processes use the transaction sender concurrently. - async fn send_rollups_claim_transaction( - self, - rollups_claim: RollupsClaim, - ) -> Result; -} - -// ------------------------------------------------------------------------------------------------ -// DefaultTransactionSender -// ------------------------------------------------------------------------------------------------ - -type Middleware = - Arc>, ConditionalSigner>>; - -type TransactionManager = - eth_tx_manager::TransactionManager; - -type TrasactionManagerError = - eth_tx_manager::Error; - -/// Instantiates the tx-manager calling `new` or `force_new`. -macro_rules! tx_manager { - ($new: ident, $middleware: expr, $database_path: expr, $chain: expr) => { - TransactionManager::$new( - $middleware.clone(), - GasOracle::new(), - Database::new($database_path.clone()), - $chain, - Configuration::default(), - ) - .await - }; -} - -struct SubmittableClaim(ethers::types::Address, RollupsClaim); - -impl From for Bytes { - fn from(submittable_claim: SubmittableClaim) -> Self { - let SubmittableClaim(dapp_address, claim) = submittable_claim; - let claim = Claim { - epoch_hash: claim.epoch_hash.into_inner(), - first_index: claim.first_index, - last_index: claim.last_index, - }; - ethers::abi::encode(&[ - Token::Address(dapp_address), - Token::FixedBytes(claim.encode()), - ]) - .into() - } -} - -#[derive(Debug)] -pub struct DefaultTransactionSender { - tx_manager: TransactionManager, - confirmations: usize, - priority: Priority, - from: ethers::types::Address, - authority: Authority>, - chain_id: u64, - metrics: AuthorityClaimerMetrics, -} - -#[derive(Debug, Snafu)] -pub enum TransactionSenderError { - #[snafu(display("Invalid provider URL"))] - ProviderUrl { source: ParseError }, - - #[snafu(display("Failed to initialize the transaction signer"))] - Signer { source: ConditionalSignerError }, - - #[snafu(display("Transaction manager error"))] - TransactionManager { source: TrasactionManagerError }, - - #[snafu(display("Internal ethers-rs error: tx `to` should not be null"))] - InternalEthers, - - #[snafu(display( - "Internal configuration error: expected address, found ENS name" - ))] - InternalConfig, -} - -/// Creates the (layered) middleware instance to be sent to the tx-manager. -fn create_middleware( - conditional_signer: ConditionalSigner, - provider_url: String, -) -> Result { - const MAX_RETRIES: u32 = 10; - const INITIAL_BACKOFF: u64 = 1000; - let url = Url::parse(&provider_url).context(ProviderUrlSnafu)?; - let base_layer = Http::new(url); - let retry_layer = Provider::new(RetryClient::new( - base_layer, - Box::new(HttpRateLimitRetryPolicy), - MAX_RETRIES, - INITIAL_BACKOFF, - )); - let signer_layer = SignerMiddleware::new(retry_layer, conditional_signer); - Ok(Arc::new(signer_layer)) -} - -/// Creates the tx-manager instance. -/// NOTE: tries to re-instantiate the tx-manager only once. -async fn create_tx_manager( - conditional_signer: &ConditionalSigner, - provider_url: String, - database_path: String, - chain: Chain, -) -> Result { - let middleware = - create_middleware(conditional_signer.clone(), provider_url)?; - let result = tx_manager!(new, middleware, database_path, chain); - let tx_manager = - if let Err(TrasactionManagerError::NonceTooLow { .. }) = result { - info!("Nonce too low! Clearing the tx-manager database."); - tx_manager!(force_new, middleware, database_path, chain) - .context(TransactionManagerSnafu)? - } else { - let (tx_manager, receipt) = - result.context(TransactionManagerSnafu)?; - trace!("Database claim transaction confirmed: `{:?}`", receipt); - tx_manager - }; - Ok(tx_manager) -} - -impl DefaultTransactionSender { - pub async fn new( - config: AuthorityClaimerConfig, - chain_id: u64, - metrics: AuthorityClaimerMetrics, - ) -> Result { - let chain: Chain = (&config.tx_manager_config).into(); - - let conditional_signer = - ConditionalSigner::new(chain.id, &config.tx_signing_config) - .await - .context(SignerSnafu)?; - - let tx_manager = create_tx_manager( - &conditional_signer, - config.tx_manager_config.provider_http_endpoint.clone(), - config.tx_manager_config.database_path.clone(), - chain, - ) - .await?; - - let authority = { - let (provider, _mock) = Provider::mocked(); - let provider = Arc::new(provider); - let address: H160 = config - .contracts_config - .authority_address - .into_inner() - .into(); - Authority::new(address, provider) - }; - - Ok(Self { - tx_manager, - confirmations: config.tx_manager_config.default_confirmations, - priority: config.tx_manager_priority, - from: conditional_signer.address(), - authority, - chain_id, - metrics, - }) - } -} - -#[async_trait] -impl TransactionSender for DefaultTransactionSender { - type Error = TransactionSenderError; - - async fn send_rollups_claim_transaction( - self, - rollups_claim: RollupsClaim, - ) -> Result { - let dapp_address = rollups_claim.dapp_address.clone(); - - let transaction = { - let submittable_claim = SubmittableClaim( - H160(dapp_address.inner().to_owned()), - rollups_claim, - ); - let call = self - .authority - .submit_claim(submittable_claim.into()) - .from(self.from); - let to = match call.tx.to().context(InternalEthersSnafu)? { - NameOrAddress::Address(a) => *a, - _ => return Err(TransactionSenderError::InternalConfig), - }; - Transaction { - from: self.from, - to, - value: Value::Nothing, - call_data: call.tx.data().cloned(), - } - }; - - trace!("Built claim transaction: `{:?}`", transaction); - - let (tx_manager, receipt) = self - .tx_manager - .send_transaction(transaction, self.confirmations, self.priority) - .await - .context(TransactionManagerSnafu)?; - self.metrics - .claims_sent - .get_or_create(&DAppMetadata { - chain_id: self.chain_id, - dapp_address, - }) - .inc(); - info!("Claim transaction confirmed: `{:?}`", receipt); - - Ok(Self { tx_manager, ..self }) - } -} diff --git a/offchain/authority-claimer/src/signer/aws_credentials.rs b/offchain/authority-claimer/src/signer/aws_credentials.rs deleted file mode 100644 index 90ec444f0..000000000 --- a/offchain/authority-claimer/src/signer/aws_credentials.rs +++ /dev/null @@ -1,143 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use rusoto_core::credential::{ - AwsCredentials, CredentialsError, DefaultCredentialsProvider, - ProvideAwsCredentials, -}; -use rusoto_sts::WebIdentityProvider; -use std::env; - -pub const WEB_IDENTITY_ENV_VARS: [&str; 3] = [ - "AWS_WEB_IDENTITY_TOKEN_FILE", - "AWS_ROLE_ARN", - "AWS_ROLE_SESSION_NAME", -]; - -/// The `AwsCredentialsProvider` wraps around a `ProvideAwsCredentials` -/// trait object, and reimplements the `ProvideAwsCredentials` trait. -/// -/// The underlying implementation can be either a `rusoto_sts::WebIdentityProvider` -/// or a `rusoto_core::credential::DefaultCredentialsProvider`. It prioritizes -/// instantiating a `WebIdentityProvider` if the correct environment variables -/// are set. -pub struct AwsCredentialsProvider(Box); - -#[async_trait] -impl ProvideAwsCredentials for AwsCredentialsProvider { - async fn credentials(&self) -> Result { - self.0.credentials().await - } -} - -impl AwsCredentialsProvider { - pub fn new() -> Result { - for env_var in WEB_IDENTITY_ENV_VARS { - if env::var(env_var).is_err() { - tracing::trace!("ENV VAR {} is not set", env_var); - tracing::trace!("instantiating default provider"); - return DefaultCredentialsProvider::new() - .map(Box::new) - .map(|inner| Self(inner)); - } - } - tracing::trace!("instantiating web identity provider"); - Ok(Self(Box::new(WebIdentityProvider::from_k8s_env()))) - } -} - -#[cfg(test)] -pub mod tests { - use serial_test::serial; - use std::env; - use tracing_test::traced_test; - - use crate::signer::aws_credentials::{ - AwsCredentialsProvider, WEB_IDENTITY_ENV_VARS, - }; - - // -------------------------------------------------------------------------------------------- - // new - // These and any other tests that use credential vars are #[serial] - // because there might be ENV VAR concurrency issues if they run - // in parallel. - // -------------------------------------------------------------------------------------------- - - #[test] - #[serial] - #[traced_test] - fn new_default_provider_when_one_web_identity_var_is_missing() { - for i in 0..3 { - clean_web_identity_vars(); - set_web_identity_vars(); - remove_web_identity_var(i); - let result = AwsCredentialsProvider::new(); - assert!(result.is_ok()); - assert!(!logs_contain("instantiating web identity provider")); - assert!(logs_contain("instantiating default provider")); - } - } - - #[test] - #[serial] - #[traced_test] - fn new_default_provider_when_two_web_identity_vars_are_missing() { - for i in 0..3 { - clean_web_identity_vars(); - set_web_identity_var(i); - let result = AwsCredentialsProvider::new(); - assert!(result.is_ok()); - assert!(!logs_contain("instantiating web identity provider")); - assert!(logs_contain("instantiating default provider")); - } - } - - #[test] - #[serial] - #[traced_test] - fn new_default_provider_when_all_web_identity_vars_are_missing() { - clean_web_identity_vars(); - let result = AwsCredentialsProvider::new(); - assert!(result.is_ok()); - assert!(!logs_contain("instantiating web identity provider")); - assert!(logs_contain("instantiating default provider")); - } - - #[test] - #[serial] - #[traced_test] - fn new_web_identity_provider_when_no_web_identity_vars_are_missing() { - clean_web_identity_vars(); - set_web_identity_vars(); - let result = AwsCredentialsProvider::new(); - assert!(result.is_ok()); - assert!(!logs_contain("instantiating default provider")); - assert!(logs_contain("instantiating web identity provider")); - } - - // -------------------------------------------------------------------------------------------- - // env vars - // Used by other tests. - // -------------------------------------------------------------------------------------------- - - fn set_web_identity_var(i: usize) { - env::set_var(WEB_IDENTITY_ENV_VARS[i], "irrelevant"); - } - - fn set_web_identity_vars() { - for env_var in WEB_IDENTITY_ENV_VARS { - env::set_var(env_var, "irrelevant"); - } - } - - fn clean_web_identity_vars() { - for env_var in WEB_IDENTITY_ENV_VARS { - env::remove_var(env_var); - } - } - - fn remove_web_identity_var(i: usize) { - env::remove_var(WEB_IDENTITY_ENV_VARS[i]); - } -} diff --git a/offchain/authority-claimer/src/signer/aws_signer.rs b/offchain/authority-claimer/src/signer/aws_signer.rs deleted file mode 100644 index 29476ee45..000000000 --- a/offchain/authority-claimer/src/signer/aws_signer.rs +++ /dev/null @@ -1,132 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use ethers::{ - signers::{AwsSigner as InnerAwsSigner, AwsSignerError, Signer}, - types::{ - transaction::{eip2718::TypedTransaction, eip712::Eip712}, - Address, Signature, - }, -}; -use rusoto_core::{HttpClient, Region}; -use rusoto_kms::KmsClient; - -use super::aws_credentials::AwsCredentialsProvider; - -/// The `AwsSigner` (re)implements the `Signer` trait for the `InnerAwsSigner`. -/// -/// We do not use an `InnerAwsSigner` directly because of lifetime and -/// borrow restrictions imposed by the underlying libraries. -/// -/// Instead, we instantiate a new `InnerAwsSigner` every time we call -/// a function from `Signer`. -#[derive(Debug, Clone)] -pub struct AwsSigner { - region: Region, - key_id: String, - chain_id: u64, - address: Address, -} - -/// Creates a `KmsClient` instance. -fn create_kms(region: &Region) -> KmsClient { - let request_dispatcher = HttpClient::new().expect("http client TLS error"); - let region = region.clone(); - let credentials_provider = AwsCredentialsProvider::new() - .expect("could not instantiate AWS credentials provider"); - KmsClient::new_with(request_dispatcher, credentials_provider, region) -} - -impl AwsSigner { - pub async fn new( - key_id: String, - chain_id: u64, - region: Region, - ) -> Result { - let kms = create_kms(®ion); - let aws_signer = - InnerAwsSigner::new(&kms, key_id.clone(), chain_id).await?; - Ok(Self { - region, - key_id, - chain_id, - address: aws_signer.address(), - }) - } -} - -/// Calls the async `$method` from an `InnerAwsSigner` instance. -/// Reinstantiates the `InnerAwsSigner`. -macro_rules! inner_aws_signer_call { - ($aws_signer: expr, - $method: ident, - $argument: expr) => { - InnerAwsSigner::new( - &create_kms(&$aws_signer.region), - &$aws_signer.key_id.clone(), - $aws_signer.chain_id, - ) - .await? - .$method($argument) - .await - }; -} - -#[async_trait] -impl Signer for AwsSigner { - type Error = AwsSignerError; - - async fn sign_message>( - &self, - message: S, - ) -> Result { - inner_aws_signer_call!(self, sign_message, message) - } - - async fn sign_transaction( - &self, - message: &TypedTransaction, - ) -> Result { - inner_aws_signer_call!(self, sign_transaction, message) - } - - async fn sign_typed_data( - &self, - payload: &T, - ) -> Result { - inner_aws_signer_call!(self, sign_typed_data, payload) - } - - fn address(&self) -> Address { - self.address - } - - fn chain_id(&self) -> u64 { - self.chain_id - } - - fn with_chain_id>(self, chain_id: T) -> Self { - Self { - key_id: self.key_id.clone(), - chain_id: chain_id.into(), - region: self.region.clone(), - address: self.address, - } - } -} - -#[cfg(test)] -pub mod tests { - use rusoto_core::Region; - - use crate::signer::aws_signer::AwsSigner; - - #[tokio::test] - async fn new_aws_signer_with_error() { - let invalid_key_id = "invalid".to_string(); - let aws_signer = - AwsSigner::new(invalid_key_id, 0, Region::UsEast1).await; - assert!(aws_signer.is_err()); - } -} diff --git a/offchain/authority-claimer/src/signer/mod.rs b/offchain/authority-claimer/src/signer/mod.rs deleted file mode 100644 index 37d4c9885..000000000 --- a/offchain/authority-claimer/src/signer/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod aws_credentials; -mod aws_signer; -mod signer; - -pub use signer::{ConditionalSigner, ConditionalSignerError}; diff --git a/offchain/authority-claimer/src/signer/signer.rs b/offchain/authority-claimer/src/signer/signer.rs deleted file mode 100644 index a38333c93..000000000 --- a/offchain/authority-claimer/src/signer/signer.rs +++ /dev/null @@ -1,261 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use ethers::{ - signers::{ - coins_bip39::English, AwsSignerError, LocalWallet, MnemonicBuilder, - Signer, WalletError, - }, - types::{ - transaction::{eip2718::TypedTransaction, eip712::Eip712}, - Address, Signature, - }, -}; -use snafu::{ResultExt, Snafu}; - -use crate::{config::TxSigningConfig, signer::aws_signer::AwsSigner}; - -/// The `ConditionalSigner` is implementing conditional dispatch (instead of -/// dynamic dispatch) by hand for objects that implement the `Sender` trait. -/// -/// We had to do this because (1) we cannot create a `Box` and -/// (2) using parametric types would move this complexity to the main loop, -/// which is undesirable. -#[derive(Debug, Clone)] -pub enum ConditionalSigner { - LocalWallet(LocalWallet), - AwsSigner(AwsSigner), -} - -#[derive(Debug, Snafu)] -pub enum ConditionalSignerError { - #[snafu(display("Local wallet signer error"))] - LocalWallet { source: WalletError }, - - #[snafu(display("AWS KMS signer error"))] - AwsSigner { source: AwsSignerError }, -} - -impl ConditionalSigner { - pub async fn new( - chain_id: u64, - tx_signing_config: &TxSigningConfig, - ) -> Result { - match tx_signing_config.clone() { - TxSigningConfig::PrivateKey { private_key } => { - let wallet = private_key - .inner() - .as_str() - .parse::() - .context(LocalWalletSnafu)? - .with_chain_id(chain_id); - Ok(ConditionalSigner::LocalWallet(wallet)) - } - TxSigningConfig::Mnemonic { - mnemonic, - account_index, - } => { - const DEFAULT_ACCOUNT_INDEX: u32 = 0; - let index = account_index.unwrap_or(DEFAULT_ACCOUNT_INDEX); - let wallet = MnemonicBuilder::::default() - .phrase(mnemonic.inner().as_str()) - .index(index) - .context(LocalWalletSnafu)? - .build() - .context(LocalWalletSnafu)? - .with_chain_id(chain_id); - Ok(ConditionalSigner::LocalWallet(wallet)) - } - TxSigningConfig::Aws { key_id, region } => { - AwsSigner::new(key_id, chain_id, region) - .await - .map(ConditionalSigner::AwsSigner) - .context(AwsSignerSnafu) - } - } - } -} - -#[async_trait] -impl Signer for ConditionalSigner { - type Error = ConditionalSignerError; - - async fn sign_message>( - &self, - message: S, - ) -> Result { - match &self { - Self::LocalWallet(local_wallet) => local_wallet - .sign_message(message) - .await - .context(LocalWalletSnafu), - Self::AwsSigner(aws_signer) => aws_signer - .sign_message(message) - .await - .context(AwsSignerSnafu), - } - } - - async fn sign_transaction( - &self, - message: &TypedTransaction, - ) -> Result { - match &self { - Self::LocalWallet(local_wallet) => local_wallet - .sign_transaction(message) - .await - .context(LocalWalletSnafu), - Self::AwsSigner(aws_signer) => aws_signer - .sign_transaction(message) - .await - .context(AwsSignerSnafu), - } - } - - async fn sign_typed_data( - &self, - payload: &T, - ) -> Result { - match &self { - Self::LocalWallet(local_wallet) => local_wallet - .sign_typed_data(payload) - .await - .context(LocalWalletSnafu), - Self::AwsSigner(aws_signer) => aws_signer - .sign_typed_data(payload) - .await - .context(AwsSignerSnafu), - } - } - - fn address(&self) -> Address { - match &self { - Self::LocalWallet(local_wallet) => local_wallet.address(), - Self::AwsSigner(aws_signer) => aws_signer.address(), - } - } - - fn chain_id(&self) -> u64 { - match &self { - Self::LocalWallet(local_wallet) => local_wallet.chain_id(), - Self::AwsSigner(aws_signer) => aws_signer.chain_id(), - } - } - - fn with_chain_id>(self, chain_id: T) -> Self { - match &self { - Self::LocalWallet(local_wallet) => { - Self::LocalWallet(local_wallet.clone().with_chain_id(chain_id)) - } - Self::AwsSigner(aws_signer) => { - Self::AwsSigner(aws_signer.clone().with_chain_id(chain_id)) - } - } - } -} - -#[cfg(test)] -mod tests { - use ethers::types::{ - transaction::{eip2718::TypedTransaction, eip2930::AccessList}, - Address, Eip1559TransactionRequest, - }; - use ethers_signers::Signer; - use redacted::Redacted; - - use crate::{config::TxSigningConfig, signer::ConditionalSigner}; - - // -------------------------------------------------------------------------------------------- - // new - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn new_local_wallet_mnemonic_conditional_signer() { - let conditional_signer = - local_wallet_mnemonic_conditional_signer().await; - assert!(matches!( - conditional_signer, - ConditionalSigner::LocalWallet(_) - )); - } - - #[tokio::test] - async fn new_local_wallet_private_key_conditional_signer() { - let conditional_signer = - local_wallet_private_key_conditional_signer().await; - assert!(matches!( - conditional_signer, - ConditionalSigner::LocalWallet(_) - )); - } - - // -------------------------------------------------------------------------------------------- - // sign_transaction - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn sign_transaction_with_mnemonic_local_wallet_conditional_signer() { - let conditional_signer = - local_wallet_mnemonic_conditional_signer().await; - let message = eip1559_message(); - let result = conditional_signer.sign_transaction(&message).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn sign_transaction_with_private_key_local_wallet_conditional_signer() - { - let conditional_signer = - local_wallet_private_key_conditional_signer().await; - let message = eip1559_message(); - let result = conditional_signer.sign_transaction(&message).await; - assert!(result.is_ok()); - } - - // -------------------------------------------------------------------------------------------- - // auxiliary - // -------------------------------------------------------------------------------------------- - - const CHAIN_ID: u64 = 1; - const PRIVATE_KEY: &str = - "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; - const MNEMONIC: &str = - "indoor dish desk flag debris potato excuse depart ticket judge file exit"; - - async fn local_wallet_mnemonic_conditional_signer() -> ConditionalSigner { - let tx_signing_config = TxSigningConfig::Mnemonic { - mnemonic: Redacted::new(MNEMONIC.to_string()), - account_index: Some(1), - }; - ConditionalSigner::new(CHAIN_ID, &tx_signing_config) - .await - .unwrap() - } - - async fn local_wallet_private_key_conditional_signer() -> ConditionalSigner - { - let tx_signing_config = TxSigningConfig::PrivateKey { - private_key: Redacted::new(PRIVATE_KEY.to_string()), - }; - ConditionalSigner::new(CHAIN_ID, &tx_signing_config) - .await - .unwrap() - } - - fn eip1559_message() -> TypedTransaction { - TypedTransaction::Eip1559( - Eip1559TransactionRequest::new() - .from(Address::default()) - .to(Address::default()) - .gas(555) - .value(1337) - .data(vec![1, 2, 3]) - .nonce(1) - .access_list(AccessList::default()) - .max_priority_fee_per_gas(10) - .max_fee_per_gas(20) - .chain_id(CHAIN_ID), - ) - } -} diff --git a/offchain/clippy.toml b/offchain/clippy.toml deleted file mode 100644 index f19238120..000000000 --- a/offchain/clippy.toml +++ /dev/null @@ -1,2 +0,0 @@ -# This avoids warning `large_enum_variant` on `pub enum MachineOneof`, built from grpc-interfaces. -enum-variant-size-threshold = 2500 diff --git a/offchain/contracts/Cargo.toml b/offchain/contracts/Cargo.toml deleted file mode 100644 index c5a76781a..000000000 --- a/offchain/contracts/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "contracts" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -eth-state-fold-types = { workspace = true, features = ["ethers"] } - -[build-dependencies] -eth-state-fold-types = { workspace = true, features = ["ethers"] } -tempfile.workspace = true -snafu.workspace = true - -[package.metadata.cargo-machete] -ignored = ["eth-state-fold-types"] diff --git a/offchain/contracts/README.md b/offchain/contracts/README.md deleted file mode 100644 index 06132e915..000000000 --- a/offchain/contracts/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contracts - -Library crate for loading a contract ABI. diff --git a/offchain/contracts/build.rs b/offchain/contracts/build.rs deleted file mode 100644 index 91140acc9..000000000 --- a/offchain/contracts/build.rs +++ /dev/null @@ -1,85 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::prelude::*; -use std::error::Error; -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::str; - -use eth_state_fold_types::contract; - -const ROLLUPS_CONTRACTS_URL: &str = - "https://registry.npmjs.org/@cartesi/rollups/-/rollups-1.2.0.tgz"; - -fn main() -> Result<(), Box> { - let tempdir = tempfile::tempdir()?; - let tarball = tempdir.path().join("rollups.tgz"); - download_contracts(&tarball)?; - unzip_contracts(&tarball, tempdir.path())?; - - let contracts = vec![ - ("inputs", "InputBox", "input_box.rs"), - ("consensus/authority", "Authority", "authority.rs"), - ("history", "History", "history.rs"), - ("dapp", "CartesiDApp", "cartesi_dapp.rs"), - ]; - for (contract_path, contract_name, bindings_file_name) in contracts { - let source_path = path(tempdir.path(), contract_path, contract_name); - let output_path: PathBuf = - [&std::env::var("OUT_DIR").unwrap(), bindings_file_name] - .iter() - .collect(); - let source = File::open(&source_path)?; - let output = File::create(&output_path)?; - contract::write(contract_name, source, output)?; - } - - println!("cargo:rerun-if-changed=build.rs"); - Ok(()) -} - -fn run_cmd(cmd: &str, args: &[&str]) -> Result<(), snafu::Whatever> { - let output = Command::new(cmd) - .args(args) - .output() - .whatever_context("failed to execute command")?; - if !output.status.success() { - let err = str::from_utf8(&output.stderr) - .whatever_context("failed to convert string")?; - whatever!("{} exited with error: {}", cmd, err); - } - Ok(()) -} - -fn download_contracts(output: &Path) -> Result<(), snafu::Whatever> { - run_cmd( - "curl", - &[ - ROLLUPS_CONTRACTS_URL, - "-o", - output.to_str().expect("failed to convert path"), - ], - ) -} - -fn unzip_contracts(file: &Path, target: &Path) -> Result<(), snafu::Whatever> { - run_cmd( - "tar", - &[ - "zxf", - file.to_str().expect("failed to convert path"), - "-C", - target.to_str().expect("failed to convert path"), - ], - ) -} - -fn path(basedir: &Path, contract_path: &str, contract_name: &str) -> PathBuf { - basedir - .join("package/export/artifacts/contracts") - .join(contract_path) - .join(format!("{}.sol", contract_name)) - .join(format!("{}.json", contract_name)) -} diff --git a/offchain/contracts/src/lib.rs b/offchain/contracts/src/lib.rs deleted file mode 100644 index b81fbdd3a..000000000 --- a/offchain/contracts/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -/// Declares `$contract_name` as a module and includes everything from the `$contract_name` ABI. -macro_rules! contract { - ($contract_name: ident) => { - pub mod $contract_name { - include!(concat!( - env!("OUT_DIR"), - "/", - stringify!($contract_name), - ".rs" - )); - } - }; -} - -contract!(input_box); -contract!(authority); -contract!(history); -contract!(cartesi_dapp); diff --git a/offchain/data/Cargo.toml b/offchain/data/Cargo.toml deleted file mode 100644 index a8b0d86b6..000000000 --- a/offchain/data/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "rollups-data" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -redacted = { path = "../redacted" } - -backoff.workspace = true -base64.workspace = true -clap = { workspace = true, features = ["derive", "env"] } -diesel_migrations.workspace = true -diesel = { workspace = true, features = ["postgres", "r2d2"]} -snafu.workspace = true -tracing.workspace = true - -[dev-dependencies] -test-fixtures = { path = "../test-fixtures" } - -serial_test.workspace = true -env_logger.workspace = true -tempfile.workspace = true -testcontainers.workspace = true -test-log = { workspace = true, features = ["trace"] } -tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/offchain/data/README.md b/offchain/data/README.md deleted file mode 100644 index 2d8b5658a..000000000 --- a/offchain/data/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Rollups data - -This crate generates the PostgreSQL database schema used to store the rollups data: -all of its inputs, notices, vouchers, reports and proofs. - -## Running PostgreSQL locally - -If you want to run the PostgreSQL in your machine, you can run the following docker command: - -```sh -docker run --rm --name test-postgres -e POSTGRES_PASSWORD=pw -p 5432:5432 -d postgres:13 -``` - -## Setup diesel - -The database migration requires [diesel](https://diesel.rs/). -Before installing diesel, you need to install the PostgreSQL development library. -In Ubuntu, run the following command: - -```sh -sudo apt install libpq-dev -``` - -Or if you are using MacOS: - -```sh -brew install libpq && -echo 'export PATH="/opt/homebrew/opt/libpq/bin:$PATH"' >> ~/.zshrc -``` - -To install diesel run the following command: - -```sh -cargo install diesel_cli --no-default-features --features postgres -``` - -Then, setup the environment variable DATABASE\_URL referencing to the local PostgreSQL. - -```sh -echo "export DATABASE_URL=postgres://postgres:pw@localhost:5432/postgres" > .env -``` - -## Perform database migrations - -Follow the commands below to perform the migration. -This procedure creates the tables in PostgreSQL and also generates the file `src/schema.rs`. - -```sh -diesel migration run -``` - -## Modifying the database schema - -To modify the database schema, you should edit the files in the `migration` dir. -For more details, please follow the instructions on [the diesel site](https://diesel.rs). - -## Test - -To run the automated tests, run the following command: - -```sh -cargo test -``` - -### Manual tests - -If you want to fiddle with the database, you can populate it by running: - -```sh -psql -h localhost -U postgres -d postgres -a -f util/populate.sql -``` diff --git a/offchain/data/build.rs b/offchain/data/build.rs deleted file mode 100644 index 3146146d4..000000000 --- a/offchain/data/build.rs +++ /dev/null @@ -1,6 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -fn main() { - println!("cargo:rerun-if-changed=migrations"); -} diff --git a/offchain/data/diesel.toml b/offchain/data/diesel.toml deleted file mode 100644 index 92267c829..000000000 --- a/offchain/data/diesel.toml +++ /dev/null @@ -1,5 +0,0 @@ -# For documentation on how to configure this file, -# see diesel.rs/guides/configuring-diesel-cli - -[print_schema] -file = "src/schema.rs" diff --git a/offchain/data/migrations/00000000000000_diesel_initial_setup/down.sql b/offchain/data/migrations/00000000000000_diesel_initial_setup/down.sql deleted file mode 100644 index a9f526091..000000000 --- a/offchain/data/migrations/00000000000000_diesel_initial_setup/down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. - -DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); -DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/offchain/data/migrations/00000000000000_diesel_initial_setup/up.sql b/offchain/data/migrations/00000000000000_diesel_initial_setup/up.sql deleted file mode 100644 index d68895b1a..000000000 --- a/offchain/data/migrations/00000000000000_diesel_initial_setup/up.sql +++ /dev/null @@ -1,36 +0,0 @@ --- This file was automatically created by Diesel to setup helper functions --- and other internal bookkeeping. This file is safe to edit, any future --- changes will be added to existing projects as new migrations. - - - - --- Sets up a trigger for the given table to automatically set a column called --- `updated_at` whenever the row is modified (unless `updated_at` was included --- in the modified columns) --- --- # Example --- --- ```sql --- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); --- --- SELECT diesel_manage_updated_at('users'); --- ``` -CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ -BEGIN - EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s - FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ -BEGIN - IF ( - NEW IS DISTINCT FROM OLD AND - NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at - ) THEN - NEW.updated_at := current_timestamp; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; diff --git a/offchain/data/migrations/20230110182039_rollups/down.sql b/offchain/data/migrations/20230110182039_rollups/down.sql deleted file mode 100644 index b17076778..000000000 --- a/offchain/data/migrations/20230110182039_rollups/down.sql +++ /dev/null @@ -1,10 +0,0 @@ --- (c) Cartesi and individual authors (see AUTHORS) --- SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -DROP TABLE "vouchers"; -DROP TABLE "notices"; -DROP TABLE "reports"; -DROP TABLE "proofs"; -DROP TABLE "inputs"; - -DROP TYPE "OutputEnum"; diff --git a/offchain/data/migrations/20230110182039_rollups/up.sql b/offchain/data/migrations/20230110182039_rollups/up.sql deleted file mode 100644 index 200542cae..000000000 --- a/offchain/data/migrations/20230110182039_rollups/up.sql +++ /dev/null @@ -1,60 +0,0 @@ --- (c) Cartesi and individual authors (see AUTHORS) --- SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -CREATE TABLE "inputs" -( - "index" INT NOT NULL, - "msg_sender" BYTEA NOT NULL, - "tx_hash" BYTEA NOT NULL, - "block_number" BIGINT NOT NULL, - "timestamp" TIMESTAMP NOT NULL, - "payload" BYTEA NOT NULL, - CONSTRAINT "inputs_pkey" PRIMARY KEY ("index") -); - -CREATE TABLE "vouchers" -( - "input_index" INT NOT NULL, - "index" INT NOT NULL, - "destination" BYTEA NOT NULL, - "payload" BYTEA NOT NULL, - CONSTRAINT "vouchers_pkey" PRIMARY KEY ("input_index", "index"), - CONSTRAINT "vouchers_input_index_fkey" FOREIGN KEY ("input_index") REFERENCES "inputs"("index") -); - -CREATE TABLE "notices" -( - "input_index" INT NOT NULL, - "index" INT NOT NULL, - "payload" BYTEA NOT NULL, - CONSTRAINT "notices_pkey" PRIMARY KEY ("input_index", "index"), - CONSTRAINT "notices_input_index_fkey" FOREIGN KEY ("input_index") REFERENCES "inputs"("index") -); - -CREATE TABLE "reports" -( - "input_index" INT NOT NULL, - "index" INT NOT NULL, - "payload" BYTEA NOT NULL, - CONSTRAINT "reports_pkey" PRIMARY KEY ("input_index", "index"), - CONSTRAINT "reports_input_index_fkey" FOREIGN KEY ("input_index") REFERENCES "inputs"("index") -); - -CREATE TYPE "OutputEnum" AS ENUM ('voucher', 'notice'); - -CREATE TABLE "proofs" -( - "input_index" INT NOT NULL, - "output_index" INT NOT NULL, - "output_enum" "OutputEnum" NOT NULL, - "validity_input_index_within_epoch" INT NOT NULL, - "validity_output_index_within_input" INT NOT NULL, - "validity_output_hashes_root_hash" BYTEA NOT NULL, - "validity_vouchers_epoch_root_hash" BYTEA NOT NULL, - "validity_notices_epoch_root_hash" BYTEA NOT NULL, - "validity_machine_state_hash" BYTEA NOT NULL, - "validity_output_hash_in_output_hashes_siblings" BYTEA[] NOT NULL, - "validity_output_hashes_in_epoch_siblings" BYTEA[] NOT NULL, - "context" BYTEA NOT NULL, - CONSTRAINT "proofs_pkey" PRIMARY KEY ("input_index", "output_index", "output_enum") -); diff --git a/offchain/data/migrations/20230921143147_completion_status/down.sql b/offchain/data/migrations/20230921143147_completion_status/down.sql deleted file mode 100644 index d392633ef..000000000 --- a/offchain/data/migrations/20230921143147_completion_status/down.sql +++ /dev/null @@ -1,5 +0,0 @@ --- This file should undo anything in `up.sql` - -ALTER TABLE "inputs" DROP "status"; - -DROP TYPE "CompletionStatus"; diff --git a/offchain/data/migrations/20230921143147_completion_status/up.sql b/offchain/data/migrations/20230921143147_completion_status/up.sql deleted file mode 100644 index c4d02f0f6..000000000 --- a/offchain/data/migrations/20230921143147_completion_status/up.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Your SQL goes here - -CREATE TYPE "CompletionStatus" AS ENUM ( - 'Unprocessed', - 'Accepted', - 'Rejected', - 'Exception', - 'MachineHalted', - 'CycleLimitExceeded', - 'TimeLimitExceeded', - 'PayloadLengthLimitExceeded' -); - -ALTER TABLE "inputs" ADD "status" "CompletionStatus" NOT NULL DEFAULT 'Unprocessed'; diff --git a/offchain/data/src/config.rs b/offchain/data/src/config.rs deleted file mode 100644 index e93c514d5..000000000 --- a/offchain/data/src/config.rs +++ /dev/null @@ -1,76 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::{ExponentialBackoff, ExponentialBackoffBuilder}; -use clap::Parser; -pub use redacted::{RedactedUrl, Url}; -use std::time::Duration; - -#[derive(Debug)] -pub struct RepositoryConfig { - pub redacted_endpoint: Option, - pub connection_pool_size: u32, - pub backoff: ExponentialBackoff, -} - -impl RepositoryConfig { - /// Get the string with the endpoint if it is set, otherwise return an empty string - pub fn endpoint(&self) -> String { - match &self.redacted_endpoint { - None => String::from(""), - Some(endpoint) => endpoint.inner().to_string(), - } - } -} - -#[derive(Debug, Parser)] -pub struct RepositoryCLIConfig { - /// Postgres endpoint in the format 'postgres://user:password@hostname:port/database'. - /// - /// If not set, or set to empty string, will defer the behaviour to the Pg driver. - /// See: https://www.postgresql.org/docs/current/libpq-envars.html - /// - /// It is also possible to set the endpoint without a password and load it from Postgres' - /// passfile. - /// See: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-PASSFILE - #[arg(long, env)] - postgres_endpoint: Option, - - /// Number of connections to the database - #[arg(long, env, default_value_t = 3)] - postgres_connection_pool_size: u32, - - /// Max elapsed time for timeout - #[arg(long, env, default_value = "120000")] - postgres_backoff_max_elapsed_duration: u64, -} - -impl From for RepositoryConfig { - fn from(cli_config: RepositoryCLIConfig) -> RepositoryConfig { - let redacted_endpoint = match cli_config.postgres_endpoint { - None => None, - Some(endpoint) => { - if endpoint.is_empty() { - None - } else { - Some(RedactedUrl::new( - Url::parse(endpoint.as_str()) - .expect("failed to parse Postgres URL"), - )) - } - } - }; - let connection_pool_size = cli_config.postgres_connection_pool_size; - let backoff_max_elapsed_duration = Duration::from_millis( - cli_config.postgres_backoff_max_elapsed_duration, - ); - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(backoff_max_elapsed_duration)) - .build(); - RepositoryConfig { - redacted_endpoint, - connection_pool_size, - backoff, - } - } -} diff --git a/offchain/data/src/error.rs b/offchain/data/src/error.rs deleted file mode 100644 index ca009b458..000000000 --- a/offchain/data/src/error.rs +++ /dev/null @@ -1,39 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::Snafu; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(super)))] -pub enum Error { - #[snafu(display("database pool connection error"))] - DatabaseConnectionError { - source: backoff::Error, - }, - - #[snafu(display("database error"))] - DatabaseError { source: diesel::result::Error }, - - #[snafu(display("{} not found", item_type))] - ItemNotFound { item_type: String }, - - #[snafu(display("failed to decode UTF8 cursor"))] - DecodeUTF8CursorError { source: std::str::Utf8Error }, - - #[snafu(display("failed to decode base64 cursor"))] - DecodeBase64CursorError { source: base64::DecodeError }, - - #[snafu(display("failed to parse cursor"))] - ParseCursorError { source: std::num::ParseIntError }, - - #[snafu(display( - "cannot mix forward pagination (first, after) with backward pagination (last, before)" - ))] - MixedPaginationError {}, - - #[snafu(display("invalid pagination cursor {}", arg))] - PaginationCursorError { arg: String }, - - #[snafu(display("invalid pagination limit {}", arg))] - PaginationLimitError { arg: String }, -} diff --git a/offchain/data/src/lib.rs b/offchain/data/src/lib.rs deleted file mode 100644 index 548414c1f..000000000 --- a/offchain/data/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod config; -mod error; -mod migrations; -mod pagination; -mod repository; -mod schema; -mod types; - -pub use config::{RedactedUrl, RepositoryCLIConfig, RepositoryConfig, Url}; -pub use error::Error; -pub use migrations::{run_migrations, MigrationError}; -pub use pagination::{Connection, Cursor, Edge, PageInfo}; -pub use repository::Repository; -pub use types::{ - CompletionStatus, Input, InputQueryFilter, Notice, NoticeQueryFilter, - OutputEnum, Proof, Report, ReportQueryFilter, Voucher, VoucherQueryFilter, -}; diff --git a/offchain/data/src/migrations.rs b/offchain/data/src/migrations.rs deleted file mode 100644 index 36f12de21..000000000 --- a/offchain/data/src/migrations.rs +++ /dev/null @@ -1,35 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use diesel::{pg::PgConnection, Connection}; -use diesel_migrations::{ - embed_migrations, EmbeddedMigrations, MigrationHarness, -}; -use snafu::{ResultExt, Snafu}; -const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); - -#[derive(Debug, Snafu)] -pub enum MigrationError { - #[snafu(display("connection error"))] - ConnectionError { source: diesel::ConnectionError }, - - #[snafu(display("migration error"))] - RunMigrationError { - source: Box, - }, -} - -pub fn run_migrations(postgres_endpoint: &str) -> Result<(), MigrationError> { - tracing::trace!("running pending migrations"); - - let mut connection = - PgConnection::establish(postgres_endpoint).context(ConnectionSnafu)?; - let migrations = connection - .run_pending_migrations(MIGRATIONS) - .context(RunMigrationSnafu)?; - for migration in migrations.iter() { - tracing::trace!("runned migration {}", migration); - } - - Ok(()) -} diff --git a/offchain/data/src/pagination.rs b/offchain/data/src/pagination.rs deleted file mode 100644 index 444cb2ced..000000000 --- a/offchain/data/src/pagination.rs +++ /dev/null @@ -1,628 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use base64::{engine::general_purpose::STANDARD as base64_engine, Engine as _}; -use snafu::ResultExt; -use std::fmt::Debug; - -use super::error::{ - DecodeBase64CursorSnafu, DecodeUTF8CursorSnafu, Error, - MixedPaginationSnafu, PaginationCursorSnafu, PaginationLimitSnafu, - ParseCursorSnafu, -}; - -const DEFAULT_PAGINATION_LIMIT: i32 = 1000; - -macro_rules! ensure_cursor { - ($arg: ident, $total_count: expr) => {{ - let cursor = Cursor::decode(&$arg)?; - snafu::ensure!( - cursor.offset >= 0 && cursor.offset < $total_count, - PaginationCursorSnafu { - arg: stringify!($arg), - } - ); - cursor.offset - }}; -} - -macro_rules! ensure_limit { - ($arg: ident) => { - match $arg { - Some(limit) => { - snafu::ensure!( - limit >= 0, - PaginationLimitSnafu { - arg: stringify!($arg), - } - ); - std::cmp::min(limit, DEFAULT_PAGINATION_LIMIT) - } - None => DEFAULT_PAGINATION_LIMIT, - } - }; -} - -#[derive(Debug, PartialEq)] -pub struct Pagination { - total_count: i32, - offset: i32, - limit: i32, -} - -impl Pagination { - pub fn new( - first: Option, - last: Option, - after: Option, - before: Option, - total_count: i32, - ) -> Result { - let forward = first.is_some() || after.is_some(); - let backward = last.is_some() || before.is_some(); - snafu::ensure!(!forward || !backward, MixedPaginationSnafu); - if backward { - let before_offset = match before { - Some(before) => ensure_cursor!(before, total_count), - None => total_count, - }; - let limit = ensure_limit!(last); - if limit >= before_offset { - Ok(Self { - total_count, - offset: 0, - limit: before_offset, - }) - } else { - Ok(Self { - total_count, - offset: before_offset - limit, - limit, - }) - } - } else { - let offset = match after { - Some(after) => ensure_cursor!(after, total_count) + 1, - None => 0, - }; - let limit = ensure_limit!(first); - if offset + limit > total_count { - Ok(Self { - total_count, - offset, - limit: total_count - offset, - }) - } else { - Ok(Self { - total_count, - offset, - limit, - }) - } - } - } - - pub fn offset(&self) -> i32 { - self.offset - } - - pub fn limit(&self) -> i32 { - self.limit - } - - pub fn create_connection(&self, nodes: Vec) -> Connection { - let mut edges = vec![]; - for (i, node) in nodes.into_iter().enumerate() { - let cursor = Cursor { - offset: self.offset + i as i32, - }; - edges.push(Edge { node, cursor }); - } - let (start_cursor, has_previous_page) = - if let Some(edge) = edges.first() { - (Some(edge.cursor), edge.cursor.offset > 0) - } else { - (None, false) - }; - let (end_cursor, has_next_page) = if let Some(edge) = edges.last() { - (Some(edge.cursor), edge.cursor.offset < self.total_count - 1) - } else { - (None, false) - }; - let page_info = PageInfo { - start_cursor, - end_cursor, - has_next_page, - has_previous_page, - }; - Connection { - total_count: self.total_count, - edges, - page_info, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Cursor { - offset: i32, -} - -impl Cursor { - /// Encode cursor as base64 - pub fn encode(&self) -> String { - base64_engine.encode(self.offset.to_string()) - } - - /// Decode cursor from base64 String - pub fn decode(value: &str) -> Result { - let bytes = base64_engine - .decode(value) - .context(DecodeBase64CursorSnafu)?; - let offset = std::str::from_utf8(&bytes) - .context(DecodeUTF8CursorSnafu)? - .parse::() - .context(ParseCursorSnafu)?; - Ok(Cursor { offset }) - } -} - -#[derive(Debug, PartialEq)] -pub struct Connection { - pub total_count: i32, - pub edges: Vec>, - pub page_info: PageInfo, -} - -#[derive(Debug, PartialEq)] -pub struct Edge { - pub node: N, - pub cursor: Cursor, -} - -#[derive(Debug, PartialEq)] -pub struct PageInfo { - pub start_cursor: Option, - pub end_cursor: Option, - pub has_next_page: bool, - pub has_previous_page: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_encodes_cursor() { - assert_eq!(Cursor { offset: 0 }.encode(), "MA=="); - assert_eq!(Cursor { offset: 1 }.encode(), "MQ=="); - assert_eq!(Cursor { offset: 2 }.encode(), "Mg=="); - assert_eq!(Cursor { offset: 1000 }.encode(), "MTAwMA=="); - assert_eq!(Cursor { offset: i32::MAX }.encode(), "MjE0NzQ4MzY0Nw=="); - } - - #[test] - fn it_decodes_cursor() { - assert_eq!(Cursor::decode("MA==").unwrap(), Cursor { offset: 0 }); - assert_eq!(Cursor::decode("MQ==").unwrap(), Cursor { offset: 1 }); - assert_eq!(Cursor::decode("Mg==").unwrap(), Cursor { offset: 2 }); - assert_eq!( - Cursor::decode("MTAwMA==").unwrap(), - Cursor { offset: 1000 } - ); - assert_eq!( - Cursor::decode("MjE0NzQ4MzY0Nw==").unwrap(), - Cursor { offset: i32::MAX } - ); - } - - #[test] - fn it_fails_to_decode_non_base64_cursor() { - assert!(matches!( - Cursor::decode("invalid").unwrap_err(), - Error::DecodeBase64CursorError { .. } - )) - } - - #[test] - fn it_fails_to_decode_invalid_string_cursor() { - assert!(matches!( - Cursor::decode("gA==").unwrap_err(), - Error::DecodeUTF8CursorError { .. } - )); - } - - #[test] - fn it_fails_to_decode_non_integer_cursor() { - assert!(matches!( - Cursor::decode("aW52YWxpZA==").unwrap_err(), - Error::ParseCursorError { .. } - )); - } - - #[test] - fn it_paginates_forward_by_default() { - assert_eq!( - Pagination::new(None, None, None, None, 1).unwrap(), - Pagination { - total_count: 1, - offset: 0, - limit: 1 - } - ); - assert_eq!( - Pagination::new(None, None, None, None, 10).unwrap(), - Pagination { - total_count: 10, - offset: 0, - limit: 10 - } - ); - assert_eq!( - Pagination::new( - None, - None, - None, - None, - DEFAULT_PAGINATION_LIMIT * 10 - ) - .unwrap(), - Pagination { - total_count: DEFAULT_PAGINATION_LIMIT * 10, - offset: 0, - limit: DEFAULT_PAGINATION_LIMIT - } - ); - } - - #[test] - fn it_paginates_with_zero_total_count() { - assert_eq!( - Pagination::new(Some(10), None, None, None, 0).unwrap(), - Pagination { - total_count: 0, - offset: 0, - limit: 0 - } - ); - assert_eq!( - Pagination::new(None, Some(10), None, None, 0).unwrap(), - Pagination { - total_count: 0, - offset: 0, - limit: 0 - } - ); - } - - #[test] - fn it_paginates_forward_with_bounded_limit() { - assert_eq!( - Pagination::new(Some(5), None, None, None, 10).unwrap(), - Pagination { - total_count: 10, - offset: 0, - limit: 5 - } - ); - let cursor = Cursor { offset: 2 }.encode(); - assert_eq!( - Pagination::new(Some(5), None, Some(cursor), None, 10).unwrap(), - Pagination { - total_count: 10, - offset: 3, - limit: 5 - } - ); - let cursor = Cursor { offset: 4 }.encode(); - assert_eq!( - Pagination::new(Some(5), None, Some(cursor), None, 10).unwrap(), - Pagination { - total_count: 10, - offset: 5, - limit: 5 - } - ); - } - - #[test] - fn it_paginates_forward_with_out_of_bounds_limit() { - let cursor = Cursor { offset: 7 }.encode(); - assert_eq!( - Pagination::new(Some(5), None, Some(cursor), None, 10).unwrap(), - Pagination { - total_count: 10, - offset: 8, - limit: 2 - } - ); - let cursor = Cursor { offset: 9 }.encode(); - assert_eq!( - Pagination::new(Some(5), None, Some(cursor), None, 10).unwrap(), - Pagination { - total_count: 10, - offset: 10, - limit: 0 - } - ); - } - - #[test] - fn it_paginates_backward_with_bounded_limit() { - assert_eq!( - Pagination::new(None, Some(5), None, None, 10).unwrap(), - Pagination { - total_count: 10, - offset: 5, - limit: 5 - } - ); - let cursor = Cursor { offset: 7 }.encode(); - assert_eq!( - Pagination::new(None, Some(5), None, Some(cursor), 10).unwrap(), - Pagination { - total_count: 10, - offset: 2, - limit: 5 - } - ); - let cursor = Cursor { offset: 5 }.encode(); - assert_eq!( - Pagination::new(None, Some(5), None, Some(cursor), 10).unwrap(), - Pagination { - total_count: 10, - offset: 0, - limit: 5 - } - ); - } - - #[test] - fn it_paginates_backward_with_out_of_bounds_limit() { - let cursor = Cursor { offset: 3 }.encode(); - assert_eq!( - Pagination::new(None, Some(5), None, Some(cursor), 10).unwrap(), - Pagination { - total_count: 10, - offset: 0, - limit: 3 - } - ); - let cursor = Cursor { offset: 0 }.encode(); - assert_eq!( - Pagination::new(None, Some(5), None, Some(cursor), 10).unwrap(), - Pagination { - total_count: 10, - offset: 0, - limit: 0 - } - ); - } - - #[test] - fn it_fails_to_paginate_when_mixing_backward_and_forward_args() { - let cursor = Cursor { offset: 0 }.encode(); - assert!(matches!( - Pagination::new(Some(1), Some(1), None, None, 1).unwrap_err(), - Error::MixedPaginationError {} - )); - assert!(matches!( - Pagination::new(None, Some(1), Some(cursor.clone()), None, 1) - .unwrap_err(), - Error::MixedPaginationError {} - )); - assert!(matches!( - Pagination::new(Some(1), None, None, Some(cursor.clone()), 1) - .unwrap_err(), - Error::MixedPaginationError {} - )); - assert!(matches!( - Pagination::new( - None, - None, - Some(cursor.clone()), - Some(cursor.clone()), - 1 - ) - .unwrap_err(), - Error::MixedPaginationError {} - )); - } - - #[test] - fn it_fails_to_paginate_when_limit_is_negative() { - assert!(matches!( - Pagination::new(Some(-1), None, None, None, 10).unwrap_err(), - Error::PaginationLimitError { arg } if arg == "first" - )); - assert!(matches!( - Pagination::new(None, Some(-1), None, None, 10).unwrap_err(), - Error::PaginationLimitError { arg } if arg == "last" - )); - } - - #[test] - fn it_fails_to_paginate_with_invalid_cursor() { - assert!(matches!( - Pagination::new(None, None, Some("invalid".to_owned()), None, 10) - .unwrap_err(), - Error::DecodeBase64CursorError { .. } - )); - assert!(matches!( - Pagination::new(None, None, None, Some("invalid".to_owned()), 10) - .unwrap_err(), - Error::DecodeBase64CursorError { .. } - )); - } - - #[test] - fn it_fails_to_paginate_with_cursor_out_of_range() { - let cursor = Cursor { offset: 10 }.encode(); - assert!(matches!( - Pagination::new(None, None, Some(cursor.clone()), None, 10) - .unwrap_err(), - Error::PaginationCursorError { arg } if arg == "after" - )); - assert!(matches!( - Pagination::new(None, None, None, Some(cursor.clone()), 10) - .unwrap_err(), - Error::PaginationCursorError { arg } if arg == "before" - )); - } - - #[test] - fn it_creates_connection_without_nodes() { - let pagination = Pagination { - total_count: 3, - offset: 0, - limit: 0, - }; - let connection = pagination.create_connection::(vec![]); - assert_eq!( - connection, - Connection { - total_count: 3, - edges: vec![], - page_info: PageInfo { - start_cursor: None, - end_cursor: None, - has_next_page: false, - has_previous_page: false, - } - } - ); - } - - #[test] - fn it_creates_connection_with_all_nodes() { - let pagination = Pagination { - total_count: 3, - offset: 0, - limit: 3, - }; - let connection = pagination.create_connection::(vec![ - "0".to_owned(), - "1".to_owned(), - "2".to_owned(), - ]); - assert_eq!( - connection, - Connection { - total_count: 3, - edges: vec![ - Edge { - node: "0".to_owned(), - cursor: Cursor { offset: 0 }, - }, - Edge { - node: "1".to_owned(), - cursor: Cursor { offset: 1 }, - }, - Edge { - node: "2".to_owned(), - cursor: Cursor { offset: 2 }, - }, - ], - page_info: PageInfo { - start_cursor: Some(Cursor { offset: 0 }), - end_cursor: Some(Cursor { offset: 2 }), - has_next_page: false, - has_previous_page: false, - } - } - ); - } - - #[test] - fn it_creates_connection_on_first_page() { - let pagination = Pagination { - total_count: 3, - offset: 0, - limit: 2, - }; - let connection = pagination - .create_connection::(vec!["0".to_owned(), "1".to_owned()]); - assert_eq!( - connection, - Connection { - total_count: 3, - edges: vec![ - Edge { - node: "0".to_owned(), - cursor: Cursor { offset: 0 }, - }, - Edge { - node: "1".to_owned(), - cursor: Cursor { offset: 1 }, - }, - ], - page_info: PageInfo { - start_cursor: Some(Cursor { offset: 0 }), - end_cursor: Some(Cursor { offset: 1 }), - has_next_page: true, - has_previous_page: false, - } - } - ); - } - - #[test] - fn it_creates_connection_on_last_page() { - let pagination = Pagination { - total_count: 3, - offset: 1, - limit: 2, - }; - let connection = pagination - .create_connection::(vec!["1".to_owned(), "2".to_owned()]); - assert_eq!( - connection, - Connection { - total_count: 3, - edges: vec![ - Edge { - node: "1".to_owned(), - cursor: Cursor { offset: 1 }, - }, - Edge { - node: "2".to_owned(), - cursor: Cursor { offset: 2 }, - }, - ], - page_info: PageInfo { - start_cursor: Some(Cursor { offset: 1 }), - end_cursor: Some(Cursor { offset: 2 }), - has_next_page: false, - has_previous_page: true, - } - } - ); - } - - #[test] - fn it_creates_connection_on_middle_page() { - let pagination = Pagination { - total_count: 3, - offset: 1, - limit: 1, - }; - let connection = - pagination.create_connection::(vec!["1".to_owned()]); - assert_eq!( - connection, - Connection { - total_count: 3, - edges: vec![Edge { - node: "1".to_owned(), - cursor: Cursor { offset: 1 }, - },], - page_info: PageInfo { - start_cursor: Some(Cursor { offset: 1 }), - end_cursor: Some(Cursor { offset: 1 }), - has_next_page: true, - has_previous_page: true, - } - } - ); - } -} diff --git a/offchain/data/src/repository.rs b/offchain/data/src/repository.rs deleted file mode 100644 index f65b67b58..000000000 --- a/offchain/data/src/repository.rs +++ /dev/null @@ -1,318 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::ExponentialBackoff; -use diesel::pg::{Pg, PgConnection}; -use diesel::r2d2::{ConnectionManager, Pool, PooledConnection}; -use diesel::{insert_into, prelude::*, update}; -use snafu::ResultExt; -use std::sync::Arc; - -use super::config::RepositoryConfig; -use super::error::{DatabaseConnectionSnafu, DatabaseSnafu, Error}; -use super::pagination::{Connection, Pagination}; -use super::schema; -use super::types::{ - CompletionStatus, Input, InputQueryFilter, Notice, NoticeQueryFilter, - OutputEnum, Proof, Report, ReportQueryFilter, Voucher, VoucherQueryFilter, -}; - -pub const POOL_CONNECTION_SIZE: u32 = 3; - -#[derive(Clone, Debug)] -pub struct Repository { - // Connection is not thread safe to share between threads, we use connection pool - db_pool: Arc>>, - backoff: ExponentialBackoff, -} - -impl Repository { - /// Create database connection pool, wait until database server is available with backoff strategy - pub fn new(config: RepositoryConfig) -> Result { - let db_pool = backoff::retry(config.backoff.clone(), || { - tracing::info!(?config, "trying to create db pool for database"); - Pool::builder() - .max_size(POOL_CONNECTION_SIZE) - .build(ConnectionManager::::new( - config.endpoint(), - )) - .map_err(backoff::Error::transient) - }) - .context(DatabaseConnectionSnafu)?; - Ok(Self { - db_pool: Arc::new(db_pool), - backoff: config.backoff, - }) - } - - /// Obtain the connection from the connection pool - fn conn( - &self, - ) -> Result>, Error> { - backoff::retry(self.backoff.clone(), || { - self.db_pool.get().map_err(backoff::Error::transient) - }) - .context(DatabaseConnectionSnafu) - } -} - -/// Basic queries that fetch by primary_key -impl Repository { - pub fn get_input(&self, index: i32) -> Result { - use schema::inputs::dsl; - let mut conn = self.conn()?; - dsl::inputs - .filter(dsl::index.eq(index)) - .load::(&mut conn) - .context(DatabaseSnafu)? - .pop() - .ok_or(Error::ItemNotFound { - item_type: "input".to_owned(), - }) - } - - pub fn get_voucher( - &self, - index: i32, - input_index: i32, - ) -> Result { - let mut conn = self.conn()?; - use schema::vouchers::dsl; - dsl::vouchers - .filter(dsl::index.eq(index)) - .filter(dsl::input_index.eq(input_index)) - .load::(&mut conn) - .context(DatabaseSnafu)? - .pop() - .ok_or(Error::ItemNotFound { - item_type: "voucher".to_owned(), - }) - } - - pub fn get_notice( - &self, - index: i32, - input_index: i32, - ) -> Result { - use schema::notices::dsl; - let mut conn = self.conn()?; - dsl::notices - .filter(dsl::index.eq(index)) - .filter(dsl::input_index.eq(input_index)) - .load::(&mut conn) - .context(DatabaseSnafu)? - .pop() - .ok_or(Error::ItemNotFound { - item_type: "notice".to_owned(), - }) - } - - pub fn get_report( - &self, - index: i32, - input_index: i32, - ) -> Result { - use schema::reports::dsl; - let mut conn = self.conn()?; - dsl::reports - .filter(dsl::index.eq(index)) - .filter(dsl::input_index.eq(input_index)) - .load::(&mut conn) - .context(DatabaseSnafu)? - .pop() - .ok_or(Error::ItemNotFound { - item_type: "report".to_owned(), - }) - } - - pub fn get_proof( - &self, - input_index: i32, - output_index: i32, - output_enum: OutputEnum, - ) -> Result, Error> { - use schema::proofs::dsl; - let mut conn = self.conn()?; - dsl::proofs - .filter(dsl::input_index.eq(input_index)) - .filter(dsl::output_index.eq(output_index)) - .filter(dsl::output_enum.eq(output_enum)) - .load::(&mut conn) - .map(|mut proofs| proofs.pop()) - .context(DatabaseSnafu) - } -} - -/// Basic queries to insert rollups' outputs -impl Repository { - pub fn insert_input(&self, input: Input) -> Result<(), Error> { - use schema::inputs; - let mut conn = self.conn()?; - insert_into(inputs::table) - .values(&input) - .on_conflict_do_nothing() - .execute(&mut conn) - .context(DatabaseSnafu)?; - tracing::trace!("Input {} was written to the db", input.index); - Ok(()) - } - - pub fn insert_notice(&self, notice: Notice) -> Result<(), Error> { - use schema::notices; - let mut conn = self.conn()?; - insert_into(notices::table) - .values(¬ice) - .on_conflict_do_nothing() - .execute(&mut conn) - .context(DatabaseSnafu)?; - tracing::trace!( - "Notice {} from Input {} was written to the db", - notice.index, - notice.input_index - ); - Ok(()) - } - - pub fn insert_voucher(&self, voucher: Voucher) -> Result<(), Error> { - use schema::vouchers; - let mut conn = self.conn()?; - insert_into(vouchers::table) - .values(&voucher) - .on_conflict_do_nothing() - .execute(&mut conn) - .context(DatabaseSnafu)?; - tracing::trace!( - "Voucher {} from Input {} was written to the db", - voucher.index, - voucher.input_index - ); - Ok(()) - } - - pub fn insert_report(&self, report: Report) -> Result<(), Error> { - use schema::reports; - let mut conn = self.conn()?; - insert_into(reports::table) - .values(&report) - .on_conflict_do_nothing() - .execute(&mut conn) - .context(DatabaseSnafu)?; - tracing::trace!( - "Report {} from Input {} was written to the db", - report.index, - report.input_index - ); - Ok(()) - } - - pub fn insert_proof(&self, proof: Proof) -> Result<(), Error> { - use schema::proofs; - let mut conn = self.conn()?; - insert_into(proofs::table) - .values(&proof) - .on_conflict_do_nothing() - .execute(&mut conn) - .context(DatabaseSnafu)?; - tracing::trace!( - "Proof for {:?} {} of Input {} was written to the db", - proof.output_enum, - proof.output_index, - proof.input_index - ); - Ok(()) - } -} - -/// Update operations -impl Repository { - pub fn update_input_status( - &self, - input_index: i32, - status: CompletionStatus, - ) -> Result<(), Error> { - use schema::inputs; - let mut conn = self.conn()?; - update(inputs::table) - .filter(inputs::dsl::index.eq(input_index)) - .set(inputs::status.eq(status)) - .execute(&mut conn) - .context(DatabaseSnafu)?; - tracing::trace!("Set {:?} status to input {}", status, input_index); - Ok(()) - } -} - -/// Generate a boxed query from an input query filter -impl InputQueryFilter { - fn to_query(&self) -> schema::inputs::BoxedQuery<'_, Pg> { - use schema::inputs::dsl; - let mut query = dsl::inputs.into_boxed(); - if let Some(other) = self.index_greater_than { - query = query.filter(dsl::index.gt(other)); - } - if let Some(other) = self.index_lower_than { - query = query.filter(dsl::index.lt(other)); - } - query - } -} - -/// Generate a boxed query from an output query filter -macro_rules! impl_output_filter_to_query { - ($filter: ty, $table: ident) => { - impl $filter { - fn to_query(&self) -> schema::$table::BoxedQuery<'_, Pg> { - use schema::$table::dsl; - let mut query = dsl::$table.into_boxed(); - if let Some(other) = self.input_index { - query = query.filter(dsl::input_index.eq(other)); - } - query - } - } - }; -} - -impl_output_filter_to_query!(VoucherQueryFilter, vouchers); -impl_output_filter_to_query!(NoticeQueryFilter, notices); -impl_output_filter_to_query!(ReportQueryFilter, reports); - -/// Implement a paginated query for the given table -macro_rules! impl_paginated_query { - ($query: ident, $table: ident, $node: ty, $filter: ty) => { - impl Repository { - pub fn $query( - &self, - first: Option, - last: Option, - after: Option, - before: Option, - filter: $filter, - ) -> Result, Error> { - let mut conn = self.conn()?; - let query = filter.to_query().count(); - let count = query - .get_result::(&mut conn) - .context(DatabaseSnafu)?; - let pagination = - Pagination::new(first, last, after, before, count as i32)?; - let nodes = if pagination.limit() > 0 { - let query = filter - .to_query() - .limit(pagination.limit().into()) - .offset(pagination.offset().into()) - .order(schema::$table::dsl::$table.primary_key()); - query.load(&mut conn).context(DatabaseSnafu)? - } else { - vec![] - }; - Ok(pagination.create_connection(nodes)) - } - } - }; -} - -impl_paginated_query!(get_inputs, inputs, Input, InputQueryFilter); -impl_paginated_query!(get_vouchers, vouchers, Voucher, VoucherQueryFilter); -impl_paginated_query!(get_notices, notices, Notice, NoticeQueryFilter); -impl_paginated_query!(get_reports, reports, Report, ReportQueryFilter); diff --git a/offchain/data/src/schema.rs b/offchain/data/src/schema.rs deleted file mode 100644 index 69e188ca5..000000000 --- a/offchain/data/src/schema.rs +++ /dev/null @@ -1,79 +0,0 @@ -// @generated automatically by Diesel CLI. - -pub mod sql_types { - #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "CompletionStatus"))] - pub struct CompletionStatus; - - #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "OutputEnum"))] - pub struct OutputEnum; -} - -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::CompletionStatus; - - inputs (index) { - index -> Int4, - msg_sender -> Bytea, - tx_hash -> Bytea, - block_number -> Int8, - timestamp -> Timestamp, - payload -> Bytea, - status -> CompletionStatus, - } -} - -diesel::table! { - notices (input_index, index) { - input_index -> Int4, - index -> Int4, - payload -> Bytea, - } -} - -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::OutputEnum; - - proofs (input_index, output_index, output_enum) { - input_index -> Int4, - output_index -> Int4, - output_enum -> OutputEnum, - validity_input_index_within_epoch -> Int4, - validity_output_index_within_input -> Int4, - validity_output_hashes_root_hash -> Bytea, - validity_vouchers_epoch_root_hash -> Bytea, - validity_notices_epoch_root_hash -> Bytea, - validity_machine_state_hash -> Bytea, - validity_output_hash_in_output_hashes_siblings -> Array>, - validity_output_hashes_in_epoch_siblings -> Array>, - context -> Bytea, - } -} - -diesel::table! { - reports (input_index, index) { - input_index -> Int4, - index -> Int4, - payload -> Bytea, - } -} - -diesel::table! { - vouchers (input_index, index) { - input_index -> Int4, - index -> Int4, - destination -> Bytea, - payload -> Bytea, - } -} - -diesel::joinable!(notices -> inputs (input_index)); -diesel::joinable!(reports -> inputs (input_index)); -diesel::joinable!(vouchers -> inputs (input_index)); - -diesel::allow_tables_to_appear_in_same_query!( - inputs, notices, proofs, reports, vouchers, -); diff --git a/offchain/data/src/types.rs b/offchain/data/src/types.rs deleted file mode 100644 index 40d9187c0..000000000 --- a/offchain/data/src/types.rs +++ /dev/null @@ -1,173 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use diesel::deserialize::{self, FromSql, FromSqlRow}; -use diesel::pg::{Pg, PgValue}; -use diesel::serialize::{self, IsNull, Output, ToSql}; -use diesel::{AsExpression, Insertable, Queryable, QueryableByName}; -use std::io::Write; - -use super::schema::{ - inputs, notices, proofs, reports, - sql_types::CompletionStatus as SQLCompletionStatus, - sql_types::OutputEnum as SQLOutputEnum, vouchers, -}; - -#[derive(Debug, PartialEq, Eq, Clone, Copy, FromSqlRow, AsExpression)] -#[diesel(sql_type = SQLCompletionStatus)] -pub enum CompletionStatus { - Unprocessed, - Accepted, - Rejected, - Exception, - MachineHalted, - CycleLimitExceeded, - TimeLimitExceeded, - PayloadLengthLimitExceeded, -} - -impl ToSql for CompletionStatus { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - match *self { - CompletionStatus::Unprocessed => out.write_all(b"Unprocessed")?, - CompletionStatus::Accepted => out.write_all(b"Accepted")?, - CompletionStatus::Rejected => out.write_all(b"Rejected")?, - CompletionStatus::Exception => out.write_all(b"Exception")?, - CompletionStatus::MachineHalted => { - out.write_all(b"MachineHalted")? - } - CompletionStatus::CycleLimitExceeded => { - out.write_all(b"CycleLimitExceeded")? - } - CompletionStatus::TimeLimitExceeded => { - out.write_all(b"TimeLimitExceeded")? - } - CompletionStatus::PayloadLengthLimitExceeded => { - out.write_all(b"PayloadLengthLimitExceeded")? - } - } - Ok(IsNull::No) - } -} - -impl FromSql for CompletionStatus { - fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { - match bytes.as_bytes() { - b"Unprocessed" => Ok(CompletionStatus::Unprocessed), - b"Accepted" => Ok(CompletionStatus::Accepted), - b"Rejected" => Ok(CompletionStatus::Rejected), - b"Exception" => Ok(CompletionStatus::Exception), - b"MachineHalted" => Ok(CompletionStatus::MachineHalted), - b"CycleLimitExceeded" => Ok(CompletionStatus::CycleLimitExceeded), - b"TimeLimitExceeded" => Ok(CompletionStatus::TimeLimitExceeded), - b"PayloadLengthLimitExceeded" => { - Ok(CompletionStatus::PayloadLengthLimitExceeded) - } - _ => Err("Unrecognized enum variant".into()), - } - } -} - -#[derive(Clone, Debug, Insertable, PartialEq, Queryable, QueryableByName)] -#[diesel(table_name = inputs)] -pub struct Input { - pub index: i32, - pub msg_sender: Vec, - pub tx_hash: Vec, - pub block_number: i64, - pub timestamp: std::time::SystemTime, - pub payload: Vec, - pub status: CompletionStatus, -} - -#[derive(Clone, Debug, Insertable, PartialEq, Queryable, QueryableByName)] -#[diesel(table_name = notices)] -pub struct Notice { - pub input_index: i32, - pub index: i32, - pub payload: Vec, -} - -#[derive(Clone, Debug, Insertable, PartialEq, Queryable, QueryableByName)] -#[diesel(table_name = vouchers)] -pub struct Voucher { - pub input_index: i32, - pub index: i32, - pub destination: Vec, - pub payload: Vec, -} - -#[derive(Clone, Debug, Insertable, PartialEq, Queryable, QueryableByName)] -#[diesel(table_name = reports)] -pub struct Report { - pub input_index: i32, - pub index: i32, - pub payload: Vec, -} - -#[derive(Debug, PartialEq, Eq, Clone, Copy, FromSqlRow, AsExpression)] -#[diesel(sql_type = SQLOutputEnum)] -pub enum OutputEnum { - Voucher, - Notice, -} - -impl ToSql for OutputEnum { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - match *self { - OutputEnum::Voucher => out.write_all(b"voucher")?, - OutputEnum::Notice => out.write_all(b"notice")?, - } - Ok(IsNull::No) - } -} - -impl FromSql for OutputEnum { - fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { - match bytes.as_bytes() { - b"voucher" => Ok(OutputEnum::Voucher), - b"notice" => Ok(OutputEnum::Notice), - _ => Err("Unrecognized enum variant".into()), - } - } -} - -impl diesel::query_builder::QueryId for SQLOutputEnum { - type QueryId = SQLOutputEnum; -} - -#[derive(Clone, Debug, Insertable, PartialEq, Queryable, QueryableByName)] -#[diesel(table_name = proofs)] -pub struct Proof { - pub input_index: i32, - pub output_index: i32, - pub output_enum: OutputEnum, - pub validity_input_index_within_epoch: i32, - pub validity_output_index_within_input: i32, - pub validity_output_hashes_root_hash: Vec, - pub validity_vouchers_epoch_root_hash: Vec, - pub validity_notices_epoch_root_hash: Vec, - pub validity_machine_state_hash: Vec, - pub validity_output_hash_in_output_hashes_siblings: Vec>>, - pub validity_output_hashes_in_epoch_siblings: Vec>>, - pub context: Vec, -} - -#[derive(Debug, Default)] -pub struct InputQueryFilter { - pub index_greater_than: Option, - pub index_lower_than: Option, -} - -macro_rules! decl_output_filter { - ($name: ident) => { - #[derive(Debug, Default)] - pub struct $name { - pub input_index: Option, - } - }; -} - -decl_output_filter!(VoucherQueryFilter); -decl_output_filter!(NoticeQueryFilter); -decl_output_filter!(ReportQueryFilter); diff --git a/offchain/data/tests/migrations.rs b/offchain/data/tests/migrations.rs deleted file mode 100644 index 10a93d0cf..000000000 --- a/offchain/data/tests/migrations.rs +++ /dev/null @@ -1,53 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use diesel::{pg::PgConnection, prelude::*, sql_query}; -use testcontainers::{clients::Cli, images::postgres::Postgres, RunnableImage}; - -const POSTGRES_PASSWORD: &'static str = "pw"; - -fn postgres_endpoint(port: u16) -> String { - format!( - "postgres://postgres:{}@localhost:{}/postgres", - POSTGRES_PASSWORD, port - ) -} - -table! { - pg_tables (tablename) { - tablename -> VarChar, - } -} - -#[derive(Debug, QueryableByName)] -#[diesel(table_name = pg_tables)] -pub struct PgTable { - pub tablename: String, -} - -#[test_log::test(test)] -fn run_migrations() { - tracing::info!("setting up Postgres container"); - let docker = Cli::default(); - let image = RunnableImage::from(Postgres::default()) - .with_tag("13") - .with_env_var(("POSTGRES_PASSWORD", POSTGRES_PASSWORD)); - let postgres = docker.run(image); - let endpoint = postgres_endpoint(postgres.get_host_port_ipv4(5432)); - - tracing::info!("running migrations"); - rollups_data::run_migrations(&endpoint).expect("failed to run migrations"); - - tracing::info!("checking whether migrations run in DB"); - let mut connection = PgConnection::establish(&endpoint) - .expect("failed to establish connection"); - let tables = sql_query("SELECT tablename FROM pg_tables;") - .load::(&mut connection) - .expect("failed to run query"); - - let expected_tables = - vec!["inputs", "vouchers", "notices", "reports", "proofs"]; - for expected in expected_tables { - assert!(tables.iter().find(|t| t.tablename == expected).is_some()); - } -} diff --git a/offchain/data/tests/repository.rs b/offchain/data/tests/repository.rs deleted file mode 100644 index 8b406ccc4..000000000 --- a/offchain/data/tests/repository.rs +++ /dev/null @@ -1,726 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::ExponentialBackoffBuilder; -use diesel::pg::Pg; -use diesel::{ - sql_query, Connection, PgConnection, QueryableByName, RunQueryDsl, -}; -use rollups_data::Connection as PaginationConnection; -use rollups_data::{ - CompletionStatus, Cursor, Edge, Error, Input, InputQueryFilter, Notice, - PageInfo, Proof, RedactedUrl, Report, Repository, RepositoryConfig, Url, - Voucher, -}; -use serial_test::serial; -use std::io::Write; -use std::os::unix::fs::PermissionsExt; -use std::time::{Duration, UNIX_EPOCH}; -use test_fixtures::DataFixture; -use test_log::test; -use testcontainers::clients::Cli; - -const BACKOFF_DURATION: u64 = 120000; - -struct TestState<'d> { - data: DataFixture<'d>, -} - -impl TestState<'_> { - fn setup(docker: &Cli) -> TestState<'_> { - let data = DataFixture::setup(docker); - TestState { data } - } - - pub fn get_repository(&self) -> Repository { - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(Duration::from_millis( - BACKOFF_DURATION, - ))) - .build(); - - let redacted_endpoint = Some(RedactedUrl::new( - Url::parse(&format!( - "postgres://{}:{}@{}:{}/{}", - self.data.user, - self.data.password, - self.data.hostname, - self.data.port, - self.data.db, - )) - .expect("failed to generate Postgres endpoint"), - )); - Repository::new(RepositoryConfig { - redacted_endpoint, - connection_pool_size: 3, - backoff, - }) - .expect("Repository should have connected successfully") - } - - pub fn get_from_sql + 'static>( - &self, - query: &str, - ) -> T { - let mut conn = PgConnection::establish(&self.data.endpoint) - .expect("failed to connect to db"); - sql_query(query) - .load::(&mut conn) - .expect("failed to run query") - .pop() - .expect("query returned no results") - } -} - -pub fn insert_test_input(repo: &Repository) { - let input = Input { - index: 0, - msg_sender: "msg-sender".as_bytes().to_vec(), - tx_hash: "tx-hash".as_bytes().to_vec(), - block_number: 0, - timestamp: UNIX_EPOCH + Duration::from_secs(1676489717), - payload: "input-0".as_bytes().to_vec(), - status: CompletionStatus::Accepted, - }; - - repo.insert_input(input) - .expect("The input should've been inserted") -} - -pub fn create_input() -> Input { - Input { - index: 0, - msg_sender: "msg-sender".as_bytes().to_vec(), - tx_hash: "tx-hash".as_bytes().to_vec(), - block_number: 0, - timestamp: UNIX_EPOCH + Duration::from_secs(1676489717), - payload: "input-0".as_bytes().to_vec(), - status: CompletionStatus::Accepted, - } -} - -#[test] -#[serial] -fn test_create_repository() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - - // Since we create the repository for every test, we created an auxiliary function - // to do so, and to avoid code duplication, we are calling this function here - test.get_repository(); -} - -#[test] -#[serial] -fn test_fail_to_create_repository() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(Duration::from_millis(2000))) - .build(); - - let redacted_endpoint = Some(RedactedUrl::new( - Url::parse(&format!( - "postgres://{}:{}@{}:{}/{}", - "Err", - test.data.password, - test.data.hostname, - test.data.port, - test.data.db, - )) - .expect("failed to generate Postgres endpoint"), - )); - let err = Repository::new(RepositoryConfig { - redacted_endpoint, - connection_pool_size: 3, - backoff, - }) - .expect_err("Repository::new should fail"); - - assert!(matches!(err, Error::DatabaseConnectionError { source: _ })); -} - -#[test] -#[serial] -fn test_postgres_file_configuration() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - - // Create Postgres pgpass file - let tempdir = tempfile::tempdir().expect("failed to create tempdir"); - let pgpass_path = - tempdir.path().join("pgpass").to_string_lossy().to_string(); - tracing::info!("Storing pgpass to {}", pgpass_path); - { - let mut pgpass = std::fs::File::create(&pgpass_path) - .expect("failed to create pgpass"); - // Set permission to 600 - let metadata = - pgpass.metadata().expect("failed to get pgpass metadata"); - let mut permissions = metadata.permissions(); - permissions.set_mode(0o600); - pgpass - .set_permissions(permissions) - .expect("failed to set pgpass permissions"); - // Write pgpass contents - write!( - &mut pgpass, - "{}:{}:{}:{}:{}\n", - test.data.hostname, - test.data.port, - test.data.db, - test.data.user, - test.data.password - ) - .expect("failed to write pgpass"); - } - - // Set Postgres environment variables - std::env::set_var("PGPASSFILE", pgpass_path); - std::env::set_var("PGHOST", test.data.hostname); - std::env::set_var("PGPORT", test.data.port.to_string()); - std::env::set_var("PGDATABASE", test.data.db); - std::env::set_var("PGUSER", test.data.user); - - // Connect to postgres using pgpass file - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(Duration::from_millis(100))) - .build(); - Repository::new(RepositoryConfig { - redacted_endpoint: None, - connection_pool_size: 3, - backoff, - }) - .expect("failed to create repository"); -} - -#[test] -#[serial] -fn test_insert_input() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let input = create_input(); - - repo.insert_input(input.clone()) - .expect("Failed to insert input"); - - let result: Input = test.get_from_sql("Select * from inputs"); - - assert_eq!(result, input); -} - -#[test] -#[serial] -fn test_get_input() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let input = create_input(); - - repo.insert_input(input.clone()) - .expect("Failed to insert input"); - - let get_input = repo.get_input(0).expect("Failed to get input"); - - assert_eq!(input, get_input); -} - -#[test] -#[serial] -fn test_get_input_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let input = create_input(); - - repo.insert_input(input.clone()) - .expect("Failed to insert input"); - - let input_error = repo.get_input(1).expect_err("Get input should fail"); - - assert!(matches!( - input_error, - Error::ItemNotFound { item_type } if item_type == "input" - )); -} - -#[test] -#[serial] -fn test_update_input_status() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let mut input = create_input(); - input.status = CompletionStatus::Unprocessed; - - repo.insert_input(input.clone()) - .expect("Failed to insert input"); - repo.update_input_status(0, CompletionStatus::Accepted) - .expect("Failed to update input status"); - - let get_input = repo.get_input(0).expect("Failed to get input"); - - assert_eq!(get_input.status, CompletionStatus::Accepted); -} - -#[test] -#[serial] -fn test_insert_notice() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let notice = Notice { - input_index: 0, - index: 0, - payload: "notice-0-0".as_bytes().to_vec(), - }; - - repo.insert_notice(notice.clone()) - .expect("Failed to insert notice"); - - let result: Notice = test.get_from_sql("Select * from notices"); - - assert_eq!(result, notice); -} - -#[test] -#[serial] -fn test_get_notice() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let notice = Notice { - input_index: 0, - index: 0, - payload: "notice-0-0".as_bytes().to_vec(), - }; - - repo.insert_notice(notice.clone()) - .expect("Failed to insert notice"); - - let get_notice = repo - .get_notice(0, 0) - .expect("Get notice should have returned a value"); - - assert_eq!(notice, get_notice); -} - -#[test] -#[serial] -fn test_insert_notice_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let notice = Notice { - input_index: 1, - index: 0, - payload: "notice-0-0".as_bytes().to_vec(), - }; - let notice_error = repo - .insert_notice(notice.clone()) - .expect_err("Insert notice should fail"); - - assert!(matches!(notice_error, Error::DatabaseError { source: _ })); -} - -#[test] -#[serial] -fn test_get_notice_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let notice = Notice { - input_index: 0, - index: 0, - payload: "notice-0-0".as_bytes().to_vec(), - }; - repo.insert_notice(notice.clone()) - .expect("Insert notice should succeed"); - - let notice_error = - repo.get_notice(1, 1).expect_err("Get notice should fail"); - - assert!(matches!( - notice_error, - Error::ItemNotFound { item_type } if item_type == "notice" - )); -} - -#[test] -#[serial] -fn test_insert_voucher() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let voucher = Voucher { - input_index: 0, - index: 0, - destination: "destination".as_bytes().to_vec(), - payload: "voucher-0-0".as_bytes().to_vec(), - }; - - repo.insert_voucher(voucher.clone()) - .expect("Insert voucher should succeed"); - - let result: Voucher = test.get_from_sql("Select * from vouchers"); - - assert_eq!(result, voucher); -} - -#[test] -#[serial] -fn test_get_voucher() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let voucher = Voucher { - input_index: 0, - index: 0, - destination: "destination".as_bytes().to_vec(), - payload: "voucher-0-0".as_bytes().to_vec(), - }; - repo.insert_voucher(voucher.clone()) - .expect("Insert voucher should succeed"); - - let get_voucher = - repo.get_voucher(0, 0).expect("Get voucher should succeed"); - - assert_eq!(voucher, get_voucher); -} - -#[test] -#[serial] -fn test_insert_voucher_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let voucher = Voucher { - input_index: 1, - index: 0, - destination: "destination".as_bytes().to_vec(), - payload: "voucher-1-0".as_bytes().to_vec(), - }; - let voucher_error = repo - .insert_voucher(voucher.clone()) - .expect_err("Insert voucher should fail"); - - assert!(matches!(voucher_error, Error::DatabaseError { source: _ })); -} - -#[test] -#[serial] -fn test_get_voucher_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let voucher = Voucher { - input_index: 0, - index: 0, - destination: "destination".as_bytes().to_vec(), - payload: "voucher-0-0".as_bytes().to_vec(), - }; - repo.insert_voucher(voucher.clone()) - .expect("Insert voucher should succeed"); - - let voucher_error = - repo.get_voucher(1, 1).expect_err("Get voucher should fail"); - - assert!(matches!( - voucher_error, - Error::ItemNotFound { item_type } if item_type == "voucher" - )); -} - -#[test] -#[serial] -fn test_insert_report() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let report = Report { - input_index: 0, - index: 0, - payload: "report-0-0".as_bytes().to_vec(), - }; - - repo.insert_report(report.clone()) - .expect("Insert report should succeed"); - - let result: Report = test.get_from_sql("Select * from reports"); - - assert_eq!(result, report); -} - -#[test] -#[serial] -fn test_get_report() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let report = Report { - input_index: 0, - index: 0, - payload: "report-0-0".as_bytes().to_vec(), - }; - repo.insert_report(report.clone()) - .expect("Insert report should succeed"); - - let get_report = repo.get_report(0, 0).expect("Get report should succeed"); - - assert_eq!(report, get_report); -} - -#[test] -#[serial] -fn test_insert_report_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let report = Report { - input_index: 1, - index: 0, - payload: "report-1-0".as_bytes().to_vec(), - }; - let report_error = repo - .insert_report(report.clone()) - .expect_err("Insert report should fail"); - - assert!(matches!(report_error, Error::DatabaseError { source: _ })); -} - -#[test] -#[serial] -fn test_get_report_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - insert_test_input(&repo); - - let report = Report { - input_index: 0, - index: 0, - payload: "report-0-0".as_bytes().to_vec(), - }; - repo.insert_report(report.clone()) - .expect("Insert report should succeed"); - - let report_error = - repo.get_report(1, 1).expect_err("Get report should fail"); - - assert!(matches!( - report_error, - Error::ItemNotFound { item_type } if item_type == "report" - )); -} - -#[test] -#[serial] -fn test_insert_proof() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let proof = Proof { - input_index: 0, - output_index: 0, - output_enum: rollups_data::OutputEnum::Voucher, - validity_input_index_within_epoch: 0, - validity_output_index_within_input: 0, - validity_output_hashes_root_hash: "".as_bytes().to_vec(), - validity_vouchers_epoch_root_hash: "".as_bytes().to_vec(), - validity_notices_epoch_root_hash: "".as_bytes().to_vec(), - validity_machine_state_hash: "".as_bytes().to_vec(), - validity_output_hash_in_output_hashes_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - validity_output_hashes_in_epoch_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - context: "".as_bytes().to_vec(), - }; - - repo.insert_proof(proof.clone()) - .expect("Insert proof should succeed"); - - let result: Proof = test.get_from_sql("Select * from proofs"); - - assert_eq!(result, proof); -} - -#[test] -#[serial] -fn test_get_proof() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let proof = Proof { - input_index: 0, - output_index: 0, - output_enum: rollups_data::OutputEnum::Voucher, - validity_input_index_within_epoch: 0, - validity_output_index_within_input: 0, - validity_output_hashes_root_hash: "".as_bytes().to_vec(), - validity_vouchers_epoch_root_hash: "".as_bytes().to_vec(), - validity_notices_epoch_root_hash: "".as_bytes().to_vec(), - validity_machine_state_hash: "".as_bytes().to_vec(), - validity_output_hash_in_output_hashes_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - validity_output_hashes_in_epoch_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - context: "".as_bytes().to_vec(), - }; - repo.insert_proof(proof.clone()) - .expect("Insert proof should succeed"); - - let get_proof = repo - .get_proof(0, 0, rollups_data::OutputEnum::Voucher) - .unwrap() - .expect("Get proof should succeed"); - - assert_eq!(proof, get_proof); -} - -#[test] -#[serial] -fn test_get_proof_error() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let proof = Proof { - input_index: 0, - output_index: 0, - output_enum: rollups_data::OutputEnum::Voucher, - validity_input_index_within_epoch: 0, - validity_output_index_within_input: 0, - validity_output_hashes_root_hash: "".as_bytes().to_vec(), - validity_vouchers_epoch_root_hash: "".as_bytes().to_vec(), - validity_notices_epoch_root_hash: "".as_bytes().to_vec(), - validity_machine_state_hash: "".as_bytes().to_vec(), - validity_output_hash_in_output_hashes_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - validity_output_hashes_in_epoch_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - context: "".as_bytes().to_vec(), - }; - repo.insert_proof(proof.clone()) - .expect("Insert proof should succeed"); - - let proof_error = repo.get_proof(1, 1, rollups_data::OutputEnum::Voucher); - - match proof_error { - Ok(None) => assert!(true), - Ok(Some(_proof)) => assert!(false), - Err(_) => assert!(false), - } -} - -#[test] -#[serial] -fn test_pagination_macro() { - let docker = Cli::default(); - let test = TestState::setup(&docker); - let repo = test.get_repository(); - - let input0 = create_input(); - - let input1 = Input { - index: 1, - msg_sender: "msg-sender".as_bytes().to_vec(), - tx_hash: "tx-hash".as_bytes().to_vec(), - block_number: 0, - timestamp: UNIX_EPOCH + Duration::from_secs(1676489717), - payload: "input-1".as_bytes().to_vec(), - status: CompletionStatus::Accepted, - }; - - repo.insert_input(input0.clone()) - .expect("Insert input should succeed"); - repo.insert_input(input1.clone()) - .expect("Insert input should succeed"); - - let query_filter = InputQueryFilter { - index_greater_than: Some(-1), - index_lower_than: Some(5), - }; - - let pagination_connection = repo - .get_inputs(Some(5), None, None, None, query_filter) - .expect("The macro should work, creating a pagination connection"); - - assert_eq!( - pagination_connection, - PaginationConnection { - total_count: 2, - edges: vec![ - Edge { - node: input0, - cursor: Cursor::decode("MA==") - .expect("Should create cursor with correct offset"), - }, - Edge { - node: input1, - cursor: Cursor::decode("MQ==") - .expect("Should create cursor with correct offset"), - }, - ], - page_info: PageInfo { - start_cursor: Some( - Cursor::decode("MA==") - .expect("Should create cursor with correct offset") - ), - end_cursor: Some( - Cursor::decode("MQ==") - .expect("Should create cursor with correct offset") - ), - has_next_page: false, - has_previous_page: false, - }, - } - ); -} diff --git a/offchain/data/util/populate.sql b/offchain/data/util/populate.sql deleted file mode 100644 index c1793d4f5..000000000 --- a/offchain/data/util/populate.sql +++ /dev/null @@ -1,40 +0,0 @@ --- insert inputs -INSERT INTO inputs VALUES (0, 'msg-sender', 'tx-hash', 0, current_timestamp, 'input-0'); -INSERT INTO inputs VALUES (1, 'msg-sender', 'tx-hash', 0, current_timestamp, 'input-1'); -INSERT INTO inputs VALUES (2, 'msg-sender', 'tx-hash', 0, current_timestamp, 'input-2'); - --- insert notices -INSERT INTO notices VALUES (0, 0, 'notice-0-0'); -INSERT INTO notices VALUES (0, 1, 'notice-0-1'); -INSERT INTO notices VALUES (0, 2, 'notice-0-2'); -INSERT INTO notices VALUES (1, 0, 'notice-1-0'); -INSERT INTO notices VALUES (1, 1, 'notice-1-1'); -INSERT INTO notices VALUES (1, 2, 'notice-1-2'); -INSERT INTO notices VALUES (2, 0, 'notice-2-0'); -INSERT INTO notices VALUES (2, 1, 'notice-2-1'); -INSERT INTO notices VALUES (2, 2, 'notice-2-2'); - --- insert vouchers -INSERT INTO vouchers VALUES (0, 0, 'destination', 'voucher-0-0'); -INSERT INTO vouchers VALUES (0, 1, 'destination', 'voucher-0-1'); -INSERT INTO vouchers VALUES (0, 2, 'destination', 'voucher-0-2'); -INSERT INTO vouchers VALUES (1, 0, 'destination', 'voucher-1-0'); -INSERT INTO vouchers VALUES (1, 1, 'destination', 'voucher-1-1'); -INSERT INTO vouchers VALUES (1, 2, 'destination', 'voucher-1-2'); -INSERT INTO vouchers VALUES (2, 0, 'destination', 'voucher-2-0'); -INSERT INTO vouchers VALUES (2, 1, 'destination', 'voucher-2-1'); -INSERT INTO vouchers VALUES (2, 2, 'destination', 'voucher-2-2'); - --- insert reports -INSERT INTO reports VALUES (0, 0, 'report-0-0'); -INSERT INTO reports VALUES (0, 1, 'report-0-1'); -INSERT INTO reports VALUES (0, 2, 'report-0-2'); -INSERT INTO reports VALUES (1, 0, 'report-1-0'); -INSERT INTO reports VALUES (1, 1, 'report-1-1'); -INSERT INTO reports VALUES (1, 2, 'report-1-2'); -INSERT INTO reports VALUES (2, 0, 'report-2-0'); -INSERT INTO reports VALUES (2, 1, 'report-2-1'); -INSERT INTO reports VALUES (2, 2, 'report-2-2'); - -INSERT INTO proofs VALUES (0, 0, 'voucher', 0, 0, '', '', '', '', ARRAY[''::bytea], ARRAY[''::bytea], ''); -INSERT INTO proofs VALUES (0, 0, 'notice', 0, 0, '', '', '', '', ARRAY[''::bytea], ARRAY[''::bytea], ''); diff --git a/offchain/dispatcher/Cargo.toml b/offchain/dispatcher/Cargo.toml deleted file mode 100644 index 03ef88301..000000000 --- a/offchain/dispatcher/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "dispatcher" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-dispatcher" -path = "src/main.rs" - -[dependencies] -http-server = { path = "../http-server" } -log = { path = "../log" } -rollups-events = { path = "../rollups-events" } -types = { path = "../types" } - -async-trait.workspace = true -backoff = { workspace = true, features = ["tokio"] } -clap = { workspace = true, features = ["derive", "env"] } -eth-state-client-lib.workspace = true -eth-state-fold-types = { workspace = true, features = ["ethers"] } -futures.workspace = true -snafu.workspace = true -tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] } -tokio-stream.workspace = true -tonic.workspace = true -tracing.workspace = true - -[dev-dependencies] -test-fixtures = { path = "../test-fixtures" } - -im = { workspace = true, features = ["serde"] } -rand.workspace = true -redis.workspace = true -serial_test.workspace = true -testcontainers.workspace = true -tracing-test = { workspace = true, features = ["no-env-filter"] } diff --git a/offchain/dispatcher/README.md b/offchain/dispatcher/README.md deleted file mode 100644 index 19fbe8749..000000000 --- a/offchain/dispatcher/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Dispatcher - -This service generates rollups inputs from state changes in the blockchain detected by the state-server. -These inputs are sent to the broker to be eventually used by the advance-runner. diff --git a/offchain/dispatcher/src/config.rs b/offchain/dispatcher/src/config.rs deleted file mode 100644 index eb1f16124..000000000 --- a/offchain/dispatcher/src/config.rs +++ /dev/null @@ -1,98 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; -use eth_state_client_lib::config::{ - Error as SCError, SCConfig, SCEnvCLIConfig, -}; -use http_server::HttpServerConfig; -use log::{LogConfig, LogEnvCliConfig}; -use snafu::{ResultExt, Snafu}; -use types::blockchain_config::{ - BlockchainCLIConfig, BlockchainConfig, BlockchainConfigError, -}; - -use rollups_events::{BrokerCLIConfig, BrokerConfig}; - -#[derive(Parser)] -#[command(name = "rd_config")] -#[command(about = "Configuration for dispatcher")] -pub struct DispatcherEnvCLIConfig { - #[command(flatten)] - pub sc_config: SCEnvCLIConfig, - - #[command(flatten)] - pub broker_config: BrokerCLIConfig, - - #[command(flatten)] - pub log_config: LogEnvCliConfig, - - #[command(flatten)] - pub blockchain_config: BlockchainCLIConfig, - - /// Duration of rollups epoch in blocks, for which dispatcher will make claims. - #[arg(long, env, default_value = "7200")] - pub rd_epoch_length: u64, - - /// Chain ID - #[arg(long, env)] - pub chain_id: u64, -} - -#[derive(Clone, Debug)] -pub struct DispatcherConfig { - pub sc_config: SCConfig, - pub broker_config: BrokerConfig, - pub log_config: LogConfig, - pub blockchain_config: BlockchainConfig, - - pub epoch_length: u64, - pub chain_id: u64, -} - -#[derive(Debug, Snafu)] -pub enum Error { - #[snafu(display("StateClient configuration error"))] - StateClientError { source: SCError }, - - #[snafu(display("Blockchain configuration error"))] - BlockchainError { source: BlockchainConfigError }, -} - -#[derive(Debug)] -pub struct Config { - pub dispatcher_config: DispatcherConfig, - pub http_server_config: HttpServerConfig, -} - -impl Config { - pub fn initialize() -> Result { - let (http_server_config, dispatcher_config) = - HttpServerConfig::parse::("dispatcher"); - - let sc_config = SCConfig::initialize(dispatcher_config.sc_config) - .context(StateClientSnafu)?; - - let log_config = LogConfig::initialize(dispatcher_config.log_config); - - let blockchain_config = - BlockchainConfig::try_from(dispatcher_config.blockchain_config) - .context(BlockchainSnafu)?; - - let broker_config = BrokerConfig::from(dispatcher_config.broker_config); - - let dispatcher_config = DispatcherConfig { - sc_config, - broker_config, - log_config, - blockchain_config, - epoch_length: dispatcher_config.rd_epoch_length, - chain_id: dispatcher_config.chain_id, - }; - - Ok(Config { - dispatcher_config, - http_server_config, - }) - } -} diff --git a/offchain/dispatcher/src/dispatcher.rs b/offchain/dispatcher/src/dispatcher.rs deleted file mode 100644 index 9246f8b20..000000000 --- a/offchain/dispatcher/src/dispatcher.rs +++ /dev/null @@ -1,167 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use eth_state_client_lib::StateServer; -use eth_state_fold_types::{Block, BlockStreamItem}; -use rollups_events::DAppMetadata; -use std::sync::Arc; -use tokio_stream::StreamExt; -use tracing::{error, instrument, trace, warn}; -use types::foldables::{InputBox, InputBoxInitialState}; - -use crate::{ - config::DispatcherConfig, - drivers::{machine::MachineDriver, Context}, - error::{BrokerSnafu, DispatcherError, StateServerSnafu}, - machine::{rollups_broker::BrokerFacade, BrokerSend}, - metrics::DispatcherMetrics, - setup::{create_block_subscription, create_context, create_state_server}, -}; - -use snafu::{whatever, ResultExt}; - -#[instrument(level = "trace", skip_all)] -pub async fn start( - config: DispatcherConfig, - metrics: DispatcherMetrics, -) -> Result<(), DispatcherError> { - trace!("Setting up dispatcher"); - - let dapp_metadata = DAppMetadata { - chain_id: config.chain_id, - dapp_address: config.blockchain_config.dapp_address.clone(), - }; - - trace!("Creating state-server connection"); - let state_server = create_state_server(&config.sc_config).await?; - - trace!("Starting block subscription with confirmations"); - let mut block_subscription = create_block_subscription( - &state_server, - config.sc_config.default_confirmations, - ) - .await?; - - trace!("Creating broker connection"); - let broker = - BrokerFacade::new(config.broker_config.clone(), dapp_metadata.clone()) - .await - .context(BrokerSnafu)?; - - trace!("Creating machine driver and blockchain driver"); - let mut machine_driver = MachineDriver::new( - config - .blockchain_config - .dapp_address - .clone() - .into_inner() - .into(), - ); - - let initial_state = InputBoxInitialState { - dapp_address: Arc::new( - config - .blockchain_config - .dapp_address - .clone() - .into_inner() - .into(), - ), - input_box_address: Arc::new( - config - .blockchain_config - .input_box_address - .clone() - .into_inner() - .into(), - ), - }; - - trace!("Creating context"); - let mut context = create_context( - &(config.clone()), - &state_server, - &broker, - dapp_metadata, - metrics, - ) - .await?; - - loop { - match block_subscription.next().await { - Some(Ok(BlockStreamItem::NewBlock(b))) => { - // Normal operation, react on newest block. - trace!( - "Received block number {} and hash {:?}, parent: {:?}", - b.number, - b.hash, - b.parent_hash - ); - process_block( - &b, - &state_server, - &initial_state, - &mut context, - &mut machine_driver, - &broker, - ) - .await? - } - - Some(Ok(BlockStreamItem::Reorg(bs))) => { - error!( - "Deep blockchain reorg of {} blocks; new latest has number {:?}, hash {:?}, and parent {:?}", - bs.len(), - bs.last().map(|b| b.number), - bs.last().map(|b| b.hash), - bs.last().map(|b| b.parent_hash) - ); - error!("Bailing..."); - whatever!("deep blockchain reorg"); - } - - Some(Err(e)) => { - warn!( - "Subscription returned error `{}`; waiting for next block...", - e - ); - } - - None => { - whatever!("subscription closed"); - } - } - } -} - -#[instrument(level = "trace", skip_all)] -#[allow(clippy::too_many_arguments)] -async fn process_block( - block: &Block, - - state_server: &impl StateServer< - InitialState = InputBoxInitialState, - State = InputBox, - >, - initial_state: &InputBoxInitialState, - - context: &mut Context, - machine_driver: &mut MachineDriver, - - broker: &impl BrokerSend, -) -> Result<(), DispatcherError> { - trace!("Querying rollup state"); - let state = state_server - .query_state(initial_state, block.hash) - .await - .context(StateServerSnafu)?; - - // Drive machine - trace!("Reacting to state with `machine_driver`"); - machine_driver - .react(context, &state.block, &state.state, broker) - .await - .context(BrokerSnafu)?; - - Ok(()) -} diff --git a/offchain/dispatcher/src/drivers/context.rs b/offchain/dispatcher/src/drivers/context.rs deleted file mode 100644 index 3ce10f54d..000000000 --- a/offchain/dispatcher/src/drivers/context.rs +++ /dev/null @@ -1,696 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::{ - machine::{rollups_broker::BrokerFacadeError, BrokerSend}, - metrics::DispatcherMetrics, -}; - -use rollups_events::DAppMetadata; -use tracing::info; -use types::foldables::Input; - -#[derive(Debug)] -pub struct Context { - inputs_sent: u64, - last_input_epoch: Option, - last_finished_epoch: Option, - - // constants - genesis_block: u64, - epoch_length: u64, - - // metrics - dapp_metadata: DAppMetadata, - metrics: DispatcherMetrics, -} - -impl Context { - pub fn new( - genesis_block: u64, - epoch_length: u64, - dapp_metadata: DAppMetadata, - metrics: DispatcherMetrics, - ) -> Self { - assert!(epoch_length > 0); - Self { - inputs_sent: 0, - last_input_epoch: None, - last_finished_epoch: None, - genesis_block, - epoch_length, - dapp_metadata, - metrics, - } - } - - pub fn inputs_sent(&self) -> u64 { - self.inputs_sent - } - - pub async fn finish_epoch_if_needed( - &mut self, - block_number: u64, - broker: &impl BrokerSend, - ) -> Result<(), BrokerFacadeError> { - let epoch = self.calculate_epoch(block_number); - if self.should_finish_epoch(epoch) { - self.finish_epoch(broker).await?; - } - Ok(()) - } - - pub async fn enqueue_input( - &mut self, - input: &Input, - broker: &impl BrokerSend, - ) -> Result<(), BrokerFacadeError> { - let input_block_number = input.block_added.number.as_u64(); - let input_epoch = self.calculate_epoch(input_block_number); - self.last_finished_epoch.map(|last_finished_epoch| { - // Asserting that the calculated epoch comes after the last finished epoch. - // (If last_finished_epoch == None then we don't need the assertion.) - assert!(input_epoch > last_finished_epoch) - }); - - self.finish_epoch_if_needed(input_block_number, broker) - .await?; - - broker.enqueue_input(self.inputs_sent, input).await?; - - self.metrics - .advance_inputs_sent - .get_or_create(&self.dapp_metadata) - .inc(); - - self.inputs_sent += 1; - self.last_input_epoch = Some(input_epoch); - - Ok(()) - } -} - -impl Context { - fn calculate_epoch(&self, block_number: u64) -> u64 { - assert!(block_number >= self.genesis_block); - (block_number - self.genesis_block) / self.epoch_length - } - - fn should_finish_epoch(&self, epoch: u64) -> bool { - // Being (last_input_epoch >= last_finished_epoch) a structural invariant. - // Being the current epoch the epoch of the last input. - // - // If last_finished_epoch is None and last_input_epoch is None, - // then there are no inputs by definition and the current epoch is empty. - // - // If last_finished_epoch is Some(x) and last_input_epoch is Some(x), - // then an epoch was finished and the last enqueued input belongs to that epoch; - // meaning that any subsequent epochs (which includes the current one) are empty. - // - // If last_finished_epoch is Some(x) and last_input_epoch is Some(y), or - // If last_finished_epoch is None and last_input_epoch is Some(_), then - // the current epoch is not empty by definition and by the structural invariant. - // - // The state in which last_finished_epoch is Some(_) and last_input_epoch is None is - // impossible (by the structural invariant). - if self.last_finished_epoch == self.last_input_epoch { - return false; // if the current epoch is empty - } - - if epoch == self.last_input_epoch.unwrap() { - return false; // if the current epoch is still not over - } - - epoch > self.last_finished_epoch.unwrap_or(0) - } - - async fn finish_epoch( - &mut self, - broker: &impl BrokerSend, - ) -> Result<(), BrokerFacadeError> { - // Asserting that there are inputs in the current epoch. - assert!( - match (self.last_input_epoch, self.last_finished_epoch) { - (Some(input_epoch), Some(finished_epoch)) => input_epoch > finished_epoch, - (Some(_), None) => true, // Consider input_epoch greater than None - (None, _) => false, // None is never greater than any value - }, - "cannot finish epoch: last_input_epoch ({:?}) is not greater than last_finished_epoch ({:?})", - self.last_input_epoch, - self.last_finished_epoch - ); - - broker.finish_epoch(self.inputs_sent).await?; - self.metrics - .finish_epochs_sent - .get_or_create(&self.dapp_metadata) - .inc(); - - self.last_finished_epoch = self.last_input_epoch; - info!( - "sent finish_epoch event for epoch {:?}", - self.last_finished_epoch - ); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::collections::VecDeque; - - use crate::drivers::mock::Event; - use rollups_events::DAppMetadata; - use serial_test::serial; - - use crate::{drivers::mock, metrics::DispatcherMetrics}; - - use super::Context; - - impl Default for Context { - fn default() -> Self { - Context::new( - /* genesis_block */ 0, - /* epoch_length */ 10, - /* dapp_metadata */ DAppMetadata::default(), - /* metrics */ DispatcherMetrics::default(), - ) - } - } - - // -------------------------------------------------------------------------------------------- - // calculate_epoch - // -------------------------------------------------------------------------------------------- - - #[test] - fn calculate_epoch_with_zero_genesis() { - let mut context = Context::default(); - context.genesis_block = 0; - context.epoch_length = 10; - - let number_of_epochs = 10; - let mut tested = 0; - for current_epoch in 0..number_of_epochs { - let block_lower_bound = current_epoch * context.epoch_length; - let block_upper_bound = (current_epoch + 1) * context.epoch_length; - for i in block_lower_bound..block_upper_bound { - assert_eq!(context.calculate_epoch(i), current_epoch); - tested += 1; - } - } - - assert_eq!(tested, number_of_epochs * context.epoch_length); - assert_eq!( - context.calculate_epoch(context.epoch_length * number_of_epochs), - context.epoch_length - ); - } - - #[test] - fn calculate_epoch_with_offset_genesis() { - let mut context = Context::default(); - context.genesis_block = 2; - context.epoch_length = 2; - - assert_eq!(context.calculate_epoch(2), 0); - assert_eq!(context.calculate_epoch(3), 0); - assert_eq!(context.calculate_epoch(4), 1); - assert_eq!(context.calculate_epoch(5), 1); - assert_eq!(context.calculate_epoch(6), 2); - } - - #[test] - #[should_panic] - fn calculate_epoch_should_panic_because_block_came_before_genesis() { - let mut context = Context::default(); - context.genesis_block = 4; - context.epoch_length = 4; - context.calculate_epoch(2); - } - - // -------------------------------------------------------------------------------------------- - // should_finish_epoch -- first epoch - // -------------------------------------------------------------------------------------------- - - #[test] - fn should_finish_the_first_epoch() { - let mut context = Context::default(); - context.inputs_sent = 1; - context.last_input_epoch = Some(0); - context.last_finished_epoch = None; - let epoch = context.calculate_epoch(10); - assert_eq!(context.should_finish_epoch(epoch), true); - } - - #[test] - fn should_finish_the_first_epoch_after_several_blocks() { - let mut context = Context::default(); - context.inputs_sent = 110; - context.last_input_epoch = Some(9); - context.last_finished_epoch = None; - let epoch = context.calculate_epoch(100); - assert_eq!(context.should_finish_epoch(epoch), true); - } - - #[test] - fn should_not_finish_an_empty_first_epoch() { - let mut context = Context::default(); - context.inputs_sent = 0; - context.last_input_epoch = None; - context.last_finished_epoch = None; - let epoch = context.calculate_epoch(10); - assert_eq!(context.should_finish_epoch(epoch), false); - } - - #[test] - fn should_not_finish_a_very_late_empty_first_epoch() { - let mut context = Context::default(); - context.inputs_sent = 0; - context.last_input_epoch = None; - context.last_finished_epoch = None; - let epoch = context.calculate_epoch(2340); - assert_eq!(context.should_finish_epoch(epoch), false); - } - - #[test] - fn should_not_finish_a_timely_first_epoch() { - let mut context = Context::default(); - context.inputs_sent = 1; - context.last_input_epoch = Some(0); - context.last_finished_epoch = None; - let epoch = context.calculate_epoch(9); - assert_eq!(context.should_finish_epoch(epoch), false); - } - - // -------------------------------------------------------------------------------------------- - // should_finish_epoch -- other epochs - // -------------------------------------------------------------------------------------------- - - #[test] - fn should_finish_epoch() { - let mut context = Context::default(); - context.inputs_sent = 42; - context.last_input_epoch = Some(4); - context.last_finished_epoch = Some(3); - let epoch = context.calculate_epoch(54); - assert_eq!(context.should_finish_epoch(epoch), true); - } - - #[test] - fn should_finish_epoch_by_a_lot() { - let mut context = Context::default(); - context.inputs_sent = 142; - context.last_input_epoch = Some(15); - context.last_finished_epoch = Some(2); - let epoch = context.calculate_epoch(190); - assert_eq!(context.should_finish_epoch(epoch), true); - } - - #[test] - fn should_not_finish_an_empty_epoch() { - let mut context = Context::default(); - context.inputs_sent = 120; - context.last_input_epoch = Some(9); - context.last_finished_epoch = Some(9); - let epoch = context.calculate_epoch(105); - assert_eq!(context.should_finish_epoch(epoch), false); - } - - #[test] - fn should_not_finish_a_very_late_empty_epoch() { - let mut context = Context::default(); - context.inputs_sent = 120; - context.last_input_epoch = Some(15); - context.last_finished_epoch = Some(15); - let epoch = context.calculate_epoch(1000); - assert_eq!(context.should_finish_epoch(epoch), false); - } - - #[test] - fn should_not_finish_a_timely_epoch() { - let mut context = Context::default(); - context.inputs_sent = 230; - context.last_input_epoch = Some(11); - context.last_finished_epoch = Some(10); - let epoch = context.calculate_epoch(110); - assert_eq!(context.should_finish_epoch(epoch), false); - } - - // -------------------------------------------------------------------------------------------- - // finish_epoch - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn finish_epoch_ok() { - let mut context = Context::default(); - context.inputs_sent = 1; - context.last_input_epoch = Some(0); - context.last_finished_epoch = None; - - let broker = mock::Broker::new(vec![], vec![]); - let result = context.finish_epoch(&broker).await; - assert!(result.is_ok()); - assert_eq!(context.inputs_sent, 1); - assert_eq!(context.last_input_epoch, Some(0)); - assert_eq!(context.last_finished_epoch, Some(0)); - } - - #[tokio::test] - async fn finish_epoch_broker_error() { - let mut context = Context::default(); - context.inputs_sent = 1; - context.last_input_epoch = Some(0); - context.last_finished_epoch = None; - - let broker = mock::Broker::with_finish_epoch_error(); - let result = context.finish_epoch(&broker).await; - assert!(result.is_err()); - assert_eq!(context.inputs_sent, 1); - assert_eq!(context.last_input_epoch, Some(0)); - assert_eq!(context.last_finished_epoch, None); - } - - // -------------------------------------------------------------------------------------------- - // new - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn new_ok() { - let genesis_block = 42; - let epoch_length = 24; - - let context = Context::new( - genesis_block, - epoch_length, - DAppMetadata::default(), - DispatcherMetrics::default(), - ); - - assert_eq!(context.genesis_block, genesis_block); - assert_eq!(context.epoch_length, epoch_length); - assert_eq!(context.dapp_metadata, DAppMetadata::default()); - } - - #[test] - #[should_panic] - fn new_should_panic_because_epoch_length_is_zero() { - Context::new( - 0, - 0, - DAppMetadata::default(), - DispatcherMetrics::default(), - ); - } - - // -------------------------------------------------------------------------------------------- - // inputs_sent_count - // -------------------------------------------------------------------------------------------- - - #[test] - fn inputs_sent_count() { - let number_of_inputs_sent = 42; - let mut context = Context::default(); - context.inputs_sent = number_of_inputs_sent; - assert_eq!(context.inputs_sent(), number_of_inputs_sent); - } - - // -------------------------------------------------------------------------------------------- - // finish_epoch_if_needed - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn finish_epoch_if_needed_true() { - let mut context = Context::default(); - context.inputs_sent = 9; - context.last_input_epoch = Some(0); - context.last_finished_epoch = None; - - let broker = mock::Broker::new(vec![], vec![]); - let result = context.finish_epoch_if_needed(12, &broker).await; - assert!(result.is_ok()); - broker.assert_state(vec![ - Event::FinishEpoch(0), // - ]); - } - - #[tokio::test] - async fn finish_epoch_if_needed_false() { - let mut context = Context::default(); - context.inputs_sent = 9; - context.last_input_epoch = Some(0); - context.last_finished_epoch = None; - - let broker = mock::Broker::new(vec![], vec![]); - let result = context.finish_epoch_if_needed(9, &broker).await; - assert!(result.is_ok()); - broker.assert_state(vec![]); - } - - #[tokio::test] - async fn finish_epoch_if_needed_broker_error() { - let mut context = Context::default(); - context.inputs_sent = 9; - context.last_input_epoch = Some(0); - context.last_finished_epoch = None; - let broker = mock::Broker::with_finish_epoch_error(); - let result = context.finish_epoch_if_needed(28, &broker).await; - assert!(result.is_err()); - } - - // -------------------------------------------------------------------------------------------- - // enqueue_input - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn enqueue_input_ok() { - let number_of_inputs_sent = 42; - let last_input_epoch = Some(1); - let last_finished_epoch = None; - - let mut context = Context::default(); - context.inputs_sent = number_of_inputs_sent; - context.last_input_epoch = last_input_epoch; - context.last_finished_epoch = last_finished_epoch; - - let input = mock::new_input(22); - let broker = mock::Broker::new(vec![], vec![]); - let result = context.enqueue_input(&input, &broker).await; - assert!(result.is_ok()); - - assert_eq!(context.inputs_sent, number_of_inputs_sent + 1); - assert_eq!(context.last_input_epoch, Some(2)); - assert_eq!(context.last_finished_epoch, Some(1)); - - broker.assert_state(vec![ - Event::FinishEpoch(0), - Event::Input(number_of_inputs_sent), - ]); - } - - #[tokio::test] - async fn enqueue_input_broker_error() { - let mut context = Context::default(); - let broker = mock::Broker::with_enqueue_input_error(); - let result = context.enqueue_input(&mock::new_input(82), &broker).await; - assert!(result.is_err()); - } - - // -------------------------------------------------------------------------------------------- - // deterministic behavior - // -------------------------------------------------------------------------------------------- - - #[derive(Clone)] - struct Case { - input_blocks: Vec, - epoch_length: u64, - last_block: u64, - expected: Vec, - } - - #[tokio::test] - #[serial] - async fn deterministic_behavior() { - let cases: Vec = vec![ - Case { - input_blocks: vec![], - epoch_length: 2, - last_block: 100, - expected: vec![], - }, - Case { - input_blocks: vec![0, 1, 4, 5], - epoch_length: 2, - last_block: 10, - expected: vec![ - Event::Input(0), - Event::Input(1), - Event::FinishEpoch(0), - Event::Input(2), - Event::Input(3), - Event::FinishEpoch(1), - ], - }, - Case { - input_blocks: vec![0, 0, 0, 7, 7], - epoch_length: 2, - last_block: 10, - expected: vec![ - Event::Input(0), - Event::Input(1), - Event::Input(2), - Event::FinishEpoch(0), - Event::Input(3), - Event::Input(4), - Event::FinishEpoch(1), - ], - }, - Case { - input_blocks: vec![0, 2], - epoch_length: 2, - last_block: 4, - expected: vec![ - Event::Input(0), - Event::FinishEpoch(0), - Event::Input(1), - Event::FinishEpoch(1), - ], - }, - Case { - input_blocks: vec![1, 2, 4], - epoch_length: 2, - last_block: 6, - expected: vec![ - Event::Input(0), - Event::FinishEpoch(0), - Event::Input(1), - Event::FinishEpoch(1), - Event::Input(2), - Event::FinishEpoch(2), - ], - }, - Case { - input_blocks: vec![0, 1, 1, 2, 3, 4, 5, 5, 5, 6, 7], - epoch_length: 2, - last_block: 7, - expected: vec![ - Event::Input(0), - Event::Input(1), - Event::Input(2), - Event::FinishEpoch(0), - Event::Input(3), - Event::Input(4), - Event::FinishEpoch(1), - Event::Input(5), - Event::Input(6), - Event::Input(7), - Event::Input(8), - Event::FinishEpoch(2), - Event::Input(9), - Event::Input(10), - ], - }, - Case { - input_blocks: vec![0, 5, 9], - epoch_length: 2, - last_block: 10, - expected: vec![ - Event::Input(0), - Event::FinishEpoch(0), - Event::Input(1), - Event::FinishEpoch(1), - Event::Input(2), - Event::FinishEpoch(2), - ], - }, - ]; - for (i, case) in cases.iter().enumerate() { - println!("Testing case {}.", i); - test_deterministic_case(case.clone()).await; - } - } - - // -------------------------------------------------------------------------------------------- - // auxiliary - // -------------------------------------------------------------------------------------------- - - async fn test_deterministic_case(case: Case) { - let broker1 = create_state_as_inputs_are_being_received( - case.epoch_length, - case.input_blocks.clone(), - case.last_block, - ) - .await; - let broker2 = create_state_by_receiving_all_inputs_at_once( - case.epoch_length, - case.input_blocks.clone(), - case.last_block, - ) - .await; - broker1.assert_state(case.expected.clone()); - broker2.assert_state(case.expected.clone()); - } - - async fn create_state_as_inputs_are_being_received( - epoch_length: u64, - input_blocks: Vec, - last_block: u64, - ) -> mock::Broker { - println!("================================================"); - println!("one_block_at_a_time:"); - - let mut input_blocks: VecDeque<_> = input_blocks.into(); - let mut current_input_block = input_blocks.pop_front(); - - let mut context = Context::default(); - context.epoch_length = epoch_length; - let broker = mock::Broker::new(vec![], vec![]); - - for block in 0..=last_block { - while let Some(input_block) = current_input_block { - if block == input_block { - println!("\tenqueue_input(input_block: {})", block); - let input = mock::new_input(block); - let result = context.enqueue_input(&input, &broker).await; - assert!(result.is_ok()); - - current_input_block = input_blocks.pop_front(); - } else { - break; - } - } - - println!("\tfinish_epoch_if_needed(block: {})\n", block); - let result = context.finish_epoch_if_needed(block, &broker).await; - assert!(result.is_ok()); - } - - broker - } - - async fn create_state_by_receiving_all_inputs_at_once( - epoch_length: u64, - input_blocks: Vec, - last_block: u64, - ) -> mock::Broker { - println!("all_inputs_at_once:"); - - let mut context = Context::default(); - context.epoch_length = epoch_length; - let broker = mock::Broker::new(vec![], vec![]); - - for block in input_blocks { - println!("\tenqueue_input(input_block: {})\n", block); - let input = mock::new_input(block); - let result = context.enqueue_input(&input, &broker).await; - assert!(result.is_ok()); - } - - println!("\tfinish_epoch_if_needed(last_block: {})", last_block); - let result = context.finish_epoch_if_needed(last_block, &broker).await; - assert!(result.is_ok()); - - println!("================================================"); - - broker - } -} diff --git a/offchain/dispatcher/src/drivers/machine.rs b/offchain/dispatcher/src/drivers/machine.rs deleted file mode 100644 index c0e9b1213..000000000 --- a/offchain/dispatcher/src/drivers/machine.rs +++ /dev/null @@ -1,422 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use super::Context; - -use crate::machine::{rollups_broker::BrokerFacadeError, BrokerSend}; - -use eth_state_fold_types::{ethereum_types::Address, Block}; -use types::foldables::{DAppInputBox, InputBox}; - -use tracing::{debug, instrument}; - -pub struct MachineDriver { - dapp_address: Address, -} - -impl MachineDriver { - pub fn new(dapp_address: Address) -> Self { - Self { dapp_address } - } - - #[instrument(level = "trace", skip_all)] - pub async fn react( - &self, - context: &mut Context, - block: &Block, - input_box: &InputBox, - broker: &impl BrokerSend, - ) -> Result<(), BrokerFacadeError> { - match input_box.dapp_input_boxes.get(&self.dapp_address) { - None => { - debug!("No inputs for dapp {}", self.dapp_address); - } - Some(dapp_input_box) => { - self.process_inputs(context, dapp_input_box, broker).await? - } - }; - - let block_number = block.number.as_u64(); - tracing::info!("received block {}", block_number); - context.finish_epoch_if_needed(block_number, broker).await?; - - Ok(()) - } -} - -impl MachineDriver { - #[instrument(level = "trace", skip_all)] - async fn process_inputs( - &self, - context: &mut Context, - dapp_input_box: &DAppInputBox, - broker: &impl BrokerSend, - ) -> Result<(), BrokerFacadeError> { - tracing::trace!( - "Last input sent to advance-runner: `{}`; current input: `{}`", - context.inputs_sent(), - dapp_input_box.inputs.len() - ); - - let input_slice = - dapp_input_box.inputs.skip(context.inputs_sent() as usize); - - for input in input_slice { - context.enqueue_input(&input, broker).await?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use eth_state_fold_types::ethereum_types::H160; - use rollups_events::DAppMetadata; - use types::foldables::InputBox; - - use crate::{ - drivers::{ - machine::MachineDriver, - mock::{self, Broker}, - Context, - }, - machine::RollupStatus, - metrics::DispatcherMetrics, - }; - - fn new_context(genesis_block: u64, epoch_length: u64) -> Context { - let context = Context::new( - genesis_block, - epoch_length, - DAppMetadata::default(), - DispatcherMetrics::default(), - ); - context - } - - fn new_broker(context: &Context) -> Broker { - mock::Broker::new( - vec![RollupStatus { - inputs_sent_count: context.inputs_sent(), - }], - Vec::new(), - ) - } - - // -------------------------------------------------------------------------------------------- - // process_inputs - // -------------------------------------------------------------------------------------------- - - async fn test_process_inputs( - mut context: Context, - broker: Broker, - input_blocks: Vec, - expected: Vec, - ) { - let machine_driver = MachineDriver::new(H160::random()); - let dapp_input_box = types::foldables::DAppInputBox { - inputs: input_blocks - .iter() - .map(|block| Arc::new(mock::new_input(*block))) - .collect::>() - .into(), - }; - let result = machine_driver - .process_inputs(&mut context, &dapp_input_box, &broker) - .await; - assert!(result.is_ok()); - - broker.assert_state(expected); - } - - #[tokio::test] - async fn process_inputs_without_skipping_inputs() { - let context = new_context(0, 10); - let broker = new_broker(&context); - let input_blocks = vec![0, 1, 2, 3]; - let expected = vec![ - mock::Event::Input(0), - mock::Event::Input(1), - mock::Event::Input(2), - mock::Event::Input(3), - ]; - test_process_inputs(context, broker, input_blocks, expected).await; - } - - #[tokio::test] - async fn process_inputs_with_some_skipped_inputs() { - let mut context = new_context(0, 10); - let mut throwaway_broker = new_broker(&context); - for i in 0..=1 { - assert!(context - .enqueue_input(&mock::new_input(i), &mut throwaway_broker) - .await - .is_ok()); - } - assert_eq!(2, context.inputs_sent()); - - let broker = new_broker(&context); - let input_blocks = vec![0, 1, 2, 3]; - let expected = vec![mock::Event::Input(2), mock::Event::Input(3)]; - test_process_inputs(context, broker, input_blocks, expected).await; - } - - #[tokio::test] - async fn process_inputs_skipping_all_inputs() { - let mut context = new_context(0, 10); - let mut throwaway_broker = new_broker(&context); - for i in 0..=3 { - assert!(context - .enqueue_input(&mock::new_input(i), &mut throwaway_broker) - .await - .is_ok()); - } - assert_eq!(4, context.inputs_sent()); - - let broker = new_broker(&context); - let input_blocks = vec![0, 1, 2, 3]; - let expected = vec![]; - test_process_inputs(context, broker, input_blocks, expected).await; - } - - // -------------------------------------------------------------------------------------------- - // react - // -------------------------------------------------------------------------------------------- - - async fn test_react( - block: u64, - mut context: Context, - broker: Option, - input_box: Option, - input_blocks: Vec, - expected: Vec, - ) -> (Context, Broker, InputBox) { - let rollup_status = RollupStatus { - inputs_sent_count: context.inputs_sent(), - }; - let broker = broker - .unwrap_or(mock::Broker::new(vec![rollup_status], Vec::new())); - let dapp_address = H160::random(); - let machine_driver = MachineDriver::new(dapp_address); - - let input_box = input_box.unwrap_or(mock::new_input_box()); - let input_box = - mock::update_input_box(input_box, dapp_address, input_blocks); - - let result = machine_driver - .react(&mut context, &mock::new_block(block), &input_box, &broker) - .await; - assert!(result.is_ok()); - - broker.assert_state(expected); - - (context, broker, input_box) - } - - #[tokio::test] - async fn react_without_finish_epoch() { - let block = 3; - let context = new_context(0, 10); - let input_blocks = vec![1, 2]; - let expected = vec![mock::Event::Input(0), mock::Event::Input(1)]; - test_react(block, context, None, None, input_blocks, expected).await; - } - - #[tokio::test] - async fn react_with_finish_epoch() { - let block = 10; - let context = new_context(0, 10); - let input_blocks = vec![1, 2]; - let expected = vec![ - mock::Event::Input(0), - mock::Event::Input(1), - mock::Event::FinishEpoch(0), - ]; - test_react(block, context, None, None, input_blocks, expected).await; - } - - #[tokio::test] - async fn react_with_internal_finish_epoch() { - let block = 14; - let context = new_context(0, 10); - let input_blocks = vec![9, 10]; - let expected = vec![ - mock::Event::Input(0), - mock::Event::FinishEpoch(0), - mock::Event::Input(1), - ]; - test_react(block, context, None, None, input_blocks, expected).await; - } - - #[tokio::test] - async fn react_without_inputs() { - let block = 10; - let context = new_context(0, 10); - let input_blocks = vec![]; - let expected = vec![]; - test_react(block, context, None, None, input_blocks, expected).await; - } - - // NOTE: this test shows we DON'T close the epoch after the first input! - #[tokio::test] - async fn react_with_inputs_after_first_epoch_length() { - let block = 20; - let context = new_context(0, 10); - let input_blocks = vec![14, 16, 18, 20]; - let expected = vec![ - mock::Event::Input(0), - mock::Event::Input(1), - mock::Event::Input(2), - mock::Event::FinishEpoch(0), - mock::Event::Input(3), - ]; - test_react(block, context, None, None, input_blocks, expected).await; - } - - #[tokio::test] - async fn react_is_deterministic() { - let final_expected = vec![ - mock::Event::Input(0), - mock::Event::FinishEpoch(0), - mock::Event::Input(1), - mock::Event::Input(2), - mock::Event::Input(3), - mock::Event::Input(4), - mock::Event::Input(5), - mock::Event::Input(6), - mock::Event::Input(7), - mock::Event::Input(8), - mock::Event::Input(9), - mock::Event::FinishEpoch(1), - mock::Event::Input(10), - mock::Event::Input(11), - mock::Event::Input(12), - mock::Event::Input(13), - mock::Event::Input(14), - mock::Event::Input(15), - mock::Event::FinishEpoch(2), - mock::Event::Input(16), - mock::Event::Input(17), - mock::Event::Input(18), - ]; - - { - // original - let block1 = 3100; - let block2 = 6944; - - let context = new_context(0, 1000); - - let input_blocks1 = vec![ - 56, // - // - 1078, // - 1091, // - 1159, // - 1204, // - 1227, // - 1280, // - 1298, // - 1442, // - 1637, // - // - 2827, // - 2881, // - 2883, // - 2887, // - 2891, // - 2934, // - ]; - let mut input_blocks2 = input_blocks1.clone(); - input_blocks2.append(&mut vec![ - 6160, // - 6864, // - 6944, // - ]); - - let expected1 = vec![ - mock::Event::Input(0), - mock::Event::FinishEpoch(0), - mock::Event::Input(1), - mock::Event::Input(2), - mock::Event::Input(3), - mock::Event::Input(4), - mock::Event::Input(5), - mock::Event::Input(6), - mock::Event::Input(7), - mock::Event::Input(8), - mock::Event::Input(9), - mock::Event::FinishEpoch(1), - mock::Event::Input(10), - mock::Event::Input(11), - mock::Event::Input(12), - mock::Event::Input(13), - mock::Event::Input(14), - mock::Event::Input(15), - mock::Event::FinishEpoch(2), - ]; - - let (context, broker, input_box) = test_react( - block1, - context, - None, - None, - input_blocks1, - expected1, - ) - .await; - - test_react( - block2, - context, - Some(broker), - Some(input_box), - input_blocks2, - final_expected.clone(), - ) - .await; - } - - { - // reconstruction - let block = 6944; - let context = new_context(0, 1000); - let input_blocks = vec![ - 56, // - // - 1078, // - 1091, // - 1159, // - 1204, // - 1227, // - 1280, // - 1298, // - 1442, // - 1637, // - // - 2827, // - 2881, // - 2883, // - 2887, // - 2891, // - 2934, // - // - 6160, // - 6864, // - 6944, // - ]; - test_react( - block, - context, - None, - None, - input_blocks, - final_expected, - ) - .await; - } - } -} diff --git a/offchain/dispatcher/src/drivers/mock.rs b/offchain/dispatcher/src/drivers/mock.rs deleted file mode 100644 index 951eb743e..000000000 --- a/offchain/dispatcher/src/drivers/mock.rs +++ /dev/null @@ -1,204 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use eth_state_fold_types::{ - ethereum_types::{Address, Bloom, H160, H256}, - Block, -}; -use im::{hashmap, Vector}; -use rollups_events::RollupsClaim; -use snafu::whatever; -use std::{ - collections::VecDeque, - ops::{Deref, DerefMut}, - sync::{ - atomic::{AtomicU16, Ordering}, - Arc, Mutex, - }, -}; -use types::foldables::{DAppInputBox, Input, InputBox}; - -use crate::machine::{ - rollups_broker::BrokerFacadeError, BrokerSend, BrokerStatus, RollupStatus, -}; - -// ------------------------------------------------------------------------------------------------ -// auxiliary functions -// ------------------------------------------------------------------------------------------------ - -pub fn new_block(number: u64) -> Block { - Block { - hash: H256::random(), - number: number.into(), - parent_hash: H256::random(), - timestamp: 0.into(), - logs_bloom: Bloom::default(), - } -} - -pub fn new_input(block: u64) -> Input { - Input { - sender: Arc::new(H160::random()), - payload: vec![], - block_added: Arc::new(new_block(block)), - dapp: Arc::new(H160::random()), - tx_hash: Arc::new(H256::default()), - } -} - -pub fn new_input_box() -> InputBox { - InputBox { - dapp_address: Arc::new(H160::random()), - input_box_address: Arc::new(H160::random()), - dapp_input_boxes: Arc::new(hashmap! {}), - } -} - -pub fn update_input_box( - input_box: InputBox, - dapp_address: Address, - blocks: Vec, -) -> InputBox { - let inputs = blocks - .iter() - .map(|block| Arc::new(new_input(*block))) - .collect::>(); - let inputs = Vector::from(inputs); - let dapp_input_boxes = input_box - .dapp_input_boxes - .update(Arc::new(dapp_address), Arc::new(DAppInputBox { inputs })); - InputBox { - dapp_address: Arc::new(dapp_address), - input_box_address: input_box.input_box_address, - dapp_input_boxes: Arc::new(dapp_input_boxes), - } -} - -// ------------------------------------------------------------------------------------------------ -// Broker -// ------------------------------------------------------------------------------------------------ - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Event { - Input(u64), // input index - FinishEpoch(u64), // index of the "external" epoch -} - -#[derive(Debug)] -pub struct Broker { - pub rollup_statuses: Mutex>, - pub next_claims: Mutex>, - pub events: Mutex>, - - finish_epochs: AtomicU16, - status_error: bool, - enqueue_input_error: bool, - finish_epoch_error: bool, -} - -impl Broker { - fn default() -> Self { - Self { - rollup_statuses: Mutex::new(VecDeque::new()), - next_claims: Mutex::new(VecDeque::new()), - events: Mutex::new(Vec::new()), - finish_epochs: AtomicU16::new(0), - status_error: false, - enqueue_input_error: false, - finish_epoch_error: false, - } - } - - pub fn new( - rollup_statuses: Vec, - next_claims: Vec, - ) -> Self { - let mut broker = Self::default(); - broker.rollup_statuses = Mutex::new(rollup_statuses.into()); - broker.next_claims = Mutex::new(next_claims.into()); - broker - } - - pub fn with_enqueue_input_error() -> Self { - let mut broker = Self::default(); - broker.enqueue_input_error = true; - broker - } - - pub fn with_finish_epoch_error() -> Self { - let mut broker = Self::default(); - broker.finish_epoch_error = true; - broker - } - - fn events_len(&self) -> usize { - let mutex_guard = self.events.lock().unwrap(); - mutex_guard.deref().len() - } - - fn get_event(&self, i: usize) -> Event { - let mutex_guard = self.events.lock().unwrap(); - mutex_guard.deref().get(i).unwrap().clone() - } - - pub fn assert_state(&self, expected: Vec) { - assert_eq!( - self.events_len(), - expected.len(), - "\n{:?}\n{:?}", - self.events.lock().unwrap().deref(), - expected - ); - println!("Events:"); - for (i, expected) in expected.iter().enumerate() { - let event = self.get_event(i); - println!("index: {:?} => {:?} - {:?}", i, event, expected); - assert_eq!(event, *expected); - } - } -} - -#[async_trait] -impl BrokerStatus for Broker { - async fn status(&self) -> Result { - if self.status_error { - whatever!("status error") - } else { - let mut mutex_guard = self.rollup_statuses.lock().unwrap(); - Ok(mutex_guard.deref_mut().pop_front().unwrap()) - } - } -} - -#[async_trait] -impl BrokerSend for Broker { - async fn enqueue_input( - &self, - input_index: u64, - _: &Input, - ) -> Result<(), BrokerFacadeError> { - if self.enqueue_input_error { - whatever!("enqueue_input error") - } else { - let mut mutex_guard = self.events.lock().unwrap(); - mutex_guard.deref_mut().push(Event::Input(input_index)); - Ok(()) - } - } - - async fn finish_epoch(&self, _: u64) -> Result<(), BrokerFacadeError> { - if self.finish_epoch_error { - whatever!("finish_epoch error") - } else { - let mut mutex_guard = self.events.lock().unwrap(); - let current_epoch = self.finish_epochs.load(Ordering::SeqCst); - mutex_guard - .deref_mut() - .push(Event::FinishEpoch(current_epoch.into())); - self.finish_epochs - .store(current_epoch + 1, Ordering::SeqCst); - Ok(()) - } - } -} diff --git a/offchain/dispatcher/src/drivers/mod.rs b/offchain/dispatcher/src/drivers/mod.rs deleted file mode 100644 index e40c82b93..000000000 --- a/offchain/dispatcher/src/drivers/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod context; -pub mod machine; - -pub use context::Context; - -#[cfg(test)] -mod mock; diff --git a/offchain/dispatcher/src/error.rs b/offchain/dispatcher/src/error.rs deleted file mode 100644 index 2b101ff7c..000000000 --- a/offchain/dispatcher/src/error.rs +++ /dev/null @@ -1,43 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use eth_state_client_lib::error::StateServerError; -use snafu::Snafu; -use std::net::AddrParseError; -use tonic::{codegen::http::uri::InvalidUri, transport::Error as TonicError}; - -use crate::machine; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum DispatcherError { - #[snafu(display("http server error"))] - HttpServerError { source: std::io::Error }, - - #[snafu(display("metrics address error"))] - MetricsAddressError { source: AddrParseError }, - - #[snafu(display("broker facade error"))] - BrokerError { - source: machine::rollups_broker::BrokerFacadeError, - }, - - #[snafu(display("connection error"))] - ChannelError { source: InvalidUri }, - - #[snafu(display("connection error"))] - ConnectError { source: TonicError }, - - #[snafu(display("state server error"))] - StateServerError { source: StateServerError }, - - #[snafu(display("can't start dispatcher with dirty broker"))] - DirtyBrokerError {}, - - #[snafu(whatever, display("{message}"))] - Whatever { - message: String, - #[snafu(source(from(Box, Some)))] - source: Option>, - }, -} diff --git a/offchain/dispatcher/src/lib.rs b/offchain/dispatcher/src/lib.rs deleted file mode 100644 index 35926d551..000000000 --- a/offchain/dispatcher/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod config; -pub mod dispatcher; -pub mod machine; - -mod drivers; -mod error; -mod metrics; -mod setup; - -use config::Config; -use error::DispatcherError; -use metrics::DispatcherMetrics; -use snafu::ResultExt; - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn run(config: Config) -> Result<(), DispatcherError> { - let metrics = DispatcherMetrics::default(); - let dispatcher_handle = - dispatcher::start(config.dispatcher_config, metrics.clone()); - let http_server_handle = - http_server::start(config.http_server_config, metrics.into()); - tokio::select! { - ret = http_server_handle => { - ret.context(error::HttpServerSnafu) - } - ret = dispatcher_handle => { - ret - } - } -} diff --git a/offchain/dispatcher/src/machine/mod.rs b/offchain/dispatcher/src/machine/mod.rs deleted file mode 100644 index 99a4f2623..000000000 --- a/offchain/dispatcher/src/machine/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod rollups_broker; - -use types::foldables::Input; - -use async_trait::async_trait; - -use self::rollups_broker::BrokerFacadeError; - -#[derive(Debug, Clone, Copy, Default)] -pub struct RollupStatus { - pub inputs_sent_count: u64, -} - -#[async_trait] -pub trait BrokerStatus: std::fmt::Debug { - async fn status(&self) -> Result; -} - -#[async_trait] -pub trait BrokerSend: std::fmt::Debug { - async fn enqueue_input( - &self, - input_index: u64, - input: &Input, - ) -> Result<(), BrokerFacadeError>; - async fn finish_epoch( - &self, - inputs_sent_count: u64, - ) -> Result<(), BrokerFacadeError>; -} diff --git a/offchain/dispatcher/src/machine/rollups_broker.rs b/offchain/dispatcher/src/machine/rollups_broker.rs deleted file mode 100644 index 99daa425d..000000000 --- a/offchain/dispatcher/src/machine/rollups_broker.rs +++ /dev/null @@ -1,513 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use snafu::{ResultExt, Snafu}; -use tokio::sync::{self, Mutex}; - -use rollups_events::{ - Broker, BrokerConfig, BrokerError, DAppMetadata, Event, InputMetadata, - RollupsAdvanceStateInput, RollupsData, RollupsInput, RollupsInputsStream, - INITIAL_ID, -}; -use types::foldables::Input; - -use super::{BrokerSend, BrokerStatus, RollupStatus}; - -#[derive(Debug, Snafu)] -pub enum BrokerFacadeError { - #[snafu(display("error connecting to the broker"))] - BrokerConnectionError { source: BrokerError }, - - #[snafu(display("error peeking at the end of the stream"))] - PeekInputError { source: BrokerError }, - - #[snafu(display("error producing input event"))] - ProduceInputError { source: BrokerError }, - - #[snafu(display("error producing finish-epoch event"))] - ProduceFinishError { source: BrokerError }, - - #[snafu(whatever, display("{message}"))] - Whatever { - message: String, - #[snafu(source(from(Box, Some)))] - source: Option>, - }, -} - -#[derive(Debug)] -pub struct BrokerFacade { - broker: Mutex, - inputs_stream: RollupsInputsStream, -} - -struct BrokerStreamStatus { - id: String, - epoch_number: u64, - status: RollupStatus, -} - -impl BrokerFacade { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn new( - config: BrokerConfig, - dapp_metadata: DAppMetadata, - ) -> Result { - tracing::trace!(?config, "connection to the broker"); - Ok(Self { - broker: Mutex::new( - Broker::new(config).await.context(BrokerConnectionSnafu)?, - ), - inputs_stream: RollupsInputsStream::new(&dapp_metadata), - }) - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn broker_status( - &self, - broker: &mut sync::MutexGuard<'_, Broker>, - ) -> Result { - let event = self.peek(broker).await?; - - let old_epoch_index = event - .clone() - .map(|event| event.payload.epoch_index) - .unwrap_or(0); - - // The epoch gets incremented inside this ".into()"! - // Check From> for BrokerStreamStatus - let status: BrokerStreamStatus = event.into(); - - // Asserting that the epoch_index is continuous. - let new_epoch_index = status.epoch_number; - assert!( - new_epoch_index == old_epoch_index - || new_epoch_index == old_epoch_index + 1 - ); - - Ok(status) - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn peek( - &self, - broker: &mut sync::MutexGuard<'_, Broker>, - ) -> Result>, BrokerFacadeError> { - tracing::trace!("peeking last produced event"); - let response = broker - .peek_latest(&self.inputs_stream) - .await - .context(PeekInputSnafu)?; - tracing::trace!(?response, "got response"); - - Ok(response) - } -} - -#[async_trait] -impl BrokerStatus for BrokerFacade { - #[tracing::instrument(level = "trace", skip_all)] - async fn status(&self) -> Result { - tracing::trace!("querying broker status"); - let mut broker = self.broker.lock().await; - let status = self.broker_status(&mut broker).await?.status; - tracing::trace!(?status, "returning rollup status"); - Ok(status) - } -} - -macro_rules! input_sanity_check { - ($event:expr, $input_index:expr) => { - assert_eq!($event.inputs_sent_count, $input_index + 1); - assert!(matches!( - $event.data, - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - epoch_index, - .. - }, - .. - }) if epoch_index == 0 - )); - assert!(matches!( - $event.data, - RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: InputMetadata { - input_index, - .. - }, - .. - }) if input_index == $input_index - )); - }; -} - -macro_rules! epoch_sanity_check { - ($event:expr, $inputs_sent_count:expr) => { - assert_eq!($event.inputs_sent_count, $inputs_sent_count); - assert!(matches!($event.data, RollupsData::FinishEpoch { .. })); - }; -} - -#[async_trait] -impl BrokerSend for BrokerFacade { - #[tracing::instrument(level = "trace", skip_all)] - async fn enqueue_input( - &self, - input_index: u64, - input: &Input, - ) -> Result<(), BrokerFacadeError> { - tracing::trace!(?input_index, ?input, "enqueueing input"); - - let mut broker = self.broker.lock().await; - let status = self.broker_status(&mut broker).await?; - - let event = build_next_input(input, &status); - tracing::info!(?event, "producing input event"); - - input_sanity_check!(event, input_index); - - let id = broker - .produce(&self.inputs_stream, event) - .await - .context(ProduceInputSnafu)?; - tracing::trace!(id, "produced event with id"); - - Ok(()) - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn finish_epoch( - &self, - inputs_sent_count: u64, - ) -> Result<(), BrokerFacadeError> { - tracing::info!(?inputs_sent_count, "finishing epoch"); - - let mut broker = self.broker.lock().await; - let status = self.broker_status(&mut broker).await?; // Epoch number gets incremented here! - - let event = build_next_finish_epoch(&status); - tracing::info!(?event, "producing finish epoch event"); - - epoch_sanity_check!(event, inputs_sent_count); - - let id = broker - .produce(&self.inputs_stream, event) - .await - .context(ProduceFinishSnafu)?; - - tracing::trace!(id, "produce event with id"); - - Ok(()) - } -} - -impl From for RollupStatus { - fn from(payload: RollupsInput) -> Self { - RollupStatus { - inputs_sent_count: payload.inputs_sent_count, - } - } -} - -impl From> for BrokerStreamStatus { - fn from(event: Event) -> Self { - let id = event.id; - let payload = event.payload; - let epoch_index = payload.epoch_index; - - match payload.data { - RollupsData::AdvanceStateInput { .. } => Self { - id, - epoch_number: epoch_index, - status: payload.into(), - }, - - RollupsData::FinishEpoch { .. } => Self { - id, - epoch_number: epoch_index + 1, // Epoch number being incremented! - status: payload.into(), - }, - } - } -} - -impl From>> for BrokerStreamStatus { - fn from(event: Option>) -> Self { - match event { - Some(e) => e.into(), - - None => Self { - id: INITIAL_ID.to_owned(), - epoch_number: 0, - status: RollupStatus::default(), - }, - } - } -} - -fn build_next_input( - input: &Input, - status: &BrokerStreamStatus, -) -> RollupsInput { - let metadata = InputMetadata { - msg_sender: input.sender.to_fixed_bytes().into(), - block_number: input.block_added.number.as_u64(), - timestamp: input.block_added.timestamp.as_u64(), - epoch_index: 0, - input_index: status.status.inputs_sent_count, - }; - - let data = RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata, - payload: input.payload.clone().into(), - tx_hash: input.tx_hash.0.into(), - }); - - RollupsInput { - parent_id: status.id.clone(), - epoch_index: status.epoch_number, - inputs_sent_count: status.status.inputs_sent_count + 1, - data, - } -} - -fn build_next_finish_epoch(status: &BrokerStreamStatus) -> RollupsInput { - RollupsInput { - parent_id: status.id.clone(), - epoch_index: status.epoch_number, - inputs_sent_count: status.status.inputs_sent_count, - data: RollupsData::FinishEpoch {}, - } -} - -#[cfg(test)] -mod broker_facade_tests { - use std::{sync::Arc, time::Duration}; - - use backoff::ExponentialBackoffBuilder; - use eth_state_fold_types::{ - ethereum_types::{Bloom, H160, H256, U256, U64}, - Block, - }; - use rollups_events::{ - BrokerConfig, BrokerEndpoint, DAppMetadata, Hash, InputMetadata, - Payload, RedactedUrl, RollupsAdvanceStateInput, RollupsData, Url, - }; - use test_fixtures::broker::BrokerFixture; - use testcontainers::clients::Cli; - use types::foldables::Input; - - use crate::machine::{ - rollups_broker::BrokerFacadeError, BrokerSend, BrokerStatus, - }; - - use super::BrokerFacade; - - // -------------------------------------------------------------------------------------------- - // new - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn new_ok() { - let docker = Cli::default(); - let (_fixture, _broker) = setup(&docker).await; - } - - #[tokio::test] - async fn new_error() { - let docker = Cli::default(); - let error = failable_setup(&docker, true) - .await - .err() - .expect("'status' function has not failed") - .to_string(); - // BrokerFacadeError::BrokerConnectionError - assert_eq!(error, "error connecting to the broker"); - } - - // -------------------------------------------------------------------------------------------- - // status - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn status_inputs_sent_count_equals_0() { - let docker = Cli::default(); - let (_fixture, broker) = setup(&docker).await; - let status = broker.status().await.expect("'status' function failed"); - assert_eq!(status.inputs_sent_count, 0); - } - - #[tokio::test] - async fn status_inputs_sent_count_equals_1() { - let docker = Cli::default(); - let (fixture, broker) = setup(&docker).await; - produce_advance_state_inputs(&fixture, 1).await; - let status = broker.status().await.expect("'status' function failed"); - assert_eq!(status.inputs_sent_count, 1); - } - - #[tokio::test] - async fn status_inputs_sent_count_equals_10() { - let docker = Cli::default(); - let (fixture, broker) = setup(&docker).await; - produce_advance_state_inputs(&fixture, 10).await; - let status = broker.status().await.expect("'status' function failed"); - assert_eq!(status.inputs_sent_count, 10); - } - - // -------------------------------------------------------------------------------------------- - // enqueue_input - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn enqueue_input_ok() { - let docker = Cli::default(); - let (_fixture, broker) = setup(&docker).await; - for i in 0..3 { - assert!(broker - .enqueue_input(i, &new_enqueue_input()) - .await - .is_ok()); - } - } - - #[tokio::test] - #[should_panic( - expected = "assertion `left == right` failed\n left: 1\n right: 6" - )] - async fn enqueue_input_assertion_error_1() { - let docker = Cli::default(); - let (_fixture, broker) = setup(&docker).await; - let _ = broker.enqueue_input(5, &new_enqueue_input()).await; - } - - #[tokio::test] - #[should_panic( - expected = "assertion `left == right` failed\n left: 5\n right: 6" - )] - async fn enqueue_input_assertion_error_2() { - let docker = Cli::default(); - let (_fixture, broker) = setup(&docker).await; - for i in 0..4 { - assert!(broker - .enqueue_input(i, &new_enqueue_input()) - .await - .is_ok()); - } - let _ = broker.enqueue_input(5, &new_enqueue_input()).await; - } - - // NOTE: cannot test result error because the dependency is not injectable. - - // -------------------------------------------------------------------------------------------- - // finish_epoch - // -------------------------------------------------------------------------------------------- - - #[tokio::test] - async fn finish_epoch_ok_1() { - let docker = Cli::default(); - let (_fixture, broker) = setup(&docker).await; - assert!(broker.finish_epoch(0).await.is_ok()); - // BONUS TEST: testing for a finished epoch with no inputs - assert!(broker.finish_epoch(0).await.is_ok()); - } - - #[tokio::test] - async fn finish_epoch_ok_2() { - let docker = Cli::default(); - let (fixture, broker) = setup(&docker).await; - let first_epoch_inputs = 3; - produce_advance_state_inputs(&fixture, first_epoch_inputs).await; - produce_finish_epoch_input(&fixture).await; - let second_epoch_inputs = 7; - produce_advance_state_inputs(&fixture, second_epoch_inputs).await; - let total_inputs = first_epoch_inputs + second_epoch_inputs; - assert!(broker.finish_epoch(total_inputs as u64).await.is_ok()); - } - - #[tokio::test] - #[should_panic( - expected = "assertion `left == right` failed\n left: 0\n right: 1" - )] - async fn finish_epoch_assertion_error() { - let docker = Cli::default(); - let (_fixture, broker) = setup(&docker).await; - let _ = broker.finish_epoch(1).await; - } - - // NOTE: cannot test result error because the dependency is not injectable. - - // -------------------------------------------------------------------------------------------- - // auxiliary - // -------------------------------------------------------------------------------------------- - - async fn failable_setup( - docker: &Cli, - should_fail: bool, - ) -> Result<(BrokerFixture, BrokerFacade), BrokerFacadeError> { - let fixture = BrokerFixture::setup(docker).await; - let redis_endpoint = if should_fail { - BrokerEndpoint::Single(RedactedUrl::new( - Url::parse("https://invalid.com").unwrap(), - )) - } else { - fixture.redis_endpoint().clone() - }; - let config = BrokerConfig { - redis_endpoint, - consume_timeout: 300000, - backoff: ExponentialBackoffBuilder::new() - .with_initial_interval(Duration::from_millis(1000)) - .with_max_elapsed_time(Some(Duration::from_millis(3000))) - .build(), - }; - let metadata = DAppMetadata { - chain_id: fixture.chain_id(), - dapp_address: fixture.dapp_address().clone(), - }; - let broker = BrokerFacade::new(config, metadata).await?; - Ok((fixture, broker)) - } - - async fn setup(docker: &Cli) -> (BrokerFixture, BrokerFacade) { - failable_setup(docker, false).await.unwrap() - } - - fn new_enqueue_input() -> Input { - Input { - sender: Arc::new(H160::random()), - payload: vec![], - block_added: Arc::new(Block { - hash: H256::random(), - number: U64::zero(), - parent_hash: H256::random(), - timestamp: U256::zero(), - logs_bloom: Bloom::default(), - }), - dapp: Arc::new(H160::random()), - tx_hash: Arc::new(H256::random()), - } - } - - async fn produce_advance_state_inputs(fixture: &BrokerFixture<'_>, n: u32) { - for _ in 0..n { - let _ = fixture - .produce_input_event(RollupsData::AdvanceStateInput( - RollupsAdvanceStateInput { - metadata: InputMetadata::default(), - payload: Payload::default(), - tx_hash: Hash::default(), - }, - )) - .await; - } - } - - async fn produce_finish_epoch_input(fixture: &BrokerFixture<'_>) { - let _ = fixture - .produce_input_event(RollupsData::FinishEpoch {}) - .await; - } -} diff --git a/offchain/dispatcher/src/main.rs b/offchain/dispatcher/src/main.rs deleted file mode 100644 index 2ef8247e3..000000000 --- a/offchain/dispatcher/src/main.rs +++ /dev/null @@ -1,15 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// NOTE: doesn't support History upgradability. -// NOTE: doesn't support changing epoch_duration in the middle of things. -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = dispatcher::config::Config::initialize()?; - - log::configure(&config.dispatcher_config.log_config); - - log::log_service_start(&config, "Dispatcher"); - - dispatcher::run(config).await.map_err(|e| e.into()) -} diff --git a/offchain/dispatcher/src/metrics.rs b/offchain/dispatcher/src/metrics.rs deleted file mode 100644 index 332e35938..000000000 --- a/offchain/dispatcher/src/metrics.rs +++ /dev/null @@ -1,40 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use http_server::{CounterRef, FamilyRef, Registry}; -use rollups_events::DAppMetadata; - -const METRICS_PREFIX: &str = "cartesi_rollups_dispatcher"; - -fn prefixed_metrics(name: &str) -> String { - format!("{}_{}", METRICS_PREFIX, name) -} - -#[derive(Debug, Clone, Default)] -pub struct DispatcherMetrics { - pub claims_sent: FamilyRef, - pub advance_inputs_sent: FamilyRef, - pub finish_epochs_sent: FamilyRef, -} - -impl From for Registry { - fn from(metrics: DispatcherMetrics) -> Self { - let mut registry = Registry::default(); - registry.register( - prefixed_metrics("claims_sent"), - "Counts the number of claims sent", - metrics.claims_sent, - ); - registry.register( - prefixed_metrics("advance_inputs_sent"), - "Counts the number of s sent", - metrics.advance_inputs_sent, - ); - registry.register( - prefixed_metrics("finish_epochs_sent"), - "Counts the number of s sent", - metrics.finish_epochs_sent, - ); - registry - } -} diff --git a/offchain/dispatcher/src/setup.rs b/offchain/dispatcher/src/setup.rs deleted file mode 100644 index c95d02bc0..000000000 --- a/offchain/dispatcher/src/setup.rs +++ /dev/null @@ -1,106 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use eth_state_client_lib::{ - config::SCConfig, error::StateServerError, BlockServer, - GrpcStateFoldClient, StateServer, -}; -use eth_state_fold_types::{ethereum_types::U64, BlockStreamItem}; -use rollups_events::DAppMetadata; -use snafu::{ensure, ResultExt}; -use tokio_stream::{Stream, StreamExt}; -use tonic::transport::Channel; -use types::foldables::{InputBox, InputBoxInitialState}; - -use crate::{ - config::DispatcherConfig, - drivers::Context, - error::{ - BrokerSnafu, ChannelSnafu, ConnectSnafu, DirtyBrokerSnafu, - DispatcherError, StateServerSnafu, - }, - machine::BrokerStatus, - metrics::DispatcherMetrics, -}; - -const BUFFER_LEN: usize = 256; - -pub async fn create_state_server( - config: &SCConfig, -) -> Result< - impl StateServer - + BlockServer, - DispatcherError, -> { - let channel = Channel::from_shared(config.grpc_endpoint.to_owned()) - .context(ChannelSnafu)? - .connect() - .await - .context(ConnectSnafu)?; - - Ok(GrpcStateFoldClient::new_from_channel(channel, config)) -} - -pub async fn create_block_subscription( - client: &impl BlockServer, - confirmations: usize, -) -> Result< - impl Stream> - + Send - + std::marker::Unpin, - DispatcherError, -> { - let s = client - .subscribe_blocks(confirmations) - .await - .context(StateServerSnafu)?; - - let s = { - use futures::StreamExt; - s.ready_chunks(BUFFER_LEN) - }; - - let s = s.filter_map( - |mut x| { - if x.len() == BUFFER_LEN { - None - } else { - x.pop() - } - }, - ); - - Ok(s) -} - -pub async fn create_context( - config: &DispatcherConfig, - block_server: &impl BlockServer, - broker: &impl BrokerStatus, - dapp_metadata: DAppMetadata, - metrics: DispatcherMetrics, -) -> Result { - let input_box_deployment_block_number = - U64::from(config.blockchain_config.input_box_deployment_block_number); - let genesis_block = block_server - .query_block(input_box_deployment_block_number) - .await - .context(StateServerSnafu)? - .number - .as_u64(); - - let status = broker.status().await.context(BrokerSnafu)?; - - // The dispatcher doesn't work properly if there are inputs in the broker from a previous run. - // Hence, we make sure that the broker is in a clean state before starting. - ensure!(status.inputs_sent_count == 0, DirtyBrokerSnafu); - - let context = Context::new( - genesis_block, - config.epoch_length, - dapp_metadata, - metrics, - ); - - Ok(context) -} diff --git a/offchain/graphql-server/Cargo.toml b/offchain/graphql-server/Cargo.toml deleted file mode 100644 index a75da1767..000000000 --- a/offchain/graphql-server/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "graphql-server" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-graphql-server" -path = "src/main.rs" - -[[bin]] -name = "generate-schema" -path = "src/schema/generate_schema.rs" - -[dependencies] -http-health-check = { path = "../http-health-check" } -log = { path = "../log" } -rollups-data = { path = "../data" } - -actix-cors.workspace = true -actix-web.workspace = true -clap = { workspace = true, features = ["derive", "env"] } -hex.workspace = true -juniper.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -tracing.workspace = true - -[dev-dependencies] -test-fixtures = { path = "../test-fixtures" } - -awc.workspace = true -serial_test.workspace = true -testcontainers.workspace = true diff --git a/offchain/graphql-server/README.md b/offchain/graphql-server/README.md deleted file mode 100644 index 86896eb6e..000000000 --- a/offchain/graphql-server/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# GraphQL Server - -This service exposes a GraphQL endpoint for easy querying of Rollups data. - -## Generating GraphQL Schema - -Run the following command to generate the GraphQL schema based on the Rust code: - -``` -cargo run --bin generate-schema -``` - -## Running - -To run the GraphQL server locally, you need to setup a PosgreSQL database as described in the data crate [README.md](../data/README.md). -Then, run the following command: - -``` -cargo run --bin graphql-server -- --postgres-password pw -``` diff --git a/offchain/graphql-server/src/config.rs b/offchain/graphql-server/src/config.rs deleted file mode 100644 index 6a7bbaea3..000000000 --- a/offchain/graphql-server/src/config.rs +++ /dev/null @@ -1,48 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; -use log::{LogConfig, LogEnvCliConfig}; -use rollups_data::{RepositoryCLIConfig, RepositoryConfig}; - -#[derive(Debug)] -pub struct GraphQLConfig { - pub repository_config: RepositoryConfig, - pub log_config: LogConfig, - pub graphql_host: String, - pub graphql_port: u16, - pub healthcheck_port: u16, -} - -#[derive(Parser)] -#[command(name = "graphql_server_config")] -#[command(about = "Configuration for graphql-server")] -pub struct CLIConfig { - #[command(flatten)] - repository_config: RepositoryCLIConfig, - - #[command(flatten)] - pub log_config: LogEnvCliConfig, - - #[arg(long, env, default_value = "127.0.0.1")] - pub graphql_host: String, - - #[arg(long, env, default_value_t = 4000)] - pub graphql_port: u16, - - /// Port of health check - #[arg(long, env = "GRAPHQL_HEALTHCHECK_PORT", default_value_t = 8080)] - pub healthcheck_port: u16, -} - -impl From for GraphQLConfig { - fn from(cli_config: CLIConfig) -> Self { - Self { - repository_config: cli_config.repository_config.into(), - log_config: cli_config.log_config.into(), - graphql_host: cli_config.graphql_host, - graphql_port: cli_config.graphql_port, - healthcheck_port: cli_config.healthcheck_port, - } - } -} diff --git a/offchain/graphql-server/src/error.rs b/offchain/graphql-server/src/error.rs deleted file mode 100644 index 2d87932b2..000000000 --- a/offchain/graphql-server/src/error.rs +++ /dev/null @@ -1,16 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::Snafu; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum GraphQLServerError { - #[snafu(display("health check error"))] - HealthCheckError { - source: http_health_check::HealthCheckError, - }, - - #[snafu(display("server error"))] - ServerError { source: std::io::Error }, -} diff --git a/offchain/graphql-server/src/http.rs b/offchain/graphql-server/src/http.rs deleted file mode 100644 index a079ed8a8..000000000 --- a/offchain/graphql-server/src/http.rs +++ /dev/null @@ -1,95 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::schema::{Context, Query, RollupsGraphQLScalarValue, Schema}; -use actix_cors::Cors; -use actix_web::dev::Server; -use actix_web::{ - middleware::Logger, web, web::Data, App, HttpResponse, HttpServer, - Responder, -}; -use juniper::http::playground::playground_source; -use juniper::http::GraphQLRequest; -use juniper::{EmptyMutation, EmptySubscription}; -use std::sync::Arc; - -struct HttpContext { - schema: Arc, - context: Context, -} - -pub fn start_service( - host: &str, - port: u16, - context: Context, -) -> std::io::Result { - Ok(HttpServer::new(move || { - let schema = std::sync::Arc::new(Schema::new_with_scalar_value( - Query, - EmptyMutation::new(), - EmptySubscription::new(), - )); - - let http_context = HttpContext { - schema: schema.clone(), - context: context.clone(), - }; - - let cors = Cors::permissive(); - - App::new() - .app_data(Data::new(http_context)) - .wrap(Logger::default()) - .wrap(cors) - .service(graphql) - .service(juniper_playground) - }) - .bind((host, port))? - .run()) -} - -#[actix_web::get("/graphql")] -async fn juniper_playground() -> impl Responder { - let html = playground_source("", None); - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html) -} - -#[actix_web::post("/graphql")] -async fn graphql( - query: web::Json>, - http_context: web::Data, -) -> HttpResponse { - // Execute resolvers in blocking thread as there are lot of blocking diesel db operations - let query = Arc::new(query); - let return_value: HttpResponse = match tokio::task::spawn_blocking( - move || { - let res = - query.execute_sync(&http_context.schema, &http_context.context); - serde_json::to_string(&res) - }, - ) - .await - { - Ok(value) => match value { - Ok(value) => HttpResponse::Ok() - .content_type("application/json") - .body(value), - Err(err) => { - let error_message = format!( - "unable to execute query, internal server error, details: {}", err - ); - return HttpResponse::BadRequest().body(error_message); - } - }, - Err(err) => { - let error_message = format!( - "unable to execute query, internal server error, details: {}", - err - ); - return HttpResponse::BadRequest().body(error_message); - } - }; - return_value -} diff --git a/offchain/graphql-server/src/lib.rs b/offchain/graphql-server/src/lib.rs deleted file mode 100644 index 8e476d82f..000000000 --- a/offchain/graphql-server/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::ResultExt; - -pub use config::{CLIConfig, GraphQLConfig}; -pub use error::GraphQLServerError; -pub use http::start_service; -pub use schema::Context; - -pub mod config; -mod error; -pub mod http; -pub mod schema; - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn run(config: GraphQLConfig) -> Result<(), GraphQLServerError> { - let repository = rollups_data::Repository::new(config.repository_config) - .expect("failed to connect to database"); - let context = Context::new(repository); - let service_handler = - start_service(&config.graphql_host, config.graphql_port, context) - .expect("failed to create server"); - - let health_handle = http_health_check::start(config.healthcheck_port); - - tokio::select! { - ret = health_handle => { - ret.context(error::HealthCheckSnafu) - } - ret = service_handler => { - ret.context(error::ServerSnafu) - } - } -} diff --git a/offchain/graphql-server/src/main.rs b/offchain/graphql-server/src/main.rs deleted file mode 100644 index 9ac649376..000000000 --- a/offchain/graphql-server/src/main.rs +++ /dev/null @@ -1,17 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; - -use graphql_server::{CLIConfig, GraphQLConfig}; - -#[actix_web::main] -async fn main() -> Result<(), Box> { - let config: GraphQLConfig = CLIConfig::parse().into(); - - log::configure(&config.log_config); - - log::log_service_start(&config, "GraphQL Server"); - - graphql_server::run(config).await.map_err(|e| e.into()) -} diff --git a/offchain/graphql-server/src/schema/generate_schema.rs b/offchain/graphql-server/src/schema/generate_schema.rs deleted file mode 100644 index 14b6c7b1e..000000000 --- a/offchain/graphql-server/src/schema/generate_schema.rs +++ /dev/null @@ -1,30 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// Note: it was impractical to generate schema with build.rs, -// because it is executed before crate is built, and many structures/entities -// from graphql module must be used to generate schema - -use juniper::{EmptyMutation, EmptySubscription}; -use std::fs::File; -use std::io::Write; - -use graphql_server::schema::{Query, Schema}; - -const GRAPHQL_SCHEMA_FILE: &str = "schema.graphql"; - -fn main() { - let schema = Schema::new_with_scalar_value( - Query {}, - EmptyMutation::new(), - EmptySubscription::new(), - ); - let graphql_schema = schema.as_schema_language(); - let mut graphql_schema_file = File::create(GRAPHQL_SCHEMA_FILE).unwrap(); - match write!(graphql_schema_file, "{}", graphql_schema) { - Ok(_) => {} - Err(e) => { - eprintln!("Error writing schema to file {}", e); - } - } -} diff --git a/offchain/graphql-server/src/schema/mod.rs b/offchain/graphql-server/src/schema/mod.rs deleted file mode 100644 index ff5e51dad..000000000 --- a/offchain/graphql-server/src/schema/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod resolvers; -mod scalar; - -pub use resolvers::{Context, Query}; -pub use scalar::RollupsGraphQLScalarValue; - -pub type Schema = juniper::RootNode< - 'static, - Query, - juniper::EmptyMutation, - juniper::EmptySubscription, - RollupsGraphQLScalarValue, ->; diff --git a/offchain/graphql-server/src/schema/resolvers.rs b/offchain/graphql-server/src/schema/resolvers.rs deleted file mode 100644 index 44b1d1c07..000000000 --- a/offchain/graphql-server/src/schema/resolvers.rs +++ /dev/null @@ -1,742 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use juniper::{ - graphql_object, DefaultScalarValue, FieldError, FieldResult, GraphQLEnum, - GraphQLInputObject, GraphQLObject, -}; -use std::time::UNIX_EPOCH; - -use rollups_data::Repository; -use rollups_data::{ - CompletionStatus as DbCompletionStatus, Connection, Edge, Input, - InputQueryFilter, Notice, NoticeQueryFilter, OutputEnum, - PageInfo as DbPageInfo, Proof, Report, ReportQueryFilter, Voucher, - VoucherQueryFilter, -}; - -use super::scalar::RollupsGraphQLScalarValue; - -#[derive(Clone)] -pub struct Context { - repository: Repository, -} - -impl Context { - pub fn new(repository: Repository) -> Self { - Self { repository } - } -} - -impl juniper::Context for Context {} - -pub struct Query; - -#[graphql_object( - context = Context, - Scalar = RollupsGraphQLScalarValue - description = "Top level queries" -)] -impl Query { - #[graphql(description = "Get input based on its identifier")] - fn input( - #[graphql(description = "Input index")] index: i32, - ) -> FieldResult { - executor - .context() - .repository - .get_input(index) - .map_err(convert_error) - } - - #[graphql(description = "Get voucher based on its index")] - fn voucher( - #[graphql(description = "Voucher index in input")] voucher_index: i32, - #[graphql(description = "Input index")] input_index: i32, - ) -> FieldResult { - executor - .context() - .repository - .get_voucher(voucher_index, input_index) - .map_err(convert_error) - } - - #[graphql(description = "Get notice based on its index")] - fn notice( - #[graphql(description = "Notice index in input")] notice_index: i32, - #[graphql(description = "Input index")] input_index: i32, - ) -> FieldResult { - executor - .context() - .repository - .get_notice(notice_index, input_index) - .map_err(convert_error) - } - - #[graphql(description = "Get report based on its index")] - fn report( - #[graphql(description = "Report index in input")] report_index: i32, - #[graphql(description = "Input index")] input_index: i32, - ) -> FieldResult { - executor - .context() - .repository - .get_report(report_index, input_index) - .map_err(convert_error) - } - - #[graphql(description = "Get inputs with support for pagination")] - fn inputs( - #[graphql( - description = "Get at most the first `n` entries (forward pagination)" - )] - first: Option, - #[graphql( - description = "Get at most the last `n` entries (backward pagination)" - )] - last: Option, - #[graphql( - description = "Get entries that come after the provided cursor (forward pagination)" - )] - after: Option, - #[graphql( - description = "Get entries that come before the provided cursor (backward pagination)" - )] - before: Option, - #[graphql(description = "Filter entries to retrieve")] r#where: Option< - InputFilter, - >, - ) -> FieldResult> { - let filter = r#where.map(InputFilter::into).unwrap_or_default(); - executor - .context() - .repository - .get_inputs(first, last, after, before, filter) - .map_err(convert_error) - } - - #[graphql(description = "Get vouchers with support for pagination")] - fn vouchers( - #[graphql( - description = "Get at most the first `n` entries (forward pagination)" - )] - first: Option, - #[graphql( - description = "Get at most the last `n` entries (backward pagination)" - )] - last: Option, - #[graphql( - description = "Get entries that come after the provided cursor (forward pagination)" - )] - after: Option, - #[graphql( - description = "Get entries that come before the provided cursor (backward pagination)" - )] - before: Option, - ) -> FieldResult> { - executor - .context() - .repository - .get_vouchers(first, last, after, before, Default::default()) - .map_err(convert_error) - } - - #[graphql(description = "Get notices with support for pagination")] - fn notices( - #[graphql( - description = "Get at most the first `n` entries (forward pagination)" - )] - first: Option, - #[graphql( - description = "Get at most the last `n` entries (backward pagination)" - )] - last: Option, - #[graphql( - description = "Get entries that come after the provided cursor (forward pagination)" - )] - after: Option, - #[graphql( - description = "Get entries that come before the provided cursor (backward pagination)" - )] - before: Option, - ) -> FieldResult> { - executor - .context() - .repository - .get_notices(first, last, after, before, Default::default()) - .map_err(convert_error) - } - - #[graphql(description = "Get reports with support for pagination")] - fn reports( - #[graphql( - description = "Get at most the first `n` entries (forward pagination)" - )] - first: Option, - #[graphql( - description = "Get at most the last `n` entries (backward pagination)" - )] - last: Option, - #[graphql( - description = "Get entries that come after the provided cursor (forward pagination)" - )] - after: Option, - #[graphql( - description = "Get entries that come before the provided cursor (backward pagination)" - )] - before: Option, - ) -> FieldResult> { - executor - .context() - .repository - .get_reports(first, last, after, before, Default::default()) - .map_err(convert_error) - } -} - -#[derive(GraphQLEnum)] -enum CompletionStatus { - Unprocessed, - Accepted, - Rejected, - Exception, - MachineHalted, - CycleLimitExceeded, - TimeLimitExceeded, - PayloadLengthLimitExceeded, -} - -impl From for CompletionStatus { - fn from(status: DbCompletionStatus) -> CompletionStatus { - match status { - DbCompletionStatus::Unprocessed => CompletionStatus::Unprocessed, - DbCompletionStatus::Accepted => CompletionStatus::Accepted, - DbCompletionStatus::Rejected => CompletionStatus::Rejected, - DbCompletionStatus::Exception => CompletionStatus::Exception, - DbCompletionStatus::MachineHalted => { - CompletionStatus::MachineHalted - } - DbCompletionStatus::CycleLimitExceeded => { - CompletionStatus::CycleLimitExceeded - } - DbCompletionStatus::TimeLimitExceeded => { - CompletionStatus::TimeLimitExceeded - } - DbCompletionStatus::PayloadLengthLimitExceeded => { - CompletionStatus::PayloadLengthLimitExceeded - } - } - } -} - -#[graphql_object( - context = Context, - Scalar = RollupsGraphQLScalarValue, - description = "Request submitted to the application to advance its state" -)] -impl Input { - #[graphql(description = "Input index starting from genesis")] - fn index(&self) -> i32 { - self.index - } - - #[graphql(description = "Status of the input")] - fn status(&self) -> CompletionStatus { - self.status.into() - } - - #[graphql(description = "Address responsible for submitting the input")] - fn msg_sender(&self) -> String { - hex_encode(&self.msg_sender) - } - - #[graphql( - description = "Timestamp associated with the input submission, as defined by the base layer's block in which it was recorded" - )] - fn timestamp(&self) -> i64 { - match self.timestamp.duration_since(UNIX_EPOCH) { - Ok(duration) => duration.as_secs() as i64, - Err(e) => { - tracing::warn!("failed to parse timestamp ({})", e); - 0 - } - } - } - - #[graphql( - description = "Number of the base layer block in which the input was recorded" - )] - fn block_number(&self) -> i64 { - self.block_number - } - - #[graphql( - description = "Input payload in Ethereum hex binary format, starting with '0x'" - )] - fn payload(&self) -> String { - hex_encode(&self.payload) - } - - #[graphql( - description = "Get voucher from this particular input given the voucher's index" - )] - fn voucher( - &self, - #[graphql(description = "Voucher index in input")] index: i32, - ) -> FieldResult { - executor - .context() - .repository - .get_voucher(index, self.index) - .map_err(convert_error) - } - - #[graphql( - description = "Get notice from this particular input given the notice's index" - )] - fn notice( - &self, - #[graphql(description = "Notice index in input")] index: i32, - ) -> FieldResult { - executor - .context() - .repository - .get_notice(index, self.index) - .map_err(convert_error) - } - - #[graphql( - description = "Get report from this particular input given the report's index" - )] - fn report( - &self, - #[graphql(description = "Report index in input")] index: i32, - ) -> FieldResult { - executor - .context() - .repository - .get_report(index, self.index) - .map_err(convert_error) - } - - #[graphql( - description = "Get vouchers from this particular input with support for pagination" - )] - fn vouchers( - &self, - #[graphql( - description = "Get at most the first `n` entries (forward pagination)" - )] - first: Option, - #[graphql( - description = "Get at most the last `n` entries (backward pagination)" - )] - last: Option, - #[graphql( - description = "Get entries that come after the provided cursor (forward pagination)" - )] - after: Option, - #[graphql( - description = "Get entries that come before the provided cursor (backward pagination)" - )] - before: Option, - ) -> FieldResult> { - let filter = VoucherQueryFilter { - input_index: Some(self.index), - }; - executor - .context() - .repository - .get_vouchers(first, last, after, before, filter) - .map_err(convert_error) - } - - #[graphql( - description = "Get notices from this particular input with support for pagination" - )] - fn notices( - &self, - #[graphql( - description = "Get at most the first `n` entries (forward pagination)" - )] - first: Option, - #[graphql( - description = "Get at most the last `n` entries (backward pagination)" - )] - last: Option, - #[graphql( - description = "Get entries that come after the provided cursor (forward pagination)" - )] - after: Option, - #[graphql( - description = "Get entries that come before the provided cursor (backward pagination)" - )] - before: Option, - ) -> FieldResult> { - let filter = NoticeQueryFilter { - input_index: Some(self.index), - }; - executor - .context() - .repository - .get_notices(first, last, after, before, filter) - .map_err(convert_error) - } - - #[graphql( - description = "Get reports from this particular input with support for pagination" - )] - fn reports( - &self, - #[graphql( - description = "Get at most the first `n` entries (forward pagination)" - )] - first: Option, - #[graphql( - description = "Get at most the last `n` entries (backward pagination)" - )] - last: Option, - #[graphql( - description = "Get entries that come after the provided cursor (forward pagination)" - )] - after: Option, - #[graphql( - description = "Get entries that come before the provided cursor (backward pagination)" - )] - before: Option, - ) -> FieldResult> { - let filter = ReportQueryFilter { - input_index: Some(self.index), - }; - executor - .context() - .repository - .get_reports(first, last, after, before, filter) - .map_err(convert_error) - } -} - -#[graphql_object( - context = Context, - Scalar = RollupsGraphQLScalarValue, - description = "Representation of a transaction that can be carried out on the base layer blockchain, such as a transfer of assets" -)] -impl Voucher { - #[graphql( - description = "Voucher index within the context of the input that produced it" - )] - fn index(&self) -> i32 { - self.index - } - - #[graphql(description = "Input whose processing produced the voucher")] - fn input(&self) -> FieldResult { - executor - .context() - .repository - .get_input(self.input_index) - .map_err(convert_error) - } - - #[graphql( - description = "Transaction destination address in Ethereum hex binary format (20 bytes), starting with '0x'" - )] - fn destination(&self) -> String { - hex_encode(&self.destination) - } - - #[graphql( - description = "Transaction payload in Ethereum hex binary format, starting with '0x'" - )] - fn payload(&self) -> String { - hex_encode(&self.payload) - } - - #[graphql( - description = "Proof object that allows this voucher to be validated and executed on the base layer blockchain" - )] - fn proof(&self) -> FieldResult> { - executor - .context() - .repository - .get_proof(self.input_index, self.index, OutputEnum::Voucher) - .map_err(convert_error) - } -} - -#[graphql_object( - context = Context, - Scalar = RollupsGraphQLScalarValue, - description = "Informational statement that can be validated in the base layer blockchain" -)] -impl Notice { - #[graphql( - description = "Notice index within the context of the input that produced it" - )] - fn index(&self) -> i32 { - self.index - } - - #[graphql(description = "Input whose processing produced the notice")] - fn input(&self) -> FieldResult { - executor - .context() - .repository - .get_input(self.input_index) - .map_err(convert_error) - } - - #[graphql( - description = "Notice data as a payload in Ethereum hex binary format, starting with '0x'" - )] - fn payload(&self) -> String { - hex_encode(&self.payload) - } - - #[graphql( - description = "Proof object that allows this notice to be validated by the base layer blockchain" - )] - fn proof(&self) -> FieldResult> { - executor - .context() - .repository - .get_proof(self.input_index, self.index, OutputEnum::Notice) - .map_err(convert_error) - } -} - -#[graphql_object( - context = Context, - Scalar = RollupsGraphQLScalarValue, - description = "Application log or diagnostic information" -)] -impl Report { - #[graphql( - description = "Report index within the context of the input that produced it" - )] - fn index(&self) -> i32 { - self.index - } - - #[graphql(description = "Input whose processing produced the report")] - fn input(&self) -> FieldResult { - executor - .context() - .repository - .get_input(self.input_index) - .map_err(convert_error) - } - - #[graphql( - description = "Report data as a payload in Ethereum hex binary format, starting with '0x'" - )] - fn payload(&self) -> String { - hex_encode(&self.payload) - } -} - -#[graphql_object( - context = Context, - Scalar = RollupsGraphQLScalarValue, - description = "Data that can be used as proof to validate notices and execute vouchers on the base layer blockchain" -)] -impl Proof { - #[graphql(description = "Validity proof for an output")] - fn validity(&self) -> OutputValidityProof { - OutputValidityProof { - input_index_within_epoch: self.validity_input_index_within_epoch, - output_index_within_input: self.validity_output_index_within_input, - output_hashes_root_hash: hex_encode( - &self.validity_output_hashes_root_hash, - ), - vouchers_epoch_root_hash: hex_encode( - &self.validity_vouchers_epoch_root_hash, - ), - notices_epoch_root_hash: hex_encode( - &self.validity_notices_epoch_root_hash, - ), - machine_state_hash: hex_encode(&self.validity_machine_state_hash), - output_hash_in_output_hashes_siblings: self - .validity_output_hash_in_output_hashes_siblings - .iter() - .map(|hash| hex_encode(hash.as_ref().unwrap_or(&vec![]))) - .collect(), - output_hashes_in_epoch_siblings: self - .validity_output_hashes_in_epoch_siblings - .iter() - .map(|hash| hex_encode(hash.as_ref().unwrap_or(&vec![]))) - .collect(), - } - } - - #[graphql( - description = "Data that allows the validity proof to be contextualized within submitted claims, given as a payload in Ethereum hex binary format, starting with '0x'" - )] - fn context(&self) -> String { - hex_encode(&self.context) - } -} - -#[derive(GraphQLObject, Debug, Clone)] -#[graphql( - description = "Validity proof for an output" - scalar = RollupsGraphQLScalarValue, -)] -struct OutputValidityProof { - #[graphql( - description = "Local input index within the context of the related epoch" - )] - pub input_index_within_epoch: i32, - - #[graphql( - description = "Output index within the context of the input that produced it" - )] - pub output_index_within_input: i32, - - #[graphql( - description = "Merkle root of all output hashes of the related input, given in Ethereum hex binary format (32 bytes), starting with '0x'" - )] - pub output_hashes_root_hash: String, - - #[graphql( - description = "Merkle root of all voucher hashes of the related epoch, given in Ethereum hex binary format (32 bytes), starting with '0x'" - )] - pub vouchers_epoch_root_hash: String, - - #[graphql( - description = "Merkle root of all notice hashes of the related epoch, given in Ethereum hex binary format (32 bytes), starting with '0x'" - )] - pub notices_epoch_root_hash: String, - - #[graphql( - description = "Hash of the machine state claimed for the related epoch, given in Ethereum hex binary format (32 bytes), starting with '0x'" - )] - pub machine_state_hash: String, - - #[graphql( - description = "Proof that this output hash is in the output-hashes merkle tree. This array of siblings is bottom-up ordered (from the leaf to the root). Each hash is given in Ethereum hex binary format (32 bytes), starting with '0x'." - )] - pub output_hash_in_output_hashes_siblings: Vec, - - #[graphql( - description = "Proof that this output-hashes root hash is in epoch's output merkle tree. This array of siblings is bottom-up ordered (from the leaf to the root). Each hash is given in Ethereum hex binary format (32 bytes), starting with '0x'." - )] - pub output_hashes_in_epoch_siblings: Vec, -} - -#[derive(Debug, Clone, GraphQLInputObject)] -#[graphql(scalar = RollupsGraphQLScalarValue)] -/// Filter object to restrict results depending on input properties -pub struct InputFilter { - /// Filter only inputs with index lower than a given value - pub index_lower_than: Option, - - /// Filter only inputs with index greater than a given value - pub index_greater_than: Option, -} - -impl From for InputQueryFilter { - fn from(filter: InputFilter) -> InputQueryFilter { - InputQueryFilter { - index_lower_than: filter.index_lower_than, - index_greater_than: filter.index_greater_than, - } - } -} - -#[derive(Debug, Clone, GraphQLObject)] -/// Page metadata for the cursor-based Connection pagination pattern -struct PageInfo { - /// Cursor pointing to the first entry of the page - start_cursor: Option, - - /// Cursor pointing to the last entry of the page - end_cursor: Option, - - /// Indicates if there are additional entries after the end curs - has_next_page: bool, - - /// Indicates if there are additional entries before the start curs - has_previous_page: bool, -} - -impl From<&DbPageInfo> for PageInfo { - fn from(page_info: &DbPageInfo) -> PageInfo { - PageInfo { - start_cursor: page_info - .start_cursor - .as_ref() - .map(|cursor| cursor.encode()), - end_cursor: page_info - .end_cursor - .as_ref() - .map(|cursor| cursor.encode()), - has_next_page: page_info.has_next_page, - has_previous_page: page_info.has_previous_page, - } - } -} - -/// Implement the Connection and Edge objects -macro_rules! impl_connection { - ($connection_name: literal, $edge_name: literal, $node: ty) => { - #[graphql_object( - name = $connection_name, - context = Context, - Scalar = RollupsGraphQLScalarValue, - description = "Pagination result" - )] - impl Connection<$node> { - #[graphql( - description = "Total number of entries that match the query" - )] - fn total_count(&self) -> i32 { - self.total_count - } - - #[graphql( - description = "Pagination entries returned for the current page" - )] - fn edges(&self) -> &Vec> { - &self.edges - } - - #[graphql(description = "Pagination metadata")] - fn page_info(&self) -> PageInfo { - (&self.page_info).into() - } - } - - #[graphql_object( - name = $edge_name - context = Context, - Scalar = RollupsGraphQLScalarValue, - description = "Pagination entry" - )] - impl Edge<$node> { - #[graphql(description = "Node instance")] - fn node(&self) -> &$node { - &self.node - } - - #[graphql(description = "Pagination cursor")] - fn cursor(&self) -> String { - self.cursor.encode() - } - } - }; -} - -impl_connection!("InputConnection", "InputEdge", Input); -impl_connection!("VoucherConnection", "VoucherEdge", Voucher); -impl_connection!("NoticeConnection", "NoticeEdge", Notice); -impl_connection!("ReportConnection", "ReportEdge", Report); - -fn convert_error(e: rollups_data::Error) -> FieldError { - tracing::warn!("Got error during query: {:?}", e); - e.into() -} - -pub fn hex_encode(data: &[u8]) -> String { - format!("0x{}", hex::encode(data)) -} diff --git a/offchain/graphql-server/src/schema/scalar.rs b/offchain/graphql-server/src/schema/scalar.rs deleted file mode 100644 index f2d2da2dc..000000000 --- a/offchain/graphql-server/src/schema/scalar.rs +++ /dev/null @@ -1,170 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) -// -// Parts of the code (BigInt scalar implementatation) is licenced -// under BSD 2-Clause Copyright (c) 2016, Magnus Hallin - -use juniper::parser::{ParseError, ScalarToken, Token}; -use juniper::{ - graphql_scalar, GraphQLScalarValue, ParseScalarResult, ScalarValue, Value, -}; - -/// Custom Graphql scalar definition, to be able to use long (signed 64) values -#[derive(Debug, Clone, PartialEq, GraphQLScalarValue)] -pub enum RollupsGraphQLScalarValue { - Int(i32), - BigInt(i64), - Float(f64), - String(String), - Boolean(bool), -} - -#[graphql_scalar(name = "BigInt")] -impl GraphQLScalar for i64 { - fn resolve(&self) -> Value { - // Convert to string because some clients can't handle 64 bits integers - Value::scalar(self.to_string()) - } - - fn from_input_value(v: &juniper::InputValue) -> Option { - v.as_scalar_value::().copied() - } - - fn from_str<'a>( - value: ScalarToken<'a>, - ) -> ParseScalarResult<'a, RollupsGraphQLScalarValue> { - if let ScalarToken::Int(v) = value { - v.parse() - .map_err(|_| ParseError::UnexpectedToken(Token::Scalar(value))) - .map(|s: i64| s.into()) - } else { - Err(ParseError::UnexpectedToken(Token::Scalar(value))) - } - } -} - -impl ScalarValue for RollupsGraphQLScalarValue { - type Visitor = RollupsGraphQLScalarValueVisitor; - - fn as_int(&self) -> Option { - match *self { - Self::Int(ref i) => Some(*i), - _ => None, - } - } - - fn as_string(&self) -> Option { - match *self { - Self::String(ref s) => Some(s.clone()), - _ => None, - } - } - - fn into_string(self) -> Option { - match self { - Self::String(s) => Some(s), - _ => None, - } - } - - fn as_str(&self) -> Option<&str> { - match *self { - Self::String(ref s) => Some(s.as_str()), - _ => None, - } - } - - fn as_float(&self) -> Option { - match *self { - Self::Int(ref i) => Some(*i as f64), - Self::Float(ref f) => Some(*f), - _ => None, - } - } - - fn as_boolean(&self) -> Option { - match *self { - Self::Boolean(ref b) => Some(*b), - _ => None, - } - } -} - -#[derive(Default)] -pub struct RollupsGraphQLScalarValueVisitor; - -impl<'de> serde::de::Visitor<'de> for RollupsGraphQLScalarValueVisitor { - type Value = RollupsGraphQLScalarValue; - - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { - formatter.write_str("a valid input value") - } - - fn visit_bool( - self, - value: bool, - ) -> Result { - Ok(RollupsGraphQLScalarValue::Boolean(value)) - } - - fn visit_i32(self, value: i32) -> Result - where - E: serde::de::Error, - { - Ok(RollupsGraphQLScalarValue::Int(value)) - } - - fn visit_i64(self, value: i64) -> Result - where - E: serde::de::Error, - { - if value <= i32::max_value() as i64 { - self.visit_i32(value as i32) - } else { - Ok(RollupsGraphQLScalarValue::BigInt(value)) - } - } - - fn visit_u32(self, value: u32) -> Result - where - E: serde::de::Error, - { - if value <= i32::max_value() as u32 { - self.visit_i32(value as i32) - } else { - self.visit_u64(value as u64) - } - } - - fn visit_u64(self, value: u64) -> Result - where - E: serde::de::Error, - { - if value <= i64::MAX as u64 { - self.visit_i64(value as i64) - } else { - Ok(RollupsGraphQLScalarValue::Float(value as f64)) - } - } - - fn visit_f64(self, value: f64) -> Result { - Ok(RollupsGraphQLScalarValue::Float(value)) - } - - fn visit_str(self, value: &str) -> Result - where - E: serde::de::Error, - { - self.visit_string(value.into()) - } - - fn visit_string( - self, - value: String, - ) -> Result { - Ok(RollupsGraphQLScalarValue::String(value)) - } -} diff --git a/offchain/graphql-server/tests/integration.rs b/offchain/graphql-server/tests/integration.rs deleted file mode 100644 index e1d6ca6be..000000000 --- a/offchain/graphql-server/tests/integration.rs +++ /dev/null @@ -1,595 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use actix_web::dev::ServerHandle; -use actix_web::rt::spawn; -use awc::{Client, ClientRequest}; -use graphql_server::{http, schema::Context}; -use rollups_data::{ - CompletionStatus, Input, Notice, Proof, Report, Repository, Voucher, -}; -use std::fs::read_to_string; -use std::str::from_utf8; -use std::time::{Duration, UNIX_EPOCH}; -use test_fixtures::RepositoryFixture; -use testcontainers::clients::Cli; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; - -const QUERY_PATH: &str = "tests/queries/"; -const RESPONSE_PATH: &str = "tests/responses/"; -const HOST: &str = "127.0.0.1"; -const PORT: u16 = 4003; - -struct TestState<'d> { - repository: RepositoryFixture<'d>, - server: GraphQLServerWrapper, -} - -impl TestState<'_> { - async fn setup(docker: &Cli) -> TestState<'_> { - let repository = RepositoryFixture::setup(docker); - let server = - GraphQLServerWrapper::spawn_server(repository.repository().clone()) - .await; - TestState { repository, server } - } - - async fn populate_database(&self) { - let input = Input { - index: 0, - msg_sender: "msg-sender".as_bytes().to_vec(), - tx_hash: "tx-hash".as_bytes().to_vec(), - block_number: 0, - timestamp: UNIX_EPOCH + Duration::from_secs(1676489717), - payload: "input-0".as_bytes().to_vec(), - status: CompletionStatus::Accepted, - }; - - let notice = Notice { - input_index: 0, - index: 0, - payload: "notice-0-0".as_bytes().to_vec(), - }; - - let voucher = Voucher { - input_index: 0, - index: 0, - destination: "destination".as_bytes().to_vec(), - payload: "voucher-0-0".as_bytes().to_vec(), - }; - - let report = Report { - input_index: 0, - index: 0, - payload: "report-0-0".as_bytes().to_vec(), - }; - - let proof_voucher = Proof { - input_index: 0, - output_index: 0, - output_enum: rollups_data::OutputEnum::Voucher, - validity_input_index_within_epoch: 0, - validity_output_index_within_input: 0, - validity_output_hashes_root_hash: "".as_bytes().to_vec(), - validity_vouchers_epoch_root_hash: "".as_bytes().to_vec(), - validity_notices_epoch_root_hash: "".as_bytes().to_vec(), - validity_machine_state_hash: "".as_bytes().to_vec(), - validity_output_hash_in_output_hashes_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - validity_output_hashes_in_epoch_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - context: "".as_bytes().to_vec(), - }; - - let proof_notice = Proof { - input_index: 0, - output_index: 0, - output_enum: rollups_data::OutputEnum::Notice, - validity_input_index_within_epoch: 0, - validity_output_index_within_input: 0, - validity_output_hashes_root_hash: "".as_bytes().to_vec(), - validity_vouchers_epoch_root_hash: "" - .as_bytes() - .to_vec(), - validity_notices_epoch_root_hash: "".as_bytes().to_vec(), - validity_machine_state_hash: "".as_bytes().to_vec(), - validity_output_hash_in_output_hashes_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - validity_output_hashes_in_epoch_siblings: vec![Some( - "".as_bytes().to_vec(), - )], - context: "".as_bytes().to_vec(), - }; - - let repo = self.repository.repository(); - - repo.insert_input(input.clone()) - .expect("Failed to insert input"); - - repo.insert_notice(notice.clone()) - .expect("Failed to insert notice"); - - repo.insert_voucher(voucher.clone()) - .expect("Failed to insert voucher"); - - repo.insert_report(report.clone()) - .expect("Failed to insert report"); - - repo.insert_proof(proof_notice.clone()) - .expect("Failed to insert notice type proof"); - - repo.insert_proof(proof_voucher.clone()) - .expect("Failed to insert voucher type proof"); - } - - async fn populate_for_pagination(&self) { - let input = Input { - index: 0, - msg_sender: "msg-sender".as_bytes().to_vec(), - tx_hash: "tx-hash".as_bytes().to_vec(), - block_number: 0, - timestamp: UNIX_EPOCH + Duration::from_secs(1676489717), - payload: "input-0".as_bytes().to_vec(), - status: CompletionStatus::Accepted, - }; - - let notice0 = Notice { - input_index: 0, - index: 0, - payload: "notice-0-0".as_bytes().to_vec(), - }; - - let notice1 = Notice { - input_index: 0, - index: 1, - payload: "notice-0-1".as_bytes().to_vec(), - }; - - let notice2 = Notice { - input_index: 0, - index: 2, - payload: "notice-0-2".as_bytes().to_vec(), - }; - - let notice3 = Notice { - input_index: 0, - index: 3, - payload: "notice-0-3".as_bytes().to_vec(), - }; - - let notice4 = Notice { - input_index: 0, - index: 4, - payload: "notice-0-4".as_bytes().to_vec(), - }; - - let repo = self.repository.repository(); - - repo.insert_input(input.clone()) - .expect("Failed to insert input"); - - repo.insert_notice(notice0.clone()) - .expect("Failed to insert notice"); - - repo.insert_notice(notice1.clone()) - .expect("Failed to insert notice"); - - repo.insert_notice(notice2.clone()) - .expect("Failed to insert notice"); - - repo.insert_notice(notice3.clone()) - .expect("Failed to insert notice"); - - repo.insert_notice(notice4.clone()) - .expect("Failed to insert notice"); - } -} - -pub struct GraphQLServerWrapper { - server_handle: ServerHandle, - join_handle: JoinHandle>, -} - -impl GraphQLServerWrapper { - async fn spawn_server(repository: Repository) -> Self { - let context = Context::new(repository); - let (tx, rx) = oneshot::channel(); - - let join_handle = spawn( - async { - let service_handler = http::start_service(HOST, PORT, context) - .expect("failed to create server"); - tx.send(service_handler.handle()) - .expect("failed to send server handle"); - service_handler - } - .await, - ); - let server_handle = rx.await.expect("failed to received server handle"); - Self { - server_handle, - join_handle, - } - } - - pub async fn stop(self) { - self.server_handle.stop(true).await; - self.join_handle - .await - .expect("failed to stop graphql server") - .expect("failed to stop graphql server"); - } -} - -#[actix_web::test] -#[serial_test::serial] -async fn get_graphql() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - - let req = create_get_request("graphql"); - let res = req.send().await.expect("Should get from graphql"); - test.server.stop().await; - - assert_eq!(res.status(), awc::http::StatusCode::from_u16(200).unwrap()); - assert_eq!(res.headers().get("content-length").unwrap(), "19050"); -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_notice() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("notice.json").await; - assert_from_body(body, "notice.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_notice_with_input() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("notice_with_input.json").await; - assert_from_body(body, "notice_with_input.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_notice_with_proof() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("notice_with_proof.json").await; - assert_from_body(body, "notice_with_proof.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_proof_from_notice() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("proof_from_notice.json").await; - assert_from_body(body, "proof_from_notice.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_notices() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("notices.json").await; - assert_from_body(body, "notices.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_voucher() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("voucher.json").await; - assert_from_body(body, "voucher.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_voucher_with_input() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("voucher_with_input.json").await; - assert_from_body(body, "voucher_with_input.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_voucher_with_proof() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("voucher_with_proof.json").await; - assert_from_body(body, "voucher_with_proof.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_proof_from_voucher() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("proof_from_voucher.json").await; - assert_from_body(body, "proof_from_voucher.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_vouchers() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("vouchers.json").await; - assert_from_body(body, "vouchers.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_report() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("report.json").await; - assert_from_body(body, "report.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_report_with_input() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("report_with_input.json").await; - assert_from_body(body, "report_with_input.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_reports() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("reports.json").await; - assert_from_body(body, "reports.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_with_variables() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("variables.json").await; - assert_from_body(body, "variables.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_input() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("input.json").await; - assert_from_body(body, "input.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_input_with_voucher() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("input_with_voucher.json").await; - assert_from_body(body, "input_with_voucher.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_input_with_vouchers() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("input_with_vouchers.json").await; - assert_from_body(body, "input_with_vouchers.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_input_with_notice() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("input_with_notice.json").await; - assert_from_body(body, "input_with_notice.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_input_with_notices() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("input_with_notices.json").await; - assert_from_body(body, "input_with_notices.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_input_with_report() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("input_with_report.json").await; - assert_from_body(body, "input_with_report.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_input_with_reports() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("input_with_reports.json").await; - assert_from_body(body, "input_with_reports.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_inputs() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("inputs.json").await; - assert_from_body(body, "inputs.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_next_page() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_for_pagination().await; - - let body = post_query_request("next_page.json").await; - assert_from_body(body, "next_page.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_previous_page() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_for_pagination().await; - - let body = post_query_request("previous_page.json").await; - assert_from_body(body, "previous_page.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_error_missing_argument() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("error_missing_argument.json").await; - assert_from_body(body, "error_missing_argument.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_error_not_found() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("error_not_found.json").await; - assert_from_body(body, "error_not_found.json"); - test.server.stop().await; -} - -#[actix_web::test] -#[serial_test::serial] -async fn query_error_unknown_field() { - let docker = Cli::default(); - let test = TestState::setup(&docker).await; - test.populate_database().await; - - let body = post_query_request("error_unknown_field.json").await; - assert_from_body(body, "error_unknown_field.json"); - test.server.stop().await; -} - -fn create_get_request(endpoint: &str) -> ClientRequest { - let client = Client::default(); - - client - .get(format!("http://localhost:{}/{}", PORT, endpoint)) - .insert_header(("Content-type", "text/html; charset=utf-8")) -} - -async fn post_query_request(query_file: &str) -> actix_web::web::Bytes { - let query = String::from(QUERY_PATH) + query_file; - let client = Client::builder().timeout(Duration::from_secs(5)).finish(); - let mut response = client - .post(format!("http://localhost:{}/graphql", PORT)) - .insert_header(("Content-type", "application/json")) - .send_body(read_to_string(query).expect("Should read request file")) - .await - .expect("Should query server"); - - let body = response.body().await.expect("Should be body"); - - body -} - -fn assert_from_body(body: actix_web::web::Bytes, res_file: &str) { - let response = String::from(RESPONSE_PATH) + res_file; - assert_eq!( - from_utf8(&body).expect("Should contain response body"), - read_to_string(response).expect("Should read response file") - ); -} diff --git a/offchain/graphql-server/tests/queries/error_missing_argument.json b/offchain/graphql-server/tests/queries/error_missing_argument.json deleted file mode 100644 index 061bbf0e4..000000000 --- a/offchain/graphql-server/tests/queries/error_missing_argument.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notice(noticeIndex: 0){index, payload}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/error_not_found.json b/offchain/graphql-server/tests/queries/error_not_found.json deleted file mode 100644 index f717ba3be..000000000 --- a/offchain/graphql-server/tests/queries/error_not_found.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notice(noticeIndex: 1, inputIndex: 0){index, payload}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/error_unknown_field.json b/offchain/graphql-server/tests/queries/error_unknown_field.json deleted file mode 100644 index 23f5ab6c8..000000000 --- a/offchain/graphql-server/tests/queries/error_unknown_field.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notice(noticeIndex: 0, inputIndex: 0){test}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/input.json b/offchain/graphql-server/tests/queries/input.json deleted file mode 100644 index 49ed65209..000000000 --- a/offchain/graphql-server/tests/queries/input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{input(index: 0){index, msgSender, timestamp, blockNumber, payload, status}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/input_with_notice.json b/offchain/graphql-server/tests/queries/input_with_notice.json deleted file mode 100644 index b51f749ca..000000000 --- a/offchain/graphql-server/tests/queries/input_with_notice.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{input(index: 0){index, notice(index: 0){index}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/input_with_notices.json b/offchain/graphql-server/tests/queries/input_with_notices.json deleted file mode 100644 index 902f6900f..000000000 --- a/offchain/graphql-server/tests/queries/input_with_notices.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{input(index: 0){index, notices(first: 10){totalCount, edges {node {index}}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/input_with_report.json b/offchain/graphql-server/tests/queries/input_with_report.json deleted file mode 100644 index acd35341b..000000000 --- a/offchain/graphql-server/tests/queries/input_with_report.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{input(index: 0){index, report(index: 0){index}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/input_with_reports.json b/offchain/graphql-server/tests/queries/input_with_reports.json deleted file mode 100644 index be750c6fa..000000000 --- a/offchain/graphql-server/tests/queries/input_with_reports.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{input(index: 0){index, reports(first: 10){totalCount, edges {node {index}}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/input_with_voucher.json b/offchain/graphql-server/tests/queries/input_with_voucher.json deleted file mode 100644 index 5bfaf5327..000000000 --- a/offchain/graphql-server/tests/queries/input_with_voucher.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{input(index: 0){index, voucher(index: 0){index}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/input_with_vouchers.json b/offchain/graphql-server/tests/queries/input_with_vouchers.json deleted file mode 100644 index 24803ed7c..000000000 --- a/offchain/graphql-server/tests/queries/input_with_vouchers.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{input(index: 0){index, vouchers(first: 10){totalCount, edges {node {index}}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/inputs.json b/offchain/graphql-server/tests/queries/inputs.json deleted file mode 100644 index c5f3da755..000000000 --- a/offchain/graphql-server/tests/queries/inputs.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{inputs(first: 10, where: {indexLowerThan: 5}){totalCount, edges {node {index, msgSender, timestamp, blockNumber, payload}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/next_page.json b/offchain/graphql-server/tests/queries/next_page.json deleted file mode 100644 index 18ad92fc2..000000000 --- a/offchain/graphql-server/tests/queries/next_page.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notices(first: 2, after: \"Mg==\"){totalCount, edges {node {index, payload}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/notice.json b/offchain/graphql-server/tests/queries/notice.json deleted file mode 100644 index 97361cabb..000000000 --- a/offchain/graphql-server/tests/queries/notice.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notice(noticeIndex: 0, inputIndex: 0){index, payload}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/notice_with_input.json b/offchain/graphql-server/tests/queries/notice_with_input.json deleted file mode 100644 index 5ef1aa4ea..000000000 --- a/offchain/graphql-server/tests/queries/notice_with_input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notice(noticeIndex: 0, inputIndex: 0){index, input {index}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/notice_with_proof.json b/offchain/graphql-server/tests/queries/notice_with_proof.json deleted file mode 100644 index 97aa962ff..000000000 --- a/offchain/graphql-server/tests/queries/notice_with_proof.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notice(noticeIndex: 0, inputIndex: 0){index, proof {context}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/notices.json b/offchain/graphql-server/tests/queries/notices.json deleted file mode 100644 index c14b1f6f0..000000000 --- a/offchain/graphql-server/tests/queries/notices.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notices(first: 10){totalCount, edges {node {index, payload}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/previous_page.json b/offchain/graphql-server/tests/queries/previous_page.json deleted file mode 100644 index 4ff79080c..000000000 --- a/offchain/graphql-server/tests/queries/previous_page.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notices(last: 2, before: \"Mg==\"){totalCount, edges {node {index, payload}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/proof_from_notice.json b/offchain/graphql-server/tests/queries/proof_from_notice.json deleted file mode 100644 index 9579a89f5..000000000 --- a/offchain/graphql-server/tests/queries/proof_from_notice.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{notice(noticeIndex: 0, inputIndex: 0){proof{validity{inputIndexWithinEpoch, outputIndexWithinInput, outputHashesRootHash, vouchersEpochRootHash, noticesEpochRootHash, machineStateHash, outputHashInOutputHashesSiblings, outputHashesInEpochSiblings}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/proof_from_voucher.json b/offchain/graphql-server/tests/queries/proof_from_voucher.json deleted file mode 100644 index 280b09ca6..000000000 --- a/offchain/graphql-server/tests/queries/proof_from_voucher.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{voucher(voucherIndex: 0, inputIndex: 0){proof{validity{inputIndexWithinEpoch, outputIndexWithinInput, outputHashesRootHash, vouchersEpochRootHash, noticesEpochRootHash, machineStateHash, outputHashInOutputHashesSiblings, outputHashesInEpochSiblings}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/report.json b/offchain/graphql-server/tests/queries/report.json deleted file mode 100644 index e359e904e..000000000 --- a/offchain/graphql-server/tests/queries/report.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{report(reportIndex: 0, inputIndex: 0){index, payload}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/report_with_input.json b/offchain/graphql-server/tests/queries/report_with_input.json deleted file mode 100644 index 79e67e3b6..000000000 --- a/offchain/graphql-server/tests/queries/report_with_input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{report(reportIndex: 0, inputIndex: 0){index, input {index}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/reports.json b/offchain/graphql-server/tests/queries/reports.json deleted file mode 100644 index ea3701be2..000000000 --- a/offchain/graphql-server/tests/queries/reports.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{reports(first: 10){totalCount, edges {node {index, payload}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/variables.json b/offchain/graphql-server/tests/queries/variables.json deleted file mode 100644 index 05bff708b..000000000 --- a/offchain/graphql-server/tests/queries/variables.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "query": "query getInput($inputIndex: Int!) { input(index: $inputIndex) { blockNumber } }", - "variables": { - "inputIndex": 0 - } -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/voucher.json b/offchain/graphql-server/tests/queries/voucher.json deleted file mode 100644 index a41494655..000000000 --- a/offchain/graphql-server/tests/queries/voucher.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{voucher(voucherIndex: 0, inputIndex: 0){index, destination, payload}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/voucher_with_input.json b/offchain/graphql-server/tests/queries/voucher_with_input.json deleted file mode 100644 index 7ac04854f..000000000 --- a/offchain/graphql-server/tests/queries/voucher_with_input.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{voucher(voucherIndex: 0, inputIndex: 0){index, input {index}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/voucher_with_proof.json b/offchain/graphql-server/tests/queries/voucher_with_proof.json deleted file mode 100644 index 3fccdbfaf..000000000 --- a/offchain/graphql-server/tests/queries/voucher_with_proof.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{voucher(voucherIndex: 0, inputIndex: 0){index, proof {context}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/queries/vouchers.json b/offchain/graphql-server/tests/queries/vouchers.json deleted file mode 100644 index 626ae4cca..000000000 --- a/offchain/graphql-server/tests/queries/vouchers.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "query": "{vouchers(first: 10){totalCount, edges {node {index, destination, payload}}}}" -} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/error_missing_argument.json b/offchain/graphql-server/tests/responses/error_missing_argument.json deleted file mode 100644 index a2da7b9d6..000000000 --- a/offchain/graphql-server/tests/responses/error_missing_argument.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":[{"message":"Field \"notice\" argument \"inputIndex\" of type \"Int!\" is required but not provided","locations":[{"line":1,"column":2}]}]} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/error_not_found.json b/offchain/graphql-server/tests/responses/error_not_found.json deleted file mode 100644 index 09bbabae2..000000000 --- a/offchain/graphql-server/tests/responses/error_not_found.json +++ /dev/null @@ -1 +0,0 @@ -{"data":null,"errors":[{"message":"notice not found","locations":[{"line":1,"column":2}],"path":["notice"]}]} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/error_unknown_field.json b/offchain/graphql-server/tests/responses/error_unknown_field.json deleted file mode 100644 index 1a7d4433e..000000000 --- a/offchain/graphql-server/tests/responses/error_unknown_field.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":[{"message":"Unknown field \"test\" on type \"Notice\"","locations":[{"line":1,"column":40}]}]} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/input.json b/offchain/graphql-server/tests/responses/input.json deleted file mode 100644 index 82bea2976..000000000 --- a/offchain/graphql-server/tests/responses/input.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"index":0,"msgSender":"0x6d73672d73656e646572","timestamp":"1676489717","blockNumber":"0","payload":"0x696e7075742d30","status":"ACCEPTED"}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/input_with_notice.json b/offchain/graphql-server/tests/responses/input_with_notice.json deleted file mode 100644 index 8c71d0d7b..000000000 --- a/offchain/graphql-server/tests/responses/input_with_notice.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"index":0,"notice":{"index":0}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/input_with_notices.json b/offchain/graphql-server/tests/responses/input_with_notices.json deleted file mode 100644 index 45f656449..000000000 --- a/offchain/graphql-server/tests/responses/input_with_notices.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"index":0,"notices":{"totalCount":1,"edges":[{"node":{"index":0}}]}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/input_with_report.json b/offchain/graphql-server/tests/responses/input_with_report.json deleted file mode 100644 index aa033ec47..000000000 --- a/offchain/graphql-server/tests/responses/input_with_report.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"index":0,"report":{"index":0}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/input_with_reports.json b/offchain/graphql-server/tests/responses/input_with_reports.json deleted file mode 100644 index 7c14d26ed..000000000 --- a/offchain/graphql-server/tests/responses/input_with_reports.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"index":0,"reports":{"totalCount":1,"edges":[{"node":{"index":0}}]}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/input_with_voucher.json b/offchain/graphql-server/tests/responses/input_with_voucher.json deleted file mode 100644 index e3f185514..000000000 --- a/offchain/graphql-server/tests/responses/input_with_voucher.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"index":0,"voucher":{"index":0}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/input_with_vouchers.json b/offchain/graphql-server/tests/responses/input_with_vouchers.json deleted file mode 100644 index 99fb230db..000000000 --- a/offchain/graphql-server/tests/responses/input_with_vouchers.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"index":0,"vouchers":{"totalCount":1,"edges":[{"node":{"index":0}}]}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/inputs.json b/offchain/graphql-server/tests/responses/inputs.json deleted file mode 100644 index 69fc44104..000000000 --- a/offchain/graphql-server/tests/responses/inputs.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"inputs":{"totalCount":1,"edges":[{"node":{"index":0,"msgSender":"0x6d73672d73656e646572","timestamp":"1676489717","blockNumber":"0","payload":"0x696e7075742d30"}}]}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/next_page.json b/offchain/graphql-server/tests/responses/next_page.json deleted file mode 100644 index b1c31b446..000000000 --- a/offchain/graphql-server/tests/responses/next_page.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"notices":{"totalCount":5,"edges":[{"node":{"index":3,"payload":"0x6e6f746963652d302d33"}},{"node":{"index":4,"payload":"0x6e6f746963652d302d34"}}]}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/notice.json b/offchain/graphql-server/tests/responses/notice.json deleted file mode 100644 index a7e66e24e..000000000 --- a/offchain/graphql-server/tests/responses/notice.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"notice":{"index":0,"payload":"0x6e6f746963652d302d30"}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/notice_with_input.json b/offchain/graphql-server/tests/responses/notice_with_input.json deleted file mode 100644 index a325acde9..000000000 --- a/offchain/graphql-server/tests/responses/notice_with_input.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"notice":{"index":0,"input":{"index":0}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/notice_with_proof.json b/offchain/graphql-server/tests/responses/notice_with_proof.json deleted file mode 100644 index a71d7e467..000000000 --- a/offchain/graphql-server/tests/responses/notice_with_proof.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"notice":{"index":0,"proof":{"context":"0x3c636f6e746578743e"}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/notices.json b/offchain/graphql-server/tests/responses/notices.json deleted file mode 100644 index 2ea97b605..000000000 --- a/offchain/graphql-server/tests/responses/notices.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"notices":{"totalCount":1,"edges":[{"node":{"index":0,"payload":"0x6e6f746963652d302d30"}}]}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/previous_page.json b/offchain/graphql-server/tests/responses/previous_page.json deleted file mode 100644 index 7e48c51f9..000000000 --- a/offchain/graphql-server/tests/responses/previous_page.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"notices":{"totalCount":5,"edges":[{"node":{"index":0,"payload":"0x6e6f746963652d302d30"}},{"node":{"index":1,"payload":"0x6e6f746963652d302d31"}}]}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/proof_from_notice.json b/offchain/graphql-server/tests/responses/proof_from_notice.json deleted file mode 100644 index 0179c1b49..000000000 --- a/offchain/graphql-server/tests/responses/proof_from_notice.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"notice":{"proof":{"validity":{"inputIndexWithinEpoch":0,"outputIndexWithinInput":0,"outputHashesRootHash":"0x3c6f74686572686173683e","vouchersEpochRootHash":"0x3c6f74686572686173683e","noticesEpochRootHash":"0x3c6f74686572686173683e","machineStateHash":"0x3c6f74686572686173683e","outputHashInOutputHashesSiblings":["0x3c6f7468657261727261793e"],"outputHashesInEpochSiblings":["0x3c6f7468657261727261793e"]}}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/proof_from_voucher.json b/offchain/graphql-server/tests/responses/proof_from_voucher.json deleted file mode 100644 index 5db703686..000000000 --- a/offchain/graphql-server/tests/responses/proof_from_voucher.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"voucher":{"proof":{"validity":{"inputIndexWithinEpoch":0,"outputIndexWithinInput":0,"outputHashesRootHash":"0x3c686173683e","vouchersEpochRootHash":"0x3c686173683e","noticesEpochRootHash":"0x3c686173683e","machineStateHash":"0x3c686173683e","outputHashInOutputHashesSiblings":["0x3c61727261793e"],"outputHashesInEpochSiblings":["0x3c61727261793e"]}}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/report.json b/offchain/graphql-server/tests/responses/report.json deleted file mode 100644 index 6dbfcee97..000000000 --- a/offchain/graphql-server/tests/responses/report.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"report":{"index":0,"payload":"0x7265706f72742d302d30"}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/report_with_input.json b/offchain/graphql-server/tests/responses/report_with_input.json deleted file mode 100644 index 52f9deefa..000000000 --- a/offchain/graphql-server/tests/responses/report_with_input.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"report":{"index":0,"input":{"index":0}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/reports.json b/offchain/graphql-server/tests/responses/reports.json deleted file mode 100644 index 21a84395f..000000000 --- a/offchain/graphql-server/tests/responses/reports.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"reports":{"totalCount":1,"edges":[{"node":{"index":0,"payload":"0x7265706f72742d302d30"}}]}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/variables.json b/offchain/graphql-server/tests/responses/variables.json deleted file mode 100644 index caf6f4e92..000000000 --- a/offchain/graphql-server/tests/responses/variables.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"input":{"blockNumber":"0"}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/voucher.json b/offchain/graphql-server/tests/responses/voucher.json deleted file mode 100644 index dc605b935..000000000 --- a/offchain/graphql-server/tests/responses/voucher.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"voucher":{"index":0,"destination":"0x64657374696e6174696f6e","payload":"0x766f75636865722d302d30"}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/voucher_with_input.json b/offchain/graphql-server/tests/responses/voucher_with_input.json deleted file mode 100644 index 3d6e36503..000000000 --- a/offchain/graphql-server/tests/responses/voucher_with_input.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"voucher":{"index":0,"input":{"index":0}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/voucher_with_proof.json b/offchain/graphql-server/tests/responses/voucher_with_proof.json deleted file mode 100644 index 70b74f348..000000000 --- a/offchain/graphql-server/tests/responses/voucher_with_proof.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"voucher":{"index":0,"proof":{"context":"0x3c636f6e746578743e"}}}} \ No newline at end of file diff --git a/offchain/graphql-server/tests/responses/vouchers.json b/offchain/graphql-server/tests/responses/vouchers.json deleted file mode 100644 index 6dc850c1f..000000000 --- a/offchain/graphql-server/tests/responses/vouchers.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"vouchers":{"totalCount":1,"edges":[{"node":{"index":0,"destination":"0x64657374696e6174696f6e","payload":"0x766f75636865722d302d30"}}]}}} \ No newline at end of file diff --git a/offchain/grpc-interfaces/Cargo.toml b/offchain/grpc-interfaces/Cargo.toml deleted file mode 100644 index 7032b4341..000000000 --- a/offchain/grpc-interfaces/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "grpc-interfaces" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -tonic.workspace = true -prost.workspace = true - -[build-dependencies] -tonic-build.workspace = true - -[package.metadata.cargo-machete] -ignored = ["prost"] diff --git a/offchain/grpc-interfaces/build.rs b/offchain/grpc-interfaces/build.rs deleted file mode 100644 index 9fd565c5b..000000000 --- a/offchain/grpc-interfaces/build.rs +++ /dev/null @@ -1,18 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -fn main() -> Result<(), Box> { - tonic_build::configure() - .protoc_arg("--experimental_allow_proto3_optional") - .compile( - &[ - "./grpc-interfaces/versioning.proto", - "./grpc-interfaces/server-manager.proto", - ], - &["./grpc-interfaces"], - )?; - println!("cargo:rerun-if-changed=./grpc-interfaces/versioning.proto"); - println!("cargo:rerun-if-changed=./grpc-interfaces/server-manager.proto"); - println!("cargo:rerun-if-changed=build.rs"); - Ok(()) -} diff --git a/offchain/grpc-interfaces/grpc-interfaces b/offchain/grpc-interfaces/grpc-interfaces deleted file mode 160000 index cba7c3f1c..000000000 --- a/offchain/grpc-interfaces/grpc-interfaces +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cba7c3f1c53b83bee83d8a1e5a1074591279220f diff --git a/offchain/grpc-interfaces/src/lib.rs b/offchain/grpc-interfaces/src/lib.rs deleted file mode 100644 index 14d404f54..000000000 --- a/offchain/grpc-interfaces/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod versioning { - tonic::include_proto!("versioning"); -} - -pub mod cartesi_machine { - tonic::include_proto!("cartesi_machine"); -} - -pub mod cartesi_server_manager { - tonic::include_proto!("cartesi_server_manager"); -} diff --git a/offchain/host-runner/Cargo.toml b/offchain/host-runner/Cargo.toml deleted file mode 100644 index 51eaab757..000000000 --- a/offchain/host-runner/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "host-runner" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-host-runner" -path = "src/main.rs" - -[dependencies] -grpc-interfaces = { path = "../grpc-interfaces" } -http-health-check = { path = "../http-health-check" } -log = { path = "../log" } - -actix-web.workspace = true -async-trait.workspace = true -byteorder.workspace = true -clap = { workspace = true, features = ["derive", "env"] } -ethabi.workspace = true -futures-util.workspace = true -hex.workspace = true -reqwest = { workspace = true, features = ["json"] } -serde = { workspace = true, features = ["derive"] } -sha3 = { workspace = true, features = ["std"] } -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -tonic.workspace = true -tonic-health.workspace = true -tracing.workspace = true - -[dev-dependencies] -rollups-http-client = { path = "../rollups-http-client" } - -mockall.workspace = true -rand.workspace = true -serial_test.workspace = true -tracing-test.workspace = true diff --git a/offchain/host-runner/README.md b/offchain/host-runner/README.md deleted file mode 100644 index c82367d67..000000000 --- a/offchain/host-runner/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Host Runner - -This project implements the gRPC server-manager API. -Different from the server-manager, the host-runner does not instantiate a Cartesi machine. -Instead, it receives HTTP requests directly from a DApp running in the host machine. - -## Tests - -As a complement to the usual [test procedure](../README.md#tests), it is possible to enable verbose logging for integration testing by setting the following environment variable: - -```shell -export CARTESI_TEST_VERBOSE=1 -``` diff --git a/offchain/host-runner/src/config.rs b/offchain/host-runner/src/config.rs deleted file mode 100644 index 9d7873b90..000000000 --- a/offchain/host-runner/src/config.rs +++ /dev/null @@ -1,77 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; -use log::{LogConfig, LogEnvCliConfig}; - -const DEFAULT_ADDRESS: &str = "0.0.0.0"; - -#[derive(Debug, Clone)] -pub struct Config { - pub log_config: LogConfig, - pub grpc_server_manager_address: String, - pub grpc_server_manager_port: u16, - pub http_inspect_address: String, - pub http_inspect_port: u16, - pub http_rollup_server_address: String, - pub http_rollup_server_port: u16, - pub finish_timeout: u64, - pub healthcheck_port: u16, -} - -#[derive(Parser)] -#[command(name = "host_runner_config")] -#[command(about = "Configuration for host-runner")] -pub struct CLIConfig { - /// Logs Config - #[command(flatten)] - pub log_config: LogEnvCliConfig, - - /// gRPC address of the Server Manager endpoint - #[arg(long, env, default_value = DEFAULT_ADDRESS)] - pub grpc_server_manager_address: String, - - /// gRPC port of the Server Manager endpoint - #[arg(long, env, default_value = "5001")] - pub grpc_server_manager_port: u16, - - /// HTTP address of the Inspect endpoint - #[arg(long, env, default_value = DEFAULT_ADDRESS)] - pub http_inspect_address: String, - - /// HTTP port of the Inspect endpoint - #[arg(long, env, default_value = "5002")] - pub http_inspect_port: u16, - - /// HTTP address of the Rollup Server endpoint - #[arg(long, env, default_value = DEFAULT_ADDRESS)] - pub http_rollup_server_address: String, - - /// HTTP port of the Rollup Server endpoint - #[arg(long, env, default_value = "5004")] - pub http_rollup_server_port: u16, - - /// Duration in ms for the finish request to timeout - #[arg(long, env, default_value = "10000")] - pub finish_timeout: u64, - - /// Port of health check - #[arg(long, env = "HOST_RUNNER_HEALTHCHECK_PORT", default_value_t = 8080)] - pub healthcheck_port: u16, -} - -impl From for Config { - fn from(cli_config: CLIConfig) -> Self { - Self { - log_config: cli_config.log_config.into(), - grpc_server_manager_address: cli_config.grpc_server_manager_address, - grpc_server_manager_port: cli_config.grpc_server_manager_port, - http_inspect_address: cli_config.http_inspect_address, - http_inspect_port: cli_config.http_inspect_port, - http_rollup_server_address: cli_config.http_rollup_server_address, - http_rollup_server_port: cli_config.http_rollup_server_port, - finish_timeout: cli_config.finish_timeout, - healthcheck_port: cli_config.healthcheck_port, - } - } -} diff --git a/offchain/host-runner/src/controller.rs b/offchain/host-runner/src/controller.rs deleted file mode 100644 index 51d50e5e4..000000000 --- a/offchain/host-runner/src/controller.rs +++ /dev/null @@ -1,941 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use async_trait::async_trait; -use snafu::Snafu; -use std::time::Duration; -use tokio::sync::{mpsc, oneshot}; - -use crate::model::*; - -const MPSC_BUFFER_SIZE: usize = 1000; - -/// State-machine that controls the rollup state. -#[derive(Clone, Debug)] -pub struct Controller { - advance_tx: mpsc::Sender, - inspect_tx: mpsc::Sender, - finish_tx: mpsc::Sender, - voucher_tx: mpsc::Sender, - notice_tx: mpsc::Sender, - report_tx: mpsc::Sender, - exception_tx: mpsc::Sender, - shutdown_tx: mpsc::Sender, -} - -impl Controller { - pub fn new(finish_timeout: Duration) -> Self { - let (advance_tx, advance_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let (inspect_tx, inspect_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let (voucher_tx, voucher_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let (notice_tx, notice_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let (report_tx, report_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let (finish_tx, finish_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let (exception_tx, exception_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let (shutdown_tx, shutdown_rx) = mpsc::channel(MPSC_BUFFER_SIZE); - let data = SharedStateData { - advance_rx, - inspect_rx, - voucher_rx, - notice_rx, - report_rx, - finish_rx, - exception_rx, - shutdown_rx, - finish_timeout, - }; - let service = Service::new(data); - tokio::spawn(service.run()); - Self { - advance_tx, - inspect_tx, - voucher_tx, - notice_tx, - report_tx, - finish_tx, - exception_tx, - shutdown_tx, - } - } - - pub async fn advance( - &self, - request: AdvanceStateRequest, - ) -> oneshot::Receiver { - SyncRequest::send(&self.advance_tx, request).await - } - - pub async fn inspect( - &self, - request: InspectStateRequest, - ) -> oneshot::Receiver { - SyncRequest::send(&self.inspect_tx, request).await - } - - pub async fn finish( - &self, - status: FinishStatus, - ) -> oneshot::Receiver> { - SyncRequest::send(&self.finish_tx, status).await - } - - pub async fn insert_voucher( - &self, - voucher: Voucher, - ) -> oneshot::Receiver> { - SyncRequest::send(&self.voucher_tx, voucher).await - } - - pub async fn insert_notice( - &self, - notice: Notice, - ) -> oneshot::Receiver> { - SyncRequest::send(&self.notice_tx, notice).await - } - - pub async fn insert_report( - &self, - report: Report, - ) -> oneshot::Receiver> { - SyncRequest::send(&self.report_tx, report).await - } - - pub async fn notify_exception( - &self, - exception: RollupException, - ) -> oneshot::Receiver> { - SyncRequest::send(&self.exception_tx, exception).await - } - - pub async fn shutdown(&self) -> oneshot::Receiver<()> { - SyncRequest::send(&self.shutdown_tx, ()).await - } -} - -#[derive(Debug, PartialEq, Snafu)] -pub enum ControllerError { - #[snafu(display("no rollup request available"))] - FetchRequestTimeout, - #[snafu(display( - "invalid request {} in {} state", - request_name, - state_name - ))] - InvalidRequest { - request_name: String, - state_name: String, - }, -} - -struct Service { - state: Box, -} - -impl Service { - fn new(data: SharedStateData) -> Self { - Self { - state: Box::new(IdleState::new(data)), - } - } - - async fn run(mut self) { - loop { - if let Some(state) = self.state.process().await { - self.state = state; - } else { - tracing::info!("controller service terminated successfully"); - break; - } - } - } - - fn handle_invalid( - request: SyncRequest>, - state: Box, - request_name: &str, - ) -> Option> - where - T: std::fmt::Debug + Send + Sync, - U: std::fmt::Debug + Send + Sync, - { - let err = ControllerError::InvalidRequest { - state_name: state.name(), - request_name: request_name.into(), - }; - tracing::warn!("{}", err.to_string()); - let (_, response_tx) = request.into_inner(); - send_response(response_tx, Err(err)); - Some(state) - } - - fn shutdown(request: SyncShutdownRequest) -> Option> { - tracing::info!("processing shutdown request"); - request.process(|_| ()); - None - } -} - -struct SyncRequest -where - T: Send + Sync, - U: Send + Sync, -{ - request: T, - response_tx: oneshot::Sender, -} - -impl SyncRequest -where - T: std::fmt::Debug + Send + Sync, - U: std::fmt::Debug + Send + Sync, -{ - async fn send(tx: &mpsc::Sender, request: T) -> oneshot::Receiver { - let (response_tx, response_rx) = oneshot::channel(); - if let Err(e) = tx - .send(SyncRequest { - request, - response_tx, - }) - .await - { - tracing::error!("failed to send request ({})", e) - } - response_rx - } - - fn into_inner(self) -> (T, oneshot::Sender) { - (self.request, self.response_tx) - } - - fn process(self, f: F) - where - F: FnOnce(T) -> U, - { - let response = f(self.request); - send_response(self.response_tx, response); - } -} - -fn send_response(tx: oneshot::Sender, response: U) -where - U: std::fmt::Debug + Send + Sync, -{ - if tx.send(response).is_err() { - tracing::warn!("failed to send response (channel dropped)"); - } -} - -impl std::fmt::Debug for SyncRequest -where - T: std::fmt::Debug + Send + Sync, - U: std::fmt::Debug + Send + Sync, -{ - fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { - write!(f, "{:?}", self.request) - } -} - -type SyncAdvanceStateRequest = SyncRequest; -type SyncInspectResult = SyncRequest; -type SyncFinishRequest = - SyncRequest>; -type SyncVoucherRequest = SyncRequest>; -type SyncNoticeRequest = SyncRequest>; -type SyncReportRequest = SyncRequest>; -type SyncExceptionRequest = - SyncRequest>; -type SyncShutdownRequest = SyncRequest<(), ()>; - -struct SharedStateData { - advance_rx: mpsc::Receiver, - inspect_rx: mpsc::Receiver, - finish_rx: mpsc::Receiver, - voucher_rx: mpsc::Receiver, - notice_rx: mpsc::Receiver, - report_rx: mpsc::Receiver, - exception_rx: mpsc::Receiver, - shutdown_rx: mpsc::Receiver, - finish_timeout: Duration, -} - -/// OOP state design-pattern -#[async_trait] -trait State: Send + Sync { - async fn process(self: Box) -> Option>; - fn name(&self) -> String; -} - -/// The controller waits for finish request from the DApp -struct IdleState { - data: SharedStateData, -} - -impl IdleState { - fn new(data: SharedStateData) -> Self { - Self { data } - } -} - -#[async_trait] -impl State for IdleState { - async fn process(mut self: Box) -> Option> { - tokio::select! { - biased; - Some(request) = self.data.finish_rx.recv() => { - tracing::debug!("received finish request; changing state to fetch request"); - tracing::debug!("request: {:?}", request); - let (_, response_tx) = request.into_inner(); - Some(Box::new(FetchRequestState::new(self.data, response_tx))) - } - Some(request) = self.data.voucher_rx.recv() => { - Service::handle_invalid(request, self, "voucher") - } - Some(request) = self.data.notice_rx.recv() => { - Service::handle_invalid(request, self, "notice") - } - Some(request) = self.data.report_rx.recv() => { - Service::handle_invalid(request, self, "report") - } - Some(request) = self.data.exception_rx.recv() => { - Service::handle_invalid(request, self, "exception") - } - Some(request) = self.data.shutdown_rx.recv() => { - Service::shutdown(request) - } - } - } - - fn name(&self) -> String { - "idle".into() - } -} - -/// The controller waits for either an inspect of an advance request from the gRPC service -struct FetchRequestState { - data: SharedStateData, - finish_response_tx: oneshot::Sender>, -} - -impl FetchRequestState { - fn new( - data: SharedStateData, - finish_response_tx: oneshot::Sender< - Result, - >, - ) -> Self { - Self { - data, - finish_response_tx, - } - } -} - -#[async_trait] -impl State for FetchRequestState { - async fn process(mut self: Box) -> Option> { - tokio::select! { - biased; - _ = tokio::time::sleep(self.data.finish_timeout) => { - tracing::debug!("fetch request timed out; setting state to idle"); - let timeout_err = ControllerError::FetchRequestTimeout; - send_response(self.finish_response_tx, Err(timeout_err)); - Some(Box::new(IdleState::new(self.data))) - } - Some(request) = self.data.inspect_rx.recv() => { - tracing::debug!("received inspect request; setting state to inspect"); - tracing::debug!("request: {:?}", request); - let (inspect_request, inspect_response_tx) = request.into_inner(); - let rollup_request = RollupRequest::InspectState(inspect_request); - send_response(self.finish_response_tx, Ok(rollup_request)); - Some(Box::new(InspectState::new(self.data, inspect_response_tx))) - } - Some(request) = self.data.advance_rx.recv() => { - tracing::debug!("received advance request; setting state to advance"); - tracing::debug!("request: {:?}", request); - let (advance_request, advance_response_tx) = request.into_inner(); - let rollup_request = RollupRequest::AdvanceState(advance_request); - send_response(self.finish_response_tx, Ok(rollup_request)); - Some(Box::new(AdvanceState::new(self.data, advance_response_tx))) - } - Some(request) = self.data.finish_rx.recv() => { - tracing::debug!("received finish request; terminating previous finish request"); - tracing::debug!("request: {:?}", request); - let timeout_err = ControllerError::FetchRequestTimeout; - send_response(self.finish_response_tx, Err(timeout_err)); - let (_, response_tx) = request.into_inner(); - Some(Box::new(FetchRequestState::new(self.data, response_tx))) - } - Some(request) = self.data.voucher_rx.recv() => { - Service::handle_invalid(request, self, "voucher") - } - Some(request) = self.data.notice_rx.recv() => { - Service::handle_invalid(request, self, "notice") - } - Some(request) = self.data.report_rx.recv() => { - Service::handle_invalid(request, self, "report") - } - Some(request) = self.data.exception_rx.recv() => { - Service::handle_invalid(request, self, "exception") - } - Some(request) = self.data.shutdown_rx.recv() => { - Service::shutdown(request) - } - } - } - - fn name(&self) -> String { - "fetch request".into() - } -} - -/// The controller wait for reports, exception, and finish -struct InspectState { - data: SharedStateData, - inspect_response_tx: oneshot::Sender, - reports: Vec, -} - -impl InspectState { - fn new( - data: SharedStateData, - inspect_response_tx: oneshot::Sender, - ) -> Self { - Self { - data, - inspect_response_tx, - reports: vec![], - } - } -} - -#[async_trait] -impl State for InspectState { - async fn process(mut self: Box) -> Option> { - tokio::select! { - biased; - Some(request) = self.data.finish_rx.recv() => { - tracing::debug!("received finish request; changing state to fetch request"); - tracing::debug!("request: {:?}", request); - let (status, response_tx) = request.into_inner(); - let result = match status { - FinishStatus::Accept => InspectResult::accepted(self.reports), - FinishStatus::Reject => InspectResult::rejected(self.reports), - }; - send_response(self.inspect_response_tx, result); - Some(Box::new(FetchRequestState::new(self.data, response_tx))) - } - Some(request) = self.data.report_rx.recv() => { - tracing::debug!("received report request"); - tracing::debug!("request: {:?}", request); - request.process(|report| { - self.reports.push(report); - Ok(()) - }); - Some(self) - } - Some(request) = self.data.exception_rx.recv() => { - tracing::debug!("received exception request; setting state to idle"); - tracing::debug!("request: {:?}", request); - let (exception, exception_response_tx) = request.into_inner(); - let result = InspectResult::exception(self.reports, exception); - send_response(self.inspect_response_tx, result); - send_response(exception_response_tx, Ok(())); - Some(Box::new(IdleState::new(self.data))) - } - Some(request) = self.data.voucher_rx.recv() => { - Service::handle_invalid(request, self, "voucher") - } - Some(request) = self.data.notice_rx.recv() => { - Service::handle_invalid(request, self, "notice") - } - Some(request) = self.data.shutdown_rx.recv() => { - Service::shutdown(request) - } - } - } - - fn name(&self) -> String { - "inspect".into() - } -} - -/// The controller waits for vouchers, notices, reports, exception, and finish -struct AdvanceState { - data: SharedStateData, - advance_response_tx: oneshot::Sender, - vouchers: Vec, - notices: Vec, - reports: Vec, -} - -impl AdvanceState { - fn new( - data: SharedStateData, - advance_response_tx: oneshot::Sender, - ) -> Self { - Self { - data, - advance_response_tx, - vouchers: vec![], - notices: vec![], - reports: vec![], - } - } -} - -#[async_trait] -impl State for AdvanceState { - async fn process(mut self: Box) -> Option> { - tokio::select! { - biased; - Some(request) = self.data.finish_rx.recv() => { - tracing::debug!("received finish request; changing state to fetch request"); - tracing::debug!("request: {:?}", request); - let (status, response_tx) = request.into_inner(); - let result = match status { - FinishStatus::Accept => { - AdvanceResult::accepted( - self.vouchers, - self.notices, - self.reports, - ) - }, - FinishStatus::Reject => { - AdvanceResult::rejected( - self.reports, - ) - }, - }; - send_response(self.advance_response_tx, result); - Some(Box::new(FetchRequestState::new(self.data, response_tx))) - } - Some(request) = self.data.voucher_rx.recv() => { - tracing::debug!("received voucher request"); - tracing::debug!("request: {:?}", request); - request.process(|voucher| { - self.vouchers.push(voucher); - Ok(self.vouchers.len() - 1) - }); - Some(self) - } - Some(request) = self.data.notice_rx.recv() => { - tracing::debug!("received notice request"); - tracing::debug!("request: {:?}", request); - request.process(|notice| { - self.notices.push(notice); - Ok(self.notices.len() - 1) - }); - Some(self) - } - Some(request) = self.data.report_rx.recv() => { - tracing::debug!("received report request"); - tracing::debug!("request: {:?}", request); - request.process(|report| { - self.reports.push(report); - Ok(()) - }); - Some(self) - } - Some(request) = self.data.exception_rx.recv() => { - tracing::debug!("received exception request; setting state to idle"); - tracing::debug!("request: {:?}", request); - let (exception, exception_response_tx) = request.into_inner(); - let result = AdvanceResult::exception( - exception, - self.reports, - ); - send_response(self.advance_response_tx, result); - send_response(exception_response_tx, Ok(())); - Some(Box::new(IdleState::new(self.data))) - } - Some(request) = self.data.shutdown_rx.recv() => { - Service::shutdown(request) - } - } - } - - fn name(&self) -> String { - "advance".into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const TEST_FINISH_TIMEOUT: Duration = Duration::from_millis(100); - - fn setup() -> Controller { - Controller::new(TEST_FINISH_TIMEOUT) - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_rejects_invalid_requests_in_idle_state() { - let controller = setup(); - let rx = controller.insert_voucher(mock_voucher()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("voucher"), - state_name: String::from("idle") - } - ); - let rx = controller.insert_notice(mock_notice()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("notice"), - state_name: String::from("idle") - } - ); - let rx = controller.insert_report(mock_report()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("report"), - state_name: String::from("idle") - } - ); - let rx = controller.notify_exception(mock_exception()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("exception"), - state_name: String::from("idle") - } - ); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_handles_multiple_finish_requests_at_the_same_time() { - let controller = setup(); - let mut handlers = vec![]; - const N: usize = 3; - for _ in 0..N { - let handler = { - let controller = controller.clone(); - tokio::spawn(async move { - controller.finish(FinishStatus::Accept).await - }) - }; - handlers.push(handler); - } - for handler in handlers { - let rx = handler.await.unwrap(); - let timeout_err = rx.await.unwrap().unwrap_err(); - assert_eq!(timeout_err, ControllerError::FetchRequestTimeout); - } - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_rejects_invalid_requests_in_fetch_request_state() { - let controller = setup(); - // Set state to fetch request by calling finish once in another thread - let _ = controller.finish(FinishStatus::Accept).await; - let rx = controller.insert_voucher(mock_voucher()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("voucher"), - state_name: String::from("fetch request") - } - ); - let rx = controller.insert_notice(mock_notice()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("notice"), - state_name: String::from("fetch request") - } - ); - let rx = controller.insert_report(mock_report()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("report"), - state_name: String::from("fetch request") - } - ); - let rx = controller.notify_exception(mock_exception()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("exception"), - state_name: String::from("fetch request") - } - ); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_rejects_invalid_requests_during_inspect() { - let controller = setup(); - let _ = controller.inspect(mock_inspect_request()).await; - let _ = controller - .finish(FinishStatus::Accept) - .await - .await - .unwrap() - .unwrap(); - let rx = controller.insert_voucher(mock_voucher()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("voucher"), - state_name: String::from("inspect") - } - ); - let rx = controller.insert_notice(mock_notice()).await; - assert_eq!( - rx.await.unwrap().unwrap_err(), - ControllerError::InvalidRequest { - request_name: String::from("notice"), - state_name: String::from("inspect") - } - ); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_advances_state_before_finish() { - let controller = setup(); - // Send advance request - let advance_request = mock_advance_request(); - let advance_rx = controller.advance(advance_request.clone()).await; - // Send first finish request - let finish_rx = controller.finish(FinishStatus::Accept).await; - let rollup_request = finish_rx.await.unwrap().unwrap(); - assert_eq!( - rollup_request, - RollupRequest::AdvanceState(advance_request) - ); - // Send second finish request - let _ = controller.finish(FinishStatus::Accept).await; - // Obtain result from advance request - let advance_result = advance_rx.await.unwrap(); - assert_eq!( - advance_result, - AdvanceResult::accepted(vec![], vec![], vec![]) - ); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_advances_state_after_finish() { - let controller = setup(); - // Send first finish request - let finish_rx = controller.finish(FinishStatus::Accept).await; - // Send advance request - let advance_request = mock_advance_request(); - let advance_rx = controller.advance(advance_request.clone()).await; - // Receive first finish result - let rollup_request = finish_rx.await.unwrap().unwrap(); - assert_eq!( - rollup_request, - RollupRequest::AdvanceState(advance_request) - ); - // Send second finish request - let _ = controller.finish(FinishStatus::Accept).await; - // Obtain result from advance request - let advance_result = advance_rx.await.unwrap(); - assert_eq!( - advance_result, - AdvanceResult::accepted(vec![], vec![], vec![]) - ); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_advances_state_after_previous_advance() { - let controller = setup(); - const N: usize = 3; - let mut advance_requests = std::collections::VecDeque::new(); - let mut advance_rxs = std::collections::VecDeque::new(); - // Send several advance requests before starting - for _ in 0..N { - let request = mock_advance_request(); - advance_requests.push_back(request.clone()); - let rx = controller.advance(request).await; - advance_rxs.push_back(rx); - } - // Send first finish - let mut finish_rx = controller.finish(FinishStatus::Accept).await; - // Process each advance request - while !advance_requests.is_empty() { - let rollup_request = finish_rx.await.unwrap().unwrap(); - let expected_request = advance_requests.pop_front().unwrap(); - assert_eq!( - rollup_request, - RollupRequest::AdvanceState(expected_request) - ); - finish_rx = controller.finish(FinishStatus::Accept).await; - let _ = advance_rxs.pop_front().unwrap().await.unwrap(); - } - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_prioritizes_inspect_over_advance_requests() { - let controller = setup(); - // Before first finish, send first an advance request and an inspect request - let _ = controller.advance(mock_advance_request()).await; - let _ = controller.inspect(mock_inspect_request()).await; - // The received request should be the inspect state - let finish_rx = controller.finish(FinishStatus::Accept).await; - let rollup_request = finish_rx.await.unwrap().unwrap(); - assert!(matches!(rollup_request, RollupRequest::InspectState(_))); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_times_out_during_fetch_request() { - let controller = setup(); - // Send first finish request without sending a rollup request - let finish_rx = controller.finish(FinishStatus::Accept).await; - let timeout_err = finish_rx.await.unwrap().unwrap_err(); - assert_eq!(timeout_err, ControllerError::FetchRequestTimeout); - // Send an advance request that should not timeout - let advance_request = mock_advance_request(); - let _ = controller.advance(advance_request.clone()).await; - let finish_rx = controller.finish(FinishStatus::Accept).await; - let rollup_request = finish_rx.await.unwrap().unwrap(); - assert_eq!( - rollup_request, - RollupRequest::AdvanceState(advance_request) - ); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_sends_vouchers_notices_and_reports_during_advance() { - let controller = setup(); - // Set state to advance - let advance_rx = controller.advance(mock_advance_request()).await; - let finish_rx = controller.finish(FinishStatus::Accept).await; - let _ = finish_rx.await.unwrap().unwrap(); - // Insert voucher - let voucher = mock_voucher(); - let voucher_rx = controller.insert_voucher(voucher.clone()).await; - let voucher_id = voucher_rx.await.unwrap().unwrap(); - assert_eq!(voucher_id, 0); - // Insert notice - let notice = mock_notice(); - let notice_rx = controller.insert_notice(notice.clone()).await; - let notice_id = notice_rx.await.unwrap().unwrap(); - assert_eq!(notice_id, 0); - // Insert report - let report = mock_report(); - let report_rx = controller.insert_report(report.clone()).await; - report_rx.await.unwrap().unwrap(); - // Finalize the current advance state - let _ = controller.finish(FinishStatus::Reject).await; - // Obtain the advance result - let result = advance_rx.await.unwrap(); - assert_eq!(result, AdvanceResult::rejected(vec![report])); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_sends_reports_during_inspect() { - let controller = setup(); - // Set state to inspect - let inspect_rx = controller.inspect(mock_inspect_request()).await; - let finish_rx = controller.finish(FinishStatus::Accept).await; - let _ = finish_rx.await.unwrap().unwrap(); - // Insert report - let report = mock_report(); - let report_rx = controller.insert_report(report.clone()).await; - report_rx.await.unwrap().unwrap(); - // Finalize the current advance state - let _ = controller.finish(FinishStatus::Accept).await; - // Obtain the inspect result - let result = inspect_rx.await.unwrap(); - assert_eq!(result, InspectResult::accepted(vec![report])); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_handles_exception_during_advance() { - let controller = setup(); - // Set state to advance - let advance_rx = controller.advance(mock_advance_request()).await; - let finish_rx = controller.finish(FinishStatus::Accept).await; - let _ = finish_rx.await.unwrap().unwrap(); - // Send rollup exception - let exception = mock_exception(); - let exception_rx = controller.notify_exception(exception.clone()).await; - exception_rx.await.unwrap().unwrap(); - let advance_result = advance_rx.await.unwrap(); - assert_eq!(advance_result, AdvanceResult::exception(exception, vec![])); - controller.shutdown().await; - } - - #[tokio::test] - #[tracing_test::traced_test] - async fn test_it_handles_exception_during_inspect() { - let controller = setup(); - // Set state to inspect - let inspect_rx = controller.inspect(mock_inspect_request()).await; - let finish_rx = controller.finish(FinishStatus::Accept).await; - let _ = finish_rx.await.unwrap().unwrap(); - // Send rollup exception - let exception = mock_exception(); - let exception_rx = controller.notify_exception(exception.clone()).await; - exception_rx.await.unwrap().unwrap(); - let result = inspect_rx.await.unwrap(); - assert_eq!(result, InspectResult::exception(vec![], exception)); - controller.shutdown().await; - } - - fn mock_voucher() -> Voucher { - Voucher::new(rand::random(), rand::random::<[u8; 32]>().into()) - } - - fn mock_notice() -> Notice { - Notice::new(rand::random::<[u8; 32]>().into()) - } - - fn mock_report() -> Report { - Report { - payload: rand::random::<[u8; 32]>().into(), - } - } - - fn mock_exception() -> RollupException { - RollupException { - payload: rand::random::<[u8; 32]>().into(), - } - } - - fn mock_advance_request() -> AdvanceStateRequest { - AdvanceStateRequest { - metadata: AdvanceMetadata { - msg_sender: rand::random(), - epoch_index: rand::random(), - input_index: rand::random(), - block_number: rand::random(), - timestamp: rand::random(), - }, - payload: rand::random::<[u8; 32]>().into(), - } - } - - fn mock_inspect_request() -> InspectStateRequest { - InspectStateRequest { - payload: rand::random::<[u8; 32]>().into(), - } - } -} diff --git a/offchain/host-runner/src/conversions.rs b/offchain/host-runner/src/conversions.rs deleted file mode 100644 index d90df1953..000000000 --- a/offchain/host-runner/src/conversions.rs +++ /dev/null @@ -1,88 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use hex::FromHexError; -use snafu::Snafu; - -#[derive(Debug, Snafu)] -pub enum DecodeError { - #[snafu(display( - "Failed to decode ethereum binary string {} (expected 0x prefix)", - s - ))] - InvalidPrefix { s: String }, - #[snafu(display("Failed to decode ethereum binary string {} ({})", s, e))] - FromHex { s: String, e: FromHexError }, -} - -/// Convert binary array to Ethereum binary format -pub fn encode_ethereum_binary(bytes: &[u8]) -> String { - String::from("0x") + &hex::encode(bytes) -} - -/// Convert string in Ethereum binary format to binary array -pub fn decode_ethereum_binary(s: &str) -> Result, DecodeError> { - snafu::ensure!(s.starts_with("0x"), InvalidPrefixSnafu { s }); - hex::decode(&s[2..]).map_err(|e| DecodeError::FromHex { - s: s.to_string(), - e, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encode() { - assert_eq!( - encode_ethereum_binary(&[0x01, 0x20, 0xFF, 0x00]).as_str(), - "0x0120ff00" - ); - } - - #[test] - fn test_encode_with_empty() { - assert_eq!(encode_ethereum_binary(&[]).as_str(), "0x"); - } - - #[test] - fn test_decode_with_uppercase() { - assert_eq!( - decode_ethereum_binary("0x0120FF00").unwrap(), - vec![0x01, 0x20, 0xFF, 0x00] - ); - } - - #[test] - fn test_decode_with_lowercase() { - assert_eq!(decode_ethereum_binary("0xff").unwrap(), vec![0xFF]); - } - - #[test] - fn test_decode_with_invalid_prefix() { - let err = decode_ethereum_binary("0X0120FF00").unwrap_err(); - assert_eq!( - err.to_string().as_str(), - "Failed to decode ethereum binary string 0X0120FF00 (expected 0x prefix)", - ); - } - - #[test] - fn test_decode_with_invalid_number() { - let err = decode_ethereum_binary("0xZZ").unwrap_err(); - assert_eq!( - err.to_string().as_str(), - "Failed to decode ethereum binary string 0xZZ (Invalid character 'Z' at position 0)" - ); - } - - #[test] - fn test_decode_with_odd_number_of_chars() { - let err = decode_ethereum_binary("0xA").unwrap_err(); - assert_eq!( - err.to_string().as_str(), - "Failed to decode ethereum binary string 0xA (Odd number of digits)" - ); - } -} diff --git a/offchain/host-runner/src/driver.rs b/offchain/host-runner/src/driver.rs deleted file mode 100644 index f8c67a909..000000000 --- a/offchain/host-runner/src/driver.rs +++ /dev/null @@ -1,74 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use byteorder::{BigEndian, WriteBytesExt}; -use std::mem::size_of; - -use crate::hash::{Digest, Hash, Hasher, HASH_SIZE}; - -pub fn compute_voucher_hash(destination: &[u8], payload: &[u8]) -> Hash { - let mut hasher = Hasher::new(); - write_data(&mut hasher, destination); - write_u64(&mut hasher, 0x40); - write_payload(&mut hasher, payload); - hasher.finalize().into() -} - -pub fn compute_notice_hash(payload: &[u8]) -> Hash { - let mut hasher = Hasher::new(); - write_u64(&mut hasher, 0x20); - write_payload(&mut hasher, payload); - hasher.finalize().into() -} - -fn write_padding(hasher: &mut Hasher, n: usize) { - let alignment = n % HASH_SIZE; - if alignment != 0 { - for _ in alignment..HASH_SIZE { - hasher.write_u8(0).expect("cannot fail"); - } - } -} - -fn write_u64(hasher: &mut Hasher, value: u64) { - write_padding(hasher, size_of::()); - hasher.write_u64::(value).expect("cannot fail"); -} - -fn write_data(hasher: &mut Hasher, data: &[u8]) { - write_padding(hasher, data.len()); - hasher.update(data); -} - -fn write_payload(hasher: &mut Hasher, payload: &[u8]) { - write_u64(hasher, payload.len() as u64); - hasher.update(payload); - write_padding(hasher, payload.len()); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_update_voucher_hash() { - let destination = - hex::decode("5555555555555555555555555555555555555555").unwrap(); - let payload: Vec = "hello world".as_bytes().into(); - let hash = compute_voucher_hash(&destination, &payload); - let expected_hash = Hash::decode( - "61a61380d2a3b5e2b09a5ff259a2e1048da1989bdd6d6ecc69594cfbedc01278", - ); - assert_eq!(&hash, &expected_hash); - } - - #[test] - fn test_update_notice_hash() { - let payload: Vec = "hello world".as_bytes().into(); - let hash = compute_notice_hash(&payload); - let expected_hash = Hash::decode( - "d9f29a4e347ad89dc70490124ee6975fbc0693c7e72d6bc383673bfd0e8841f2", - ); - assert_eq!(&hash, &expected_hash); - } -} diff --git a/offchain/host-runner/src/grpc/mod.rs b/offchain/host-runner/src/grpc/mod.rs deleted file mode 100644 index 82ec8fbef..000000000 --- a/offchain/host-runner/src/grpc/mod.rs +++ /dev/null @@ -1,47 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod server_manager; - -use futures_util::FutureExt; -use std::future::Future; -use tonic::transport::Server; -use tonic_health::pb::health_server::{Health, HealthServer}; - -use grpc_interfaces::cartesi_server_manager::server_manager_server::ServerManagerServer; -use server_manager::ServerManagerService; - -use crate::config::Config; -use crate::controller::Controller; - -/// Create the grpc healthcheck for the host-runner -/// -/// Since the host-runner doesn't rely on any other service to function, it is always -/// healthy. -async fn create_health_service() -> HealthServer { - let (mut health_reporter, health_service) = - tonic_health::server::health_reporter(); - health_reporter - .set_serving::>() - .await; - health_service -} - -pub async fn start_service>( - config: &Config, - controller: Controller, - signal: F, -) -> Result<(), tonic::transport::Error> { - let addr = format!( - "{}:{}", - config.grpc_server_manager_address, config.grpc_server_manager_port - ) - .parse() - .expect("invalid config"); - let service = ServerManagerService::new(controller); - Server::builder() - .add_service(create_health_service().await) - .add_service(ServerManagerServer::new(service)) - .serve_with_shutdown(addr, signal.map(|_| ())) - .await -} diff --git a/offchain/host-runner/src/grpc/server_manager.rs b/offchain/host-runner/src/grpc/server_manager.rs deleted file mode 100644 index 634c39d5c..000000000 --- a/offchain/host-runner/src/grpc/server_manager.rs +++ /dev/null @@ -1,1036 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::controller::Controller; -use crate::hash::{Hash, HASH_SIZE}; -use crate::merkle_tree::{ - complete::Tree, proof::Proof, Error as MerkleTreeError, -}; -use crate::model::{ - AdvanceMetadata, AdvanceResult, AdvanceStateRequest, CompletionStatus, - InspectStateRequest, InspectStatus, Notice, Report, Voucher, -}; -use crate::proofs::compute_proofs; -use ethabi::ethereum_types::U256; -use ethabi::Token; -use grpc_interfaces::cartesi_machine::{ - Hash as GrpcHash, MerkleTreeProof as GrpcMerkleTreeProof, Void, -}; -use grpc_interfaces::cartesi_server_manager::{ - processed_input::ProcessedInputOneOf, server_manager_server::ServerManager, - AcceptedData, Address, AdvanceStateRequest as GrpcAdvanceStateRequest, - CompletionStatus as GrpcCompletionStatus, - DeleteEpochRequest as GrpcDeleteEpochRequest, EndSessionRequest, - EpochState, FinishEpochRequest, FinishEpochResponse, GetEpochStatusRequest, - GetEpochStatusResponse, GetSessionStatusRequest, GetSessionStatusResponse, - GetStatusResponse, InspectStateRequest as GrpcInspectStateRequest, - InspectStateResponse, Notice as GrpcNotice, OutputEnum, - OutputValidityProof, ProcessedInput, Proof as GrpcProof, - Report as GrpcReport, StartSessionRequest, StartSessionResponse, - TaintStatus, Voucher as GrpcVoucher, -}; -use grpc_interfaces::versioning::{GetVersionResponse, SemanticVersion}; -use std::{collections::HashMap, sync::Arc}; -use tokio::sync::Mutex; -use tonic::{Request, Response, Status}; - -pub struct ServerManagerService { - controller: Controller, - sessions: SessionManager, -} - -impl ServerManagerService { - pub fn new(controller: Controller) -> Self { - Self { - controller, - sessions: SessionManager::new(), - } - } -} - -#[tonic::async_trait] -impl ServerManager for ServerManagerService { - async fn get_version( - &self, - _: Request, - ) -> Result, Status> { - tracing::info!("received get_version"); - let response = GetVersionResponse { - version: Some(SemanticVersion { - major: 0, - minor: 2, - patch: 0, - pre_release: String::from(""), - build: String::from("host-runner"), - }), - }; - Ok(Response::new(response)) - } - - async fn start_session( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - tracing::info!("received start_session with id={}", request.session_id); - self.sessions - .try_set_session( - request.session_id, - request.active_epoch_index, - request.processed_input_count, - self.controller.clone(), - ) - .await?; - let response = StartSessionResponse { config: None }; - Ok(Response::new(response)) - } - - async fn end_session( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - tracing::info!("received end_session with id={}", request.session_id); - self.sessions.try_del_session(&request.session_id).await?; - Ok(Response::new(Void {})) - } - - async fn advance_state( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - tracing::info!("received advance_state with id={}", request.session_id); - let metadata = request - .input_metadata - .ok_or(Status::invalid_argument("missing metadata from request"))?; - let msg_sender = metadata - .msg_sender - .ok_or(Status::invalid_argument( - "missing msg_sender from metadata", - ))? - .data - .try_into() - .or(Err(Status::invalid_argument("invalid address")))?; - if metadata.epoch_index != 0 { - return Err(Status::invalid_argument( - "metadata epoch index is deprecated and should always be 0", - )); - } - if metadata.input_index != request.current_input_index { - return Err(Status::invalid_argument( - "metadata input index mismatch", - )); - } - let advance_request = AdvanceStateRequest { - metadata: AdvanceMetadata { - msg_sender, - epoch_index: metadata.epoch_index, - input_index: metadata.input_index, - block_number: metadata.block_number, - timestamp: metadata.timestamp, - }, - payload: request.input_payload, - }; - self.sessions - .try_get_session(&request.session_id) - .await? - .try_lock() - .or(Err(Status::aborted("concurrent call in session")))? - .try_advance( - request.active_epoch_index, - request.current_input_index, - advance_request, - ) - .await?; - Ok(Response::new(Void {})) - } - - async fn finish_epoch( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - tracing::info!("received finish_epoch with id={}", request.session_id); - if !request.storage_directory.is_empty() { - tracing::warn!("ignoring storage_directory parameter"); - } - let response = self - .sessions - .try_get_session(&request.session_id) - .await? - .try_lock() - .or(Err(Status::aborted("concurrent call in session")))? - .try_finish_epoch( - request.active_epoch_index, - request.processed_input_count_within_epoch, - ) - .await?; - Ok(Response::new(response)) - } - - async fn inspect_state( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - tracing::info!("received inspect_state with id={}", request.session_id); - self.sessions - .try_get_session(&request.session_id) - .await? - .try_lock() - .or(Err(Status::aborted("concurrent call in session")))? - .try_inspect(request.session_id, request.query_payload) - .await - .map(Response::new) - } - - async fn get_status( - &self, - _: Request, - ) -> Result, Status> { - tracing::info!("received get_status"); - let session_id = self.sessions.get_sessions().await; - Ok(Response::new(GetStatusResponse { session_id })) - } - - async fn get_session_status( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - tracing::info!( - "received get_session_status with id={}", - request.session_id - ); - let response = self - .sessions - .try_get_session(&request.session_id) - .await? - .try_lock() - .or(Err(Status::aborted("concurrent call in session")))? - .get_status(request.session_id) - .await; - Ok(Response::new(response)) - } - - async fn get_epoch_status( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - tracing::info!( - "received get_epoch_status with id={} and epoch_index={}", - request.session_id, - request.epoch_index - ); - let response = self - .sessions - .try_get_session(&request.session_id) - .await? - .try_lock() - .or(Err(Status::aborted("concurrent call in session")))? - .try_get_epoch_status(request.session_id, request.epoch_index) - .await?; - Ok(Response::new(response)) - } - - async fn delete_epoch( - &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - self.sessions - .try_get_session(&request.session_id) - .await? - .try_lock() - .or(Err(Status::aborted("concurrent call in session")))? - .try_delete_epoch(request.epoch_index) - .await?; - Ok(Response::new(Void {})) - } -} - -struct SessionManager { - entry: Mutex>, -} - -impl SessionManager { - fn new() -> Self { - Self { - entry: Mutex::new(None), - } - } - - async fn try_set_session( - &self, - session_id: String, - active_epoch_index: u64, - processed_input_count: u64, - controller: Controller, - ) -> Result<(), Status> { - if session_id.is_empty() { - return Err(Status::invalid_argument("session id is empty")); - } - let mut entry = self.entry.lock().await; - match *entry { - Some(_) => { - tracing::warn!( - "the host-runner only supports a single session" - ); - Err(Status::already_exists("session id is taken")) - } - None => { - *entry = Some(SessionEntry::new( - session_id, - active_epoch_index, - processed_input_count, - controller, - )); - Ok(()) - } - } - } - - async fn try_get_session( - &self, - request_id: &String, - ) -> Result>, Status> { - self.entry - .lock() - .await - .as_ref() - .and_then(|entry| entry.get_session(request_id)) - .ok_or(Status::invalid_argument("session id not found")) - } - - async fn try_del_session(&self, request_id: &String) -> Result<(), Status> { - self.try_get_session(request_id) - .await? - .try_lock() - .or(Err(Status::aborted("concurrent call in session")))? - .check_endable() - .await?; - let mut entry = self.entry.lock().await; - *entry = None; - Ok(()) - } - - async fn get_sessions(&self) -> Vec { - let mut sessions = Vec::new(); - if let Some(entry) = self.entry.lock().await.as_ref() { - sessions.push(entry.get_id()); - } - sessions - } -} - -struct SessionEntry { - id: String, - session: Arc>, -} - -impl SessionEntry { - fn new( - id: String, - active_epoch_index: u64, - processed_input_count: u64, - controller: Controller, - ) -> Self { - Self { - id, - session: Arc::new(Mutex::new(Session::new( - active_epoch_index, - processed_input_count, - controller, - ))), - } - } - - fn get_session(&self, request_id: &String) -> Option>> { - if &self.id == request_id { - Some(self.session.clone()) - } else { - None - } - } - - fn get_id(&self) -> String { - self.id.clone() - } -} - -struct Session { - active_epoch_index: u64, - controller: Controller, - epochs: HashMap>>, - tainted: Arc>>, -} - -impl Session { - fn new( - active_epoch_index: u64, - processed_input_count: u64, - controller: Controller, - ) -> Self { - let epoch = Arc::new(Mutex::new(Epoch::new(processed_input_count))); - let mut epochs = HashMap::new(); - epochs.insert(active_epoch_index, epoch); - Self { - active_epoch_index, - controller, - epochs, - tainted: Arc::new(Mutex::new(None)), - } - } - - async fn try_advance( - &mut self, - active_epoch_index: u64, - current_input_index: u64, - advance_request: AdvanceStateRequest, - ) -> Result<(), Status> { - self.check_epoch_index_overflow()?; - self.check_tainted().await?; - self.check_active_epoch(active_epoch_index)?; - let epoch = self.try_get_epoch(active_epoch_index)?; - epoch - .lock() - .await - .try_add_pending_input(current_input_index)?; - let rx = self.controller.advance(advance_request).await; - let epoch = epoch.clone(); - let tainted = self.tainted.clone(); - // Handle the advance response in another thread - tokio::spawn(async move { - match rx.await { - Ok(result) => { - if let Err(e) = - epoch.lock().await.add_processed_input(result) - { - tracing::error!( - "failed to add processed input; tainting session" - ); - *tainted.lock().await = Some(e); - } - } - Err(_) => { - tracing::error!("sender dropped the channel"); - } - } - }); - Ok(()) - } - - async fn try_inspect( - &mut self, - session_id: String, - payload: Vec, - ) -> Result { - self.check_tainted().await?; - let rx = self - .controller - .inspect(InspectStateRequest { payload }) - .await; - let result = rx.await.map_err(|e| { - tracing::error!("sender dropped the channel ({})", e); - Status::internal("unexpected error during inspect") - })?; - let active_epoch_index = self.active_epoch_index; - let epoch = self.try_get_epoch(active_epoch_index)?; - let processed_input_count = - epoch.lock().await.get_num_processed_inputs_since_genesis(); - Ok(InspectStateResponse { - session_id, - active_epoch_index, - processed_input_count, - status: (&result.status).into(), - exception_data: match result.status { - InspectStatus::Exception { exception } => { - Some(exception.payload) - } - _ => None, - }, - reports: result.reports.into_iter().map(GrpcReport::from).collect(), - }) - } - - async fn try_finish_epoch( - &mut self, - active_epoch_index: u64, - processed_input_count_within_epoch: u64, - ) -> Result { - self.check_epoch_index_overflow()?; - self.check_tainted().await?; - self.check_active_epoch(active_epoch_index)?; - let (response, processed_input_count_since_genesis) = { - let mut last_epoch = - self.try_get_epoch(active_epoch_index)?.lock().await; - ( - last_epoch.try_finish( - processed_input_count_within_epoch, - active_epoch_index, - )?, - last_epoch.processed_input_count_since_genesis, - ) - }; - self.active_epoch_index += 1; - let epoch = Arc::new(Mutex::new(Epoch::new( - processed_input_count_since_genesis - + processed_input_count_within_epoch, - ))); - self.epochs.insert(self.active_epoch_index, epoch); - Ok(response) - } - - async fn try_delete_epoch( - &mut self, - epoch_index: u64, - ) -> Result<(), Status> { - self.check_tainted().await?; - self.try_get_epoch(epoch_index)? - .lock() - .await - .check_finished()?; - self.epochs.remove(&epoch_index); - Ok(()) - } - - async fn get_status(&self, session_id: String) -> GetSessionStatusResponse { - let mut epoch_index: Vec = self.epochs.keys().cloned().collect(); - epoch_index.sort(); - GetSessionStatusResponse { - session_id, - active_epoch_index: self.active_epoch_index, - epoch_index, - taint_status: self.get_taint_status().await, - } - } - - async fn get_taint_status(&self) -> Option { - self.tainted - .lock() - .await - .as_ref() - .map(|status| TaintStatus { - error_code: status.code() as i32, - error_message: String::from(status.message()), - }) - } - - async fn try_get_epoch_status( - &self, - session_id: String, - epoch_index: u64, - ) -> Result { - let taint_status = self.get_taint_status().await; - let response = self - .try_get_epoch(epoch_index)? - .lock() - .await - .get_status(session_id, epoch_index, taint_status); - Ok(response) - } - - fn try_get_epoch( - &self, - epoch_index: u64, - ) -> Result<&Arc>, Status> { - self.epochs - .get(&epoch_index) - .ok_or(Status::invalid_argument("unknown epoch index")) - } - - async fn check_endable(&self) -> Result<(), Status> { - if self.tainted.lock().await.is_none() { - self.try_get_epoch(self.active_epoch_index)? - .lock() - .await - .check_endable()?; - } - Ok(()) - } - - async fn check_tainted(&self) -> Result<(), Status> { - if self.tainted.lock().await.is_some() { - Err(Status::data_loss("session is tainted")) - } else { - Ok(()) - } - } - - fn check_epoch_index_overflow(&self) -> Result<(), Status> { - if self.active_epoch_index == std::u64::MAX { - Err(Status::out_of_range("active epoch index will overflow")) - } else { - Ok(()) - } - } - - fn check_active_epoch( - &self, - active_epoch_index: u64, - ) -> Result<(), Status> { - if self.active_epoch_index != active_epoch_index { - Err(Status::invalid_argument(format!( - "incorrect active epoch index (expected {}, got {})", - self.active_epoch_index, active_epoch_index - ))) - } else { - Ok(()) - } - } -} - -/// The keccak output has 32 bytes -const LOG2_KECCAK_SIZE: usize = 5; - -/// The epoch tree has 2^32 leafs -const LOG2_ROOT_SIZE: usize = 32 + LOG2_KECCAK_SIZE; - -/// The max number of inputs in an epoch is limited by the size of the merkle tree -const MAX_INPUTS_IN_EPOCH: usize = 1 << (LOG2_ROOT_SIZE - LOG2_KECCAK_SIZE); - -#[derive(Debug)] -struct Epoch { - state: EpochState, - pending_inputs: u64, - processed_inputs: Vec, - vouchers_tree: Tree, - notices_tree: Tree, - processed_input_count_since_genesis: u64, -} - -impl Epoch { - fn new(processed_input_count_since_genesis: u64) -> Self { - Self { - state: EpochState::Active, - pending_inputs: 0, - processed_inputs: vec![], - vouchers_tree: Tree::new( - LOG2_ROOT_SIZE, - LOG2_KECCAK_SIZE, - LOG2_KECCAK_SIZE, - ) - .expect("cannot fail"), - notices_tree: Tree::new( - LOG2_ROOT_SIZE, - LOG2_KECCAK_SIZE, - LOG2_KECCAK_SIZE, - ) - .expect("cannot fail"), - processed_input_count_since_genesis, - } - } - - fn try_add_pending_input( - &mut self, - current_input_index: u64, - ) -> Result<(), Status> { - self.check_active()?; - self.check_current_input_index(current_input_index)?; - self.check_input_limit()?; - self.pending_inputs += 1; - Ok(()) - } - - fn add_processed_input( - &mut self, - mut result: AdvanceResult, - ) -> Result<(), Status> { - // Compute proofs and update vouchers and notices trees - if let CompletionStatus::Accepted { vouchers, notices } = - &mut result.status - { - let voucher_root = compute_proofs(vouchers)?; - result.voucher_root = Some(voucher_root.clone()); - self.vouchers_tree.push(voucher_root)?; - let notice_root = compute_proofs(notices)?; - result.notice_root = Some(notice_root.clone()); - self.notices_tree.push(notice_root)?; - } else { - self.vouchers_tree.push(Hash::default())?; - self.notices_tree.push(Hash::default())?; - } - // Setup proofs for the current result - let address = (self.vouchers_tree.len() - 1) << LOG2_KECCAK_SIZE; - result.voucher_hashes_in_epoch = - Some(self.vouchers_tree.get_proof(address, LOG2_KECCAK_SIZE)?); - result.notice_hashes_in_epoch = - Some(self.notices_tree.get_proof(address, LOG2_KECCAK_SIZE)?); - // Add result to processed inputs - self.pending_inputs -= 1; - self.processed_inputs.push(result); - Ok(()) - } - - fn try_finish( - &mut self, - processed_input_count_within_epoch: u64, - epoch_index: u64, - ) -> Result { - self.check_active()?; - self.check_pending_inputs()?; - self.check_processed_inputs(processed_input_count_within_epoch)?; - self.state = EpochState::Finished; - - let machine_state_hash = GrpcHash { - data: vec![0_u8; HASH_SIZE], - }; - let mut proofs: Vec = vec![]; - let index = Token::Int(U256::from(epoch_index)); - let context = ethabi::encode(&[index]); - - for (local_input_index, result) in - self.processed_inputs.iter_mut().enumerate() - { - let address = local_input_index << LOG2_KECCAK_SIZE; - let voucher_hashes_in_epoch = - self.vouchers_tree.get_proof(address, LOG2_KECCAK_SIZE)?; - let notice_hashes_in_epoch = - self.notices_tree.get_proof(address, LOG2_KECCAK_SIZE)?; - let global_input_index = self.processed_input_count_since_genesis - + local_input_index as u64; - - if let CompletionStatus::Accepted { vouchers, notices } = - &mut result.status - { - // Create GrpcProof for each voucher - for (output_index, voucher) in vouchers.iter().enumerate() { - proofs.push(GrpcProof { - input_index: global_input_index, - output_index: output_index as u64, - output_enum: OutputEnum::Voucher.into(), - // Create OutputValidityProof for each voucher - validity: Some(OutputValidityProof { - input_index_within_epoch: local_input_index as u64, - output_index_within_input: output_index as u64, - output_hashes_root_hash: Some(GrpcHash::from( - result.voucher_root.clone().expect( - "expected voucher's root hash to exist", - ), - )), - vouchers_epoch_root_hash: Some(GrpcHash::from( - self.vouchers_tree.get_root_hash().clone(), - )), - notices_epoch_root_hash: Some(GrpcHash::from( - self.notices_tree.get_root_hash().clone(), - )), - machine_state_hash: Some( - machine_state_hash.clone(), - ), - output_hash_in_output_hashes_siblings: voucher - .keccak_in_voucher_hashes - .clone() - .expect("expected voucher proof to exist") - .sibling_hashes - .into_iter() - .map(GrpcHash::from) - .collect(), - output_hashes_in_epoch_siblings: - voucher_hashes_in_epoch - .clone() - .sibling_hashes - .into_iter() - .map(GrpcHash::from) - .collect(), - }), - context: context.clone(), - }) - } - // Create GrpcProof for each notice - for (output_index, notice) in notices.iter().enumerate() { - proofs.push(GrpcProof { - input_index: global_input_index, - output_index: output_index as u64, - output_enum: OutputEnum::Notice.into(), - // Create OutputValidityProof for each notice - validity: Some(OutputValidityProof { - input_index_within_epoch: local_input_index as u64, - output_index_within_input: output_index as u64, - output_hashes_root_hash: Some(GrpcHash::from( - result.notice_root.clone().expect( - "expected notice's root hash to exist", - ), - )), - vouchers_epoch_root_hash: Some(GrpcHash::from( - self.vouchers_tree.get_root_hash().clone(), - )), - notices_epoch_root_hash: Some(GrpcHash::from( - self.notices_tree.get_root_hash().clone(), - )), - machine_state_hash: Some( - machine_state_hash.clone(), - ), - output_hash_in_output_hashes_siblings: notice - .keccak_in_notice_hashes - .clone() - .expect("expected notice proof to exist") - .sibling_hashes - .into_iter() - .map(GrpcHash::from) - .collect(), - output_hashes_in_epoch_siblings: - notice_hashes_in_epoch - .clone() - .sibling_hashes - .into_iter() - .map(GrpcHash::from) - .collect(), - }), - context: context.clone(), - }) - } - } - } - - Ok(FinishEpochResponse { - machine_hash: Some(machine_state_hash.clone()), - vouchers_epoch_root_hash: Some(GrpcHash::from( - self.vouchers_tree.get_root_hash().clone(), - )), - notices_epoch_root_hash: Some(GrpcHash::from( - self.notices_tree.get_root_hash().clone(), - )), - proofs, - }) - } - - fn get_status( - &self, - session_id: String, - epoch_index: u64, - taint_status: Option, - ) -> GetEpochStatusResponse { - let processed_inputs = self - .processed_inputs - .iter() - .cloned() - .enumerate() - .map(|(local_input_index, input)| { - let input_index = local_input_index as u64 - + self.processed_input_count_since_genesis; - ProcessedInput { - input_index, - status: (&input.status).into(), - processed_input_one_of: input.status.into(), - reports: input - .reports - .into_iter() - .map(GrpcReport::from) - .collect(), - } - }) - .collect(); - GetEpochStatusResponse { - session_id, - epoch_index, - state: self.state as i32, - processed_inputs, - pending_input_count: self.pending_inputs, - taint_status, - } - } - - fn get_num_processed_inputs_within_epoch(&self) -> u64 { - self.processed_inputs.len() as u64 - } - - fn get_num_processed_inputs_since_genesis(&self) -> u64 { - self.get_num_processed_inputs_within_epoch() - + self.processed_input_count_since_genesis - } - - fn get_current_input_index(&self) -> u64 { - self.pending_inputs + self.get_num_processed_inputs_since_genesis() - } - - fn check_endable(&self) -> Result<(), Status> { - self.check_pending_inputs()?; - self.check_no_processed_inputs()?; - Ok(()) - } - - fn check_active(&self) -> Result<(), Status> { - if self.state != EpochState::Active { - Err(Status::invalid_argument("epoch is finished")) - } else { - Ok(()) - } - } - - fn check_finished(&self) -> Result<(), Status> { - match self.check_active() { - Ok(_) => Err(Status::invalid_argument("epoch is not finished")), - Err(_) => Ok(()), - } - } - - fn check_current_input_index( - &self, - current_input_index: u64, - ) -> Result<(), Status> { - let epoch_current_input_index = self.get_current_input_index(); - if epoch_current_input_index != current_input_index { - Err(Status::invalid_argument(format!( - "incorrect current input index (expected {}, got {})", - epoch_current_input_index, current_input_index - ))) - } else { - Ok(()) - } - } - - fn check_pending_inputs(&self) -> Result<(), Status> { - if self.pending_inputs != 0 { - Err(Status::invalid_argument("epoch still has pending inputs")) - } else { - Ok(()) - } - } - - fn check_processed_inputs( - &self, - processed_input_count_within_epoch: u64, - ) -> Result<(), Status> { - if self.get_num_processed_inputs_within_epoch() - != processed_input_count_within_epoch - { - Err(Status::invalid_argument(format!( - "incorrect processed input count (expected {}, got {})", - self.get_num_processed_inputs_within_epoch(), - processed_input_count_within_epoch - ))) - } else { - Ok(()) - } - } - - fn check_no_processed_inputs(&self) -> Result<(), Status> { - if self.get_num_processed_inputs_within_epoch() != 0 { - Err(Status::invalid_argument("epoch still has processed inputs")) - } else { - Ok(()) - } - } - - fn check_input_limit(&self) -> Result<(), Status> { - if self.pending_inputs - + self.get_num_processed_inputs_within_epoch() - + 1 - >= MAX_INPUTS_IN_EPOCH as u64 - { - Err(Status::invalid_argument( - "reached max number of inputs per epoch", - )) - } else { - Ok(()) - } - } -} - -impl From<&CompletionStatus> for i32 { - fn from(status: &CompletionStatus) -> i32 { - let status = match status { - CompletionStatus::Accepted { .. } => GrpcCompletionStatus::Accepted, - CompletionStatus::Rejected => GrpcCompletionStatus::Rejected, - CompletionStatus::Exception { .. } => { - GrpcCompletionStatus::Exception - } - }; - status as i32 - } -} - -impl From<&InspectStatus> for i32 { - fn from(status: &InspectStatus) -> i32 { - let status = match status { - InspectStatus::Accepted => GrpcCompletionStatus::Accepted, - InspectStatus::Rejected => GrpcCompletionStatus::Rejected, - InspectStatus::Exception { .. } => GrpcCompletionStatus::Exception, - }; - status as i32 - } -} - -impl From for Option { - fn from(status: CompletionStatus) -> Option { - match status { - CompletionStatus::Accepted { vouchers, notices } => { - Some(ProcessedInputOneOf::AcceptedData(AcceptedData { - vouchers: vouchers - .into_iter() - .map(GrpcVoucher::from) - .collect(), - notices: notices - .into_iter() - .map(GrpcNotice::from) - .collect(), - })) - } - CompletionStatus::Rejected => None, - CompletionStatus::Exception { exception } => { - Some(ProcessedInputOneOf::ExceptionData(exception.payload)) - } - } - } -} - -impl From for GrpcVoucher { - fn from(voucher: Voucher) -> GrpcVoucher { - GrpcVoucher { - destination: Some(Address { - data: voucher.destination.into(), - }), - payload: voucher.payload, - } - } -} - -impl From for GrpcNotice { - fn from(notice: Notice) -> GrpcNotice { - GrpcNotice { - payload: notice.payload, - } - } -} - -impl From for GrpcReport { - fn from(report: Report) -> GrpcReport { - GrpcReport { - payload: report.payload, - } - } -} - -impl From for GrpcHash { - fn from(hash: Hash) -> GrpcHash { - GrpcHash { data: hash.into() } - } -} - -impl From for GrpcMerkleTreeProof { - fn from(proof: Proof) -> GrpcMerkleTreeProof { - GrpcMerkleTreeProof { - target_address: proof.target_address as u64, - log2_target_size: proof.log2_target_size as u64, - target_hash: Some(proof.target_hash.into()), - log2_root_size: proof.log2_root_size as u64, - root_hash: Some(proof.root_hash.into()), - sibling_hashes: proof - .sibling_hashes - .into_iter() - .map(GrpcHash::from) - .collect(), - } - } -} - -impl From for Status { - fn from(e: MerkleTreeError) -> Status { - Status::internal(format!( - "unexpected error when updating merkle tree ({})", - e - )) - } -} diff --git a/offchain/host-runner/src/hash.rs b/offchain/host-runner/src/hash.rs deleted file mode 100644 index ed5706ecc..000000000 --- a/offchain/host-runner/src/hash.rs +++ /dev/null @@ -1,72 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub use sha3::Digest; -use sha3::{digest::Output, Keccak256}; - -pub const HASH_SIZE: usize = 32; - -pub type Hasher = Keccak256; - -#[derive(Clone, PartialEq, Eq)] -pub struct Hash { - data: [u8; HASH_SIZE], -} - -impl Hash { - pub fn data(&self) -> &[u8; HASH_SIZE] { - &self.data - } - - #[cfg(test)] - pub fn decode(s: &str) -> Hash { - Hash { - data: hex::decode(&s) - .expect("invalid hex string") - .try_into() - .expect("cannot fail"), - } - } -} - -impl Default for Hash { - fn default() -> Self { - Self { - data: [0; HASH_SIZE], - } - } -} - -impl From> for Hash { - fn from(arr: Output) -> Hash { - Hash { data: arr.into() } - } -} - -impl From<[u8; HASH_SIZE]> for Hash { - fn from(data: [u8; HASH_SIZE]) -> Hash { - Hash { data } - } -} - -impl TryFrom> for Hash { - type Error = Vec; - - fn try_from(v: Vec) -> Result> { - Ok(Hash { - data: v.try_into()?, - }) - } -} - -impl From for Vec { - fn from(hash: Hash) -> Vec { - Vec::from(hash.data) - } -} - -impl std::fmt::Debug for Hash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", hex::encode(self.data)) - } -} diff --git a/offchain/host-runner/src/http/errors.rs b/offchain/host-runner/src/http/errors.rs deleted file mode 100644 index 79143cb53..000000000 --- a/offchain/host-runner/src/http/errors.rs +++ /dev/null @@ -1,40 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use actix_web::{error, error::Error}; - -use crate::controller::ControllerError; -use crate::conversions::DecodeError; -use crate::model::RollupException; - -use super::model::{DecodeStatusError, VoucherDecodeError}; - -impl From for Error { - fn from(e: RollupException) -> Error { - error::ErrorInternalServerError(e.to_string()) - } -} - -impl From for Error { - fn from(e: ControllerError) -> Error { - error::ErrorBadRequest(e.to_string()) - } -} - -impl From for Error { - fn from(e: DecodeError) -> Error { - error::ErrorBadRequest(e.to_string()) - } -} - -impl From for Error { - fn from(e: VoucherDecodeError) -> Error { - error::ErrorBadRequest(e.to_string()) - } -} - -impl From for Error { - fn from(e: DecodeStatusError) -> Error { - error::ErrorBadRequest(e.to_string()) - } -} diff --git a/offchain/host-runner/src/http/mod.rs b/offchain/host-runner/src/http/mod.rs deleted file mode 100644 index fc14244b0..000000000 --- a/offchain/host-runner/src/http/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod errors; -pub mod model; -mod rollup_server; - -use crate::config::Config; -use crate::controller::Controller; - -/// Setup the HTTP server that receives requests from the DApp backend -pub async fn start_services( - config: &Config, - controller: Controller, -) -> std::io::Result<()> { - rollup_server::start_service(config, controller.clone()).await -} diff --git a/offchain/host-runner/src/http/model.rs b/offchain/host-runner/src/http/model.rs deleted file mode 100644 index 3cd67fc6d..000000000 --- a/offchain/host-runner/src/http/model.rs +++ /dev/null @@ -1,204 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use serde::{Deserialize, Serialize}; -use snafu::Snafu; - -use crate::conversions::{self, DecodeError}; -use crate::model::*; - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpAdvanceMetadata { - pub msg_sender: String, - pub epoch_index: u64, - pub input_index: u64, - pub block_number: u64, - pub timestamp: u64, -} - -impl From for HttpAdvanceMetadata { - fn from(metadata: AdvanceMetadata) -> HttpAdvanceMetadata { - HttpAdvanceMetadata { - msg_sender: conversions::encode_ethereum_binary( - &metadata.msg_sender, - ), - epoch_index: metadata.epoch_index, - input_index: metadata.input_index, - block_number: metadata.block_number, - timestamp: metadata.timestamp, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpAdvanceStateRequest { - pub metadata: HttpAdvanceMetadata, - pub payload: String, -} - -impl From for HttpAdvanceStateRequest { - fn from(request: AdvanceStateRequest) -> HttpAdvanceStateRequest { - HttpAdvanceStateRequest { - metadata: request.metadata.into(), - payload: conversions::encode_ethereum_binary(&request.payload), - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpInspectStateRequest { - pub payload: String, -} - -impl From for HttpInspectStateRequest { - fn from(request: InspectStateRequest) -> HttpInspectStateRequest { - HttpInspectStateRequest { - payload: conversions::encode_ethereum_binary(&request.payload), - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "request_type")] -pub enum HttpRollupRequest { - #[serde(rename = "advance_state")] - AdvanceState { data: HttpAdvanceStateRequest }, - #[serde(rename = "inspect_state")] - InspectState { data: HttpInspectStateRequest }, -} - -impl From for HttpRollupRequest { - fn from(request: RollupRequest) -> HttpRollupRequest { - match request { - RollupRequest::AdvanceState(request) => { - HttpRollupRequest::AdvanceState { - data: request.into(), - } - } - RollupRequest::InspectState(request) => { - HttpRollupRequest::InspectState { - data: request.into(), - } - } - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpVoucher { - pub destination: String, - pub payload: String, -} - -impl TryFrom for Voucher { - type Error = VoucherDecodeError; - fn try_from(voucher: HttpVoucher) -> Result { - Ok(Voucher::new( - conversions::decode_ethereum_binary(&voucher.destination)? - .try_into()?, - conversions::decode_ethereum_binary(&voucher.payload)?, - )) - } -} - -#[derive(Debug, Snafu)] -pub enum VoucherDecodeError { - #[snafu(display( - "Invalid Ethereum address size (got {} bytes, expected 20 bytes)", - got - ))] - InvalidAddressSize { got: usize }, - #[snafu(display("{}", e))] - HexDecodeError { e: DecodeError }, -} - -impl From for VoucherDecodeError { - fn from(e: DecodeError) -> VoucherDecodeError { - VoucherDecodeError::HexDecodeError { e } - } -} - -impl From> for VoucherDecodeError { - fn from(bytes: Vec) -> VoucherDecodeError { - VoucherDecodeError::InvalidAddressSize { got: bytes.len() } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpNotice { - pub payload: String, -} - -impl TryFrom for Notice { - type Error = DecodeError; - fn try_from(notice: HttpNotice) -> Result { - Ok(Notice::new(conversions::decode_ethereum_binary( - ¬ice.payload, - )?)) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpReport { - pub payload: String, -} - -impl From for HttpReport { - fn from(report: Report) -> HttpReport { - HttpReport { - payload: conversions::encode_ethereum_binary(&report.payload), - } - } -} - -impl TryFrom for Report { - type Error = DecodeError; - fn try_from(report: HttpReport) -> Result { - Ok(Report { - payload: conversions::decode_ethereum_binary(&report.payload)?, - }) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpRollupException { - pub payload: String, -} - -impl TryFrom for RollupException { - type Error = DecodeError; - fn try_from( - report: HttpRollupException, - ) -> Result { - Ok(RollupException { - payload: conversions::decode_ethereum_binary(&report.payload)?, - }) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpFinishRequest { - pub status: String, -} - -impl TryFrom for FinishStatus { - type Error = DecodeStatusError; - fn try_from( - request: HttpFinishRequest, - ) -> Result { - match request.status.as_str() { - "accept" => Ok(FinishStatus::Accept), - "reject" => Ok(FinishStatus::Reject), - _ => Err(DecodeStatusError {}), - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpIndexResponse { - pub index: u64, -} - -#[derive(Debug, Snafu)] -#[snafu(display("status must be 'accept' or 'reject'"))] -pub struct DecodeStatusError {} diff --git a/offchain/host-runner/src/http/rollup_server.rs b/offchain/host-runner/src/http/rollup_server.rs deleted file mode 100644 index 2b85877c1..000000000 --- a/offchain/host-runner/src/http/rollup_server.rs +++ /dev/null @@ -1,127 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use actix_web::{ - error, error::Result as HttpResult, middleware::Logger, web::Data, - web::Json, App, HttpResponse, HttpServer, Responder, -}; - -use crate::config::Config; -use crate::controller::{Controller, ControllerError}; -use crate::model::{FinishStatus, Notice, Report, RollupException, Voucher}; - -use super::model::{ - HttpFinishRequest, HttpIndexResponse, HttpNotice, HttpReport, - HttpRollupException, HttpRollupRequest, HttpVoucher, -}; - -pub async fn start_service( - config: &Config, - controller: Controller, -) -> std::io::Result<()> { - HttpServer::new(move || { - App::new() - .app_data(Data::new(controller.clone())) - .wrap(Logger::default()) - .service(voucher) - .service(notice) - .service(report) - .service(exception) - .service(finish) - }) - .bind(( - config.http_rollup_server_address.as_str(), - config.http_rollup_server_port, - ))? - .run() - .await -} - -#[actix_web::post("/voucher")] -async fn voucher( - voucher: Json, - controller: Data, -) -> HttpResult { - let voucher: Voucher = voucher.into_inner().try_into()?; - let rx = controller.insert_voucher(voucher).await; - let index = rx.await.map_err(|_| { - tracing::error!("sender dropped the channel"); - error::ErrorInternalServerError("failed to insert voucher") - })??; - let response = HttpIndexResponse { - index: index as u64, - }; - Ok(HttpResponse::Ok().json(response)) -} - -#[actix_web::post("/notice")] -async fn notice( - notice: Json, - controller: Data, -) -> HttpResult { - let notice: Notice = notice.into_inner().try_into()?; - let rx = controller.insert_notice(notice).await; - let index = rx.await.map_err(|_| { - tracing::error!("sender dropped the channel"); - error::ErrorInternalServerError("failed to insert notice") - })??; - let response = HttpIndexResponse { - index: index as u64, - }; - Ok(HttpResponse::Ok().json(response)) -} - -#[actix_web::post("/report")] -async fn report( - report: Json, - controller: Data, -) -> HttpResult { - let report: Report = report.into_inner().try_into()?; - let rx = controller.insert_report(report).await; - rx.await.map_err(|_| { - tracing::error!("sender dropped the channel"); - error::ErrorInternalServerError("failed to insert report") - })??; - Ok(HttpResponse::Ok()) -} - -#[actix_web::post("/exception")] -async fn exception( - exception: Json, - controller: Data, -) -> HttpResult { - let exception: RollupException = exception.into_inner().try_into()?; - let rx = controller.notify_exception(exception).await; - rx.await.map_err(|_| { - tracing::error!("sender dropped the channel"); - error::ErrorInternalServerError("failed to notify exception") - })??; - Ok(HttpResponse::Ok()) -} - -#[actix_web::post("/finish")] -async fn finish( - body: Json, - controller: Data, -) -> HttpResult { - let status: FinishStatus = body.into_inner().try_into()?; - let rx = controller.finish(status).await; - let result = rx.await.map_err(|_| { - tracing::error!("sender dropped the channel"); - error::ErrorInternalServerError("failed to finish") - })?; - let response = match result { - Ok(rollup_request) => { - HttpResponse::Ok().json(HttpRollupRequest::from(rollup_request)) - } - Err(e) => match e { - ControllerError::FetchRequestTimeout => { - HttpResponse::Accepted().body(e.to_string()) - } - ControllerError::InvalidRequest { .. } => { - HttpResponse::BadRequest().body(e.to_string()) - } - }, - }; - Ok(response) -} diff --git a/offchain/host-runner/src/main.rs b/offchain/host-runner/src/main.rs deleted file mode 100644 index cefeb5841..000000000 --- a/offchain/host-runner/src/main.rs +++ /dev/null @@ -1,84 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod config; -mod controller; -mod conversions; -mod driver; -mod grpc; -mod hash; -mod http; -mod merkle_tree; -mod model; -mod proofs; - -use futures_util::FutureExt; -use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; -use std::time::Duration; -use tokio::sync::oneshot; - -use clap::Parser; -use config::{CLIConfig, Config}; -use controller::Controller; - -fn log_result(name: &str, result: Result) { - let prefix = format!("http {} terminated ", name); - match result { - Ok(_) => tracing::info!("{} successfully", prefix), - Err(e) => tracing::warn!("{} with error: {}", prefix, e), - }; -} - -#[actix_web::main] -async fn main() { - let config: Config = CLIConfig::parse().into(); - - log::configure(&config.log_config); - - log::log_service_start(&config, "Host Runner"); - - let controller = - Controller::new(Duration::from_millis(config.finish_timeout)); - let http_service_running = Arc::new(AtomicBool::new(true)); - let (grpc_shutdown_tx, grpc_shutdown_rx) = oneshot::channel::<()>(); - let grpc_service = { - let controller = controller.clone(); - let config = config.clone(); - let shutdown = grpc_shutdown_rx.map(|_| ()); - let http_service_running = http_service_running.clone(); - tokio::spawn(async move { - log_result( - "gRPC service", - grpc::start_service(&config, controller.clone(), shutdown) - .await, - ); - if http_service_running.load(Ordering::Relaxed) { - panic!("gRPC service terminated before shutdown signal"); - } - }) - }; - - // We run the actix-web in the main thread because it handles the SIGINT - let host_runner_handle = http::start_services(&config, controller.clone()); - let health_handle = http_health_check::start(config.healthcheck_port); - tokio::select! { - result = health_handle => { - log_result("http health check", result); - } - result = host_runner_handle => { - log_result("http service", result); - } - } - http_service_running.store(false, Ordering::Relaxed); - - // Shutdown the other services - if let Err(e) = controller.shutdown().await.await { - tracing::error!("failed to shutdown controller ({})", e); - } - if grpc_shutdown_tx.send(()).is_err() { - tracing::error!("failed to send the shutdown signal to grpc"); - } - if let Err(e) = grpc_service.await { - tracing::error!("failed to shutdown the grpc service ({})", e); - } -} diff --git a/offchain/host-runner/src/merkle_tree/complete.rs b/offchain/host-runner/src/merkle_tree/complete.rs deleted file mode 100644 index fde9ae629..000000000 --- a/offchain/host-runner/src/merkle_tree/complete.rs +++ /dev/null @@ -1,476 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//! Complete merkle tree based on Cartesi machine-emulator implementation - -use super::{ - get_concat_hash, pristine, proof::Proof, Error, - LeafSizeGreaterThanRootSizeSnafu, MisalignedAddressSnafu, - SizeOutOfRangeSnafu, TooManyLeavesSnafu, TreeIsFullSnafu, - TreeTooLargeSnafu, WordSizeGreaterThanLeafSizeSnafu, -}; -use crate::hash::{Digest, Hash, Hasher}; - -/// Complete merkle tree -/// -/// A merkle tree with any number of non-pristine leaves follwed by a number of pristine leaves. -/// The tree is optimized to store only the hashes that are not pristine. -#[derive(Debug)] -pub struct Tree { - log2_root_size: usize, - log2_leaf_size: usize, - pristine: pristine::Tree, - tree: Vec, -} - -impl Tree { - /// Create a new complete merkle tree - /// - /// - `log2_root_size`: Log2 of the size in bytes of the whole merkle tree. - /// - `log2_leaf_size`: Log2 of the size in bytes of a single leaf. - /// - `log2_word_size`: Log2 of the size in bytes of a single word. This is used to compute the - /// pristine hash of a leave. - pub fn new( - log2_root_size: usize, - log2_leaf_size: usize, - log2_word_size: usize, - ) -> Result { - snafu::ensure!( - log2_leaf_size <= log2_root_size, - LeafSizeGreaterThanRootSizeSnafu - ); - snafu::ensure!( - log2_word_size <= log2_leaf_size, - WordSizeGreaterThanLeafSizeSnafu - ); - snafu::ensure!( - log2_root_size <= std::mem::size_of::() * 8, - TreeTooLargeSnafu - ); - Ok(Self { - log2_root_size, - log2_leaf_size, - pristine: pristine::Tree::new(log2_root_size, log2_word_size)?, - tree: vec![vec![]; log2_root_size - log2_leaf_size + 1], - }) - } - - /// Create a new complete merkle tree from non-pristine leaves - /// - /// - `leaves`: Array with non-pristine hash leaves bound to the left side of the tree. - /// - /// For more information regarding the other parameters, see Tree::new(). - pub fn new_from_leaves( - log2_root_size: usize, - log2_leaf_size: usize, - log2_word_size: usize, - leaves: Level, - ) -> Result { - let max_len = 1 << (log2_root_size - log2_leaf_size); - snafu::ensure!(leaves.len() <= max_len, TooManyLeavesSnafu); - let mut tree = - Self::new(log2_root_size, log2_leaf_size, log2_word_size)?; - let level = tree.get_level_mut(log2_leaf_size).expect("cannot fail"); - *level = leaves; - tree.bubble_up(); - Ok(tree) - } - - /// Return the tree's root hash - pub fn get_root_hash(&self) -> &Hash { - self.get_node_hash(0, self.log2_root_size) - .expect("cannot fail") - } - - /// Return proof for a given node - /// - /// - `address`: The address is represented by the node index at the level shifted by - /// `log2_size`. - /// - `log2_size`: Log2 of the size in bytes of the subtree. - pub fn get_proof( - &self, - address: usize, - log2_size: usize, - ) -> Result { - snafu::ensure!( - log2_size >= self.log2_leaf_size - && log2_size <= self.log2_root_size, - SizeOutOfRangeSnafu - ); - let aligned_address = (address >> log2_size) << log2_size; - snafu::ensure!(address == aligned_address, MisalignedAddressSnafu); - let target_hash = self.get_node_hash(address, log2_size)?.clone(); - let log2_root_size = self.log2_root_size; - let root_hash = self.get_root_hash().clone(); - let mut proof = Proof::new( - address, - log2_size, - target_hash, - log2_root_size, - root_hash, - )?; - for log2_sibling_size in log2_size..log2_root_size { - let sibling_address = address ^ (1 << log2_sibling_size); - let hash = - self.get_node_hash(sibling_address, log2_sibling_size)?; - proof.set_sibling_hash(hash.clone(), log2_sibling_size)?; - } - Ok(proof) - } - - /// Append a new leaf hash to the tree - /// - /// - `leaf`: Hash to append. - pub fn push(&mut self, leaf: Hash) -> Result<(), Error> { - let max_len = 1 << (self.log2_root_size - self.log2_leaf_size); - let leaves = self - .get_level_mut(self.log2_leaf_size) - .expect("cannot fail"); - snafu::ensure!(leaves.len() < max_len, TreeIsFullSnafu); - leaves.push(leaf); - self.bubble_up(); - Ok(()) - } - - /// Return the number of leafs - pub fn len(&self) -> usize { - self.get_level(self.log2_leaf_size) - .expect("cannot fail") - .len() - } - - /// Return the hash of a node at a given address - /// - /// For more information regarding the other parameters, see Tree::get_proof(). - fn get_node_hash( - &self, - address: usize, - log2_size: usize, - ) -> Result<&Hash, Error> { - let address = address >> log2_size; - let bounds = 1 << (self.log2_root_size - log2_size); - snafu::ensure!(address < bounds, SizeOutOfRangeSnafu); - let level = self.get_level(log2_size)?; - if address < level.len() { - Ok(&level[address]) - } else { - self.pristine.get_hash(log2_size) - } - } - - /// Update node hashes when a new set of non-pristine nodes is added to the leaf level - fn bubble_up(&mut self) { - let mut hasher = Hasher::new(); - // Go bottom up, updating hashes - for log2_prev_size in self.log2_leaf_size..self.log2_root_size { - let log2_next_size = log2_prev_size + 1; - // Extract the next level from self to deal with borrow-checker - let mut next = vec![]; - std::mem::swap( - &mut next, - self.get_level_mut(log2_next_size).expect("cannot fail"), - ); - let prev = self.get_level(log2_prev_size).expect("cannot fail"); - // Redo last entry (if any) because it may have been constructed - // from the last non-pristine entry in the previous level paired - // with a pristine entry (i.e., the previous level was odd). - let first_entry = if next.is_empty() { 0 } else { next.len() - 1 }; - // Next level needs half as many (rounded up) as previous - next.resize_with((prev.len() + 1) / 2, Default::default); - // Last safe entry has two non-pristine leafs - let last_safe_entry = prev.len() / 2; - // Do all entries for which we have two non-pristine children - for i in first_entry..last_safe_entry { - next[i] = get_concat_hash( - &mut hasher, - &prev[2 * i], - &prev[2 * i + 1], - ); - } - // Maybe do last odd entry - if prev.len() > 2 * last_safe_entry { - let prev_pristine = self - .pristine - .get_hash(log2_prev_size) - .expect("cannot fail"); - next[last_safe_entry] = get_concat_hash( - &mut hasher, - &prev[prev.len() - 1], - prev_pristine, - ); - } - // Put the level back in self - std::mem::swap( - &mut next, - self.get_level_mut(log2_next_size).expect("cannot fail"), - ); - } - } - - /// Return the hashes at the given level - fn get_level(&self, log2_size: usize) -> Result<&Level, Error> { - let index = self.get_level_index(log2_size)?; - Ok(&self.tree[index]) - } - - /// Mutable version of Tree::get_level() - fn get_level_mut(&mut self, log2_size: usize) -> Result<&mut Level, Error> { - let index = self.get_level_index(log2_size)?; - Ok(&mut self.tree[index]) - } - - /// Compute the level index given the sub-tree size - /// - /// - `log2_size`: Log2 of the size in bytes of the subtree. - fn get_level_index(&self, log2_size: usize) -> Result { - snafu::ensure!( - log2_size >= self.log2_leaf_size - && log2_size <= self.log2_root_size, - SizeOutOfRangeSnafu - ); - Ok(self.log2_root_size - log2_size) - } -} - -type Level = Vec; - -#[cfg(test)] -mod tests { - use super::*; - use crate::hash::HASH_SIZE; - - fn compare_to_pristine( - tree: Tree, - log2_root_size: usize, - log2_leaf_size: usize, - log2_word_size: usize, - ) { - let pristine = - pristine::Tree::new(log2_root_size, log2_word_size).unwrap(); - for log2_size in log2_leaf_size..log2_root_size { - let max_address = 1 << (log2_root_size - log2_size); - for address in 0..max_address { - assert_eq!( - tree.get_node_hash(address << log2_size, log2_size) - .unwrap(), - pristine.get_hash(log2_size).unwrap() - ); - } - } - } - - #[test] - fn test_it_fails_to_create_a_tree_with_leaf_size_greater_than_root_size() { - let err = Tree::new(2, 3, 0).unwrap_err(); - assert_eq!(err, Error::LeafSizeGreaterThanRootSize); - } - - #[test] - fn test_it_fails_to_create_a_tree_with_word_size_greater_than_leaf_size() { - let err = Tree::new(2, 1, 2).unwrap_err(); - assert_eq!(err, Error::WordSizeGreaterThanLeafSize); - } - - #[test] - fn test_it_fails_to_create_that_does_not_fit_in_memory() { - let err = Tree::new(65, 1, 0).unwrap_err(); - assert_eq!(err, Error::TreeTooLarge); - } - - #[test] - fn test_it_is_equals_to_pristine_tree_when_empty() { - let tree = Tree::new(8, 3, 0).unwrap(); - compare_to_pristine(tree, 8, 3, 0); - } - - #[test] - fn test_it_fails_to_create_tree_with_too_many_leaves() { - // It should have at most 2 leaves - let leaves = vec![Hash::default(); 3]; - let err = Tree::new_from_leaves(3, 2, 1, leaves).unwrap_err(); - assert_eq!(err, Error::TooManyLeaves); - } - - #[test] - fn test_it_is_equals_to_pristine_tree_when_created_with_no_leaves() { - let leaves = vec![]; - let tree = Tree::new_from_leaves(8, 3, 0, leaves).unwrap(); - compare_to_pristine(tree, 8, 3, 0); - } - - #[test] - fn test_it_works_propertly_when_created_with_all_leaves() { - let leaves = vec![Hash::from([0xFF; HASH_SIZE]); 8]; - let tree = Tree::new_from_leaves(3, 0, 0, leaves).unwrap(); - assert_eq!(tree.get_level(0).unwrap().len(), 8); - assert_eq!(tree.get_level(1).unwrap().len(), 4); - assert_eq!(tree.get_level(2).unwrap().len(), 2); - assert_eq!(tree.get_level(3).unwrap().len(), 1); - assert_eq!( - tree.get_root_hash(), - &Hash::decode("ec06b3285e5018dbd1981c64dbf6e9cea02fa591f0322b51cfbc31a729500928") - ); - } - - #[test] - fn test_it_works_propertly_when_created_with_odd_number_of_leaves() { - let leaves = vec![Hash::from([0xFF; HASH_SIZE]); 3]; - let tree = Tree::new_from_leaves(3, 0, 0, leaves).unwrap(); - assert_eq!(tree.get_level(0).unwrap().len(), 3); - assert_eq!(tree.get_level(1).unwrap().len(), 2); - assert_eq!(tree.get_level(2).unwrap().len(), 1); - assert_eq!(tree.get_level(3).unwrap().len(), 1); - assert_eq!( - tree.get_root_hash(), - &Hash::decode("4d41dd9b105ebb70fbed098caccf3d56b286c690a96202c1357f951cca4dad5d") - ); - } - - #[test] - fn test_it_works_properly_when_root_size_equals_to_leaf_size() { - let leaves = vec![Hash::from([0xFF; HASH_SIZE])]; - let tree = Tree::new_from_leaves(0, 0, 0, leaves).unwrap(); - assert_eq!(tree.get_root_hash(), &Hash::from([0xFF; HASH_SIZE])); - } - - #[test] - fn test_it_computes_the_level_index_properly() { - let tree = Tree::new(3, 1, 0).unwrap(); - assert_eq!(tree.get_level_index(1).unwrap(), 2); - assert_eq!(tree.get_level_index(2).unwrap(), 1); - assert_eq!(tree.get_level_index(3).unwrap(), 0); - } - - #[test] - fn test_it_fails_to_compute_the_index_when_log2_size_is_out_of_range() { - let tree = Tree::new(3, 2, 1).unwrap(); - assert_eq!(tree.get_level_index(4).unwrap_err(), Error::SizeOutOfRange); - assert_eq!(tree.get_level_index(1).unwrap_err(), Error::SizeOutOfRange); - } - - #[test] - fn test_it_fails_to_get_proof_when_log2_size_is_out_of_range() { - let tree = Tree::new(3, 2, 1).unwrap(); - assert_eq!(tree.get_proof(0, 4).unwrap_err(), Error::SizeOutOfRange); - assert_eq!(tree.get_proof(0, 1).unwrap_err(), Error::SizeOutOfRange); - } - - #[test] - fn test_it_fails_to_get_proof_when_address_is_misalign() { - let tree = Tree::new(3, 2, 1).unwrap(); - assert_eq!( - tree.get_proof((0 << 2) + 1, 2).unwrap_err(), - Error::MisalignedAddress - ); - assert_eq!( - tree.get_proof((1 << 2) + 1, 2).unwrap_err(), - Error::MisalignedAddress - ); - } - - #[test] - fn test_it_fails_to_get_proof_when_address_is_out_of_bound() { - let tree = Tree::new(3, 2, 1).unwrap(); - assert_eq!( - tree.get_proof(2 << 2, 2).unwrap_err(), - Error::SizeOutOfRange - ); - assert_eq!( - tree.get_proof(1 << 3, 2).unwrap_err(), - Error::SizeOutOfRange - ); - } - - #[test] - fn test_it_gets_correct_proof_of_root_node() { - let tree = Tree::new(3, 2, 1).unwrap(); - let proof = tree.get_proof(0, 3).unwrap(); - let root_hash = tree.pristine.get_hash(3).unwrap(); - assert_eq!(proof.target_address, 0); - assert_eq!(proof.log2_target_size, 3); - assert_eq!(&proof.target_hash, root_hash); - assert_eq!(proof.log2_root_size, 3); - assert_eq!(&proof.root_hash, root_hash); - assert_eq!(proof.sibling_hashes, vec![]); - } - - #[test] - fn test_it_gets_correct_proof_of_leaf_node_when_empty() { - let tree = Tree::new(3, 0, 0).unwrap(); - let proof = tree.get_proof(0, 0).unwrap(); - assert_eq!(proof.target_address, 0); - assert_eq!(proof.log2_target_size, 0); - assert_eq!(&proof.target_hash, tree.pristine.get_hash(0).unwrap()); - assert_eq!(proof.log2_root_size, 3); - assert_eq!(&proof.root_hash, tree.pristine.get_hash(3).unwrap()); - assert_eq!( - proof.sibling_hashes, - vec![ - tree.pristine.get_hash(0).unwrap().clone(), - tree.pristine.get_hash(1).unwrap().clone(), - tree.pristine.get_hash(2).unwrap().clone(), - ] - ); - } - - #[test] - fn test_it_gets_correct_proof_of_leaf_node_when_half_full() { - let leaves = vec![Hash::from([0xFF; HASH_SIZE]); 3]; - let tree = Tree::new_from_leaves(3, 0, 0, leaves).unwrap(); - let proof = tree.get_proof(3, 0).unwrap(); - assert_eq!(proof.target_address, 3); - assert_eq!(proof.log2_target_size, 0); - assert_eq!(&proof.target_hash, tree.pristine.get_hash(0).unwrap()); - assert_eq!(proof.log2_root_size, 3); - assert_eq!( - proof.root_hash, - Hash::decode("4d41dd9b105ebb70fbed098caccf3d56b286c690a96202c1357f951cca4dad5d") - ); - assert_eq!( - proof.sibling_hashes, - vec![ - Hash::from([0xFF; HASH_SIZE]), - Hash::decode("bd8b151773dbbefd7b0df67f2dcc482901728b6df477f4fb2f192733a005d396"), - Hash::decode("bb1bfb5bfc9ba6ba8e25341a7b70725d8f74121b9e31dd2314e68e27b8d24244"), - ] - ); - } - - #[test] - fn test_it_pushes_leaf_in_empty_tree() { - let mut tree = Tree::new(3, 0, 0).unwrap(); - tree.push(Hash::from([0xFF; HASH_SIZE])).unwrap(); - assert_eq!(tree.get_level(0).unwrap().len(), 1); - assert_eq!(tree.get_level(1).unwrap().len(), 1); - assert_eq!(tree.get_level(2).unwrap().len(), 1); - assert_eq!(tree.get_level(3).unwrap().len(), 1); - assert_eq!( - tree.get_root_hash(), - &Hash::decode("da0df8c5459cfe821402ad885e95e31e6bb4abf940e13b4c69afa5245b4eeb7d") - ); - } - - #[test] - fn test_it_pushes_leaf_in_almost_full_tree() { - let mut tree = Tree::new(3, 0, 0).unwrap(); - for i in 0..8 { - tree.push(Hash::from([0xFF; HASH_SIZE])).unwrap(); - assert_eq!(tree.get_level(0).unwrap().len(), i + 1); - } - assert_eq!(tree.get_level(1).unwrap().len(), 4); - assert_eq!(tree.get_level(2).unwrap().len(), 2); - assert_eq!(tree.get_level(3).unwrap().len(), 1); - assert_eq!( - tree.get_root_hash(), - &Hash::decode("ec06b3285e5018dbd1981c64dbf6e9cea02fa591f0322b51cfbc31a729500928") - ); - } - - #[test] - fn test_it_fails_to_push_leaf_in_full_tree() { - let leaves = vec![Hash::from([0xFF; HASH_SIZE]); 8]; - let mut tree = Tree::new_from_leaves(3, 0, 0, leaves).unwrap(); - let err = tree.push(Hash::default()).unwrap_err(); - assert_eq!(err, Error::TreeIsFull); - } -} diff --git a/offchain/host-runner/src/merkle_tree/mod.rs b/offchain/host-runner/src/merkle_tree/mod.rs deleted file mode 100644 index 82b205c45..000000000 --- a/offchain/host-runner/src/merkle_tree/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod complete; -pub mod pristine; -pub mod proof; - -use snafu::Snafu; - -use crate::hash::{Digest, Hash, Hasher}; - -#[derive(Debug, Snafu, PartialEq)] -pub enum Error { - #[snafu(display("log2_target_size is greater than log2_root_size"))] - TargetSizeGreaterThanRootSize, - #[snafu(display("log2_leaf_size is greater than log2_root_size"))] - LeafSizeGreaterThanRootSize, - #[snafu(display("log2_word_size is greater than log2_leaf_size"))] - WordSizeGreaterThanLeafSize, - #[snafu(display("log2_word_size is greater than log2_root_size"))] - WordSizeGreaterThanRootSize, - #[snafu(display("tree is too large for address type"))] - TreeTooLarge, - #[snafu(display("tree is full"))] - TreeIsFull, - #[snafu(display("too many leaves"))] - TooManyLeaves, - #[snafu(display("log2_size is out of range"))] - SizeOutOfRange, - #[snafu(display("address is misaligned"))] - MisalignedAddress, -} - -fn get_concat_hash(hasher: &mut Hasher, left: &Hash, right: &Hash) -> Hash { - hasher.reset(); - hasher.update(left.data()); - hasher.update(right.data()); - hasher.finalize_reset().into() -} diff --git a/offchain/host-runner/src/merkle_tree/pristine.rs b/offchain/host-runner/src/merkle_tree/pristine.rs deleted file mode 100644 index a203702af..000000000 --- a/offchain/host-runner/src/merkle_tree/pristine.rs +++ /dev/null @@ -1,127 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//! Pristine merkle tree based on Cartesi machine-emulator implementation - -use super::{ - get_concat_hash, Error, SizeOutOfRangeSnafu, - WordSizeGreaterThanRootSizeSnafu, -}; -use crate::hash::{Digest, Hash, Hasher}; - -/// Merkle tree where all leaves are zero -#[derive(Debug)] -pub struct Tree { - log2_root_size: usize, - log2_word_size: usize, - hashes: Vec, -} - -impl Tree { - /// Create a new pristine merkle tree - /// - /// - `log2_root_size`: Log2 of the size in bytes of the whole merkle tree. - /// - `log2_word_size`: Log2 of the size in bytes of a single word. - pub fn new( - log2_root_size: usize, - log2_word_size: usize, - ) -> Result { - snafu::ensure!( - log2_word_size <= log2_root_size, - WordSizeGreaterThanRootSizeSnafu - ); - let num_hashes = log2_root_size - log2_word_size + 1; - let mut hashes = vec![]; - let mut hasher = Hasher::new(); - let word: Vec = vec![0; 1 << log2_word_size]; - hasher.update(&word); - hashes.push(hasher.finalize_reset().into()); - for i in 1..num_hashes { - hashes.push(get_concat_hash( - &mut hasher, - &hashes[i - 1], - &hashes[i - 1], - )); - } - Ok(Self { - log2_root_size, - log2_word_size, - hashes, - }) - } - - /// Get a hash for a sub-tree of the given size - /// - /// - `log2_size`: Log2 of the size in bytes of the subtree. - pub fn get_hash(&self, log2_size: usize) -> Result<&Hash, Error> { - snafu::ensure!( - log2_size >= self.log2_word_size - && log2_size <= self.log2_root_size, - SizeOutOfRangeSnafu - ); - Ok(&self.hashes[log2_size - self.log2_word_size]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_it_fails_to_create_a_tree_with_word_size_greater_than_root_size() { - let err = Tree::new(2, 3).unwrap_err(); - assert_eq!(err, Error::WordSizeGreaterThanRootSize); - } - - #[test] - fn test_it_fails_to_get_hash_greater_than_root_size() { - let tree = Tree::new(5, 3).unwrap(); - let err = tree.get_hash(6).unwrap_err(); - assert_eq!(err, Error::SizeOutOfRange); - } - - #[test] - fn test_it_fails_to_get_hash_smaller_than_word_size() { - let tree = Tree::new(5, 3).unwrap(); - let err = tree.get_hash(2).unwrap_err(); - assert_eq!(err, Error::SizeOutOfRange); - } - - #[test] - fn test_it_creates_a_tree_with_root_size_equals_to_word_size() { - let tree = Tree::new(5, 5).unwrap(); - assert_eq!( - tree.get_hash(5).unwrap(), - &Hash::decode("290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563") - ); - } - - #[test] - fn test_it_creates_a_tree_with_correct_hashes() { - let tree = Tree::new(8, 3).unwrap(); - assert_eq!( - tree.get_hash(3).unwrap(), - &Hash::decode("011b4d03dd8c01f1049143cf9c4c817e4b167f1d1b83e5c6f0f10d89ba1e7bce") - ); - assert_eq!( - tree.get_hash(4).unwrap(), - &Hash::decode("4d9470a821fbe90117ec357e30bad9305732fb19ddf54a07dd3e29f440619254") - ); - assert_eq!( - tree.get_hash(5).unwrap(), - &Hash::decode("ae39ce8537aca75e2eff3e38c98011dfe934e700a0967732fc07b430dd656a23") - ); - assert_eq!( - tree.get_hash(6).unwrap(), - &Hash::decode("3fc9a15f5b4869c872f81087bb6104b7d63e6f9ab47f2c43f3535eae7172aa7f") - ); - assert_eq!( - tree.get_hash(7).unwrap(), - &Hash::decode("17d2dd614cddaa4d879276b11e0672c9560033d3e8453a1d045339d34ba601b9") - ); - assert_eq!( - tree.get_hash(8).unwrap(), - &Hash::decode("c37b8b13ca95166fb7af16988a70fcc90f38bf9126fd833da710a47fb37a55e6") - ); - } -} diff --git a/offchain/host-runner/src/merkle_tree/proof.rs b/offchain/host-runner/src/merkle_tree/proof.rs deleted file mode 100644 index d40955dbe..000000000 --- a/offchain/host-runner/src/merkle_tree/proof.rs +++ /dev/null @@ -1,69 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//! Merkle tree proof based on Cartesi machine-emulator implementation - -use super::{Error, SizeOutOfRangeSnafu, TargetSizeGreaterThanRootSizeSnafu}; - -use crate::hash::Hash; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Proof { - pub target_address: usize, - pub log2_target_size: usize, - pub target_hash: Hash, - pub log2_root_size: usize, - pub root_hash: Hash, - pub sibling_hashes: Vec, -} - -/// Merkle tree proof structure -/// -/// This structure holds a proof that the node spanning a log2_target_size at a given address in -/// the tree has a certain hash. -impl Proof { - /// Constructs a merkle_tree_proof object and allocates room for the sibling hashes - pub fn new( - target_address: usize, - log2_target_size: usize, - target_hash: Hash, - log2_root_size: usize, - root_hash: Hash, - ) -> Result { - snafu::ensure!( - log2_target_size <= log2_root_size, - TargetSizeGreaterThanRootSizeSnafu - ); - Ok(Self { - target_address, - log2_target_size, - target_hash, - log2_root_size, - root_hash, - sibling_hashes: vec![ - Hash::default(); - log2_root_size - log2_target_size - ], - }) - } - - /// Modify hash corresponding to log2_size in the list of siblings. - pub fn set_sibling_hash( - &mut self, - hash: Hash, - log2_size: usize, - ) -> Result<(), Error> { - let index = self.log2_size_to_index(log2_size)?; - self.sibling_hashes[index] = hash; - Ok(()) - } - - /// Converts log2_size to index into siblings array - fn log2_size_to_index(&self, log2_size: usize) -> Result { - snafu::ensure!(log2_size < self.log2_root_size, SizeOutOfRangeSnafu); - snafu::ensure!(log2_size >= self.log2_target_size, SizeOutOfRangeSnafu); - let index = log2_size - self.log2_target_size; - snafu::ensure!(index < self.sibling_hashes.len(), SizeOutOfRangeSnafu); - Ok(index) - } -} diff --git a/offchain/host-runner/src/model.rs b/offchain/host-runner/src/model.rs deleted file mode 100644 index 282e4bbb6..000000000 --- a/offchain/host-runner/src/model.rs +++ /dev/null @@ -1,209 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::conversions; -use crate::driver::{compute_notice_hash, compute_voucher_hash}; -use crate::hash::Hash; -use crate::merkle_tree::proof::Proof; -use crate::proofs::Proofable; - -const ADDRESS_SIZE: usize = 20; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdvanceStateRequest { - pub metadata: AdvanceMetadata, - pub payload: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdvanceMetadata { - pub msg_sender: [u8; ADDRESS_SIZE], - pub epoch_index: u64, - pub input_index: u64, - pub block_number: u64, - pub timestamp: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdvanceResult { - pub status: CompletionStatus, - pub reports: Vec, - pub voucher_hashes_in_epoch: Option, - pub voucher_root: Option, - pub notice_hashes_in_epoch: Option, - pub notice_root: Option, -} - -impl AdvanceResult { - pub fn accepted( - vouchers: Vec, - notices: Vec, - reports: Vec, - ) -> Self { - let status = CompletionStatus::Accepted { vouchers, notices }; - Self::new(status, reports) - } - - pub fn rejected(reports: Vec) -> Self { - Self::new(CompletionStatus::Rejected, reports) - } - - pub fn exception(exception: RollupException, reports: Vec) -> Self { - let status = CompletionStatus::Exception { exception }; - Self::new(status, reports) - } - - fn new(status: CompletionStatus, reports: Vec) -> Self { - Self { - status, - reports, - voucher_hashes_in_epoch: None, - voucher_root: None, - notice_hashes_in_epoch: None, - notice_root: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CompletionStatus { - Accepted { - vouchers: Vec, - notices: Vec, - }, - Rejected, - Exception { - exception: RollupException, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InspectStateRequest { - pub payload: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InspectResult { - pub status: InspectStatus, - pub reports: Vec, -} - -impl InspectResult { - pub fn accepted(reports: Vec) -> Self { - Self { - status: InspectStatus::Accepted, - reports, - } - } - - pub fn rejected(reports: Vec) -> Self { - Self { - status: InspectStatus::Rejected, - reports, - } - } - - pub fn exception(reports: Vec, exception: RollupException) -> Self { - Self { - status: InspectStatus::Exception { exception }, - reports, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum InspectStatus { - Accepted, - Rejected, - Exception { exception: RollupException }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FinishStatus { - Accept, - Reject, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RollupRequest { - AdvanceState(AdvanceStateRequest), - InspectState(InspectStateRequest), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Voucher { - pub destination: [u8; ADDRESS_SIZE], - pub payload: Vec, - pub keccak: Hash, - pub keccak_in_voucher_hashes: Option, -} - -impl Voucher { - pub fn new(destination: [u8; ADDRESS_SIZE], payload: Vec) -> Self { - let keccak = compute_voucher_hash(&destination, &payload); - Self { - destination, - payload, - keccak, - keccak_in_voucher_hashes: None, - } - } -} - -impl Proofable for Voucher { - fn get_hash(&self) -> &Hash { - &self.keccak - } - - fn set_proof(&mut self, proof: Proof) { - self.keccak_in_voucher_hashes = Some(proof); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Notice { - pub payload: Vec, - pub keccak: Hash, - pub keccak_in_notice_hashes: Option, -} - -impl Notice { - pub fn new(payload: Vec) -> Self { - let keccak = compute_notice_hash(&payload); - Self { - payload, - keccak, - keccak_in_notice_hashes: None, - } - } -} - -impl Proofable for Notice { - fn get_hash(&self) -> &Hash { - &self.keccak - } - - fn set_proof(&mut self, proof: Proof) { - self.keccak_in_notice_hashes = Some(proof); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Report { - pub payload: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RollupException { - pub payload: Vec, -} - -impl std::fmt::Display for RollupException { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - f, - "rollup exception ({})", - conversions::encode_ethereum_binary(&self.payload) - ) - } -} diff --git a/offchain/host-runner/src/proofs.rs b/offchain/host-runner/src/proofs.rs deleted file mode 100644 index a3bb830ea..000000000 --- a/offchain/host-runner/src/proofs.rs +++ /dev/null @@ -1,47 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::hash::{Digest, Hash, Hasher}; -use crate::merkle_tree::{self, complete::Tree, proof::Proof}; - -const LOG2_ROOT_SIZE: usize = 16 + LOG2_HASH_SIZE; -const LOG2_WORD_SIZE: usize = 3; -const LOG2_HASH_SIZE: usize = 5; -const WORD_SIZE: usize = 1 << LOG2_WORD_SIZE; -const WORDS_PER_HASH: usize = 1 << (LOG2_HASH_SIZE - LOG2_WORD_SIZE); - -/// Trait to be implemented by vouchers and notices -pub trait Proofable { - fn get_hash(&self) -> &Hash; - fn set_proof(&mut self, proof: Proof); -} - -/// Update the merkle proofs of every proofable in the array and return the merkle-tree's root hash -pub fn compute_proofs( - proofables: &mut [impl Proofable], -) -> Result { - let mut hasher = Hasher::new(); - let mut leaves: Vec = vec![]; - for proofable in proofables.iter() { - let hash = proofable.get_hash(); - for word in 0..WORDS_PER_HASH { - let start = word * WORD_SIZE; - let end = start + WORD_SIZE; - hasher.update(&hash.data()[start..end]); - let word_hash = hasher.finalize_reset().into(); - leaves.push(word_hash); - } - } - let tree = Tree::new_from_leaves( - LOG2_ROOT_SIZE, - LOG2_WORD_SIZE, - LOG2_WORD_SIZE, - leaves, - )?; - for (i, proofable) in proofables.iter_mut().enumerate() { - let proof = - tree.get_proof(i * (1 << LOG2_HASH_SIZE), LOG2_HASH_SIZE)?; - proofable.set_proof(proof); - } - Ok(tree.get_root_hash().clone()) -} diff --git a/offchain/host-runner/tests/common/config.rs b/offchain/host-runner/tests/common/config.rs deleted file mode 100644 index 8f9eaae4c..000000000 --- a/offchain/host-runner/tests/common/config.rs +++ /dev/null @@ -1,28 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub const GRPC_SERVER_MANAGER_PORT: u16 = 50001; -pub const HTTP_INSPECT_PORT: u16 = 50002; -pub const HTTP_ROLLUP_SERVER_PORT: u16 = 50004; -pub const FINISH_TIMEOUT: u64 = 100; - -pub fn get_grpc_server_manager_address() -> String { - format!("http://127.0.0.1:{}", GRPC_SERVER_MANAGER_PORT) -} - -pub fn get_http_inspect_address() -> String { - format!("http://127.0.0.1:{}", HTTP_INSPECT_PORT) -} - -pub fn get_http_rollup_server_address() -> String { - format!("http://127.0.0.1:{}", HTTP_ROLLUP_SERVER_PORT) -} - -pub fn get_host_runner_path() -> String { - std::env::var("CARTESI_HOST_RUNNER_PATH") - .unwrap_or(String::from("../target/debug/cartesi-rollups-host-runner")) -} - -pub fn get_test_verbose() -> bool { - std::env::var("CARTESI_TEST_VERBOSE").is_ok() -} diff --git a/offchain/host-runner/tests/common/grpc_client.rs b/offchain/host-runner/tests/common/grpc_client.rs deleted file mode 100644 index 52a665a5f..000000000 --- a/offchain/host-runner/tests/common/grpc_client.rs +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub use grpc_interfaces::cartesi_machine::*; -pub use grpc_interfaces::cartesi_server_manager::*; - -use super::config; - -pub type ServerManagerClient = - server_manager_client::ServerManagerClient; - -pub async fn connect() -> ServerManagerClient { - ServerManagerClient::connect(config::get_grpc_server_manager_address()) - .await - .expect("failed to connect to grpc server") -} - -pub fn create_timestamp() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() -} - -pub fn create_start_session_request(session_id: &str) -> StartSessionRequest { - StartSessionRequest { - session_id: session_id.into(), - machine_directory: "".into(), - active_epoch_index: 0, - processed_input_count: 0, - server_cycles: None, - server_deadline: None, - runtime: None, - } -} - -pub fn create_advance_state_request( - session_id: &str, - epoch_index: u64, - input_index: u64, -) -> AdvanceStateRequest { - AdvanceStateRequest { - session_id: session_id.into(), - active_epoch_index: epoch_index, - current_input_index: input_index, - input_metadata: Some(InputMetadata { - msg_sender: Some(Address { - data: super::create_address(), - }), - block_number: 0, - timestamp: create_timestamp(), - epoch_index: 0, //this field is deprecated and should always be 0 - input_index, - }), - input_payload: super::create_payload(), - } -} diff --git a/offchain/host-runner/tests/common/http_client.rs b/offchain/host-runner/tests/common/http_client.rs deleted file mode 100644 index f3f287d13..000000000 --- a/offchain/host-runner/tests/common/http_client.rs +++ /dev/null @@ -1,118 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub use rollups_http_client::rollup::*; - -use reqwest::Response; -use serde::{de::DeserializeOwned, Deserialize}; -use std::collections::HashMap; - -use super::config; - -#[derive(Debug, PartialEq)] -pub struct HttpError { - pub status: u16, - pub message: String, -} - -#[derive(Debug, Deserialize, PartialEq)] -#[serde(tag = "request_type")] -pub enum RollupHttpRequest { - #[serde(rename = "advance_state")] - Advance { data: AdvanceRequest }, - #[serde(rename = "inspect_state")] - Inspect { data: InspectRequest }, -} - -#[derive(Debug, Deserialize, PartialEq)] -pub struct InspectStateResponse { - pub reports: Vec, -} - -#[derive(Debug, Deserialize, PartialEq)] -pub struct IndexResponse { - pub index: usize, -} - -pub fn convert_binary_to_hex(payload: &Vec) -> String { - String::from("0x") + &hex::encode(payload) -} - -pub fn create_address() -> String { - convert_binary_to_hex(&super::create_address()) -} - -pub fn create_payload() -> String { - convert_binary_to_hex(&super::create_payload()) -} - -pub async fn finish(status: String) -> Result { - let url = format!("{}/finish", config::get_http_rollup_server_address()); - let mut request = HashMap::new(); - request.insert("status", status); - let client = reqwest::Client::new(); - let response = client.post(url).json(&request).send().await.unwrap(); - handle_json_response(response).await -} - -pub async fn insert_voucher( - destination: String, - payload: String, -) -> Result { - let url = format!("{}/voucher", config::get_http_rollup_server_address()); - let mut request = HashMap::new(); - request.insert("destination", destination); - request.insert("payload", payload); - let client = reqwest::Client::new(); - let response = client.post(url).json(&request).send().await.unwrap(); - handle_json_response(response).await -} - -pub async fn insert_notice( - payload: String, -) -> Result { - let url = format!("{}/notice", config::get_http_rollup_server_address()); - let mut request = HashMap::new(); - request.insert("payload", payload); - let client = reqwest::Client::new(); - let response = client.post(url).json(&request).send().await.unwrap(); - handle_json_response(response).await -} - -pub async fn insert_report(payload: String) -> Result<(), HttpError> { - let url = format!("{}/report", config::get_http_rollup_server_address()); - let mut request = HashMap::new(); - request.insert("payload", payload); - let client = reqwest::Client::new(); - let response = client.post(url).json(&request).send().await.unwrap(); - handle_response(response).await.map(|_| ()) -} - -pub async fn notify_exception(payload: String) -> Result<(), HttpError> { - let url = format!("{}/exception", config::get_http_rollup_server_address()); - let mut request = HashMap::new(); - request.insert("payload", payload); - let client = reqwest::Client::new(); - let response = client.post(url).json(&request).send().await.unwrap(); - handle_response(response).await.map(|_| ()) -} - -async fn handle_response(response: Response) -> Result { - if response.status() == reqwest::StatusCode::OK { - Ok(response) - } else { - Err(HttpError { - status: response.status().as_u16(), - message: response.text().await.unwrap(), - }) - } -} - -async fn handle_json_response( - response: Response, -) -> Result { - match handle_response(response).await { - Ok(response) => Ok(response.json::().await.unwrap()), - Err(e) => Err(e), - } -} diff --git a/offchain/host-runner/tests/common/manager.rs b/offchain/host-runner/tests/common/manager.rs deleted file mode 100644 index e4b701b99..000000000 --- a/offchain/host-runner/tests/common/manager.rs +++ /dev/null @@ -1,57 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use super::config; -use super::grpc_client::{ServerManagerClient, Void}; - -pub struct Wrapper { - child: Child, -} - -impl Wrapper { - /// Start the manager and waits until it is ready to answer - pub async fn new() -> Self { - let mut command = Command::new(config::get_host_runner_path()); - command - .env("RUST_LOG", "host_runner=debug,info") - .arg("--grpc-server-manager-port") - .arg(config::GRPC_SERVER_MANAGER_PORT.to_string()) - .arg("--http-inspect-port") - .arg(config::HTTP_INSPECT_PORT.to_string()) - .arg("--http-rollup-server-port") - .arg(config::HTTP_ROLLUP_SERVER_PORT.to_string()) - .arg("--finish-timeout") - .arg(config::FINISH_TIMEOUT.to_string()); - if !config::get_test_verbose() { - command.stdout(Stdio::null()).stderr(Stdio::null()); - } - // Wait for a bit to clean up the port from previous test - tokio::time::sleep(Duration::from_millis(10)).await; - let child = command.spawn().expect("failed to start manager process"); - wait_for_manager().await; - Self { child } - } -} - -impl Drop for Wrapper { - fn drop(&mut self) { - self.child.kill().expect("failed to kill manager process"); - } -} - -async fn wait_for_manager() { - const RETRIES: u64 = 100; - for _ in 0..RETRIES { - let address = config::get_grpc_server_manager_address(); - if let Ok(mut client) = ServerManagerClient::connect(address).await { - if let Ok(_) = client.get_version(Void {}).await { - return; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("manager timed out"); -} diff --git a/offchain/host-runner/tests/common/mod.rs b/offchain/host-runner/tests/common/mod.rs deleted file mode 100644 index 599ecae71..000000000 --- a/offchain/host-runner/tests/common/mod.rs +++ /dev/null @@ -1,71 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -#![allow(dead_code)] - -pub mod config; -pub mod grpc_client; -pub mod http_client; -pub mod manager; - -pub fn create_address() -> Vec { - rand::random::<[u8; 20]>().into() -} - -pub fn create_payload() -> Vec { - rand::random::<[u8; 16]>().into() -} - -pub async fn setup_advance_state( - grpc_client: &mut grpc_client::ServerManagerClient, - session_id: &str, -) { - grpc_client - .start_session(grpc_client::create_start_session_request(session_id)) - .await - .unwrap(); - grpc_client - .advance_state(grpc_client::create_advance_state_request( - session_id, 0, 0, - )) - .await - .unwrap(); - http_client::finish("accept".into()).await.unwrap(); -} - -pub async fn finish_advance_state( - grpc_client: &mut grpc_client::ServerManagerClient, - session_id: &str, -) -> Option { - // Send a finish request in a separate thread. - let handle = tokio::spawn(http_client::finish("accept".into())); - - // Wait for the input to be processed. - const RETRIES: i32 = 10; - let mut processed = None; - for _ in 0..RETRIES { - processed = grpc_client - .get_epoch_status(grpc_client::GetEpochStatusRequest { - session_id: session_id.into(), - epoch_index: 0, - }) - .await - .unwrap() - .into_inner() - .processed_inputs - .pop(); - if processed.is_some() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - // Wait for the finish to return. - // It should return error because there isn't a next request to be processed. - handle - .await - .expect("tokio spawn failed") - .expect_err("finish should return error"); - - processed -} diff --git a/offchain/host-runner/tests/grpc.rs b/offchain/host-runner/tests/grpc.rs deleted file mode 100644 index 7df179239..000000000 --- a/offchain/host-runner/tests/grpc.rs +++ /dev/null @@ -1,17 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod common; - -mod grpc_tests { - mod advance_state; - mod delete_epoch; - mod end_session; - mod finish_epoch; - mod get_epoch_status; - mod get_session_status; - mod get_status; - mod get_version; - mod inspect_state; - mod start_session; -} diff --git a/offchain/host-runner/tests/grpc_tests/advance_state.rs b/offchain/host-runner/tests/grpc_tests/advance_state.rs deleted file mode 100644 index 3cd352993..000000000 --- a/offchain/host-runner/tests/grpc_tests/advance_state.rs +++ /dev/null @@ -1,58 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_advances_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let response = grpc_client - .advance_state(grpc_client::create_advance_state_request( - "rollup session", - 0, - 0, - )) - .await - .unwrap() - .into_inner(); - assert_eq!(response, grpc_client::Void {}); - // Check if state changed to advance with HTTP finish - let response = http_client::finish("accept".into()).await.unwrap(); - assert!(matches!( - response, - http_client::RollupHttpRequest::Advance { .. } - )); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_advance_request_with_wrong_parameters() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let invalid_requests = vec![ - // Wrong session id - grpc_client::create_advance_state_request("rollup session 1", 0, 0), - // Wrong epoch number - grpc_client::create_advance_state_request("rollup session", 123, 0), - // Wrong input index - grpc_client::create_advance_state_request("rollup session", 0, 123), - ]; - for request in invalid_requests { - let err = grpc_client.advance_state(request).await.unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); - } -} diff --git a/offchain/host-runner/tests/grpc_tests/delete_epoch.rs b/offchain/host-runner/tests/grpc_tests/delete_epoch.rs deleted file mode 100644 index e64e9fbaf..000000000 --- a/offchain/host-runner/tests/grpc_tests/delete_epoch.rs +++ /dev/null @@ -1,116 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use serial_test::serial; -use tonic::Code; - -use crate::common::{ - grpc_client::{self, DeleteEpochRequest, FinishEpochRequest}, - manager, -}; - -const SESSION_ID: &str = "rollup session"; - -#[tokio::test] -#[serial] -async fn test_it_fails_to_delete_active_epoch() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - - let err = grpc_client - .delete_epoch(DeleteEpochRequest { - epoch_index: 0, - session_id: SESSION_ID.into(), - }) - .await - .expect_err("should fail to delete epoch"); - - assert_eq!(err.code(), Code::InvalidArgument); -} - -#[tokio::test] -#[serial] -async fn test_it_fails_to_delete_unexisting_epoch() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .expect("should start session"); - - let err = grpc_client - .delete_epoch(DeleteEpochRequest { - epoch_index: 1, - session_id: SESSION_ID.into(), - }) - .await - .expect_err("should fail to delete epoch"); - - assert_eq!(err.code(), Code::InvalidArgument); -} - -#[tokio::test] -#[serial] -async fn test_it_fails_to_delete_when_there_is_no_session() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - - let err = grpc_client - .delete_epoch(DeleteEpochRequest { - epoch_index: 1, - session_id: SESSION_ID.into(), - }) - .await - .expect_err("should fail to delete epoch"); - - assert_eq!(err.code(), Code::InvalidArgument); -} - -#[tokio::test] -#[serial] -async fn test_it_deletes_epoch() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .expect("should start session"); - - grpc_client - .finish_epoch(FinishEpochRequest { - session_id: SESSION_ID.into(), - active_epoch_index: 0, - processed_input_count_within_epoch: 0, - storage_directory: "".into(), - }) - .await - .expect("should finish epoch"); - - let response = grpc_client - .delete_epoch(DeleteEpochRequest { - epoch_index: 0, - session_id: SESSION_ID.into(), - }) - .await; - assert!(response.is_ok()); - - let err = grpc_client - .get_epoch_status(grpc_client::GetEpochStatusRequest { - epoch_index: 0, - session_id: SESSION_ID.into(), - }) - .await - .expect_err("epoch should have been deleted"); - - assert_eq!(err.code(), Code::InvalidArgument); -} diff --git a/offchain/host-runner/tests/grpc_tests/end_session.rs b/offchain/host-runner/tests/grpc_tests/end_session.rs deleted file mode 100644 index 8a8ba0065..000000000 --- a/offchain/host-runner/tests/grpc_tests/end_session.rs +++ /dev/null @@ -1,50 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_ends_existing_session() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - grpc_client - .end_session(grpc_client::EndSessionRequest { - session_id: "rollup session".into(), - }) - .await - .unwrap(); - let err = grpc_client - .end_session(grpc_client::EndSessionRequest { - session_id: "rollup session".into(), - }) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_ends_non_existing_session() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session 1", - )) - .await - .unwrap(); - let err = grpc_client - .end_session(grpc_client::EndSessionRequest { - session_id: "rollup session 2".into(), - }) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); -} diff --git a/offchain/host-runner/tests/grpc_tests/finish_epoch.rs b/offchain/host-runner/tests/grpc_tests/finish_epoch.rs deleted file mode 100644 index 400c0c0df..000000000 --- a/offchain/host-runner/tests/grpc_tests/finish_epoch.rs +++ /dev/null @@ -1,360 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::{grpc_client::FinishEpochResponse, *}; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_finishes_existing_epoch() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let result = grpc_client - .finish_epoch(grpc_client::FinishEpochRequest { - session_id: "rollup session".into(), - active_epoch_index: 0, - processed_input_count_within_epoch: 0, - storage_directory: "".into(), - }) - .await - .unwrap() - .into_inner(); - let expected_result = FinishEpochResponse { - machine_hash: Some(grpc_client::Hash { - data: vec![0 as u8; 32], - }), - vouchers_epoch_root_hash: Some(grpc_client::Hash { - data: vec![ - 207, 39, 127, 184, 10, 130, 71, 132, 96, 232, 152, 133, 112, - 183, 24, 241, 224, 131, 206, 183, 111, 126, 39, 26, 26, 20, - 151, 229, 151, 95, 83, 174, - ], - }), - notices_epoch_root_hash: Some(grpc_client::Hash { - data: vec![ - 207, 39, 127, 184, 10, 130, 71, 132, 96, 232, 152, 133, 112, - 183, 24, 241, 224, 131, 206, 183, 111, 126, 39, 26, 26, 20, - 151, 229, 151, 95, 83, 174, - ], - }), - proofs: vec![], - }; - assert_eq_finish_epoch_response(result, expected_result); - - let response = grpc_client - .get_epoch_status(grpc_client::GetEpochStatusRequest { - session_id: "rollup session".into(), - epoch_index: 0, - }) - .await - .unwrap() - .into_inner(); - assert_eq!(response.state, grpc_client::EpochState::Finished as i32); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_finishes_existing_epoch_with_outputs() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - let destination = String::from("0x") + &"fa".repeat(20); - http_client::insert_voucher(destination, "0xdeadbeef".into()) - .await - .unwrap(); - http_client::insert_notice("0xdeadbeef".into()) - .await - .unwrap(); - finish_advance_state(&mut grpc_client, "rollup session").await; - - let result = grpc_client - .finish_epoch(grpc_client::FinishEpochRequest { - session_id: "rollup session".into(), - active_epoch_index: 0, - processed_input_count_within_epoch: 1, - storage_directory: "".into(), - }) - .await - .unwrap() - .into_inner(); - - let machine_hash = grpc_client::Hash { - data: vec![0 as u8; 32], - }; - let vouchers_epoch_root_hash = decode_hash( - "29676ea41aaf54b4d66d45bc60b9c8f71b5f9166035d375626746e7396baa7a1", - ); - let notices_epoch_root_hash = decode_hash( - "63a367741b1feb9c2dc64bda8ac4a083ebbe5fd1f7bb4746e94597c988f30197", - ); - let context = vec![0 as u8; 32]; - let expected_result = FinishEpochResponse { - machine_hash: Some(machine_hash.clone()), - vouchers_epoch_root_hash: Some(vouchers_epoch_root_hash.clone()), - notices_epoch_root_hash: Some(notices_epoch_root_hash.clone()), - proofs: vec![ - grpc_client::Proof { - input_index: 0, - output_index: 0, - output_enum: grpc_client::OutputEnum::Voucher.into(), - validity: Some(grpc_client::OutputValidityProof { - input_index_within_epoch: 0, - output_index_within_input: 0, - output_hashes_root_hash: Some(decode_hash( - "bf21d3dd50b9c5e542ea86c0f555b1bde6373829b59f51afd4a95eef24f05245", - )), - vouchers_epoch_root_hash: Some(vouchers_epoch_root_hash.clone()), - notices_epoch_root_hash: Some(notices_epoch_root_hash.clone()), - machine_state_hash: Some(machine_hash.clone()), - output_hash_in_output_hashes_siblings: vec![ - decode_hash("ae39ce8537aca75e2eff3e38c98011dfe934e700a0967732fc07b430dd656a23"), - decode_hash("3fc9a15f5b4869c872f81087bb6104b7d63e6f9ab47f2c43f3535eae7172aa7f"), - decode_hash("17d2dd614cddaa4d879276b11e0672c9560033d3e8453a1d045339d34ba601b9"), - decode_hash("c37b8b13ca95166fb7af16988a70fcc90f38bf9126fd833da710a47fb37a55e6"), - decode_hash("8e7a427fa943d9966b389f4f257173676090c6e95f43e2cb6d65f8758111e309"), - decode_hash("30b0b9deb73e155c59740bacf14a6ff04b64bb8e201a506409c3fe381ca4ea90"), - decode_hash("cd5deac729d0fdaccc441d09d7325f41586ba13c801b7eccae0f95d8f3933efe"), - decode_hash("d8b96e5b7f6f459e9cb6a2f41bf276c7b85c10cd4662c04cbbb365434726c0a0"), - decode_hash("c9695393027fb106a8153109ac516288a88b28a93817899460d6310b71cf1e61"), - decode_hash("63e8806fa0d4b197a259e8c3ac28864268159d0ac85f8581ca28fa7d2c0c03eb"), - decode_hash("91e3eee5ca7a3da2b3053c9770db73599fb149f620e3facef95e947c0ee860b7"), - decode_hash("2122e31e4bbd2b7c783d79cc30f60c6238651da7f0726f767d22747264fdb046"), - decode_hash("f7549f26cc70ed5e18baeb6c81bb0625cb95bb4019aeecd40774ee87ae29ec51"), - decode_hash("7a71f6ee264c5d761379b3d7d617ca83677374b49d10aec50505ac087408ca89"), - decode_hash("2b573c267a712a52e1d06421fe276a03efb1889f337201110fdc32a81f8e1524"), - decode_hash("99af665835aabfdc6740c7e2c3791a31c3cdc9f5ab962f681b12fc092816a62f"), - ], - output_hashes_in_epoch_siblings: vec![ - decode_hash("290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563"), - decode_hash("633dc4d7da7256660a892f8f1604a44b5432649cc8ec5cb3ced4c4e6ac94dd1d"), - decode_hash("890740a8eb06ce9be422cb8da5cdafc2b58c0a5e24036c578de2a433c828ff7d"), - decode_hash("3b8ec09e026fdc305365dfc94e189a81b38c7597b3d941c279f042e8206e0bd8"), - decode_hash("ecd50eee38e386bd62be9bedb990706951b65fe053bd9d8a521af753d139e2da"), - decode_hash("defff6d330bb5403f63b14f33b578274160de3a50df4efecf0e0db73bcdd3da5"), - decode_hash("617bdd11f7c0a11f49db22f629387a12da7596f9d1704d7465177c63d88ec7d7"), - decode_hash("292c23a9aa1d8bea7e2435e555a4a60e379a5a35f3f452bae60121073fb6eead"), - decode_hash("e1cea92ed99acdcb045a6726b2f87107e8a61620a232cf4d7d5b5766b3952e10"), - decode_hash("7ad66c0a68c72cb89e4fb4303841966e4062a76ab97451e3b9fb526a5ceb7f82"), - decode_hash("e026cc5a4aed3c22a58cbd3d2ac754c9352c5436f638042dca99034e83636516"), - decode_hash("3d04cffd8b46a874edf5cfae63077de85f849a660426697b06a829c70dd1409c"), - decode_hash("ad676aa337a485e4728a0b240d92b3ef7b3c372d06d189322bfd5f61f1e7203e"), - decode_hash("a2fca4a49658f9fab7aa63289c91b7c7b6c832a6d0e69334ff5b0a3483d09dab"), - decode_hash("4ebfd9cd7bca2505f7bef59cc1c12ecc708fff26ae4af19abe852afe9e20c862"), - decode_hash("2def10d13dd169f550f578bda343d9717a138562e0093b380a1120789d53cf10"), - decode_hash("776a31db34a1a0a7caaf862cffdfff1789297ffadc380bd3d39281d340abd3ad"), - decode_hash("e2e7610b87a5fdf3a72ebe271287d923ab990eefac64b6e59d79f8b7e08c46e3"), - decode_hash("504364a5c6858bf98fff714ab5be9de19ed31a976860efbd0e772a2efe23e2e0"), - decode_hash("4f05f4acb83f5b65168d9fef89d56d4d77b8944015e6b1eed81b0238e2d0dba3"), - decode_hash("44a6d974c75b07423e1d6d33f481916fdd45830aea11b6347e700cd8b9f0767c"), - decode_hash("edf260291f734ddac396a956127dde4c34c0cfb8d8052f88ac139658ccf2d507"), - decode_hash("6075c657a105351e7f0fce53bc320113324a522e8fd52dc878c762551e01a46e"), - decode_hash("6ca6a3f763a9395f7da16014725ca7ee17e4815c0ff8119bf33f273dee11833b"), - decode_hash("1c25ef10ffeb3c7d08aa707d17286e0b0d3cbcb50f1bd3b6523b63ba3b52dd0f"), - decode_hash("fffc43bd08273ccf135fd3cacbeef055418e09eb728d727c4d5d5c556cdea7e3"), - decode_hash("c5ab8111456b1f28f3c7a0a604b4553ce905cb019c463ee159137af83c350b22"), - decode_hash("0ff273fcbf4ae0f2bd88d6cf319ff4004f8d7dca70d4ced4e74d2c74139739e6"), - decode_hash("7fa06ba11241ddd5efdc65d4e39c9f6991b74fd4b81b62230808216c876f827c"), - decode_hash("7e275adf313a996c7e2950cac67caba02a5ff925ebf9906b58949f3e77aec5b9"), - decode_hash("8f6162fa308d2b3a15dc33cffac85f13ab349173121645aedf00f471663108be"), - decode_hash("78ccaaab73373552f207a63599de54d7d8d0c1805f86ce7da15818d09f4cff62"), - ], - }), - context: context.clone(), - }, - grpc_client::Proof { - input_index: 0, - output_index: 0, - output_enum: grpc_client::OutputEnum::Notice.into(), - validity: Some(grpc_client::OutputValidityProof { - input_index_within_epoch: 0, - output_index_within_input: 0, - output_hashes_root_hash: Some(decode_hash( - "660c2d35b0a43d8179792345211d0eab28d88f47fafadd8334b80196cad41ded", - )), - vouchers_epoch_root_hash: Some(vouchers_epoch_root_hash.clone()), - notices_epoch_root_hash: Some(notices_epoch_root_hash.clone()), - machine_state_hash: Some(machine_hash.clone()), - output_hash_in_output_hashes_siblings: vec![ - decode_hash("ae39ce8537aca75e2eff3e38c98011dfe934e700a0967732fc07b430dd656a23"), - decode_hash("3fc9a15f5b4869c872f81087bb6104b7d63e6f9ab47f2c43f3535eae7172aa7f"), - decode_hash("17d2dd614cddaa4d879276b11e0672c9560033d3e8453a1d045339d34ba601b9"), - decode_hash("c37b8b13ca95166fb7af16988a70fcc90f38bf9126fd833da710a47fb37a55e6"), - decode_hash("8e7a427fa943d9966b389f4f257173676090c6e95f43e2cb6d65f8758111e309"), - decode_hash("30b0b9deb73e155c59740bacf14a6ff04b64bb8e201a506409c3fe381ca4ea90"), - decode_hash("cd5deac729d0fdaccc441d09d7325f41586ba13c801b7eccae0f95d8f3933efe"), - decode_hash("d8b96e5b7f6f459e9cb6a2f41bf276c7b85c10cd4662c04cbbb365434726c0a0"), - decode_hash("c9695393027fb106a8153109ac516288a88b28a93817899460d6310b71cf1e61"), - decode_hash("63e8806fa0d4b197a259e8c3ac28864268159d0ac85f8581ca28fa7d2c0c03eb"), - decode_hash("91e3eee5ca7a3da2b3053c9770db73599fb149f620e3facef95e947c0ee860b7"), - decode_hash("2122e31e4bbd2b7c783d79cc30f60c6238651da7f0726f767d22747264fdb046"), - decode_hash("f7549f26cc70ed5e18baeb6c81bb0625cb95bb4019aeecd40774ee87ae29ec51"), - decode_hash("7a71f6ee264c5d761379b3d7d617ca83677374b49d10aec50505ac087408ca89"), - decode_hash("2b573c267a712a52e1d06421fe276a03efb1889f337201110fdc32a81f8e1524"), - decode_hash("99af665835aabfdc6740c7e2c3791a31c3cdc9f5ab962f681b12fc092816a62f"), - ], - output_hashes_in_epoch_siblings: vec![ - decode_hash("290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563"), - decode_hash("633dc4d7da7256660a892f8f1604a44b5432649cc8ec5cb3ced4c4e6ac94dd1d"), - decode_hash("890740a8eb06ce9be422cb8da5cdafc2b58c0a5e24036c578de2a433c828ff7d"), - decode_hash("3b8ec09e026fdc305365dfc94e189a81b38c7597b3d941c279f042e8206e0bd8"), - decode_hash("ecd50eee38e386bd62be9bedb990706951b65fe053bd9d8a521af753d139e2da"), - decode_hash("defff6d330bb5403f63b14f33b578274160de3a50df4efecf0e0db73bcdd3da5"), - decode_hash("617bdd11f7c0a11f49db22f629387a12da7596f9d1704d7465177c63d88ec7d7"), - decode_hash("292c23a9aa1d8bea7e2435e555a4a60e379a5a35f3f452bae60121073fb6eead"), - decode_hash("e1cea92ed99acdcb045a6726b2f87107e8a61620a232cf4d7d5b5766b3952e10"), - decode_hash("7ad66c0a68c72cb89e4fb4303841966e4062a76ab97451e3b9fb526a5ceb7f82"), - decode_hash("e026cc5a4aed3c22a58cbd3d2ac754c9352c5436f638042dca99034e83636516"), - decode_hash("3d04cffd8b46a874edf5cfae63077de85f849a660426697b06a829c70dd1409c"), - decode_hash("ad676aa337a485e4728a0b240d92b3ef7b3c372d06d189322bfd5f61f1e7203e"), - decode_hash("a2fca4a49658f9fab7aa63289c91b7c7b6c832a6d0e69334ff5b0a3483d09dab"), - decode_hash("4ebfd9cd7bca2505f7bef59cc1c12ecc708fff26ae4af19abe852afe9e20c862"), - decode_hash("2def10d13dd169f550f578bda343d9717a138562e0093b380a1120789d53cf10"), - decode_hash("776a31db34a1a0a7caaf862cffdfff1789297ffadc380bd3d39281d340abd3ad"), - decode_hash("e2e7610b87a5fdf3a72ebe271287d923ab990eefac64b6e59d79f8b7e08c46e3"), - decode_hash("504364a5c6858bf98fff714ab5be9de19ed31a976860efbd0e772a2efe23e2e0"), - decode_hash("4f05f4acb83f5b65168d9fef89d56d4d77b8944015e6b1eed81b0238e2d0dba3"), - decode_hash("44a6d974c75b07423e1d6d33f481916fdd45830aea11b6347e700cd8b9f0767c"), - decode_hash("edf260291f734ddac396a956127dde4c34c0cfb8d8052f88ac139658ccf2d507"), - decode_hash("6075c657a105351e7f0fce53bc320113324a522e8fd52dc878c762551e01a46e"), - decode_hash("6ca6a3f763a9395f7da16014725ca7ee17e4815c0ff8119bf33f273dee11833b"), - decode_hash("1c25ef10ffeb3c7d08aa707d17286e0b0d3cbcb50f1bd3b6523b63ba3b52dd0f"), - decode_hash("fffc43bd08273ccf135fd3cacbeef055418e09eb728d727c4d5d5c556cdea7e3"), - decode_hash("c5ab8111456b1f28f3c7a0a604b4553ce905cb019c463ee159137af83c350b22"), - decode_hash("0ff273fcbf4ae0f2bd88d6cf319ff4004f8d7dca70d4ced4e74d2c74139739e6"), - decode_hash("7fa06ba11241ddd5efdc65d4e39c9f6991b74fd4b81b62230808216c876f827c"), - decode_hash("7e275adf313a996c7e2950cac67caba02a5ff925ebf9906b58949f3e77aec5b9"), - decode_hash("8f6162fa308d2b3a15dc33cffac85f13ab349173121645aedf00f471663108be"), - decode_hash("78ccaaab73373552f207a63599de54d7d8d0c1805f86ce7da15818d09f4cff62"), - ], - }), - context: context.clone(), - }, - ], - }; - - assert_eq_finish_epoch_response(result, expected_result); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_finish_unexistent_epoch() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let err = grpc_client - .finish_epoch(grpc_client::FinishEpochRequest { - session_id: "rollup session".into(), - active_epoch_index: 10, - processed_input_count_within_epoch: 0, - storage_directory: "".into(), - }) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_updates_input_index_after_finishing_epoch() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - let session_id = "rollup session"; - - setup_advance_state(&mut grpc_client, session_id).await; - finish_advance_state(&mut grpc_client, session_id).await; - - grpc_client - .finish_epoch(grpc_client::FinishEpochRequest { - active_epoch_index: 0, - session_id: session_id.into(), - processed_input_count_within_epoch: 1, - storage_directory: "".into(), - }) - .await - .unwrap(); - - grpc_client - .advance_state(grpc_client::create_advance_state_request( - session_id, 1, 1, - )) - .await - .unwrap(); - - http_client::finish("accept".into()).await.unwrap(); - finish_advance_state(&mut grpc_client, session_id).await; - - let epoch_status = grpc_client - .get_epoch_status(grpc_client::GetEpochStatusRequest { - session_id: "rollup session".into(), - epoch_index: 1, - }) - .await - .unwrap() - .into_inner(); - - assert_eq!(epoch_status.processed_inputs[0].input_index, 1); -} - -fn assert_eq_finish_epoch_response( - lhs: FinishEpochResponse, - rhs: FinishEpochResponse, -) { - assert_eq!(lhs.machine_hash, rhs.machine_hash); - assert_eq!(lhs.notices_epoch_root_hash, rhs.notices_epoch_root_hash); - assert_eq!(lhs.vouchers_epoch_root_hash, rhs.vouchers_epoch_root_hash); - assert_eq!(lhs.proofs.len(), rhs.proofs.len()); - - for (idx, proof) in lhs.proofs.into_iter().enumerate() { - assert_eq_proof(proof, rhs.proofs[idx].clone()); - } -} - -fn assert_eq_proof(lhs: grpc_client::Proof, rhs: grpc_client::Proof) { - assert_eq!(lhs.context, rhs.context); - assert_eq!(lhs.input_index, rhs.input_index); - assert_eq!(lhs.output_index, rhs.output_index); - assert_eq!(lhs.output_enum(), rhs.output_enum()); - assert_eq_validity_proof( - lhs.validity.expect("should contain OutputValidityProof"), - rhs.validity.expect("should contain OutputValidityProof"), - ); -} - -fn assert_eq_validity_proof( - lhs: grpc_client::OutputValidityProof, - rhs: grpc_client::OutputValidityProof, -) { - assert_eq!(lhs.input_index_within_epoch, rhs.input_index_within_epoch); - assert_eq!(lhs.output_index_within_input, rhs.output_index_within_input); - assert_eq!(lhs.machine_state_hash, rhs.machine_state_hash); - assert_eq!(lhs.notices_epoch_root_hash, rhs.notices_epoch_root_hash); - assert_eq!(lhs.vouchers_epoch_root_hash, rhs.vouchers_epoch_root_hash); - for (h_idx, hash) in lhs - .output_hash_in_output_hashes_siblings - .into_iter() - .enumerate() - { - assert_eq!(hash, rhs.output_hash_in_output_hashes_siblings[h_idx]); - } - for (h_idx, hash) in - lhs.output_hashes_in_epoch_siblings.into_iter().enumerate() - { - assert_eq!(hash, rhs.output_hashes_in_epoch_siblings[h_idx]); - } -} - -fn decode_hash(s: &str) -> grpc_client::Hash { - grpc_client::Hash { - data: hex::decode(s).unwrap(), - } -} diff --git a/offchain/host-runner/tests/grpc_tests/get_epoch_status.rs b/offchain/host-runner/tests/grpc_tests/get_epoch_status.rs deleted file mode 100644 index 849e407bd..000000000 --- a/offchain/host-runner/tests/grpc_tests/get_epoch_status.rs +++ /dev/null @@ -1,118 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_get_epoch_status_of_empty_epoch() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let response = grpc_client - .get_epoch_status(grpc_client::GetEpochStatusRequest { - session_id: "rollup session".into(), - epoch_index: 0, - }) - .await - .unwrap() - .into_inner(); - assert_eq!( - response, - grpc_client::GetEpochStatusResponse { - session_id: "rollup session".into(), - epoch_index: 0, - state: grpc_client::EpochState::Active as i32, - processed_inputs: vec![], - pending_input_count: 0, - taint_status: None, - } - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_get_epoch_status_of_epoch_with_voucher_notice_and_report() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - let destination = String::from("0x") + &"fa".repeat(20); - http_client::insert_voucher(destination, "0xdeadbeef".into()) - .await - .unwrap(); - http_client::insert_notice("0xdeadbeef".into()) - .await - .unwrap(); - http_client::insert_report("0xdeadbeef".into()) - .await - .unwrap(); - finish_advance_state(&mut grpc_client, "rollup session").await; - let response = grpc_client - .get_epoch_status(grpc_client::GetEpochStatusRequest { - session_id: "rollup session".into(), - epoch_index: 0, - }) - .await - .unwrap() - .into_inner(); - let expected = grpc_client::GetEpochStatusResponse { - session_id: "rollup session".into(), - epoch_index: 0, - state: grpc_client::EpochState::Active as i32, - processed_inputs: vec![grpc_client::ProcessedInput { - input_index: 0, - reports: vec![grpc_client::Report { - payload: vec![222, 173, 190, 239], - }], - status: grpc_client::CompletionStatus::Accepted as i32, - processed_input_one_of: Some( - grpc_client::processed_input::ProcessedInputOneOf::AcceptedData( - grpc_client::AcceptedData { - vouchers: vec![grpc_client::Voucher { - destination: Some(grpc_client::Address { - data: vec![ - 250, 250, 250, 250, 250, 250, 250, 250, - 250, 250, 250, 250, 250, 250, 250, 250, - 250, 250, 250, 250, - ], - }), - payload: vec![222, 173, 190, 239], - }], - notices: vec![grpc_client::Notice { - payload: vec![222, 173, 190, 239], - }], - }, - ), - ), - }], - pending_input_count: 0, - taint_status: None, - }; - assert_eq!(response, expected); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_get_non_existent_epoch_status() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let err = grpc_client - .get_epoch_status(grpc_client::GetEpochStatusRequest { - session_id: "rollup session".into(), - epoch_index: 123, - }) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); -} diff --git a/offchain/host-runner/tests/grpc_tests/get_session_status.rs b/offchain/host-runner/tests/grpc_tests/get_session_status.rs deleted file mode 100644 index 0dfd2a07b..000000000 --- a/offchain/host-runner/tests/grpc_tests/get_session_status.rs +++ /dev/null @@ -1,88 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_gets_session_status_with_no_advance_requests() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let response = grpc_client - .get_session_status(grpc_client::GetSessionStatusRequest { - session_id: "rollup session".into(), - }) - .await - .unwrap() - .into_inner(); - assert_eq!( - response, - grpc_client::GetSessionStatusResponse { - session_id: "rollup session".into(), - active_epoch_index: 0, - epoch_index: vec![0], - taint_status: None, - } - ) -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_gets_session_with_multiple_epochs() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - const N: u64 = 10; - for i in 0..N { - grpc_client - .finish_epoch(grpc_client::FinishEpochRequest { - session_id: "rollup session".into(), - active_epoch_index: i, - processed_input_count_within_epoch: 0, - storage_directory: "".into(), - }) - .await - .unwrap(); - } - let response = grpc_client - .get_session_status(grpc_client::GetSessionStatusRequest { - session_id: "rollup session".into(), - }) - .await - .unwrap() - .into_inner(); - assert_eq!( - response, - grpc_client::GetSessionStatusResponse { - session_id: "rollup session".into(), - active_epoch_index: N, - epoch_index: (0..N + 1).collect::>(), - taint_status: None, - } - ) -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_get_session_status_of_unexistent_session() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - let err = grpc_client - .get_session_status(grpc_client::GetSessionStatusRequest { - session_id: "rollup session".into(), - }) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::InvalidArgument); -} diff --git a/offchain/host-runner/tests/grpc_tests/get_status.rs b/offchain/host-runner/tests/grpc_tests/get_status.rs deleted file mode 100644 index 322391d2a..000000000 --- a/offchain/host-runner/tests/grpc_tests/get_status.rs +++ /dev/null @@ -1,44 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_gets_status_with_no_sessions_running() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - let response = grpc_client - .get_status(grpc_client::Void {}) - .await - .unwrap() - .into_inner(); - assert_eq!( - response, - grpc_client::GetStatusResponse { session_id: vec![] } - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_gets_status_with_a_single_session_running() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let response = grpc_client - .get_status(grpc_client::Void {}) - .await - .unwrap() - .into_inner(); - assert_eq!( - response, - grpc_client::GetStatusResponse { - session_id: vec![String::from("rollup session")] - } - ); -} diff --git a/offchain/host-runner/tests/grpc_tests/get_version.rs b/offchain/host-runner/tests/grpc_tests/get_version.rs deleted file mode 100644 index f8d9f0a54..000000000 --- a/offchain/host-runner/tests/grpc_tests/get_version.rs +++ /dev/null @@ -1,29 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; -use grpc_interfaces::versioning::{GetVersionResponse, SemanticVersion}; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_gets_version() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - let response = grpc_client - .get_version(grpc_client::Void {}) - .await - .unwrap() - .into_inner(); - assert_eq!( - response, - GetVersionResponse { - version: Some(SemanticVersion { - major: 0, - minor: 2, - patch: 0, - pre_release: String::from(""), - build: String::from("host-runner"), - }) - } - ); -} diff --git a/offchain/host-runner/tests/grpc_tests/inspect_state.rs b/offchain/host-runner/tests/grpc_tests/inspect_state.rs deleted file mode 100644 index fcb36623e..000000000 --- a/offchain/host-runner/tests/grpc_tests/inspect_state.rs +++ /dev/null @@ -1,169 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_inspects_and_receive_a_report() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - // Send the inspect request in a separate thread - let inspect_handle = tokio::spawn(async move { - grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload: create_payload(), - }) - .await - .unwrap() - .into_inner() - }); - // Send HTTP requests - while let Err(e) = http_client::finish("accept".into()).await { - assert_eq!(e.status, 202); - } - let payload = create_payload(); - http_client::insert_report(http_client::convert_binary_to_hex(&payload)) - .await - .unwrap(); - http_client::finish("accept".into()).await.unwrap_err(); - // Obtain the inspect response and check it - let response = inspect_handle.await.unwrap(); - let expected = grpc_client::InspectStateResponse { - session_id: String::from("rollup session"), - active_epoch_index: 0, - processed_input_count: 0, - status: grpc_client::CompletionStatus::Accepted as i32, - exception_data: None, - reports: vec![grpc_client::Report { payload }], - }; - assert_eq!(response, expected); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_reports_session_state_correctly() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - - // Send an input and finish the first epoch - setup_advance_state(&mut grpc_client, "rollup session").await; - finish_advance_state(&mut grpc_client, "rollup session").await; - grpc_client - .finish_epoch(grpc_client::FinishEpochRequest { - session_id: "rollup session".into(), - active_epoch_index: 0, - processed_input_count_within_epoch: 1, - storage_directory: "".into(), - }) - .await - .expect("should finish epoch"); - - // Send an inspect request in the second epoch - let inspect_handle = tokio::spawn(async move { - grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload: create_payload(), - }) - .await - .unwrap() - .into_inner() - }); - - // Get inspect request state request - http_client::finish("accept".into()).await.unwrap(); - - // Accept inspect request - http_client::finish("accept".into()).await.unwrap_err(); - - let response = inspect_handle.await.unwrap(); - let expected = grpc_client::InspectStateResponse { - session_id: String::from("rollup session"), - active_epoch_index: 1, - processed_input_count: 1, - status: grpc_client::CompletionStatus::Accepted as i32, - exception_data: None, - reports: vec![], - }; - assert_eq!(response, expected); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_inspect_state_concurrently() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - // Send the inspect request in a separate thread - let inspect_handle = { - let mut grpc_client = grpc_client.clone(); - tokio::spawn(async move { - grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload: create_payload(), - }) - .await - .unwrap() - .into_inner() - }) - }; - // Wait until the first request starts to be processed - while let Err(e) = http_client::finish("accept".into()).await { - assert_eq!(e.status, 202); - } - // Send second inspect request - let status = grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload: create_payload(), - }) - .await - .unwrap_err(); - assert_eq!(status.code(), tonic::Code::Aborted); - assert_eq!(status.message(), "concurrent call in session"); - // Finish first inspect request - http_client::finish("accept".into()).await.unwrap_err(); - inspect_handle.await.unwrap(); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_queue_inspect_during_advance_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - // Start advance state - setup_advance_state(&mut grpc_client, "rollup session").await; - // Send the inspect request in a separate thread - let inspect_handle = { - let mut grpc_client = grpc_client.clone(); - tokio::spawn(async move { - grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload: create_payload(), - }) - .await - .unwrap() - .into_inner() - }) - }; - // Finish advance request and start inspect request - http_client::finish("accept".into()).await.unwrap(); - // Finish inspect request - http_client::finish("accept".into()).await.unwrap_err(); - inspect_handle.await.unwrap(); -} diff --git a/offchain/host-runner/tests/grpc_tests/start_session.rs b/offchain/host-runner/tests/grpc_tests/start_session.rs deleted file mode 100644 index ebfeaaa3c..000000000 --- a/offchain/host-runner/tests/grpc_tests/start_session.rs +++ /dev/null @@ -1,60 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_starts_session_with_new_valid_session() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - let response = grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap() - .into_inner(); - assert_eq!(response, grpc_client::StartSessionResponse { config: None }); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_start_session_with_same_name() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let err = grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::AlreadyExists); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_start_multiple_sessions_because_it_supports_only_one() -{ - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session 1", - )) - .await - .unwrap(); - let err = grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session 2", - )) - .await - .unwrap_err(); - assert_eq!(err.code(), tonic::Code::AlreadyExists); -} diff --git a/offchain/host-runner/tests/http.rs b/offchain/host-runner/tests/http.rs deleted file mode 100644 index 4ada7a06c..000000000 --- a/offchain/host-runner/tests/http.rs +++ /dev/null @@ -1,12 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod common; - -mod http_tests { - mod exception; - mod finish; - mod notice; - mod report; - mod voucher; -} diff --git a/offchain/host-runner/tests/http_tests/exception.rs b/offchain/host-runner/tests/http_tests/exception.rs deleted file mode 100644 index 123a1c023..000000000 --- a/offchain/host-runner/tests/http_tests/exception.rs +++ /dev/null @@ -1,98 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_notifies_exception_during_advance_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - let payload = create_payload(); - http_client::notify_exception(http_client::convert_binary_to_hex(&payload)) - .await - .unwrap(); - let processed = finish_advance_state(&mut grpc_client, "rollup session") - .await - .unwrap(); - match processed.processed_input_one_of.unwrap() { - grpc_client::processed_input::ProcessedInputOneOf::AcceptedData(_) => { - panic!("unexpected advance result"); - } - grpc_client::processed_input::ProcessedInputOneOf::ExceptionData( - result, - ) => { - assert_eq!(result, payload); - } - } -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_notifies_exception_during_inspect_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let inspect_handle = tokio::spawn(async move { - grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload: create_payload(), - }) - .await - .unwrap() - .into_inner() - }); - http_client::finish("accept".into()).await.unwrap(); - let payload = create_payload(); - http_client::notify_exception(http_client::convert_binary_to_hex(&payload)) - .await - .unwrap(); - let response = inspect_handle.await.unwrap(); - let expected = grpc_client::InspectStateResponse { - session_id: String::from("rollup session"), - active_epoch_index: 0, - processed_input_count: 0, - status: grpc_client::CompletionStatus::Exception as i32, - exception_data: Some(payload), - reports: vec![], - }; - assert_eq!(response, expected); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_notify_exception_with_incorrect_data() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - let response = http_client::notify_exception("deadbeef".into()).await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "Failed to decode ethereum binary string deadbeef (expected 0x prefix)".into(), - }) - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_notify_exception_during_idle_state() { - let _manager = manager::Wrapper::new().await; - let response = - http_client::notify_exception(http_client::create_payload()).await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "invalid request exception in idle state".into(), - }) - ); -} diff --git a/offchain/host-runner/tests/http_tests/finish.rs b/offchain/host-runner/tests/http_tests/finish.rs deleted file mode 100644 index 8a9e7867e..000000000 --- a/offchain/host-runner/tests/http_tests/finish.rs +++ /dev/null @@ -1,179 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_finishes_after_advance_request() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - // Perform the advance request - let advance_request = - grpc_client::create_advance_state_request("rollup session", 0, 0); - grpc_client - .advance_state(advance_request.clone()) - .await - .unwrap(); - // Then perform the finish request - let response = http_client::finish("accept".into()).await.unwrap(); - // Then compare the received request with the expected one - let expected_metadata = advance_request.input_metadata.unwrap(); - let expected_sender = expected_metadata.msg_sender.unwrap().data; - assert_eq!( - response, - http_client::RollupHttpRequest::Advance { - data: http_client::AdvanceRequest { - metadata: http_client::AdvanceMetadata { - msg_sender: String::from("0x") - + &hex::encode(&expected_sender), - epoch_index: expected_metadata.epoch_index, - input_index: expected_metadata.input_index, - block_number: expected_metadata.block_number, - timestamp: expected_metadata.timestamp, - }, - payload: String::from("0x") - + &hex::encode(&advance_request.input_payload), - } - } - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_finishes_after_inspect_request() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let query_payload = create_payload(); - let payload = http_client::convert_binary_to_hex(&query_payload); - // Perform the inspect request in another thread because it is blocking - tokio::spawn(async move { - grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload, - }) - .await - .unwrap() - .into_inner() - }); - // Then perform the finish request - let response = http_client::finish("accept".into()).await.unwrap(); - // Then compare the received request with the expected one - assert_eq!( - response, - http_client::RollupHttpRequest::Inspect { - data: http_client::InspectRequest { payload } - } - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_finishes_before_advance_request() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let finish_handler = tokio::spawn(http_client::finish("accept".into())); - // Wait for a bit before sending advance request - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - grpc_client - .advance_state(grpc_client::create_advance_state_request( - "rollup session", - 0, - 0, - )) - .await - .unwrap(); - // Then receive and compare finish response - let response = finish_handler.await.unwrap(); - assert!(matches!( - response, - Ok(http_client::RollupHttpRequest::Advance { .. }) - )); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_finishes_current_advance_request() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - finish_advance_state(&mut grpc_client, "rollup session") - .await - .unwrap(); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_handles_finish_while_waiting_for_rollup_request() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - // Peform the first finish call in another thread - let first_handler = tokio::spawn(http_client::finish("accept".into())); - // Wait for a bit and perform another finish call before the previous one returned - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - // Check the second finish response - let err = http_client::finish("accept".into()).await.unwrap_err(); - assert_eq!( - err, - http_client::HttpError { - status: 202, - message: String::from("no rollup request available"), - } - ); - // Check the first finish response - let err = first_handler.await.unwrap().unwrap_err(); - assert_eq!( - err, - http_client::HttpError { - status: 202, - message: String::from("no rollup request available"), - } - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_times_out_finish_request() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - // Perform the finish request and wait until it times out - let err = http_client::finish("accept".into()).await.unwrap_err(); - assert_eq!( - err, - http_client::HttpError { - status: 202, - message: String::from("no rollup request available"), - } - ); -} diff --git a/offchain/host-runner/tests/http_tests/notice.rs b/offchain/host-runner/tests/http_tests/notice.rs deleted file mode 100644 index b047c6d99..000000000 --- a/offchain/host-runner/tests/http_tests/notice.rs +++ /dev/null @@ -1,66 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_insert_notice_during_advance_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - // Send notices - const N: usize = 3; - for i in 0..N { - let result = http_client::insert_notice(http_client::create_payload()) - .await - .unwrap(); - assert_eq!(result.index, i); - } - // Check if notices arrived - let processed = finish_advance_state(&mut grpc_client, "rollup session") - .await - .unwrap(); - match processed.processed_input_one_of.unwrap() { - grpc_client::processed_input::ProcessedInputOneOf::AcceptedData( - result, - ) => { - assert_eq!(result.notices.len(), N); - } - grpc_client::processed_input::ProcessedInputOneOf::ExceptionData(_) => { - panic!("unexpected advance result"); - } - } -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_insert_notice_with_incorrect_data() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - let response = http_client::insert_notice("deadbeef".into()).await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "Failed to decode ethereum binary string deadbeef (expected 0x prefix)".into(), - }) - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_insert_notice_during_idle_state() { - let _manager = manager::Wrapper::new().await; - // Don't perform setup on purpose - let response = - http_client::insert_notice(http_client::create_payload()).await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "invalid request notice in idle state".into(), - }) - ); -} diff --git a/offchain/host-runner/tests/http_tests/report.rs b/offchain/host-runner/tests/http_tests/report.rs deleted file mode 100644 index b37e475c6..000000000 --- a/offchain/host-runner/tests/http_tests/report.rs +++ /dev/null @@ -1,92 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_insert_report_during_advance_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - // Send reports - const N: usize = 3; - for _ in 0..N { - http_client::insert_report(http_client::create_payload()) - .await - .unwrap(); - } - // Check if reports arrived - let processed = finish_advance_state(&mut grpc_client, "rollup session") - .await - .unwrap(); - assert_eq!(processed.reports.len(), N); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_insert_report_during_inspect_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - grpc_client - .start_session(grpc_client::create_start_session_request( - "rollup session", - )) - .await - .unwrap(); - let inspect_handle = tokio::spawn(async move { - grpc_client - .inspect_state(grpc_client::InspectStateRequest { - session_id: "rollup session".into(), - query_payload: create_payload(), - }) - .await - .unwrap() - .into_inner() - }); - http_client::finish("accept".into()).await.unwrap(); - // Send a reports - const N: usize = 3; - for _ in 0..N { - http_client::insert_report(http_client::create_payload()) - .await - .unwrap(); - } - // Perform final finish call - tokio::spawn(http_client::finish("accept".into())); - // Obtain the inspect result - let response = inspect_handle.await.unwrap(); - assert_eq!(response.reports.len(), N); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_insert_report_with_incorrect_data() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - let response = http_client::insert_report("deadbeef".into()).await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "Failed to decode ethereum binary string deadbeef (expected 0x prefix)".into(), - }) - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_insert_report_during_idle_state() { - let _manager = manager::Wrapper::new().await; - // Don't perform setup on purpose - let response = - http_client::insert_report(http_client::create_payload()).await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "invalid request report in idle state".into(), - }) - ); -} diff --git a/offchain/host-runner/tests/http_tests/voucher.rs b/offchain/host-runner/tests/http_tests/voucher.rs deleted file mode 100644 index 693914213..000000000 --- a/offchain/host-runner/tests/http_tests/voucher.rs +++ /dev/null @@ -1,73 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::common::*; - -#[tokio::test] -#[serial_test::serial] -async fn test_it_insert_voucher_during_advance_state() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - // Send vouchers - const N: usize = 3; - for i in 0..N { - let destination = http_client::create_address(); - let payload = http_client::create_payload(); - let result = http_client::insert_voucher(destination, payload) - .await - .unwrap(); - assert_eq!(result.index, i); - } - // Check if vouchers arrived - let processed = finish_advance_state(&mut grpc_client, "rollup session") - .await - .unwrap(); - match processed.processed_input_one_of.unwrap() { - grpc_client::processed_input::ProcessedInputOneOf::AcceptedData( - result, - ) => { - assert_eq!(result.vouchers.len(), N); - } - grpc_client::processed_input::ProcessedInputOneOf::ExceptionData(_) => { - panic!("unexpected advance result"); - } - } -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_insert_voucher_with_incorrect_data() { - let _manager = manager::Wrapper::new().await; - let mut grpc_client = grpc_client::connect().await; - setup_advance_state(&mut grpc_client, "rollup session").await; - let response = http_client::insert_voucher( - http_client::create_address(), - "deadbeef".into(), - ) - .await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "Failed to decode ethereum binary string deadbeef (expected 0x prefix)".into(), - }) - ); -} - -#[tokio::test] -#[serial_test::serial] -async fn test_it_fails_to_insert_voucher_during_idle_state() { - let _manager = manager::Wrapper::new().await; - // Don't perform setup on purpose - let destination = http_client::create_address(); - let payload = http_client::create_payload(); - let response = http_client::insert_voucher(destination, payload).await; - assert_eq!( - response, - Err(http_client::HttpError { - status: 400, - message: "invalid request voucher in idle state".into(), - }) - ); -} diff --git a/offchain/http-health-check/Cargo.toml b/offchain/http-health-check/Cargo.toml deleted file mode 100644 index 2fd439148..000000000 --- a/offchain/http-health-check/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "http-health-check" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -axum.workspace = true -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -tracing.workspace = true diff --git a/offchain/http-health-check/README.md b/offchain/http-health-check/README.md deleted file mode 100644 index ad5e2d30e..000000000 --- a/offchain/http-health-check/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# HTTP Healthcheck - -This crate is a library that starts an HTTP server with the healthcheck endpoint. diff --git a/offchain/http-health-check/src/lib.rs b/offchain/http-health-check/src/lib.rs deleted file mode 100644 index fcf45c6f5..000000000 --- a/offchain/http-health-check/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use axum::{routing::get, Router}; -use snafu::{ResultExt, Snafu}; -use std::net::SocketAddr; - -#[derive(Debug, Snafu)] -pub enum HealthCheckError { - #[snafu(display("could not parse host address"))] - ParseAddressError { source: std::net::AddrParseError }, - - #[snafu(display("http health-check server error"))] - HttpServerError { source: std::io::Error }, -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn start(port: u16) -> Result<(), HealthCheckError> { - tracing::trace!(?port, "starting health-check server on this port"); - - let ip = "0.0.0.0".parse().context(ParseAddressSnafu)?; - let addr = SocketAddr::new(ip, port); - let app = Router::new().route("/healthz", get(|| async { "" })); - let listener = tokio::net::TcpListener::bind(&addr) - .await - .context(HttpServerSnafu)?; - - tracing::trace!(address = ?listener.local_addr(), "http healthcheck address bound"); - - axum::serve(listener, app).await.context(HttpServerSnafu) -} diff --git a/offchain/http-server/Cargo.toml b/offchain/http-server/Cargo.toml deleted file mode 100644 index 9a8cb4faa..000000000 --- a/offchain/http-server/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "http-server" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -axum.workspace = true -clap = { workspace = true, features = ["derive", "env", "string"] } -hyper.workspace = true -prometheus-client.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -tracing.workspace = true diff --git a/offchain/http-server/src/config.rs b/offchain/http-server/src/config.rs deleted file mode 100644 index 7a32ac82d..000000000 --- a/offchain/http-server/src/config.rs +++ /dev/null @@ -1,45 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::{ - value_parser, Arg, Command, CommandFactory, FromArgMatches, Parser, -}; - -#[derive(Debug, Clone, Parser)] -pub struct HttpServerConfig { - pub(crate) port: u16, -} - -impl HttpServerConfig { - /// Returns the HTTP server config and the app's config after parsing - /// it from the command line and/or environment variables. - /// - /// The parameter `service` must be a lowercase string that - /// uses underlines as spaces. - /// - /// The parametric type `C` must be a struct that derives `Parser`. - pub fn parse( - service: &'static str, - ) -> (HttpServerConfig, C) { - let command = ::command(); - let command = add_port_arg(command, service); - - let matches = command.get_matches(); - let http_server_config: HttpServerConfig = - FromArgMatches::from_arg_matches(&matches).unwrap(); - let inner_config: C = - FromArgMatches::from_arg_matches(&matches).unwrap(); - (http_server_config, inner_config) - } -} - -fn add_port_arg(command: Command, service: S) -> Command { - let service = service.to_string().to_uppercase(); - command.arg( - Arg::new("port") - .long("http-server-port") - .env(format!("{}_HTTP_SERVER_PORT", service)) - .value_parser(value_parser!(u16)) - .default_value("8080"), - ) -} diff --git a/offchain/http-server/src/lib.rs b/offchain/http-server/src/lib.rs deleted file mode 100644 index 0fbbf914e..000000000 --- a/offchain/http-server/src/lib.rs +++ /dev/null @@ -1,52 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod config; -pub use config::HttpServerConfig; - -// Re-exporting prometheus' Registry. -pub use prometheus_client::registry::Registry; - -// Re-exporting prometheus metrics. -// Add any other metrics to re-export here. -pub use prometheus_client::metrics::counter::Counter as CounterRef; -pub use prometheus_client::metrics::family::Family as FamilyRef; -// End of metrics to re-export. - -// Re-exporting hyper error. -pub use hyper::Error as HttpServerError; - -use axum::{routing::get, Router}; -use prometheus_client::encoding::text::encode; -use std::{ - net::SocketAddr, - sync::{Arc, Mutex}, -}; - -/// Starts a HTTP server with two endpoints: /healthz and /metrics. -/// -/// The `Registry` parameter is a `prometheus` type used for metric tracking. -pub async fn start( - config: HttpServerConfig, - registry: Registry, -) -> Result<(), std::io::Error> { - let ip = "0.0.0.0".parse().expect("could not parse host address"); - let addr = SocketAddr::new(ip, config.port); - tracing::info!("Starting HTTP server at {}", addr); - - let registry = Arc::new(Mutex::new(registry)); - let router = Router::new() - .route("/healthz", get(|| async { "" })) - .route("/metrics", get(|| get_metrics(registry))); - - let listener = tokio::net::TcpListener::bind(&addr).await?; - axum::serve(listener, router).await -} - -/// Returns the metrics as a specially encoded string. -async fn get_metrics(registry: Arc>) -> String { - let registry = registry.lock().unwrap(); - let mut buffer = String::new(); - encode(&mut buffer, ®istry).unwrap(); - buffer -} diff --git a/offchain/indexer/Cargo.toml b/offchain/indexer/Cargo.toml deleted file mode 100644 index d3d8d524e..000000000 --- a/offchain/indexer/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "indexer" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-indexer" -path = "src/main.rs" -test = false - -[dependencies] -http-health-check = { path = "../http-health-check" } -log = { path = "../log" } -rollups-data = { path = "../data" } -rollups-events = { path = "../rollups-events" } - -clap = { workspace = true, features = ["derive", "env"] } -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -tracing.workspace = true - -[dev-dependencies] -test-fixtures = { path = "../test-fixtures" } - -backoff.workspace = true -env_logger.workspace = true -rand.workspace = true -serial_test.workspace = true -test-log = { workspace = true, features = ["trace"] } -testcontainers.workspace = true -tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/offchain/indexer/README.md b/offchain/indexer/README.md deleted file mode 100644 index ef595dc31..000000000 --- a/offchain/indexer/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Indexer - -This service is responsible for inserting Rollups inputs and outputs in the PostgreSQL database. -The indexer consumes the inputs and the outputs from the rollups broker. diff --git a/offchain/indexer/src/config.rs b/offchain/indexer/src/config.rs deleted file mode 100644 index 747bec999..000000000 --- a/offchain/indexer/src/config.rs +++ /dev/null @@ -1,56 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; - -use log::{LogConfig, LogEnvCliConfig}; -pub use rollups_data::{RepositoryCLIConfig, RepositoryConfig}; -pub use rollups_events::{ - BrokerCLIConfig, BrokerConfig, DAppMetadata, DAppMetadataCLIConfig, -}; - -#[derive(Debug)] -pub struct IndexerConfig { - pub repository_config: RepositoryConfig, - pub dapp_metadata: DAppMetadata, - pub broker_config: BrokerConfig, - pub log_config: LogConfig, - pub healthcheck_port: u16, -} - -#[derive(Parser)] -#[command(name = "indexer_config")] -#[command(about = "Configuration for indexer")] -pub struct CLIConfig { - #[command(flatten)] - repository_config: RepositoryCLIConfig, - - #[command(flatten)] - dapp_metadata_config: DAppMetadataCLIConfig, - - #[command(flatten)] - broker_config: BrokerCLIConfig, - - #[command(flatten)] - pub log_config: LogEnvCliConfig, - - /// Port of health check - #[arg( - long = "healthcheck-port", - env = "INDEXER_HEALTHCHECK_PORT", - default_value_t = 8080 - )] - pub healthcheck_port: u16, -} - -impl From for IndexerConfig { - fn from(cli_config: CLIConfig) -> Self { - Self { - repository_config: cli_config.repository_config.into(), - dapp_metadata: cli_config.dapp_metadata_config.into(), - broker_config: cli_config.broker_config.into(), - log_config: cli_config.log_config.into(), - healthcheck_port: cli_config.healthcheck_port, - } - } -} diff --git a/offchain/indexer/src/conversions.rs b/offchain/indexer/src/conversions.rs deleted file mode 100644 index 1b579c428..000000000 --- a/offchain/indexer/src/conversions.rs +++ /dev/null @@ -1,126 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//! Convert from rollups-events types to rollups-data types. -//! This code cannot use the From trait because both types are defined in -//! external crates. -use std::time::{Duration, UNIX_EPOCH}; - -use rollups_events::{ - RollupsAdvanceStateInput, RollupsCompletionStatus, RollupsNotice, - RollupsOutputEnum, RollupsProof, RollupsReport, RollupsVoucher, -}; - -use rollups_data::{ - CompletionStatus, Input, Notice, OutputEnum, Proof, Report, Voucher, -}; - -pub fn convert_status(status: RollupsCompletionStatus) -> CompletionStatus { - match status { - RollupsCompletionStatus::Accepted => CompletionStatus::Accepted, - RollupsCompletionStatus::Rejected => CompletionStatus::Rejected, - RollupsCompletionStatus::Exception => CompletionStatus::Exception, - RollupsCompletionStatus::MachineHalted => { - CompletionStatus::MachineHalted - } - RollupsCompletionStatus::CycleLimitExceeded => { - CompletionStatus::CycleLimitExceeded - } - RollupsCompletionStatus::TimeLimitExceeded => { - CompletionStatus::TimeLimitExceeded - } - RollupsCompletionStatus::PayloadLengthLimitExceeded => { - CompletionStatus::PayloadLengthLimitExceeded - } - } -} - -pub fn convert_input(input: RollupsAdvanceStateInput) -> Input { - let timestamp = UNIX_EPOCH + Duration::from_secs(input.metadata.timestamp); - Input { - index: input.metadata.input_index as i32, - msg_sender: input.metadata.msg_sender.into_inner().into(), - tx_hash: input.tx_hash.into_inner().into(), - block_number: input.metadata.block_number as i64, - timestamp, - payload: input.payload.into_inner(), - status: CompletionStatus::Unprocessed, - } -} - -pub fn convert_voucher(voucher: RollupsVoucher) -> Voucher { - Voucher { - input_index: voucher.input_index as i32, - index: voucher.index as i32, - destination: voucher.destination.into_inner().into(), - payload: voucher.payload.into_inner(), - } -} - -pub fn convert_notice(notice: RollupsNotice) -> Notice { - Notice { - input_index: notice.input_index as i32, - index: notice.index as i32, - payload: notice.payload.into_inner(), - } -} - -pub fn convert_report(report: RollupsReport) -> Report { - Report { - input_index: report.input_index as i32, - index: report.index as i32, - payload: report.payload.into_inner(), - } -} - -pub fn convert_proof(proof: RollupsProof) -> Proof { - Proof { - input_index: proof.input_index as i32, - output_index: proof.output_index as i32, - output_enum: match proof.output_enum { - RollupsOutputEnum::Voucher => OutputEnum::Voucher, - RollupsOutputEnum::Notice => OutputEnum::Notice, - }, - validity_input_index_within_epoch: proof - .validity - .input_index_within_epoch - as i32, - validity_output_index_within_input: proof - .validity - .output_index_within_input - as i32, - validity_output_hashes_root_hash: proof - .validity - .output_hashes_root_hash - .into_inner() - .into(), - validity_vouchers_epoch_root_hash: proof - .validity - .vouchers_epoch_root_hash - .into_inner() - .into(), - validity_notices_epoch_root_hash: proof - .validity - .notices_epoch_root_hash - .into_inner() - .into(), - validity_machine_state_hash: proof - .validity - .machine_state_hash - .into_inner() - .into(), - validity_output_hash_in_output_hashes_siblings: proof - .validity - .output_hash_in_output_hashes_siblings - .into_iter() - .map(|hash| Some(hash.into_inner().into())) - .collect(), - validity_output_hashes_in_epoch_siblings: proof - .validity - .output_hashes_in_epoch_siblings - .into_iter() - .map(|hash| Some(hash.into_inner().into())) - .collect(), - context: proof.context.into_inner(), - } -} diff --git a/offchain/indexer/src/error.rs b/offchain/indexer/src/error.rs deleted file mode 100644 index 9fad2bf3e..000000000 --- a/offchain/indexer/src/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::Snafu; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum IndexerError { - #[snafu(display("health check error"))] - HealthCheckError { - source: http_health_check::HealthCheckError, - }, - - #[snafu(display("broker error"))] - BrokerError { source: rollups_events::BrokerError }, - - #[snafu(display("migrations error"))] - MigrationsError { - source: rollups_data::MigrationError, - }, - - #[snafu(display("repository error"))] - RepositoryError { source: rollups_data::Error }, - - #[snafu(display("join error"))] - JoinError { source: tokio::task::JoinError }, -} diff --git a/offchain/indexer/src/indexer.rs b/offchain/indexer/src/indexer.rs deleted file mode 100644 index 47dc38ed7..000000000 --- a/offchain/indexer/src/indexer.rs +++ /dev/null @@ -1,130 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use rollups_data::Repository; -use rollups_events::indexer::{IndexerEvent, IndexerState}; -use rollups_events::{ - Broker, BrokerError, RollupsData, RollupsInput, RollupsOutput, -}; -use snafu::ResultExt; - -use crate::conversions::*; -use crate::error::{ - BrokerSnafu, IndexerError, JoinSnafu, MigrationsSnafu, RepositorySnafu, -}; -use crate::IndexerConfig; - -pub struct Indexer { - repository: Repository, - broker: Broker, - state: IndexerState, -} - -impl Indexer { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn start(config: IndexerConfig) -> Result<(), IndexerError> { - tracing::info!("running database migrations"); - let endpoint = config.repository_config.endpoint(); - rollups_data::run_migrations(&endpoint).context(MigrationsSnafu)?; - - tracing::info!("runned migrations; connecting to DB"); - let repository = tokio::task::spawn_blocking(|| { - Repository::new(config.repository_config) - }) - .await - .context(JoinSnafu)? - .context(RepositorySnafu)?; - - tracing::info!("connected to database; connecting to broker"); - let broker = Broker::new(config.broker_config) - .await - .context(BrokerSnafu)?; - - let state = IndexerState::new(&config.dapp_metadata); - let mut indexer = Indexer { - repository, - broker, - state, - }; - - tracing::info!("connected to broker; starting main loop"); - loop { - let event = indexer.consume_event().await?; - let repository = indexer.repository.clone(); - tokio::task::spawn_blocking(move || match event { - IndexerEvent::Input(input) => { - store_input(&repository, input.payload) - } - IndexerEvent::Output(output) => { - store_output(&repository, output.payload) - } - }) - .await - .context(JoinSnafu)? - .context(RepositorySnafu)?; - } - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn consume_event(&mut self) -> Result { - tracing::info!(?self.state, "waiting for next event"); - loop { - match self.broker.indexer_consume(&mut self.state).await { - Ok(event) => { - tracing::info!(?event, "received event"); - return Ok(event); - } - Err(source) => match source { - BrokerError::ConsumeTimeout => { - tracing::trace!("broker timed out, trying again"); - continue; - } - _ => { - return Err(IndexerError::BrokerError { source }); - } - }, - } - } - } -} - -#[tracing::instrument(level = "trace", skip_all)] -fn store_input( - repository: &Repository, - input: RollupsInput, -) -> Result<(), rollups_data::Error> { - match input.data { - RollupsData::AdvanceStateInput(input) => { - repository.insert_input(convert_input(input)) - } - RollupsData::FinishEpoch {} => { - tracing::trace!("ignoring finish epoch"); - Ok(()) - } - } -} - -#[tracing::instrument(level = "trace", skip_all)] -fn store_output( - repository: &Repository, - output: RollupsOutput, -) -> Result<(), rollups_data::Error> { - match output { - RollupsOutput::AdvanceResult(result) => repository.update_input_status( - result.input_index as i32, - convert_status(result.status), - ), - RollupsOutput::Voucher(voucher) => { - repository.insert_voucher(convert_voucher(voucher)) - } - RollupsOutput::Notice(notice) => { - repository.insert_notice(convert_notice(notice)) - } - RollupsOutput::Report(report) => { - repository.insert_report(convert_report(report)) - } - RollupsOutput::Proof(proof) => { - repository.insert_proof(convert_proof(proof)) - } - } -} diff --git a/offchain/indexer/src/lib.rs b/offchain/indexer/src/lib.rs deleted file mode 100644 index f8e8cf4d2..000000000 --- a/offchain/indexer/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::ResultExt; - -pub use config::{CLIConfig, IndexerConfig}; -pub use error::IndexerError; - -pub mod config; -mod conversions; -mod error; -mod indexer; - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn run(config: IndexerConfig) -> Result<(), IndexerError> { - let health_handle = http_health_check::start(config.healthcheck_port); - let indexer_handle = indexer::Indexer::start(config); - tokio::select! { - ret = health_handle => { - ret.context(error::HealthCheckSnafu) - } - ret = indexer_handle => { - ret - } - } -} diff --git a/offchain/indexer/src/main.rs b/offchain/indexer/src/main.rs deleted file mode 100644 index 4ca5f6955..000000000 --- a/offchain/indexer/src/main.rs +++ /dev/null @@ -1,17 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; - -use indexer::{CLIConfig, IndexerConfig}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let config: IndexerConfig = CLIConfig::parse().into(); - - log::configure(&config.log_config); - - log::log_service_start(&config, "Indexer"); - - indexer::run(config).await.map_err(|e| e.into()) -} diff --git a/offchain/indexer/tests/integration.rs b/offchain/indexer/tests/integration.rs deleted file mode 100644 index 7b8a51bd8..000000000 --- a/offchain/indexer/tests/integration.rs +++ /dev/null @@ -1,543 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use indexer::IndexerError; -use log::LogConfig; -use rand::Rng; -use rollups_data::{ - Input, Notice, OutputEnum, Proof, Report, RepositoryConfig, Voucher, -}; -use rollups_events::{ - BrokerConfig, BrokerEndpoint, DAppMetadata, InputMetadata, - RollupsAdvanceStateInput, RollupsData, RollupsNotice, RollupsOutput, - RollupsOutputEnum, RollupsOutputValidityProof, RollupsProof, RollupsReport, - RollupsVoucher, -}; -use serial_test::serial; -use std::time::UNIX_EPOCH; -use test_fixtures::{BrokerFixture, RepositoryFixture}; -use testcontainers::clients::Cli; -use tokio::task::JoinHandle; - -const BROKER_CONSUME_TIMEOUT: usize = 100; - -/// Starts one container with the broker, one container with the database, -/// and the indexer in a background thread. -struct TestState<'d> { - broker: BrokerFixture<'d>, - repository: RepositoryFixture<'d>, - indexer: JoinHandle>, -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_inserts_inputs() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: u64 = 3; - let mut inputs = vec![]; - for i in 0..N { - let input = state.produce_input_in_broker(i).await; - inputs.push(input); - } - - for input_sent in inputs.into_iter() { - let input_read = state.get_input_from_database(&input_sent).await; - assert_input_eq(&input_sent, &input_read); - } -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_inserts_vouchers() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: u64 = 3; - let mut vouchers = vec![]; - for i in 0..N { - state.produce_input_in_broker(i).await; - for j in 0..N { - let voucher = state.produce_voucher_in_broker(i, j).await; - vouchers.push(voucher) - } - } - - for voucher_sent in vouchers.into_iter() { - let voucher_read = state.get_voucher_from_database(&voucher_sent).await; - assert_voucher_eq(&voucher_sent, &voucher_read); - } -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_inserts_notices() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: u64 = 3; - let mut notices = vec![]; - for i in 0..N { - state.produce_input_in_broker(i).await; - for j in 0..N { - let notice = state.produce_notice_in_broker(i, j).await; - notices.push(notice); - } - } - - for notice_sent in notices.into_iter() { - let notice_read = state.get_notice_from_database(¬ice_sent).await; - assert_notice_eq(¬ice_sent, ¬ice_read); - } -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_inserts_reports() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: u64 = 3; - let mut reports = vec![]; - for i in 0..N { - state.produce_input_in_broker(i).await; - for j in 0..N { - let report = state.produce_report_in_broker(i, j).await; - reports.push(report); - } - } - - for report_sent in reports.into_iter() { - let report_read = state.get_report_from_database(&report_sent).await; - assert_report_eq(&report_sent, &report_read); - } -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_inserts_proofs() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - const N: u64 = 3; - let mut proofs = vec![]; - for i in 0..N { - state.produce_input_in_broker(i).await; - for j in 0..N { - state.produce_voucher_in_broker(i, j).await; - state.produce_notice_in_broker(i, j).await; - proofs.push( - state - .produce_proof_in_broker(i, j, RollupsOutputEnum::Voucher) - .await, - ); - proofs.push( - state - .produce_proof_in_broker(i, j, RollupsOutputEnum::Notice) - .await, - ); - } - } - - for proof_sent in proofs.into_iter() { - let proof_read = state.get_proof_from_database(&proof_sent).await; - assert_proof_eq(&proof_sent, &proof_read); - } -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_ignores_finish_epoch_and_insert_input_after() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("producing finish epoch"); - let data = RollupsData::FinishEpoch {}; - state.broker.produce_input_event(data).await; - - let input_sent = state.produce_input_in_broker(0).await; - let input_read = state.get_input_from_database(&input_sent).await; - assert_input_eq(&input_sent, &input_read); -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_does_not_override_existing_input() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - let original_input = state.produce_input_in_broker(0).await; - let _second_input = state.produce_input_in_broker(0).await; - let input_read = state.get_input_from_database(&original_input).await; - assert_input_eq(&original_input, &input_read); -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_inserts_input_after_broker_timeout() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - tracing::info!("sleeping so the broker consume times out in indexer"); - tokio::time::sleep(std::time::Duration::from_millis( - 2 * BROKER_CONSUME_TIMEOUT as u64, - )) - .await; - - let input_sent = state.produce_input_in_broker(0).await; - let input_read = state.get_input_from_database(&input_sent).await; - assert_input_eq(&input_sent, &input_read); -} - -#[test_log::test(tokio::test)] -#[serial] -async fn indexer_fails_to_insert_output_when_input_does_not_exist() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - - state.produce_voucher_in_broker(0, 0).await; - let error = state.get_indexer_error().await; - assert!(matches!(error, IndexerError::RepositoryError { .. })); -} - -impl TestState<'_> { - async fn setup(docker: &Cli) -> TestState<'_> { - let broker = BrokerFixture::setup(docker).await; - let repository = RepositoryFixture::setup(docker); - let indexer = spawn_indexer( - repository.config(), - broker.redis_endpoint().to_owned(), - broker.dapp_metadata(), - ) - .await; - TestState { - broker, - repository, - indexer, - } - } - - /// Wait for the indexer to fail and return the error - async fn get_indexer_error(self) -> IndexerError { - tracing::info!("waiting for indexer to fail"); - self.indexer - .await - .expect("failed to wait for indexer") - .expect_err("indexer should exit with error") - } - - async fn produce_input_in_broker( - &self, - input_index: u64, - ) -> RollupsAdvanceStateInput { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - self.produce_input_in_broker_with_timestamp(input_index, timestamp) - .await - } - - async fn produce_input_in_broker_with_timestamp( - &self, - input_index: u64, - timestamp: u64, - ) -> RollupsAdvanceStateInput { - let metadata = InputMetadata { - epoch_index: 0, - input_index, - block_number: rand::thread_rng().gen(), - msg_sender: random_array().into(), - timestamp, - }; - let input = RollupsAdvanceStateInput { - metadata, - payload: random_array::<32>().to_vec().into(), - tx_hash: random_array().into(), - }; - let data = RollupsData::AdvanceStateInput(input.clone()); - - tracing::info!(?input, "producing input"); - self.broker.produce_input_event(data).await; - input - } - - async fn get_input_from_database( - &self, - input_sent: &RollupsAdvanceStateInput, - ) -> Input { - tracing::info!("waiting for input in database"); - let index = input_sent.metadata.input_index as i32; - self.repository.retry(move |r| r.get_input(index)).await - } - - async fn produce_voucher_in_broker( - &self, - input_index: u64, - index: u64, - ) -> RollupsVoucher { - let voucher = RollupsVoucher { - index, - input_index, - destination: random_array().into(), - payload: random_array::<32>().to_vec().into(), - }; - let output = RollupsOutput::Voucher(voucher.clone()); - - tracing::info!(?voucher, "producing voucher"); - self.broker.produce_output(output).await; - voucher - } - - async fn get_voucher_from_database( - &self, - voucher_sent: &RollupsVoucher, - ) -> Voucher { - tracing::info!("waiting for voucher in database"); - let input_index = voucher_sent.input_index as i32; - let index = voucher_sent.index as i32; - self.repository - .retry(move |r| r.get_voucher(index, input_index)) - .await - } - - async fn produce_notice_in_broker( - &self, - input_index: u64, - index: u64, - ) -> RollupsNotice { - let notice = RollupsNotice { - index, - input_index, - payload: random_array::<32>().to_vec().into(), - }; - let output = RollupsOutput::Notice(notice.clone()); - - tracing::info!(?notice, "producing notice"); - self.broker.produce_output(output).await; - notice - } - - async fn get_notice_from_database( - &self, - notice_sent: &RollupsNotice, - ) -> Notice { - tracing::info!("waiting for notice in database"); - let input_index = notice_sent.input_index as i32; - let index = notice_sent.index as i32; - self.repository - .retry(move |r| r.get_notice(index, input_index)) - .await - } - - async fn produce_report_in_broker( - &self, - input_index: u64, - index: u64, - ) -> RollupsReport { - let report = RollupsReport { - index, - input_index, - payload: random_array::<32>().to_vec().into(), - }; - let output = RollupsOutput::Report(report.clone()); - - tracing::info!(?report, "producing report"); - self.broker.produce_output(output).await; - report - } - - async fn get_report_from_database( - &self, - report_sent: &RollupsReport, - ) -> Report { - tracing::info!("waiting for report in database"); - let input_index = report_sent.input_index as i32; - let index = report_sent.index as i32; - self.repository - .retry(move |r| r.get_report(index, input_index)) - .await - } - - async fn produce_proof_in_broker( - &self, - input_index: u64, - output_index: u64, - output_enum: RollupsOutputEnum, - ) -> RollupsProof { - let validity = RollupsOutputValidityProof { - input_index_within_epoch: input_index, - output_index_within_input: output_index, - output_hashes_root_hash: random_array().into(), - vouchers_epoch_root_hash: random_array().into(), - notices_epoch_root_hash: random_array().into(), - machine_state_hash: random_array().into(), - output_hash_in_output_hashes_siblings: vec![random_array().into()], - output_hashes_in_epoch_siblings: vec![random_array().into()], - }; - let proof = RollupsProof { - input_index, - output_index, - output_enum, - validity, - context: random_array::<32>().to_vec().into(), - }; - let output = RollupsOutput::Proof(proof.clone()); - - tracing::info!(?proof, "producing proof"); - self.broker.produce_output(output).await; - proof - } - - async fn get_proof_from_database( - &self, - proof_sent: &RollupsProof, - ) -> Proof { - tracing::info!("waiting for proof in database"); - let input_index = proof_sent.input_index as i32; - let output_index = proof_sent.output_index as i32; - let output_enum = match proof_sent.output_enum { - RollupsOutputEnum::Voucher => OutputEnum::Voucher, - RollupsOutputEnum::Notice => OutputEnum::Notice, - }; - self.repository - .retry(move |r| { - match r.get_proof(input_index, output_index, output_enum) { - Ok(option_proof) => { - // The retry only works properly if the query - // returns item not found - option_proof.ok_or(rollups_data::Error::ItemNotFound { - item_type: "proof".to_owned(), - }) - } - Err(e) => Err(e), - } - }) - .await - } -} - -async fn spawn_indexer( - repository_config: RepositoryConfig, - redis_endpoint: BrokerEndpoint, - dapp_metadata: DAppMetadata, -) -> JoinHandle> { - let broker_config = BrokerConfig { - redis_endpoint, - consume_timeout: BROKER_CONSUME_TIMEOUT, - backoff: Default::default(), - }; - - let indexer_config = indexer::IndexerConfig { - repository_config, - dapp_metadata, - broker_config, - healthcheck_port: 0, - log_config: LogConfig::default(), - }; - tokio::spawn(async move { - indexer::run(indexer_config).await.map_err(|e| { - tracing::error!("{:?}", e); - e - }) - }) -} - -fn random_array() -> [u8; N] { - let mut arr = [0; N]; - for i in 0..N { - arr[i] = rand::thread_rng().gen(); - } - arr -} - -fn assert_input_eq(input_sent: &RollupsAdvanceStateInput, input_read: &Input) { - assert_eq!(input_read.index as u64, input_sent.metadata.input_index); - assert_eq!( - &input_read.msg_sender, - input_sent.metadata.msg_sender.inner() - ); - assert_eq!(&input_read.tx_hash, input_sent.tx_hash.inner()); - assert_eq!( - input_read.block_number as u64, - input_sent.metadata.block_number - ); - assert_eq!( - input_read - .timestamp - .duration_since(UNIX_EPOCH) - .expect("failed to get time") - .as_secs(), - input_sent.metadata.timestamp - ); - assert_eq!(&input_read.payload, input_sent.payload.inner()); -} - -fn assert_voucher_eq(voucher_sent: &RollupsVoucher, voucher_read: &Voucher) { - assert_eq!(voucher_read.index as u64, voucher_sent.index); - assert_eq!(voucher_read.input_index as u64, voucher_sent.input_index); - assert_eq!(&voucher_read.destination, voucher_sent.destination.inner()); - assert_eq!(&voucher_read.payload, voucher_sent.payload.inner()); -} - -fn assert_notice_eq(notice_sent: &RollupsNotice, notice_read: &Notice) { - assert_eq!(notice_read.index as u64, notice_sent.index); - assert_eq!(notice_read.input_index as u64, notice_sent.input_index); - assert_eq!(¬ice_read.payload, notice_sent.payload.inner()); -} - -fn assert_report_eq(report_sent: &RollupsReport, report_read: &Report) { - assert_eq!(report_read.index as u64, report_sent.index); - assert_eq!(report_read.input_index as u64, report_sent.input_index); - assert_eq!(&report_read.payload, report_sent.payload.inner()); -} - -fn assert_proof_eq(proof_sent: &RollupsProof, proof_read: &Proof) { - let output_enum = match proof_sent.output_enum { - RollupsOutputEnum::Voucher => OutputEnum::Voucher, - RollupsOutputEnum::Notice => OutputEnum::Notice, - }; - assert_eq!(proof_read.input_index as u64, proof_sent.input_index); - assert_eq!(proof_read.output_index as u64, proof_sent.output_index); - assert_eq!(proof_read.output_enum, output_enum); - assert_eq!( - proof_read.validity_input_index_within_epoch as u64, - proof_sent.validity.input_index_within_epoch - ); - assert_eq!( - proof_read.validity_output_index_within_input as u64, - proof_sent.validity.output_index_within_input - ); - assert_eq!( - &proof_read.validity_output_hashes_root_hash, - proof_sent.validity.output_hashes_root_hash.inner() - ); - assert_eq!( - &proof_read.validity_vouchers_epoch_root_hash, - proof_sent.validity.vouchers_epoch_root_hash.inner() - ); - assert_eq!( - &proof_read.validity_notices_epoch_root_hash, - proof_sent.validity.notices_epoch_root_hash.inner() - ); - assert_eq!( - &proof_read.validity_machine_state_hash, - proof_sent.validity.machine_state_hash.inner() - ); - for (siblings_read, siblings_sent) in proof_read - .validity_output_hash_in_output_hashes_siblings - .iter() - .zip(&proof_sent.validity.output_hash_in_output_hashes_siblings) - { - assert_eq!(siblings_read.as_ref().unwrap(), siblings_sent.inner()); - } - for (siblings_read, siblings_sent) in proof_read - .validity_output_hashes_in_epoch_siblings - .iter() - .zip(&proof_sent.validity.output_hashes_in_epoch_siblings) - { - assert_eq!(siblings_read.as_ref().unwrap(), siblings_sent.inner()); - } - assert_eq!(&proof_read.context, proof_sent.context.inner()); -} diff --git a/offchain/inspect-server/Cargo.toml b/offchain/inspect-server/Cargo.toml deleted file mode 100644 index 02039e551..000000000 --- a/offchain/inspect-server/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "inspect-server" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-inspect-server" -path = "src/main.rs" - -[dependencies] -grpc-interfaces = { path = "../grpc-interfaces" } -http-health-check = { path = "../http-health-check" } -log = { path = "../log" } - -actix-cors.workspace = true -actix-web.workspace = true -clap = { workspace = true, features = ["derive", "env"] } -hex.workspace = true -serde = { workspace = true, features = ["rc", "derive"] } -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -toml.workspace = true -tonic.workspace = true -tracing.workspace = true -tracing-actix-web.workspace = true -uuid = { workspace = true, features = ["v4"] } - -[dev-dependencies] -futures.workspace = true -reqwest = { workspace = true, features = ["json"] } -serial_test.workspace = true diff --git a/offchain/inspect-server/README.md b/offchain/inspect-server/README.md deleted file mode 100644 index 436f6fdec..000000000 --- a/offchain/inspect-server/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Inspect Server - -This server receives HTTP inspect-state requests and sends them to the server-manager. -The specification of the HTTP inspect API can be found in the [openapi-interfaces](https://github.com/cartesi/openapi-interfaces/) repository. - -## Running - -To run the inspect-server locally you need to setup an instance of the server-manager. -This can be done setting up an example in the [rollups-examples](https://github.com/cartesi/rollups-examples) repository. - -1. Assuming you are running the server-manager on local port 5001, you could run the inspect server on port 5002 as such: - -```shell -cargo run -- --inspect-server-address localhost:5002 --server-manager-address localhost:5001 --session-id default_rollups_id -``` - -2. Then, you could submit an inspect request with payload "mypayload" by sending a GET HTTP request as follows: - -```shell -curl http://localhost:5002/inspect/mypayload -``` diff --git a/offchain/inspect-server/src/config.rs b/offchain/inspect-server/src/config.rs deleted file mode 100644 index 5bd1124e8..000000000 --- a/offchain/inspect-server/src/config.rs +++ /dev/null @@ -1,132 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -/// Configuration can be provided using command-line options, environment variables or -/// configuration file. -/// Command-line parameters take precedence over environment variables and environment variables -/// take precedence over same parameter from file configuration. -use clap::Parser; -use log::{LogConfig, LogEnvCliConfig}; -use serde::Deserialize; -use snafu::{ResultExt, Snafu}; - -#[derive(Debug, Snafu)] -pub enum ConfigError { - #[snafu(display("parse configuration file error"))] - FileError { source: std::io::Error }, - - #[snafu(display("parse configuration file error"))] - ParseError { source: toml::de::Error }, - - #[snafu(whatever, display("{message}"))] - Whatever { - message: String, - #[snafu(source(from(Box, Some)))] - source: Option>, - }, -} -#[derive(Debug)] -pub struct InspectServerConfig { - pub log_config: LogConfig, - pub inspect_server_address: String, - pub server_manager_address: String, - pub session_id: String, - pub queue_size: usize, - pub healthcheck_port: u16, -} - -#[derive(Parser)] -#[command(name = "inspect_server_config")] -#[command(about = "Configuration for inspect-server")] -pub struct CLIConfig { - #[command(flatten)] - pub log_config: LogEnvCliConfig, - - /// HTTP address for the inspect server - #[arg(long, env)] - inspect_server_address: Option, - - /// Server manager gRPC address - #[arg(long, env)] - server_manager_address: Option, - - /// Server manager session id - #[arg(long, env)] - session_id: Option, - - /// Queue size for concurrent inspect requests - #[arg(long, env)] - queue_size: Option, - - /// Path to the config file - #[arg(long, env)] - pub config_path: Option, - - /// Port of health check - #[arg( - long, - env = "INSPECT_SERVER_HEALTHCHECK_PORT", - default_value_t = 8080 - )] - pub healthcheck_port: u16, -} - -impl From for InspectServerConfig { - fn from(cli_config: CLIConfig) -> Self { - let file_config: FileConfig = load_config_file(cli_config.config_path) - .expect("couldn't read config file"); - - let inspect_server_address: String = cli_config - .inspect_server_address - .or(file_config.inspect_server_address) - .expect("couldn't retrieve inspect server address"); - - let server_manager_address: String = cli_config - .server_manager_address - .or(file_config.server_manager_address) - .expect("couldn't retrieve server manager address"); - - let session_id: String = cli_config - .session_id - .or(file_config.session_id) - .expect("couldn't retrieve session id"); - - let queue_size: usize = cli_config - .queue_size - .or(file_config.queue_size) - .unwrap_or(100); - - Self { - log_config: cli_config.log_config.into(), - inspect_server_address, - server_manager_address, - session_id, - queue_size, - healthcheck_port: cli_config.healthcheck_port, - } - } -} - -#[derive(Clone, Debug, Deserialize, Default)] -struct FileConfig { - inspect_server_address: Option, - server_manager_address: Option, - session_id: Option, - queue_size: Option, -} - -fn load_config_file( - // path to the config file if provided - config_file: Option, -) -> Result { - match config_file { - Some(config) => { - let s = std::fs::read_to_string(config).context(FileSnafu)?; - - let file_config: T = toml::from_str(&s).context(ParseSnafu)?; - - Ok(file_config) - } - None => Ok(T::default()), - } -} diff --git a/offchain/inspect-server/src/error.rs b/offchain/inspect-server/src/error.rs deleted file mode 100644 index 4155edcf5..000000000 --- a/offchain/inspect-server/src/error.rs +++ /dev/null @@ -1,22 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use snafu::Snafu; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum InspectError { - #[snafu(display("health check error"))] - HealthCheckError { - source: http_health_check::HealthCheckError, - }, - - #[snafu(display("server error"))] - ServerError { source: std::io::Error }, - - #[snafu(display("Failed to connect to server manager: {}", message))] - FailedToConnect { message: String }, - - #[snafu(display("Failed to inspect state: {}", message))] - InspectFailed { message: String }, -} diff --git a/offchain/inspect-server/src/inspect.rs b/offchain/inspect-server/src/inspect.rs deleted file mode 100644 index 8948d5c0d..000000000 --- a/offchain/inspect-server/src/inspect.rs +++ /dev/null @@ -1,174 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use tokio::sync::{ - mpsc, - oneshot::{self}, -}; -use tonic::{transport::Channel, Code, Request, Response, Status}; -use uuid::Uuid; - -use crate::config::InspectServerConfig; -use crate::error::InspectError; - -use grpc_interfaces::cartesi_server_manager::{ - server_manager_client::ServerManagerClient, GetSessionStatusRequest, - GetSessionStatusResponse, InspectStateRequest, -}; -pub use grpc_interfaces::cartesi_server_manager::{ - CompletionStatus, InspectStateResponse, Report, -}; - -#[derive(Clone)] -pub struct InspectClient { - inspect_tx: mpsc::Sender, -} - -/// The inspect client is a wrapper that just sends the inspect requests to another thread and -/// waits for the result. The actual request to the server manager is done by the handle_inspect -/// function. -impl InspectClient { - pub fn new(config: &InspectServerConfig) -> Self { - let (inspect_tx, inspect_rx) = mpsc::channel(config.queue_size); - let address = config.server_manager_address.clone(); - let session_id = config.session_id.clone(); - tokio::spawn(handle_inspect(address, session_id, inspect_rx)); - Self { inspect_tx } - } - - pub async fn inspect( - &self, - payload: Vec, - ) -> Result { - let (response_tx, response_rx) = oneshot::channel(); - let request = InspectRequest { - payload, - response_tx, - }; - if let Err(e) = self.inspect_tx.try_send(request) { - return Err(InspectError::InspectFailed { - message: e.to_string(), - }); - } else { - tracing::debug!("inspect request added to the queue"); - } - response_rx.await.expect("handle_inspect never fails") - } -} - -struct InspectRequest { - payload: Vec, - response_tx: oneshot::Sender>, -} - -fn respond( - response_tx: oneshot::Sender>, - response: Result, -) { - if response_tx.send(response).is_err() { - tracing::warn!("failed to respond inspect request (client dropped)"); - } -} - -/// Loop that answers requests coming from inspect_rx. -async fn handle_inspect( - address: String, - session_id: String, - mut inspect_rx: mpsc::Receiver, -) { - let endpoint = format!("http://{}", address); - while let Some(request) = inspect_rx.recv().await { - match ServerManagerClient::connect(endpoint.clone()).await { - Err(e) => { - respond( - request.response_tx, - Err(InspectError::FailedToConnect { - message: e.to_string(), - }), - ); - } - Ok(mut client) => { - let request_id = Uuid::new_v4().to_string(); - let grpc_request = InspectStateRequest { - session_id: session_id.clone(), - query_payload: request.payload, - }; - - tracing::debug!( - "calling grpc inspect_state request={:?} request_id={}", - grpc_request, - request_id - ); - let mut grpc_request = Request::new(grpc_request); - grpc_request - .metadata_mut() - .insert("request-id", request_id.parse().unwrap()); - let inspect_response = client.inspect_state(grpc_request).await; - - tracing::debug!("got grpc response from inspect_state response={:?} request_id={}", - inspect_response, request_id); - - let response = if inspect_response.is_ok() { - Ok(inspect_response.unwrap().into_inner()) - } else { - // The server-manager does not inform the session tainted reason. - // Trying to get it from the session's status. - let message = handle_inspect_error( - inspect_response.unwrap_err(), - session_id.clone(), - &mut client, - ) - .await; - Err(InspectError::InspectFailed { message }) - }; - respond(request.response_tx, response); - } - } - } -} - -async fn get_session_status( - session_id: String, - client: &mut ServerManagerClient, -) -> Result, Status> { - let session_id = session_id.clone(); - let mut status_request = - Request::new(GetSessionStatusRequest { session_id }); - let request_id = Uuid::new_v4().to_string(); - status_request - .metadata_mut() - .insert("request-id", request_id.parse().unwrap()); - client.get_session_status(status_request).await -} - -async fn handle_inspect_error( - status: Status, - session_id: String, - client: &mut ServerManagerClient, -) -> String { - let mut message = status.message().to_string(); - - // If the session was previously tainted, the server-manager replies it with code DataLoss. - // Trying to recover the reason for the session tainted from the session's status. - // If not available, we return the original status error message. - if status.code() == Code::DataLoss { - let status_response = get_session_status(session_id, client).await; - if status_response.is_err() { - let err = status_response.unwrap_err().message().to_string(); - tracing::error!("get-session-status error: {:?}", err); - } else { - let status_response = status_response.unwrap(); - let status_response = status_response.get_ref(); - let taint_status = status_response.taint_status.clone(); - if let Some(taint_status) = taint_status { - message = format!( - "Server manager session was tainted: {} ({})", - taint_status.error_code, taint_status.error_message - ); - tracing::error!(message); - } - } - } - - message -} diff --git a/offchain/inspect-server/src/lib.rs b/offchain/inspect-server/src/lib.rs deleted file mode 100644 index ab3510bd0..000000000 --- a/offchain/inspect-server/src/lib.rs +++ /dev/null @@ -1,29 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use error::InspectError; -use snafu::ResultExt; - -pub use config::InspectServerConfig; -pub use inspect::InspectClient; - -pub mod config; -mod error; -pub mod inspect; -pub mod server; - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn run(config: InspectServerConfig) -> Result<(), InspectError> { - let health_handle = http_health_check::start(config.healthcheck_port); - let inspect_client = InspectClient::new(&config); - let inspect_server = - server::create(&config, inspect_client).context(error::ServerSnafu)?; - tokio::select! { - ret = health_handle => { - ret.context(error::HealthCheckSnafu) - } - ret = inspect_server => { - ret.context(error::ServerSnafu) - } - } -} diff --git a/offchain/inspect-server/src/main.rs b/offchain/inspect-server/src/main.rs deleted file mode 100644 index 07cff6f75..000000000 --- a/offchain/inspect-server/src/main.rs +++ /dev/null @@ -1,17 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; - -use inspect_server::{config::CLIConfig, InspectServerConfig}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let config: InspectServerConfig = CLIConfig::parse().into(); - - log::configure(&config.log_config); - - log::log_service_start(&config, "Inspect Server"); - - inspect_server::run(config).await.map_err(|e| e.into()) -} diff --git a/offchain/inspect-server/src/server.rs b/offchain/inspect-server/src/server.rs deleted file mode 100644 index b02b03831..000000000 --- a/offchain/inspect-server/src/server.rs +++ /dev/null @@ -1,143 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use actix_cors::Cors; -use actix_web::{ - dev::Server, error, web, App, HttpRequest, HttpResponse, HttpServer, - Responder, -}; -use serde::{Deserialize, Serialize}; -use tracing_actix_web::TracingLogger; - -use crate::config::InspectServerConfig; -use crate::error::InspectError; -use crate::inspect::{ - CompletionStatus, InspectClient, InspectStateResponse, Report, -}; - -// 2^20 bytes, which is the length of the RX buffer -pub const CARTESI_MACHINE_RX_BUFFER_LIMIT: usize = 1_048_576; - -pub fn create( - config: &InspectServerConfig, - inspect_client: InspectClient, -) -> std::io::Result { - let server = HttpServer::new(move || { - let cors = Cors::permissive(); - App::new() - .app_data(web::Data::new(inspect_client.clone())) - .app_data(web::PayloadConfig::new(CARTESI_MACHINE_RX_BUFFER_LIMIT)) - .wrap(TracingLogger::default()) - .wrap(cors) - .service(inspect_get) - .service(inspect_post) - }) - .bind(config.inspect_server_address.clone())? - .run(); - Ok(server) -} - -#[actix_web::get("/inspect/{payload:.*}")] -async fn inspect_get( - request: HttpRequest, - payload: web::Path, - inspect_client: web::Data, -) -> actix_web::error::Result { - let mut payload = payload.into_inner(); - if let Some(query) = request.uri().query() { - payload = payload + "?" + query; - } - let payload = payload.as_bytes().to_vec(); - let response = inspect_client.inspect(payload).await?; - let http_response = HttpInspectResponse::from(response); - Ok(HttpResponse::Ok().json(http_response)) -} - -#[actix_web::post("/inspect")] -async fn inspect_post( - payload: web::Bytes, - inspect_client: web::Data, -) -> actix_web::error::Result { - let response = inspect_client.inspect(payload.to_vec()).await?; - let http_response = HttpInspectResponse::from(response); - Ok(HttpResponse::Ok().json(http_response)) -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct HttpInspectResponse { - pub status: String, - pub exception_payload: Option, - pub reports: Vec, - pub processed_input_count: u64, -} - -impl From for HttpInspectResponse { - fn from(response: InspectStateResponse) -> HttpInspectResponse { - let reports = - response.reports.into_iter().map(HttpReport::from).collect(); - HttpInspectResponse { - status: convert_status(response.status), - exception_payload: response.exception_data.map(hex_encode), - reports, - processed_input_count: response.processed_input_count, - } - } -} - -fn convert_status(status: i32) -> String { - // Unfortunaly, the gRPC interface uses i32 instead of a Enum type, - // so it is clearer to use if-else instead of match. - if status == CompletionStatus::Accepted as i32 { - String::from("Accepted") - } else if status == CompletionStatus::Rejected as i32 { - String::from("Rejected") - } else if status == CompletionStatus::Exception as i32 { - String::from("Exception") - } else if status == CompletionStatus::MachineHalted as i32 { - String::from("MachineHalted") - } else if status == CompletionStatus::CycleLimitExceeded as i32 { - String::from("CycleLimitExceeded") - } else if status == CompletionStatus::TimeLimitExceeded as i32 { - String::from("TimeLimitExceeded") - } else if status == CompletionStatus::PayloadLengthLimitExceeded as i32 { - String::from("PayloadLengthLimitExceeded") - } else { - tracing::error!( - "Invalid status received from server-manager: {}", - status - ); - String::from("Unknown") - } -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct HttpReport { - pub payload: String, -} - -impl From for HttpReport { - fn from(report: Report) -> HttpReport { - HttpReport { - payload: hex_encode(report.payload), - } - } -} - -fn hex_encode(payload: Vec) -> String { - String::from("0x") + &hex::encode(payload) -} - -impl From for error::Error { - fn from(e: InspectError) -> error::Error { - tracing::warn!("{}", e.to_string()); - match e { - InspectError::FailedToConnect { .. } => { - error::ErrorBadGateway(e.to_string()) - } - InspectError::InspectFailed { .. } => { - error::ErrorBadRequest(e.to_string()) - } - _ => error::ErrorBadGateway(e.to_string()), - } - } -} diff --git a/offchain/inspect-server/tests/common/mod.rs b/offchain/inspect-server/tests/common/mod.rs deleted file mode 100644 index 175577afd..000000000 --- a/offchain/inspect-server/tests/common/mod.rs +++ /dev/null @@ -1,311 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -#![allow(dead_code)] - -use actix_web::dev::ServerHandle; -use inspect_server::config::InspectServerConfig; -use log::LogConfig; -pub use reqwest::StatusCode; -use std::sync::Arc; -use tokio::sync::{oneshot, Notify}; -use tokio::task::JoinHandle; -use tonic::{transport::Server, Request, Response, Status}; - -use grpc_interfaces::cartesi_machine::Void; -use grpc_interfaces::cartesi_server_manager::{ - server_manager_server::{ServerManager, ServerManagerServer}, - AdvanceStateRequest, DeleteEpochRequest, EndSessionRequest, - FinishEpochRequest, FinishEpochResponse, GetEpochStatusRequest, - GetEpochStatusResponse, GetSessionStatusRequest, GetSessionStatusResponse, - GetStatusResponse, InspectStateRequest, InspectStateResponse, - StartSessionRequest, StartSessionResponse, -}; -pub use grpc_interfaces::cartesi_server_manager::{CompletionStatus, Report}; -use grpc_interfaces::versioning::GetVersionResponse; - -use inspect_server::inspect::InspectClient; -use inspect_server::server::HttpInspectResponse; - -pub const SERVER_MANAGER_ADDRESS: &'static str = "127.0.0.1:50001"; -pub const INSPECT_SERVER_ADDRESS: &'static str = "127.0.0.1:50002"; -pub const SESSION_ID: &'static str = "default session"; -pub const ACTIVE_EPOCH_INDEX: u64 = 123; -pub const PROCESSED_INPUT_COUNT: u64 = 456; -pub const QUEUE_SIZE: usize = 3; - -pub struct TestState { - server_manager: MockServerManagerWrapper, - inspect_server: InspectServerWrapper, -} - -impl TestState { - /// Start the inspect-server and the mock-server-manager - pub async fn setup(mock: impl MockInspect) -> Self { - let server_manager = MockServerManagerWrapper::start(mock).await; - let inspect_server = InspectServerWrapper::start().await; - Self { - server_manager, - inspect_server, - } - } - - /// Shutdown both servers. - /// This function cannot be implemented as the drop trait because it is async. - pub async fn teardown(self) { - self.inspect_server.stop().await; - self.server_manager.stop().await; - } -} - -#[derive(Clone, Debug, Default)] -pub struct MockInspectResponse { - pub reports: Vec, - pub exception: Option>, - pub completion_status: CompletionStatus, -} - -#[tonic::async_trait] -pub trait MockInspect: Send + Sync + 'static { - async fn inspect_state(&self, payload: Vec) -> MockInspectResponse; -} - -pub struct InspectServerWrapper { - server_handle: ServerHandle, - join_handle: JoinHandle<()>, -} - -impl InspectServerWrapper { - /// Start the inspect server in another thread. - /// This function blocks until the server is ready. - pub async fn start() -> Self { - let inspect_server_config = InspectServerConfig { - inspect_server_address: INSPECT_SERVER_ADDRESS.to_string(), - server_manager_address: SERVER_MANAGER_ADDRESS.to_string(), - session_id: SESSION_ID.to_string(), - queue_size: QUEUE_SIZE, - healthcheck_port: 0, - log_config: LogConfig::default(), - }; - - let inspect_client = InspectClient::new(&inspect_server_config); - let (handle_tx, handle_rx) = oneshot::channel(); - let join_handle = tokio::spawn(async move { - let server = inspect_server::server::create( - &inspect_server_config, - inspect_client, - ) - .expect("failed to start inspect server"); - handle_tx - .send(server.handle()) - .expect("failed to send server handle"); - server.await.expect("inspect server execution failed"); - }); - let server_handle = - handle_rx.await.expect("failed to received server handle"); - Self { - server_handle, - join_handle, - } - } - - /// Stop the inspect server. - /// This function blocks util the server is shut down. - pub async fn stop(self) { - self.server_handle.stop(true).await; - self.join_handle - .await - .expect("failed to stop inspect server"); - } -} - -pub struct MockServerManagerWrapper { - shutdown: Arc, - join_handle: JoinHandle<()>, -} - -impl MockServerManagerWrapper { - /// Start the server manager in another thread. - /// This function blocks until the server is ready. - pub async fn start(mock: impl MockInspect) -> Self { - let service = MockServerManager { mock }; - let address = SERVER_MANAGER_ADDRESS.parse().expect("invalid address"); - let ready = Arc::new(Notify::new()); - let shutdown = Arc::new(Notify::new()); - let join_handle = { - let ready = ready.clone(); - let shutdown = shutdown.clone(); - tokio::spawn(async move { - let server = Server::builder() - .add_service(ServerManagerServer::new(service)) - .serve_with_shutdown(address, shutdown.notified()); - ready.notify_one(); - server.await.expect("failed to start server manager"); - }) - }; - ready.notified().await; - Self { - shutdown, - join_handle, - } - } - - /// Stop the server manager. - /// This function blocks until the server is shut down. - pub async fn stop(self) { - self.shutdown.notify_one(); - self.join_handle - .await - .expect("failed to shutdown server manager"); - } -} - -struct MockServerManager { - mock: T, -} - -#[tonic::async_trait] -impl ServerManager for MockServerManager { - async fn inspect_state( - &self, - request: Request, - ) -> Result, Status> { - let mock_response = self - .mock - .inspect_state(request.into_inner().query_payload) - .await; - let response = InspectStateResponse { - session_id: SESSION_ID.to_string(), - active_epoch_index: ACTIVE_EPOCH_INDEX, - processed_input_count: PROCESSED_INPUT_COUNT, - exception_data: mock_response.exception, - status: mock_response.completion_status as i32, - reports: mock_response.reports, - }; - Ok(Response::new(response)) - } - - async fn get_version( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn start_session( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn end_session( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn advance_state( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn finish_epoch( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn get_status( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn get_session_status( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn get_epoch_status( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn delete_epoch( - &self, - _: Request, - ) -> Result, Status> { - unimplemented!() - } -} - -/// Send an inspect-state request to the inspect server via GET. -/// If the status code is 200, return the HttpInspectResponse. -/// Else, return the status code and the error message. -pub async fn send_get_request( - payload: &str, -) -> Result { - let url = format!("http://{}/inspect/{}", INSPECT_SERVER_ADDRESS, payload); - let response = reqwest::get(url) - .await - .expect("failed to send inspect via GET"); - let status = response.status(); - if status == 200 { - let response = response - .json::() - .await - .expect("failed to decode json response"); - Ok(response) - } else { - let message = response - .text() - .await - .expect("failed to obtain response body"); - Err((status, message)) - } -} - -/// Send an inspect-state request to the inspect server via POST. -/// If the status code is 200, return the HttpInspectResponse. -/// Else, return the status code and the error message. -pub async fn send_post_request( - payload: &str, -) -> Result { - let url = format!("http://{}/inspect", INSPECT_SERVER_ADDRESS); - let client = reqwest::Client::new(); - let response = client - .post(url) - .body(String::from(payload)) - .send() - .await - .expect("failed to send inspect via POST"); - let status = response.status(); - if status == 200 { - let response = response - .json::() - .await - .expect("failed to decode json response"); - Ok(response) - } else { - let message = response - .text() - .await - .expect("failed to obtain response body"); - Err((status, message)) - } -} - -/// Convert binary value to the hex format -pub fn hex_to_bin(payload: &Vec) -> String { - String::from("0x") + &hex::encode(payload) -} diff --git a/offchain/inspect-server/tests/payload.rs b/offchain/inspect-server/tests/payload.rs deleted file mode 100644 index 19baeff56..000000000 --- a/offchain/inspect-server/tests/payload.rs +++ /dev/null @@ -1,135 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod common; -use crate::common::*; -use inspect_server::server::CARTESI_MACHINE_RX_BUFFER_LIMIT; - -struct EchoInspect {} - -#[tonic::async_trait] -impl MockInspect for EchoInspect { - async fn inspect_state(&self, payload: Vec) -> MockInspectResponse { - MockInspectResponse { - reports: vec![Report { payload }], - exception: None, - completion_status: CompletionStatus::Accepted, - } - } -} - -async fn test_get_payload(sent_payload: &str, expected_payload: &str) { - let test_state = TestState::setup(EchoInspect {}).await; - let response = send_get_request(sent_payload) - .await - .expect("failed to obtain response"); - assert_eq!(response.status, "Accepted"); - assert_eq!(response.exception_payload, None); - assert_eq!(response.reports.len(), 1); - let expected_payload = String::from("0x") + &hex::encode(expected_payload); - assert_eq!(response.reports[0].payload, expected_payload); - test_state.teardown().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_simple_payload() { - test_get_payload("hello", "hello").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_payload_with_spaces() { - test_get_payload("hello world", "hello world").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_url_encoded_payload() { - test_get_payload("hello%20world", "hello world").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_payload_with_slashes() { - test_get_payload("user/123/name", "user/123/name").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_payload_with_path_and_query() { - test_get_payload( - "user/data?key=value&key2=value2", - "user/data?key=value&key2=value2", - ) - .await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_raw_json_payload() { - test_get_payload( - r#"{"key": ["value1", "value2"]}"#, - r#"{"key": ["value1", "value2"]}"#, - ) - .await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_empty_payload() { - test_get_payload("", "").await; -} - -async fn test_post_payload(sent_payload: &str, expected_payload: &str) { - let test_state = TestState::setup(EchoInspect {}).await; - let response = send_post_request(sent_payload) - .await - .expect("failed to obtain response"); - assert_eq!(response.status, "Accepted"); - assert_eq!(response.exception_payload, None); - assert_eq!(response.reports.len(), 1); - let expected_payload = String::from("0x") + &hex::encode(expected_payload); - assert_eq!(response.reports[0].payload, expected_payload); - test_state.teardown().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_empty_payload() { - test_post_payload("", "").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_simple_payload() { - test_post_payload("hello", "hello").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_raw_json_payload() { - test_post_payload( - r#"{"key": ["value1", "value2"]}"#, - r#"{"key": ["value1", "value2"]}"#, - ) - .await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_payload_on_limit() { - let payload = "0".repeat(CARTESI_MACHINE_RX_BUFFER_LIMIT); - test_post_payload(&payload.clone(), &payload.clone()).await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_fails_when_payload_over_limit() { - let payload = "0".repeat(CARTESI_MACHINE_RX_BUFFER_LIMIT + 1); - let test_state = TestState::setup(EchoInspect {}).await; - send_post_request(&payload) - .await - .expect_err("Payload reached size limit"); - test_state.teardown().await; -} diff --git a/offchain/inspect-server/tests/queue.rs b/offchain/inspect-server/tests/queue.rs deleted file mode 100644 index 50a96b256..000000000 --- a/offchain/inspect-server/tests/queue.rs +++ /dev/null @@ -1,242 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod common; -use crate::common::*; - -use futures::stream::FuturesUnordered; -use futures::StreamExt; -use tokio::sync::{mpsc, Mutex}; - -struct SyncInspect { - response_rx: Mutex>, -} - -#[tonic::async_trait] -impl MockInspect for SyncInspect { - async fn inspect_state(&self, _: Vec) -> MockInspectResponse { - self.response_rx.lock().await.recv().await.unwrap() - } -} - -impl SyncInspect { - fn setup() -> (Self, mpsc::Sender) { - let (response_tx, response_rx) = mpsc::channel(1000); - let mock = SyncInspect { - response_rx: Mutex::new(response_rx), - }; - (mock, response_tx) - } -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_error_when_server_manager_is_down() { - let inspect_server = InspectServerWrapper::start().await; - let (status, message) = send_get_request("hello") - .await - .expect_err("failed to obtain response"); - assert_eq!(status, StatusCode::BAD_GATEWAY); - assert_eq!( - &message, - "Failed to connect to server manager: transport error" - ); - inspect_server.stop().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_succeeds_after_server_manager_starts() { - let inspect_server = InspectServerWrapper::start().await; - let (status, _) = send_get_request("hello") - .await - .expect_err("failed to obtain response"); - assert_eq!(status, StatusCode::BAD_GATEWAY); - let (mock, response_tx) = SyncInspect::setup(); - let server_manager = MockServerManagerWrapper::start(mock).await; - // Add response to queue before sending request - response_tx - .send(MockInspectResponse::default()) - .await - .expect("failed to send response"); - send_get_request("hello") - .await - .expect("failed to obtain response"); - server_manager.stop().await; - inspect_server.stop().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_it_handle_concurrent_inspect_requests() { - let (mock, response_tx) = SyncInspect::setup(); - let state = TestState::setup(mock).await; - // Send multiple concurrent requests - let handlers: Vec<_> = (0..QUEUE_SIZE) - .map(|_| { - tokio::spawn(async { - send_get_request("hello") - .await - .expect("failed to obtain response"); - }) - }) - .collect(); - // Wait until the requests arrive in the inspect-server - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - // Add the responses to the queue - for _ in 0..QUEUE_SIZE { - response_tx - .send(MockInspectResponse::default()) - .await - .expect("failed to send response"); - } - // Check the responses - for handler in handlers { - handler.await.expect("failed to wait handler"); - } - state.teardown().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_it_returns_error_when_queue_is_full() { - let (mock, response_tx) = SyncInspect::setup(); - let state = TestState::setup(mock).await; - // Send concurrent requests to overflow the queue. - // We need to 2 extra requests to overflow the queue because the first message will be - // imediatelly consumed and removed from the queue. - let mut handlers = FuturesUnordered::new(); - for _ in 0..(QUEUE_SIZE + 2) { - handlers.push(tokio::spawn(send_get_request("hello"))); - } - // Poll the handlers to find the overflow error - let (status, message) = handlers - .next() - .await - .expect("failed to poll") - .expect("failed to join handler") - .expect_err("failed to receive error"); - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!( - message, - String::from("Failed to inspect state: no available capacity") - ); - // Add the responses to the queue - for _ in 0..(QUEUE_SIZE + 1) { - response_tx - .send(MockInspectResponse::default()) - .await - .expect("failed to send response"); - } - // Wait for responses so we don't have zombie threads - while let Some(handler) = handlers.next().await { - let _ = handler.expect("failed to join handler"); - } - state.teardown().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_error_when_server_manager_is_down() { - let inspect_server = InspectServerWrapper::start().await; - let (status, message) = send_get_request("hello") - .await - .expect_err("failed to obtain response"); - assert_eq!(status, StatusCode::BAD_GATEWAY); - assert_eq!( - &message, - "Failed to connect to server manager: transport error" - ); - inspect_server.stop().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_succeeds_after_server_manager_starts() { - let inspect_server = InspectServerWrapper::start().await; - let (status, _) = send_post_request("hello") - .await - .expect_err("failed to obtain response"); - assert_eq!(status, StatusCode::BAD_GATEWAY); - let (mock, response_tx) = SyncInspect::setup(); - let server_manager = MockServerManagerWrapper::start(mock).await; - // Add response to queue before sending request - response_tx - .send(MockInspectResponse::default()) - .await - .expect("failed to send response"); - send_post_request("hello") - .await - .expect("failed to obtain response"); - server_manager.stop().await; - inspect_server.stop().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_it_handle_concurrent_inspect_requests() { - let (mock, response_tx) = SyncInspect::setup(); - let state = TestState::setup(mock).await; - // Send multiple concurrent requests - let handlers: Vec<_> = (0..QUEUE_SIZE) - .map(|_| { - tokio::spawn(async { - send_post_request("hello") - .await - .expect("failed to obtain response"); - }) - }) - .collect(); - // Wait until the requests arrive in the inspect-server - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - // Add the responses to the queue - for _ in 0..QUEUE_SIZE { - response_tx - .send(MockInspectResponse::default()) - .await - .expect("failed to send response"); - } - // Check the responses - for handler in handlers { - handler.await.expect("failed to wait handler"); - } - state.teardown().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_it_returns_error_when_queue_is_full() { - let (mock, response_tx) = SyncInspect::setup(); - let state = TestState::setup(mock).await; - // Send concurrent requests to overflow the queue. - // We need to 2 extra requests to overflow the queue because the first message will be - // imediatelly consumed and removed from the queue. - let mut handlers = FuturesUnordered::new(); - for _ in 0..(QUEUE_SIZE + 2) { - handlers.push(tokio::spawn(send_post_request("hello"))); - } - // Poll the handlers to find the overflow error - let (status, message) = handlers - .next() - .await - .expect("failed to poll") - .expect("failed to join handler") - .expect_err("failed to receive error"); - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!( - message, - String::from("Failed to inspect state: no available capacity") - ); - // Add the responses to the queue - for _ in 0..(QUEUE_SIZE + 1) { - response_tx - .send(MockInspectResponse::default()) - .await - .expect("failed to send response"); - } - // Wait for responses so we don't have zombie threads - while let Some(handler) = handlers.next().await { - let _ = handler.expect("failed to join handler"); - } - state.teardown().await; -} diff --git a/offchain/inspect-server/tests/response.rs b/offchain/inspect-server/tests/response.rs deleted file mode 100644 index a15ee596a..000000000 --- a/offchain/inspect-server/tests/response.rs +++ /dev/null @@ -1,176 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod common; -use crate::common::*; - -struct FixedResponseInspect { - response: MockInspectResponse, -} - -#[tonic::async_trait] -impl MockInspect for FixedResponseInspect { - async fn inspect_state(&self, _: Vec) -> MockInspectResponse { - self.response.clone() - } -} - -async fn test_get_response(sent: MockInspectResponse, expected_status: &str) { - let mock = FixedResponseInspect { - response: sent.clone(), - }; - let state = TestState::setup(mock).await; - let response = send_get_request("") - .await - .expect("failed to obtain response"); - assert_eq!(&response.status, expected_status); - assert_eq!( - response.exception_payload, - sent.exception.as_ref().map(hex_to_bin) - ); - assert_eq!(response.reports.len(), sent.reports.len()); - for (received, sent) in response.reports.iter().zip(sent.reports) { - assert_eq!(received.payload, hex_to_bin(&sent.payload)); - } - assert_eq!(response.processed_input_count, PROCESSED_INPUT_COUNT); - state.teardown().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_response_with_no_reports() { - let response = MockInspectResponse { - reports: vec![], - exception: None, - completion_status: CompletionStatus::Accepted, - }; - test_get_response(response, "Accepted").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_response_with_single_report() { - let response = MockInspectResponse { - reports: vec![Report { - payload: vec![1, 2, 3], - }], - exception: None, - completion_status: CompletionStatus::Accepted, - }; - test_get_response(response, "Accepted").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_response_with_multiple_reports() { - let reports = vec![ - Report { - payload: vec![1, 2, 3], - }, - Report { - payload: vec![4, 5, 6], - }, - Report { - payload: vec![7, 8, 9], - }, - ]; - let response = MockInspectResponse { - reports, - exception: None, - completion_status: CompletionStatus::Accepted, - }; - test_get_response(response, "Accepted").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_get_response_with_reports_and_exception() { - let response = MockInspectResponse { - reports: vec![Report { - payload: vec![1, 2, 3], - }], - exception: Some(vec![4, 5, 6]), - completion_status: CompletionStatus::Exception, - }; - test_get_response(response, "Exception").await; -} - -async fn test_post_response(sent: MockInspectResponse, expected_status: &str) { - let mock = FixedResponseInspect { - response: sent.clone(), - }; - let state = TestState::setup(mock).await; - let response = send_post_request("") - .await - .expect("failed to obtain response"); - assert_eq!(&response.status, expected_status); - assert_eq!( - response.exception_payload, - sent.exception.as_ref().map(hex_to_bin) - ); - assert_eq!(response.reports.len(), sent.reports.len()); - for (received, sent) in response.reports.iter().zip(sent.reports) { - assert_eq!(received.payload, hex_to_bin(&sent.payload)); - } - assert_eq!(response.processed_input_count, PROCESSED_INPUT_COUNT); - state.teardown().await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_response_with_no_reports() { - let response = MockInspectResponse { - reports: vec![], - exception: None, - completion_status: CompletionStatus::Accepted, - }; - test_post_response(response, "Accepted").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_response_with_single_report() { - let response = MockInspectResponse { - reports: vec![Report { - payload: vec![1, 2, 3], - }], - exception: None, - completion_status: CompletionStatus::Accepted, - }; - test_post_response(response, "Accepted").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_response_with_multiple_reports() { - let reports = vec![ - Report { - payload: vec![1, 2, 3], - }, - Report { - payload: vec![4, 5, 6], - }, - Report { - payload: vec![7, 8, 9], - }, - ]; - let response = MockInspectResponse { - reports, - exception: None, - completion_status: CompletionStatus::Accepted, - }; - test_post_response(response, "Accepted").await; -} - -#[tokio::test] -#[serial_test::serial] -async fn test_post_response_with_reports_and_exception() { - let response = MockInspectResponse { - reports: vec![Report { - payload: vec![1, 2, 3], - }], - exception: Some(vec![4, 5, 6]), - completion_status: CompletionStatus::Exception, - }; - test_post_response(response, "Exception").await; -} diff --git a/offchain/log/Cargo.toml b/offchain/log/Cargo.toml deleted file mode 100644 index b8b1f33d8..000000000 --- a/offchain/log/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "log" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -clap = { workspace = true, features = ["derive", "env"] } -tracing.workspace = true -tracing-subscriber = { workspace = true, features = ["env-filter"] } - -[build-dependencies] -built = { workspace = true, features = ["git2"] } diff --git a/offchain/log/build.rs b/offchain/log/build.rs deleted file mode 100644 index 5b20b4b6d..000000000 --- a/offchain/log/build.rs +++ /dev/null @@ -1,7 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub(crate) fn main() { - built::write_built_file() - .expect("Failed to acquire build-time information"); -} diff --git a/offchain/log/src/lib.rs b/offchain/log/src/lib.rs deleted file mode 100644 index 426afef03..000000000 --- a/offchain/log/src/lib.rs +++ /dev/null @@ -1,71 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) -use std::fmt::Debug; - -use clap::Parser; -use tracing::info; -use tracing_subscriber::filter::{EnvFilter, LevelFilter}; - -pub mod built_info { - include!(concat!(env!("OUT_DIR"), "/built.rs")); -} - -#[derive(Debug, Parser)] -#[command(name = "log_config")] -pub struct LogEnvCliConfig { - #[arg(long, env, default_value = "false")] - pub log_enable_timestamp: bool, - - #[arg(long, env, default_value = "false")] - pub log_enable_color: bool, -} - -#[derive(Clone, Debug, Default)] -pub struct LogConfig { - pub enable_timestamp: bool, - pub enable_color: bool, -} - -impl LogConfig { - pub fn initialize(env_cli_config: LogEnvCliConfig) -> Self { - let enable_timestamp = env_cli_config.log_enable_timestamp; - - let enable_color = env_cli_config.log_enable_color; - - LogConfig { - enable_timestamp, - enable_color, - } - } -} - -impl From for LogConfig { - fn from(cli_config: LogEnvCliConfig) -> LogConfig { - LogConfig::initialize(cli_config) - } -} - -pub fn configure(config: &LogConfig) { - let filter = EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(); - - let subscribe_builder = tracing_subscriber::fmt() - .compact() - .with_env_filter(filter) - .with_ansi(config.enable_color); - - if !config.enable_timestamp { - subscribe_builder.without_time().init(); - } else { - subscribe_builder.init(); - } -} - -pub fn log_service_start(config: &C, service_name: &str) { - let git_ref = built_info::GIT_HEAD_REF.unwrap_or("N/A"); - let git_hash = built_info::GIT_COMMIT_HASH.unwrap_or("N/A"); - - let message = format!("Starting {service} (version={version}, git ref={git_ref}, git hash={git_hash}) with config {:?}",config, service = service_name, version = built_info::PKG_VERSION, git_ref = git_ref, git_hash = git_hash); - info!(message); -} diff --git a/offchain/redacted/Cargo.toml b/offchain/redacted/Cargo.toml deleted file mode 100644 index 6592c3db4..000000000 --- a/offchain/redacted/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "redacted" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -url.workspace = true diff --git a/offchain/redacted/src/lib.rs b/offchain/redacted/src/lib.rs deleted file mode 100644 index 00158e8f4..000000000 --- a/offchain/redacted/src/lib.rs +++ /dev/null @@ -1,117 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use std::fmt; -pub use url::{self, Url}; - -/// Wrapper that redacts the entire field -#[derive(Clone)] -pub struct Redacted(T); - -impl Redacted { - pub fn new(data: T) -> Redacted { - Self(data) - } - - pub fn inner(&self) -> &T { - &self.0 - } - - pub fn into_inner(self) -> T { - self.0 - } -} - -impl fmt::Debug for Redacted { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[REDACTED]") - } -} - -#[test] -fn redacts_debug_fmt() { - let password = Redacted::new("super-security"); - assert_eq!(format!("{:?}", password), "[REDACTED]"); -} - -/// Wrapper that redacts the credentials in an URL -#[derive(Clone)] -pub struct RedactedUrl(Url); - -impl RedactedUrl { - pub fn new(url: Url) -> Self { - Self(url) - } - - pub fn inner(&self) -> &Url { - &self.0 - } - - pub fn into_inner(self) -> Url { - self.0 - } -} - -impl fmt::Debug for RedactedUrl { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut url = self.inner().clone(); - let result = { - if url.cannot_be_a_base() { - Err(()) - } else { - Ok(()) - } - } - .and_then(|_| { - if !url.username().is_empty() { - url.set_username("***") - } else { - Ok(()) - } - }) - .and_then(|_| { - if url.password().is_some() { - url.set_password(Some("***")) - } else { - Ok(()) - } - }); - match result { - Ok(_) => write!(f, "{}", url.as_str()), - Err(_) => write!(f, "[NON-BASE URL REDACTED]"), - } - } -} - -#[test] -fn redacts_valid_url_without_credentials() { - let url = RedactedUrl::new(Url::parse("http://example.com/").unwrap()); - assert_eq!(format!("{:?}", url), "http://example.com/"); -} - -#[test] -fn redacts_valid_url_with_username() { - let url = - RedactedUrl::new(Url::parse("http://james@example.com/").unwrap()); - assert_eq!(format!("{:?}", url), "http://***@example.com/"); -} - -#[test] -fn redacts_valid_url_with_password() { - let url = - RedactedUrl::new(Url::parse("http://:bond@example.com/").unwrap()); - assert_eq!(format!("{:?}", url), "http://:***@example.com/"); -} - -#[test] -fn redacts_valid_url_with_full_credentials() { - let url = - RedactedUrl::new(Url::parse("http://james:bond@example.com/").unwrap()); - assert_eq!(format!("{:?}", url), "http://***:***@example.com/"); -} - -#[test] -fn redacts_non_base_url() { - let url = RedactedUrl::new(Url::parse("james:bond@example.com").unwrap()); - assert_eq!(format!("{:?}", url), "[NON-BASE URL REDACTED]"); -} diff --git a/offchain/rollups-events/Cargo.toml b/offchain/rollups-events/Cargo.toml deleted file mode 100644 index d784f8024..000000000 --- a/offchain/rollups-events/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "rollups-events" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -redacted = { path = "../redacted" } - -backoff = { workspace = true, features = ["tokio"] } -base64.workspace = true -clap = { workspace = true, features = ["derive", "env"] } -hex.workspace = true -prometheus-client.workspace = true -regex.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "time", "rt-multi-thread"] } -tracing.workspace = true - -redis = { workspace = true, features = [ - "streams", - "tokio-comp", - "connection-manager", - "tls-native-tls", - "tokio-native-tls-comp", - "cluster", - "cluster-async" -] } - -[dev-dependencies] -env_logger.workspace = true -test-log = { workspace = true, features = ["trace"] } -testcontainers.workspace = true -tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/offchain/rollups-events/README.md b/offchain/rollups-events/README.md deleted file mode 100644 index 4de838318..000000000 --- a/offchain/rollups-events/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Rollups Events - -This crate works as an abstraction layer for producing and consuming Cartesi Rollups events. -Currently, it uses Redis Streams as the event broker and defines the following event streams: - -- `rollups-inputs`, for exchanging *Input* events; -- `rollups-outputs`, for exchanging *Output* events; -- `rollups-claims`, for exchanging *Claim* events. diff --git a/offchain/rollups-events/src/broker/indexer.rs b/offchain/rollups-events/src/broker/indexer.rs deleted file mode 100644 index 01422167d..000000000 --- a/offchain/rollups-events/src/broker/indexer.rs +++ /dev/null @@ -1,106 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//! This module is an indexer-specific extension for the broker -//! -//! It would be too complex to implement the indexer extension as a generic broker method. -//! Instead, we decided to implement the extension that we need for the indexer as a submodule. -//! This extension should be in this crate because it accesses the Redis interface directly. -//! (All Redis interaction should be hidden in this crate.) -use backoff::future::retry; -use redis::streams::{StreamReadOptions, StreamReadReply}; -use redis::AsyncCommands; -use snafu::ResultExt; - -use super::ConnectionSnafu; -use crate::{ - Broker, BrokerError, BrokerStream, DAppMetadata, Event, RollupsInput, - RollupsInputsStream, RollupsOutput, RollupsOutputsStream, INITIAL_ID, -}; - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum IndexerEvent { - Input(Event), - Output(Event), -} - -#[derive(Debug)] -pub struct IndexerState { - inputs_last_id: String, - outputs_last_id: String, - inputs_stream: RollupsInputsStream, - outputs_stream: RollupsOutputsStream, -} - -impl IndexerState { - pub fn new(dapp_metadata: &DAppMetadata) -> Self { - Self { - inputs_last_id: INITIAL_ID.to_owned(), - outputs_last_id: INITIAL_ID.to_owned(), - inputs_stream: RollupsInputsStream::new(dapp_metadata), - outputs_stream: RollupsOutputsStream::new(dapp_metadata), - } - } -} - -impl Broker { - /// Consume an event from the Input stream and if there is none, - /// consume from the Output stream. This is a blocking operation. - /// Return IndexerEvent::Input if present or IndexerEvent::Output otherwise - #[tracing::instrument(level = "trace", skip_all)] - pub async fn indexer_consume( - &self, - state: &mut IndexerState, - ) -> Result { - let input_stream_key = state.inputs_stream.key(); - let output_stream_key = state.outputs_stream.key(); - let mut reply = retry(self.backoff.clone(), || async { - let stream_keys = [&input_stream_key, &output_stream_key]; - let last_consumed_ids = - [&state.inputs_last_id, &state.outputs_last_id]; - tracing::trace!( - ?stream_keys, - ?last_consumed_ids, - "consuming event" - ); - let opts = StreamReadOptions::default() - .count(1) - .block(self.consume_timeout); - let reply: StreamReadReply = self - .connection - .clone() - .xread_options(&stream_keys, &last_consumed_ids, &opts) - .await?; - Ok(reply) - }) - .await - .context(ConnectionSnafu)?; - - let input_stream_id = reply - .keys - .iter_mut() - .find(|stream| stream.key == input_stream_key) - .and_then(|stream| stream.ids.pop()); - if let Some(stream_id) = input_stream_id { - tracing::trace!("found input event; parsing it"); - let event: Event = stream_id.try_into()?; - state.inputs_last_id = event.id.clone(); - return Ok(IndexerEvent::Input(event)); - } - - let output_stream_id = reply - .keys - .iter_mut() - .find(|stream| stream.key == output_stream_key) - .and_then(|stream| stream.ids.pop()); - if let Some(stream_id) = output_stream_id { - tracing::trace!("found output event; parsing it"); - let event: Event = stream_id.try_into()?; - state.outputs_last_id = event.id.clone(); - return Ok(IndexerEvent::Output(event)); - } - - tracing::trace!("indexer consume timed out"); - Err(BrokerError::ConsumeTimeout) - } -} diff --git a/offchain/rollups-events/src/broker/mod.rs b/offchain/rollups-events/src/broker/mod.rs deleted file mode 100644 index 1883f46fd..000000000 --- a/offchain/rollups-events/src/broker/mod.rs +++ /dev/null @@ -1,596 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::{future::retry, ExponentialBackoff, ExponentialBackoffBuilder}; -use clap::Parser; -use redis::aio::{ConnectionLike, ConnectionManager}; -use redis::cluster::ClusterClient; -use redis::cluster_async::ClusterConnection; -use redis::streams::{ - StreamId, StreamRangeReply, StreamReadOptions, StreamReadReply, -}; -use redis::{ - AsyncCommands, Client, Cmd, Pipeline, RedisError, RedisFuture, Value, -}; -use serde::{de::DeserializeOwned, Serialize}; -use snafu::{ResultExt, Snafu}; -use std::collections::HashMap; -use std::convert::identity; -use std::fmt; -use std::str::FromStr; -use std::time::Duration; - -pub use redacted::{RedactedUrl, Url}; - -use crate::Address; - -pub mod indexer; - -pub const INITIAL_ID: &str = "0"; -pub const DAPPS_KEY: &str = "experimental-dapp-addresses-config"; - -/// The `BrokerConnection` enum implements the `ConnectionLike` trait -/// to satisfy the `AsyncCommands` trait bounds. -/// As `AsyncCommands` requires its implementors to be `Sized`, we couldn't -/// use a trait object instead. -#[derive(Clone)] -enum BrokerConnection { - ConnectionManager(ConnectionManager), - ClusterConnection(ClusterConnection), -} - -impl ConnectionLike for BrokerConnection { - fn req_packed_command<'a>( - &'a mut self, - cmd: &'a Cmd, - ) -> RedisFuture<'a, Value> { - match self { - Self::ConnectionManager(connection) => { - connection.req_packed_command(cmd) - } - Self::ClusterConnection(connection) => { - connection.req_packed_command(cmd) - } - } - } - - fn req_packed_commands<'a>( - &'a mut self, - cmd: &'a Pipeline, - offset: usize, - count: usize, - ) -> RedisFuture<'a, Vec> { - match self { - Self::ConnectionManager(connection) => { - connection.req_packed_commands(cmd, offset, count) - } - Self::ClusterConnection(connection) => { - connection.req_packed_commands(cmd, offset, count) - } - } - } - - fn get_db(&self) -> i64 { - match self { - Self::ConnectionManager(connection) => connection.get_db(), - Self::ClusterConnection(connection) => connection.get_db(), - } - } -} - -/// Client that connects to the broker -#[derive(Clone)] -pub struct Broker { - connection: BrokerConnection, - backoff: ExponentialBackoff, - consume_timeout: usize, -} - -impl Broker { - /// Create a new client - /// The broker_address should be in the format redis://host:port/db. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn new(config: BrokerConfig) -> Result { - tracing::trace!(?config, "connecting to broker"); - - let connection = retry(config.backoff.clone(), || async { - match config.redis_endpoint.clone() { - BrokerEndpoint::Single(endpoint) => { - tracing::trace!("creating Redis Client"); - let client = Client::open(endpoint.inner().as_str())?; - - tracing::trace!("creating Redis ConnectionManager"); - let connection = ConnectionManager::new(client).await?; - - Ok(BrokerConnection::ConnectionManager(connection)) - } - BrokerEndpoint::Cluster(endpoints) => { - tracing::trace!("creating Redis Cluster Client"); - let client = ClusterClient::new( - endpoints - .iter() - .map(|endpoint| endpoint.inner().as_str()) - .collect::>(), - )?; - tracing::trace!("connecting to Redis Cluster"); - let connection = client.get_async_connection().await?; - Ok(BrokerConnection::ClusterConnection(connection)) - } - } - }) - .await - .context(ConnectionSnafu)?; - - tracing::trace!("returning successful connection"); - Ok(Self { - connection, - backoff: config.backoff, - consume_timeout: config.consume_timeout, - }) - } - - /// Produce an event and return its id - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce( - &mut self, - stream: &S, - payload: S::Payload, - ) -> Result { - tracing::trace!("converting payload to JSON string"); - let payload = - serde_json::to_string(&payload).context(InvalidPayloadSnafu)?; - - let event_id = retry(self.backoff.clone(), || async { - tracing::trace!( - stream_key = stream.key(), - payload, - "producing event" - ); - let event_id = self - .connection - .clone() - .xadd(stream.key(), "*", &[("payload", &payload)]) - .await?; - - Ok(event_id) - }) - .await - .context(ConnectionSnafu)?; - - tracing::trace!(event_id, "returning event id"); - Ok(event_id) - } - - /// Peek at the end of the stream - /// This function doesn't block; if there is no event in the stream it returns None. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn peek_latest( - &mut self, - stream: &S, - ) -> Result>, BrokerError> { - let mut reply = retry(self.backoff.clone(), || async { - tracing::trace!(stream_key = stream.key(), "peeking at the stream"); - let reply: StreamRangeReply = self - .connection - .clone() - .xrevrange_count(stream.key(), "+", "-", 1) - .await?; - - Ok(reply) - }) - .await - .context(ConnectionSnafu)?; - - if let Some(event) = reply.ids.pop() { - tracing::trace!("parsing received event"); - Some(event.try_into()).transpose() - } else { - tracing::trace!("stream is empty"); - Ok(None) - } - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn _consume_blocking( - &mut self, - stream: &S, - last_consumed_id: &str, - ) -> Result, BrokerError> { - let mut reply = retry(self.backoff.clone(), || async { - tracing::trace!( - stream_key = stream.key(), - last_consumed_id, - "consuming event" - ); - let opts = StreamReadOptions::default() - .count(1) - .block(self.consume_timeout); - let reply: StreamReadReply = self - .connection - .clone() - .xread_options(&[stream.key()], &[last_consumed_id], &opts) - .await?; - - Ok(reply) - }) - .await - .context(ConnectionSnafu)?; - - tracing::trace!("checking for timeout"); - let mut events = reply.keys.pop().ok_or(BrokerError::ConsumeTimeout)?; - - tracing::trace!("checking if event was received"); - let event = events.ids.pop().ok_or(BrokerError::FailedToConsume)?; - - tracing::trace!("parsing received event"); - event.try_into() - } - - /// Consume the next event in stream - /// - /// This function blocks until a new event is available - /// and retries whenever a timeout happens instead of returning an error. - /// - /// To consume the first event in the stream, `last_consumed_id` should be `INITIAL_ID`. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn consume_blocking( - &mut self, - stream: &S, - last_consumed_id: &str, - ) -> Result, BrokerError> { - loop { - let result = self._consume_blocking(stream, last_consumed_id).await; - - if let Err(BrokerError::ConsumeTimeout) = result { - tracing::trace!("consume timed out, retrying"); - } else { - return result; - } - } - } - - /// Consume the next event in stream without blocking - /// This function returns None if there are no more remaining events. - /// To consume the first event in the stream, `last_consumed_id` should be `INITIAL_ID`. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn consume_nonblocking( - &mut self, - stream: &S, - last_consumed_id: &str, - ) -> Result>, BrokerError> { - let mut reply = retry(self.backoff.clone(), || async { - tracing::trace!( - stream_key = stream.key(), - last_consumed_id, - "consuming event (non-blocking)" - ); - let opts = StreamReadOptions::default().count(1); - let reply: StreamReadReply = self - .connection - .clone() - .xread_options(&[stream.key()], &[last_consumed_id], &opts) - .await?; - - Ok(reply) - }) - .await - .context(ConnectionSnafu)?; - - tracing::trace!("checking if event was received"); - if let Some(mut events) = reply.keys.pop() { - let event = events.ids.pop().ok_or(BrokerError::FailedToConsume)?; - tracing::trace!("parsing received event"); - Some(event.try_into()).transpose() - } else { - tracing::trace!("stream is empty"); - Ok(None) - } - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn _consume_blocking_from_multiple_streams( - &mut self, - streams: &Vec, - last_consumed_ids: &Vec, - ) -> Result)>, BrokerError> { - let reply = retry(self.backoff.clone(), || async { - let stream_keys: Vec = streams - .iter() - .map(|stream| stream.key().to_string()) - .collect(); - - let opts = StreamReadOptions::default() - .count(1) - .block(self.consume_timeout); - let reply: StreamReadReply = self - .connection - .clone() - .xread_options(&stream_keys, &last_consumed_ids, &opts) - .await?; - - Ok(reply) - }) - .await - .context(ConnectionSnafu)?; - - tracing::trace!("checking for timeout"); - if reply.keys.is_empty() { - return Err(BrokerError::ConsumeTimeout); - } - - tracing::trace!("getting the consumed events"); - let mut response: Vec<(S, Event)> = vec![]; - for mut stream_key in reply.keys { - tracing::trace!("parsing stream key {:?}", stream_key); - if let Some(event) = stream_key.ids.pop() { - tracing::trace!("parsing received event"); - let stream = S::from_key(stream_key.key); - let event = event.try_into()?; - response.push((stream, event)); - } - } - if response.is_empty() { - Err(BrokerError::FailedToConsume) - } else { - Ok(response) - } - } - - /// Consume the next event from one of the streams. - /// - /// This function blocks until a new event is available in one of the streams. - /// It timeouts with BrokerError::FailedToConsume. - /// - /// To consume the first event for a stream, `last_consumed_id[...]` should be `INITIAL_ID`. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn consume_blocking_from_multiple_streams< - S: BrokerMultiStream, - >( - &mut self, - streams: HashMap, // streams to last-consumed-ids - ) -> Result)>, BrokerError> { - let (streams, last_consumed_ids): (Vec<_>, Vec<_>) = - streams.into_iter().map(identity).unzip(); - - let result = self - ._consume_blocking_from_multiple_streams( - &streams, - &last_consumed_ids, - ) - .await; - - if let Err(BrokerError::ConsumeTimeout) = result { - Err(BrokerError::FailedToConsume) - } else { - result - } - } - - #[tracing::instrument(level = "trace", skip_all)] - pub async fn _get_dapps(&mut self) -> Result, BrokerError> { - let reply = retry(self.backoff.clone(), || async { - tracing::trace!(key = DAPPS_KEY, "getting key"); - let reply: Vec = - self.connection.clone().smembers(DAPPS_KEY).await?; - - let mut dapp_addresses: Vec
= vec![]; - for value in reply { - let normalized = value.to_lowercase(); - let dapp_address = Address::from_str(&normalized); - match dapp_address { - Ok(dapp_address) => { - if dapp_addresses.contains(&dapp_address) { - tracing::info!( - "Ignored duplicate DApp address {:?}", - value, - ) - } else { - dapp_addresses.push(dapp_address); - } - } - Err(message) => tracing::info!( - "Error while parsing DApp address {:?}: {}", - normalized, - message, - ), - } - } - - Ok(dapp_addresses) - }) - .await - .context(ConnectionSnafu)?; - - if reply.is_empty() { - Err(BrokerError::ConsumeTimeout) - } else { - Ok(reply) - } - } - - /// Gets the dapp addresses. - pub async fn get_dapps(&mut self) -> Result, BrokerError> { - loop { - let result = self._get_dapps().await; - if let Err(BrokerError::ConsumeTimeout) = result { - tracing::trace!("consume timed out, retrying"); - } else { - return result; - } - } - } - - /// Sets the dapp addresses. - /// NOTE: this function is used strictly for testing. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn dapps_set(&mut self, dapp_addresses: Vec
) { - tracing::trace!(key = DAPPS_KEY, "setting key"); - let _: () = self.connection.clone().del(DAPPS_KEY).await.unwrap(); - for dapp_address in dapp_addresses { - let _: () = self - .connection - .clone() - .sadd(DAPPS_KEY, dapp_address.to_string()) - .await - .unwrap(); - } - } - - /// Adds a dapp address (as a string). - /// NOTE: this function is used strictly for testing. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn dapps_add(&mut self, dapp_address: String) { - tracing::trace!(dapp = dapp_address, "adding dapp"); - self.connection - .clone() - .sadd(DAPPS_KEY, dapp_address) - .await - .unwrap() - } - - /// Gets the dapp addresses as strings. - /// NOTE: this function is used strictly for testing. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn dapps_members(&mut self) -> Vec { - tracing::trace!("getting dapps members"); - self.connection.clone().smembers(DAPPS_KEY).await.unwrap() - } -} - -/// Custom implementation of Debug because ConnectionManager doesn't implement debug -impl fmt::Debug for Broker { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Broker") - .field("consume_timeout", &self.consume_timeout) - .finish() - } -} - -/// Trait that defines the type of a stream -pub trait BrokerStream { - type Payload: Serialize + DeserializeOwned + Clone + Eq + PartialEq; - fn key(&self) -> &str; -} - -pub trait BrokerMultiStream: BrokerStream { - fn from_key(key: String) -> Self; -} - -/// Event that goes through the broker -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct Event { - pub id: String, - pub payload: P, -} - -impl TryFrom - for Event

-{ - type Error = BrokerError; - - #[tracing::instrument(level = "trace", skip_all)] - fn try_from(stream_id: StreamId) -> Result, BrokerError> { - tracing::trace!("getting event payload"); - let payload = stream_id - .get::("payload") - .ok_or(BrokerError::InvalidEvent)?; - let id = stream_id.id; - - tracing::trace!(id, payload, "received event"); - - tracing::trace!("parsing JSON payload"); - let payload = - serde_json::from_str(&payload).context(InvalidPayloadSnafu)?; - - tracing::trace!("returning event"); - Ok(Event { id, payload }) - } -} - -#[derive(Debug, Snafu)] -pub enum BrokerError { - #[snafu(display("error connecting to Redis"))] - ConnectionError { source: RedisError }, - - #[snafu(display("failed to consume event"))] - FailedToConsume, - - #[snafu(display("timed out when consuming event"))] - ConsumeTimeout, - - #[snafu(display("event in invalid format"))] - InvalidEvent, - - #[snafu(display("error parsing event payload"))] - InvalidPayload { source: serde_json::Error }, -} - -#[derive(Debug, Parser)] -#[command(name = "broker")] -pub struct BrokerCLIConfig { - /// Redis address - #[arg(long, env, default_value = "redis://127.0.0.1:6379")] - redis_endpoint: String, - - /// Address list of Redis cluster nodes, defined by a single string - /// separated by commas. If present, it supersedes `redis_endpoint`. - /// A single endpoint can be enough as the client will discover - /// other nodes automatically - #[arg(long, env, num_args = 1.., value_delimiter = ',')] - redis_cluster_endpoints: Option>, - - /// Timeout when consuming input events (in millis) - #[arg(long, env, default_value = "5000")] - broker_consume_timeout: usize, - - /// The max elapsed time for backoff in ms - #[arg(long, env, default_value = "120000")] - broker_backoff_max_elapsed_duration: u64, -} - -#[derive(Debug, Clone)] -pub enum BrokerEndpoint { - Single(RedactedUrl), - Cluster(Vec), -} - -#[derive(Debug, Clone)] -pub struct BrokerConfig { - pub redis_endpoint: BrokerEndpoint, - pub consume_timeout: usize, - pub backoff: ExponentialBackoff, -} - -impl From for BrokerConfig { - fn from(cli_config: BrokerCLIConfig) -> BrokerConfig { - let max_elapsed_time = Duration::from_millis( - cli_config.broker_backoff_max_elapsed_duration, - ); - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(max_elapsed_time)) - .build(); - let redis_endpoint = - if let Some(endpoints) = cli_config.redis_cluster_endpoints { - let urls = endpoints - .iter() - .map(|endpoint| { - RedactedUrl::new( - Url::parse(endpoint) - .expect("failed to parse Redis URL"), - ) - }) - .collect(); - BrokerEndpoint::Cluster(urls) - } else { - let url = Url::parse(&cli_config.redis_endpoint) - .map(RedactedUrl::new) - .expect("failed to parse Redis URL"); - BrokerEndpoint::Single(url) - }; - BrokerConfig { - redis_endpoint, - consume_timeout: cli_config.broker_consume_timeout, - backoff, - } - } -} diff --git a/offchain/rollups-events/src/common.rs b/offchain/rollups-events/src/common.rs deleted file mode 100644 index 31fa2ce19..000000000 --- a/offchain/rollups-events/src/common.rs +++ /dev/null @@ -1,247 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use base64::{engine::general_purpose::STANDARD as base64_engine, Engine as _}; -use prometheus_client::encoding::EncodeLabelValue; -use prometheus_client::encoding::LabelValueEncoder; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::fmt::Write; -use std::str::FromStr; - -pub const ADDRESS_SIZE: usize = 20; -pub const HASH_SIZE: usize = 32; - -const PAYLOAD_DEBUG_MAX_LEN: usize = 100; - -/// A binary array that is converted to a hex string when serialized -#[derive(Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] -pub struct HexArray([u8; N]); - -impl HexArray { - pub const fn new(data: [u8; N]) -> Self { - Self(data) - } - - pub fn inner(&self) -> &[u8; N] { - &self.0 - } - - pub fn mut_inner(&mut self) -> &mut [u8; N] { - &mut self.0 - } - - pub fn into_inner(self) -> [u8; N] { - self.0 - } -} - -impl From<[u8; N]> for HexArray { - fn from(data: [u8; N]) -> Self { - Self::new(data) - } -} - -impl FromStr for HexArray { - type Err = serde_json::Error; - - fn from_str(s: &str) -> Result { - serde_json::from_value(serde_json::Value::String(s.to_string())) - } -} - -impl Serialize for HexArray { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - String::serialize(&hex::encode(self.inner()), serializer) - } -} - -impl<'de, const N: usize> Deserialize<'de> for HexArray { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let mut string_data = String::deserialize(deserializer)?; - // The hex crate doesn't decode '0x' at the start, so we treat the value before decoding - if string_data[..2].eq("0x") { - string_data.drain(..2); - } - let vec_data = hex::decode(string_data).map_err(|e| { - serde::de::Error::custom(format!("fail to decode hex ({})", e)) - })?; - let data = vec_data - .try_into() - .or(Err(serde::de::Error::custom("incorrect array size")))?; - Ok(Self::new(data)) - } -} - -impl ToString for HexArray { - fn to_string(&self) -> String { - let s = serde_json::to_string(self).unwrap(); - let mut chars = s.chars(); - chars.next(); - chars.next_back(); - chars.as_str().to_string() - } -} - -impl std::fmt::Debug for HexArray { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", hex::encode(self.inner())) - } -} - -impl Default for HexArray { - fn default() -> Self { - Self::new([0; N]) - } -} - -impl EncodeLabelValue for HexArray { - fn encode( - &self, - encoder: &mut LabelValueEncoder<'_>, - ) -> Result<(), std::fmt::Error> { - write!(encoder, "{}", hex::encode(self.inner())) - } -} - -/// Blockchain hash -pub type Hash = HexArray; - -/// Blockchain address -pub type Address = HexArray; - -/// Rollups payload. -/// When serialized, it is converted to a base64 string -#[derive(Default, Clone, Eq, PartialEq)] -pub struct Payload(Vec); - -impl Payload { - pub const fn new(data: Vec) -> Self { - Self(data) - } - - pub fn inner(&self) -> &Vec { - &self.0 - } - - pub fn mut_inner(&mut self) -> &mut Vec { - &mut self.0 - } - - pub fn into_inner(self) -> Vec { - self.0 - } -} - -impl From> for Payload { - fn from(data: Vec) -> Self { - Self::new(data) - } -} - -impl Serialize for Payload { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - String::serialize(&base64_engine.encode(self.inner()), serializer) - } -} - -impl<'de> Deserialize<'de> for Payload { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let string_data = String::deserialize(deserializer)?; - let data = base64_engine.decode(string_data).map_err(|e| { - serde::de::Error::custom(format!("fail to decode base64 ({})", e)) - })?; - Ok(Payload::new(data)) - } -} - -impl std::fmt::Debug for Payload { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let len = self.inner().len(); - if len > PAYLOAD_DEBUG_MAX_LEN { - let slice = &self.inner().as_slice()[0..PAYLOAD_DEBUG_MAX_LEN]; - write!( - f, - "{}...[total: {} bytes]", - base64_engine.encode(slice), - len - ) - } else { - write!(f, "{}", base64_engine.encode(self.inner())) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn serialize_array() { - assert_eq!( - serde_json::to_string(&Hash::new([0xfa; HASH_SIZE])).unwrap(), - r#""fafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafa""# - ); - } - - #[test] - fn deserialize_array() { - assert_eq!( - serde_json::from_str::( - r#""fafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafafa""#).unwrap(), - Hash::new([0xfa; HASH_SIZE]) - ); - } - - #[test] - fn fail_to_deserialized_invalid_array() { - assert!(serde_json::from_str::("\"....\"") - .unwrap_err() - .to_string() - .contains("fail to decode hex")); - } - - #[test] - fn fail_to_deserialized_array_with_wrong_size() { - assert!(serde_json::from_str::("\"ff\"") - .unwrap_err() - .to_string() - .contains("incorrect array size")); - } - - #[test] - fn serialize_payload() { - assert_eq!( - serde_json::to_string(&Payload::new(vec![0xfa; 20])).unwrap(), - "\"+vr6+vr6+vr6+vr6+vr6+vr6+vo=\"" - ); - } - - #[test] - fn deserialize_payload() { - assert_eq!( - serde_json::from_str::("\"+vr6+vr6+vr6+vr6+vr6+vr6+vo=\"") - .unwrap(), - Payload::new(vec![0xfa; 20]) - ); - } - - #[test] - fn fail_to_deserialized_invalid_payload() { - assert!(serde_json::from_str::("\".\"") - .unwrap_err() - .to_string() - .contains("fail to decode base64")); - } -} diff --git a/offchain/rollups-events/src/lib.rs b/offchain/rollups-events/src/lib.rs deleted file mode 100644 index b660c7bdd..000000000 --- a/offchain/rollups-events/src/lib.rs +++ /dev/null @@ -1,29 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -mod broker; -mod common; -mod rollups_claims; -mod rollups_inputs; -mod rollups_outputs; -mod rollups_stream; - -pub use broker::{ - indexer, Broker, BrokerCLIConfig, BrokerConfig, BrokerEndpoint, - BrokerError, BrokerMultiStream, BrokerStream, Event, RedactedUrl, Url, - DAPPS_KEY, INITIAL_ID, -}; -pub use common::{Address, Hash, Payload, ADDRESS_SIZE, HASH_SIZE}; -pub use rollups_claims::{RollupsClaim, RollupsClaimsStream}; -pub use rollups_inputs::{ - InputMetadata, RollupsAdvanceStateInput, RollupsData, RollupsInput, - RollupsInputsStream, -}; -pub use rollups_outputs::{ - RollupsAdvanceResult, RollupsCompletionStatus, RollupsNotice, - RollupsOutput, RollupsOutputEnum, RollupsOutputValidityProof, - RollupsOutputsStream, RollupsProof, RollupsReport, RollupsVoucher, -}; -pub use rollups_stream::{ - parse_stream_with_key, DAppMetadata, DAppMetadataCLIConfig, -}; diff --git a/offchain/rollups-events/src/rollups_claims.rs b/offchain/rollups-events/src/rollups_claims.rs deleted file mode 100644 index 93b9a856a..000000000 --- a/offchain/rollups-events/src/rollups_claims.rs +++ /dev/null @@ -1,27 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use serde::{Deserialize, Serialize}; - -use crate::{rollups_stream::decl_broker_stream, Address, Hash}; - -decl_broker_stream!(RollupsClaimsStream, RollupsClaim, "rollups-claims"); - -/// Event generated when the Cartesi Rollups epoch finishes -#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsClaim { - // DApp address - pub dapp_address: Address, - - /// Epoch index - pub epoch_index: u64, - - /// Hash of the Epoch - pub epoch_hash: Hash, - - /// Index of the first input of the Epoch - pub first_index: u128, - - /// Index of the last input of the Epoch - pub last_index: u128, -} diff --git a/offchain/rollups-events/src/rollups_inputs.rs b/offchain/rollups-events/src/rollups_inputs.rs deleted file mode 100644 index a5069414b..000000000 --- a/offchain/rollups-events/src/rollups_inputs.rs +++ /dev/null @@ -1,69 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use serde::{Deserialize, Serialize}; - -use crate::{rollups_stream::decl_broker_stream, Address, Hash, Payload}; - -decl_broker_stream!(RollupsInputsStream, RollupsInput, "rollups-inputs"); - -/// Cartesi Rollups event -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsInput { - /// Id of the parent of the event - /// This field must be supplied by the producer of the event. - /// Notice that the parent might not be the latest event in the stream; - /// this happens during a reorg. - /// The parent of the first event should be INITIAL_ID. - pub parent_id: String, - - /// Epoch index - pub epoch_index: u64, - - /// Number of sent inputs for all epochs - pub inputs_sent_count: u64, - - /// Data that depends on the kind of event - pub data: RollupsData, -} - -/// Rollups data enumeration -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub enum RollupsData { - /// Input that advances the Cartesi Rollups epoch - AdvanceStateInput(RollupsAdvanceStateInput), - - /// End of an Cartesi Rollups epoch - FinishEpoch {}, -} - -/// Input that advances the Cartesi Rollups epoch -#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsAdvanceStateInput { - /// Information sent via the input metadata memory range - pub metadata: InputMetadata, - - /// Payload of the input - pub payload: Payload, - - /// Transaction hash - pub tx_hash: Hash, -} - -#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct InputMetadata { - /// Address of the message sender - pub msg_sender: Address, - - /// Block number when the input was posted - pub block_number: u64, - - /// Timestamp of the block - pub timestamp: u64, - - /// Epoch index - pub epoch_index: u64, - - /// Input index in epoch - pub input_index: u64, -} diff --git a/offchain/rollups-events/src/rollups_outputs.rs b/offchain/rollups-events/src/rollups_outputs.rs deleted file mode 100644 index a485391e7..000000000 --- a/offchain/rollups-events/src/rollups_outputs.rs +++ /dev/null @@ -1,88 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -//! For more information about each type, see the GraphQL API definition in -//! `offchain/graphql-server/schema.graphql` -use serde::{Deserialize, Serialize}; - -use crate::{rollups_stream::decl_broker_stream, Address, Hash, Payload}; - -decl_broker_stream!(RollupsOutputsStream, RollupsOutput, "rollups-outputs"); - -/// Cartesi output -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub enum RollupsOutput { - AdvanceResult(RollupsAdvanceResult), - Voucher(RollupsVoucher), - Notice(RollupsNotice), - Report(RollupsReport), - Proof(RollupsProof), -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsAdvanceResult { - pub input_index: u64, - pub status: RollupsCompletionStatus, -} - -/// Based on CompletionStatus from the server-manager gRPC interface -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub enum RollupsCompletionStatus { - Accepted, - Rejected, - Exception, - MachineHalted, - CycleLimitExceeded, - TimeLimitExceeded, - PayloadLengthLimitExceeded, -} - -#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsVoucher { - pub index: u64, - pub input_index: u64, - pub destination: Address, - pub payload: Payload, -} - -#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsNotice { - pub index: u64, - pub input_index: u64, - pub payload: Payload, -} - -#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsReport { - pub index: u64, - pub input_index: u64, - pub payload: Payload, -} - -#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)] -pub enum RollupsOutputEnum { - #[default] - Voucher, - Notice, -} - -#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsProof { - pub input_index: u64, - pub output_index: u64, - pub output_enum: RollupsOutputEnum, - pub validity: RollupsOutputValidityProof, - pub context: Payload, -} - -#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct RollupsOutputValidityProof { - pub input_index_within_epoch: u64, - pub output_index_within_input: u64, - pub output_hashes_root_hash: Hash, - pub vouchers_epoch_root_hash: Hash, - pub notices_epoch_root_hash: Hash, - pub machine_state_hash: Hash, - pub output_hash_in_output_hashes_siblings: Vec, - pub output_hashes_in_epoch_siblings: Vec, -} diff --git a/offchain/rollups-events/src/rollups_stream.rs b/offchain/rollups-events/src/rollups_stream.rs deleted file mode 100644 index 7d9eb9d97..000000000 --- a/offchain/rollups-events/src/rollups_stream.rs +++ /dev/null @@ -1,178 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::Address; -use clap::Parser; -use prometheus_client::encoding::EncodeLabelSet; -use serde_json::Value; -use std::{fs::File, io::BufReader}; - -/// DApp metadata used to define the stream keys -#[derive(Clone, Debug, Default, Hash, Eq, PartialEq, EncodeLabelSet)] -pub struct DAppMetadata { - pub chain_id: u64, - pub dapp_address: Address, -} - -/// CLI configuration used to generate the DApp metadata -#[derive(Debug, Parser)] -pub struct DAppMetadataCLIConfig { - /// Chain identifier - #[arg(long, env, default_value = "0")] - chain_id: u64, - - /// Address of rollups dapp - #[arg(long, env)] - dapp_contract_address: Option, - - /// Path to file with address of rollups dapp - #[arg(long, env)] - dapp_contract_address_file: Option, -} - -impl From for DAppMetadata { - fn from(cli_config: DAppMetadataCLIConfig) -> DAppMetadata { - let dapp_contract_address_raw = match cli_config.dapp_contract_address { - Some(address) => address, - None => { - let path = cli_config - .dapp_contract_address_file - .expect("Configuration missing dapp address"); - let file = File::open(path).expect("Dapp json read file error"); - let reader = BufReader::new(file); - let mut json: Value = serde_json::from_reader(reader) - .expect("Dapp json parse error"); - match json["address"].take() { - Value::String(s) => s, - Value::Null => panic!("Configuration missing dapp address"), - _ => panic!("Dapp json wrong type error"), - } - } - }; - - let dapp_contract_address: [u8; 20] = - hex::decode(&dapp_contract_address_raw[2..]) - .expect("Dapp json parse error") - .try_into() - .expect("Dapp address with wrong size"); - - DAppMetadata { - chain_id: cli_config.chain_id, - dapp_address: dapp_contract_address.into(), - } - } -} - -pub fn parse_stream_with_key(key: String, inner_key: &str) -> (u64, Address) { - let mut re = r"^\{chain-([^:]+):dapp-([^}]+)\}:".to_string(); - re.push_str(inner_key); - re.push_str("$"); - let re = regex::Regex::new(&re).unwrap(); - let caps = re.captures(&key).unwrap(); - - let chain_id = caps - .get(1) - .unwrap() - .as_str() - .to_string() - .parse::() - .unwrap(); - let address = caps.get(2).unwrap().as_str().to_string(); - let address = - serde_json::from_value(serde_json::Value::String(address)).unwrap(); - - return (chain_id, address); -} - -/// Declares a struct that implements the BrokerStream interface -/// The generated key has the format `{chain-:dapp-}:`. -/// The curly braces define a hash tag to ensure that all of a dapp's streams -/// are located in the same node when connected to a Redis cluster. -macro_rules! decl_broker_stream { - ($stream: ident, $payload: ty, $key: literal) => { - #[derive(Debug, Clone, PartialEq, Eq, Hash)] - pub struct $stream { - key: String, - pub chain_id: u64, - pub dapp_address: Address, - } - - impl crate::broker::BrokerStream for $stream { - type Payload = $payload; - - fn key(&self) -> &str { - &self.key - } - } - - impl crate::broker::BrokerMultiStream for $stream { - fn from_key(key: String) -> Self { - let (chain_id, dapp_address) = - crate::parse_stream_with_key(key.clone(), $key); - Self { - key: key, - chain_id: chain_id, - dapp_address: dapp_address, - } - } - } - - impl $stream { - pub fn new(metadata: &crate::rollups_stream::DAppMetadata) -> Self { - let chain_id = metadata.chain_id; - let dapp_address = metadata.dapp_address.clone(); - Self { - key: format!( - "{{chain-{}:dapp-{}}}:{}", - chain_id, - hex::encode(dapp_address.inner()), - $key - ), - chain_id: chain_id, - dapp_address: dapp_address, - } - } - } - }; -} - -pub(crate) use decl_broker_stream; - -#[cfg(test)] -mod tests { - use super::*; - use crate::{broker::BrokerMultiStream, BrokerStream, ADDRESS_SIZE}; - use serde::{Deserialize, Serialize}; - - #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] - pub struct MockPayload; - - decl_broker_stream!(MockStream, MockPayload, "rollups-mock"); - - #[test] - fn it_generates_the_key() { - let metadata = DAppMetadata { - chain_id: 123, - dapp_address: Address::new([0xfa; ADDRESS_SIZE]), - }; - let stream = MockStream::new(&metadata); - assert_eq!(stream.key, "{chain-123:dapp-fafafafafafafafafafafafafafafafafafafafa}:rollups-mock"); - } - - #[test] - fn it_parses_the_key() { - let metadata = DAppMetadata { - chain_id: 123, - dapp_address: Address::new([0xfe; ADDRESS_SIZE]), - }; - - let stream = MockStream::new(&metadata); - let expected = "{chain-123:dapp-fefefefefefefefefefefefefefefefefefefefe}:rollups-mock"; - let key = stream.key().to_string(); - assert_eq!(expected, &key); - - let stream = MockStream::from_key(key); - assert_eq!(metadata.chain_id, stream.chain_id); - assert_eq!(metadata.dapp_address, stream.dapp_address); - } -} diff --git a/offchain/rollups-events/tests/indexer.rs b/offchain/rollups-events/tests/indexer.rs deleted file mode 100644 index 1d5f84171..000000000 --- a/offchain/rollups-events/tests/indexer.rs +++ /dev/null @@ -1,224 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::ExponentialBackoff; -use rollups_events::indexer::{IndexerEvent, IndexerState}; -use rollups_events::{ - Address, Broker, BrokerConfig, BrokerEndpoint, BrokerError, BrokerStream, - DAppMetadata, Event, Hash, RedactedUrl, RollupsAdvanceStateInput, - RollupsData, RollupsInput, RollupsInputsStream, RollupsOutput, - RollupsOutputsStream, Url, -}; -use testcontainers::{ - clients::Cli, core::WaitFor, images::generic::GenericImage, Container, -}; - -pub const CONSUME_TIMEOUT: usize = 10; -pub const CHAIN_ID: u64 = 99; -pub const DAPP_ADDRESS: Address = Address::new([0xfa; 20]); - -pub struct TestState<'d> { - _node: Container<'d, GenericImage>, - redis_endpoint: RedactedUrl, -} - -impl TestState<'_> { - pub async fn setup(docker: &Cli) -> TestState { - let image = GenericImage::new("redis", "6.2").with_wait_for( - WaitFor::message_on_stdout("Ready to accept connections"), - ); - let node = docker.run(image); - let port = node.get_host_port_ipv4(6379); - let redis_endpoint = Url::parse(&format!("redis://127.0.0.1:{}", port)) - .map(RedactedUrl::new) - .expect("failed to parse Redis Url"); - TestState { - _node: node, - redis_endpoint, - } - } - - pub async fn create_broker(&self) -> Broker { - let backoff = ExponentialBackoff::default(); - let config = BrokerConfig { - redis_endpoint: BrokerEndpoint::Single(self.redis_endpoint.clone()), - consume_timeout: CONSUME_TIMEOUT, - backoff, - }; - Broker::new(config) - .await - .expect("failed to initialize broker") - } -} - -#[test_log::test(tokio::test)] -async fn it_times_out_when_no_indexer_event_is_produced() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - let broker = state.create_broker().await; - let mut indexer_state = IndexerState::new(&dapp_metadata()); - let err = broker - .indexer_consume(&mut indexer_state) - .await - .expect_err("consume event worked but it should have failed"); - assert!(matches!(err, BrokerError::ConsumeTimeout)); -} - -#[test_log::test(tokio::test)] -async fn it_consumes_input_events() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - let mut broker = state.create_broker().await; - // Produce input events - let inputs = generate_inputs(); - let metadata = dapp_metadata(); - let stream = RollupsInputsStream::new(&metadata); - produce_all(&mut broker, &stream, &inputs).await; - // Consume indexer events - let consumed_events = - consume_all(&mut broker, &metadata, inputs.len()).await; - for (event, input) in consumed_events.iter().zip(&inputs) { - assert!(matches!(event, - IndexerEvent::Input( - Event { - payload, - .. - } - ) - if payload == input - )); - } -} - -#[test_log::test(tokio::test)] -async fn it_consumes_output_events() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - let mut broker = state.create_broker().await; - // Produce output events - let outputs = generate_outputs(); - let metadata = dapp_metadata(); - let stream = RollupsOutputsStream::new(&metadata); - produce_all(&mut broker, &stream, &outputs).await; - // Consume indexer events - let consumed_events = - consume_all(&mut broker, &metadata, outputs.len()).await; - for (event, output) in consumed_events.iter().zip(&outputs) { - assert!(matches!(event, - IndexerEvent::Output( - Event { - payload, - .. - } - ) - if payload == output - )); - } -} - -#[test_log::test(tokio::test)] -async fn it_consumes_inputs_before_outputs() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - let mut broker = state.create_broker().await; - // First, produce output events - let outputs = generate_outputs(); - let metadata = dapp_metadata(); - let outputs_stream = RollupsOutputsStream::new(&metadata); - produce_all(&mut broker, &outputs_stream, &outputs).await; - // Then, produce input events - let inputs = generate_inputs(); - let inputs_stream = RollupsInputsStream::new(&metadata); - produce_all(&mut broker, &inputs_stream, &inputs).await; - // Finally, consume indexer events - let consumed_events = - consume_all(&mut broker, &metadata, outputs.len() + inputs.len()).await; - for (i, input) in inputs.iter().enumerate() { - assert!(matches!(&consumed_events[i], - IndexerEvent::Input( - Event { - payload, - .. - } - ) - if payload == input - )); - } - for (i, output) in outputs.iter().enumerate() { - assert!(matches!(&consumed_events[inputs.len() + i], - IndexerEvent::Output( - Event { - payload, - .. - } - ) - if payload == output - )); - } -} - -fn dapp_metadata() -> DAppMetadata { - DAppMetadata { - chain_id: CHAIN_ID, - dapp_address: DAPP_ADDRESS.to_owned(), - } -} - -fn generate_outputs() -> Vec { - vec![ - RollupsOutput::Voucher(Default::default()), - RollupsOutput::Notice(Default::default()), - RollupsOutput::Report(Default::default()), - ] -} - -fn generate_inputs() -> Vec { - vec![ - RollupsInput { - parent_id: "".to_owned(), - epoch_index: 0, - inputs_sent_count: 1, - data: RollupsData::AdvanceStateInput(RollupsAdvanceStateInput { - metadata: Default::default(), - payload: Default::default(), - tx_hash: Hash::default(), - }), - }, - RollupsInput { - parent_id: "".to_owned(), - epoch_index: 0, - inputs_sent_count: 1, - data: RollupsData::FinishEpoch {}, - }, - ] -} - -async fn produce_all( - broker: &mut Broker, - stream: &S, - payloads: &[S::Payload], -) { - for payload in payloads { - broker - .produce(stream, payload.clone()) - .await - .expect("failed to produce"); - } -} - -async fn consume_all( - broker: &mut Broker, - dapp_metadata: &DAppMetadata, - n: usize, -) -> Vec { - let mut state = IndexerState::new(dapp_metadata); - let mut payloads = vec![]; - for _ in 0..n { - let payload = broker - .indexer_consume(&mut state) - .await - .expect("failed to consume indexer payload"); - payloads.push(payload); - } - payloads -} diff --git a/offchain/rollups-events/tests/integration.rs b/offchain/rollups-events/tests/integration.rs deleted file mode 100644 index daf67e905..000000000 --- a/offchain/rollups-events/tests/integration.rs +++ /dev/null @@ -1,420 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use std::collections::HashMap; - -use backoff::ExponentialBackoff; -use redis::aio::ConnectionManager; -use redis::streams::StreamRangeReply; -use redis::{AsyncCommands, Client}; -use serde::{Deserialize, Serialize}; -use testcontainers::{ - clients::Cli, core::WaitFor, images::generic::GenericImage, Container, -}; - -use rollups_events::{ - Broker, BrokerConfig, BrokerEndpoint, BrokerError, BrokerMultiStream, - BrokerStream, RedactedUrl, Url, INITIAL_ID, -}; - -const STREAM_KEY: &'static str = "test-stream"; -const CONSUME_TIMEOUT: usize = 10; - -struct TestState<'d> { - _node: Container<'d, GenericImage>, - redis_endpoint: RedactedUrl, - conn: ConnectionManager, - backoff: ExponentialBackoff, -} - -impl TestState<'_> { - async fn setup(docker: &Cli) -> TestState { - let image = GenericImage::new("redis", "6.2").with_wait_for( - WaitFor::message_on_stdout("Ready to accept connections"), - ); - let node = docker.run(image); - let port = node.get_host_port_ipv4(6379); - let redis_endpoint = Url::parse(&format!("redis://127.0.0.1:{}", port)) - .map(RedactedUrl::new) - .expect("failed to parse Redis Url"); - let backoff = ExponentialBackoff::default(); - - let client = Client::open(redis_endpoint.inner().as_str()) - .expect("failed to create client"); - let conn = ConnectionManager::new(client) - .await - .expect("failed to create connection"); - - TestState { - _node: node, - redis_endpoint, - conn, - backoff, - } - } - - async fn create_broker(&self) -> Broker { - let config = BrokerConfig { - redis_endpoint: BrokerEndpoint::Single(self.redis_endpoint.clone()), - backoff: self.backoff.clone(), - consume_timeout: CONSUME_TIMEOUT, - }; - Broker::new(config) - .await - .expect("failed to initialize broker") - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct MockPayload { - data: String, -} - -struct MockStream {} - -impl BrokerStream for MockStream { - type Payload = MockPayload; - - fn key(&self) -> &str { - STREAM_KEY - } -} - -#[test_log::test(tokio::test)] -async fn test_it_produces_events() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - let mut broker = state.create_broker().await; - // Produce events using the Broker struct - const N: usize = 3; - let mut ids = vec![]; - for i in 0..N { - let data = MockPayload { - data: i.to_string(), - }; - let id = broker - .produce(&MockStream {}, data) - .await - .expect("failed to produce"); - ids.push(id); - } - // Check the events directly in Redis - let reply: StreamRangeReply = state - .conn - .xrange(STREAM_KEY, "-", "+") - .await - .expect("failed to read"); - assert_eq!(reply.ids.len(), 3); - for i in 0..N { - let expected = format!(r#"{{"data":"{}"}}"#, i); - assert_eq!(reply.ids[i].id, ids[i]); - assert_eq!(reply.ids[i].get::("payload").unwrap(), expected); - } -} - -#[test_log::test(tokio::test)] -async fn test_it_peeks_in_stream_with_no_events() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - let mut broker = state.create_broker().await; - let event = broker - .peek_latest(&MockStream {}) - .await - .expect("failed to peek"); - assert!(matches!(event, None)); -} - -#[test_log::test(tokio::test)] -async fn test_it_peeks_in_stream_with_multiple_events() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - // Produce multiple events directly in Redis - const N: usize = 3; - for i in 0..N { - let id = format!("1-{}", i); - let data = format!(r#"{{"data":"{}"}}"#, i); - let _: String = state - .conn - .xadd(STREAM_KEY, id, &[("payload", data)]) - .await - .expect("failed to add events"); - } - // Peek the event using the Broker struct - let mut broker = state.create_broker().await; - let event = broker - .peek_latest(&MockStream {}) - .await - .expect("failed to peek"); - if let Some(event) = event { - assert_eq!(&event.id, "1-2"); - assert_eq!(&event.payload.data, "2"); - } else { - panic!("expected some event"); - } -} - -#[test_log::test(tokio::test)] -async fn test_it_fails_to_peek_event_in_invalid_format() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - // Produce event directly in Redis - let _: String = state - .conn - .xadd(STREAM_KEY, "1-0", &[("wrong_field", "0")]) - .await - .expect("failed to add events"); - // Peek the event using the Broker struct - let mut broker = state.create_broker().await; - let err = broker - .peek_latest(&MockStream {}) - .await - .expect_err("failed to get error"); - assert!(matches!(err, BrokerError::InvalidEvent)); -} - -#[test_log::test(tokio::test)] -async fn test_it_fails_to_peek_event_with_invalid_data_encoding() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - // Produce event directly in Redis - let _: String = state - .conn - .xadd(STREAM_KEY, "1-0", &[("payload", "not a json")]) - .await - .expect("failed to add events"); - // Peek the event using the Broker struct - let mut broker = state.create_broker().await; - let err = broker - .peek_latest(&MockStream {}) - .await - .expect_err("failed to get error"); - assert!(matches!(err, BrokerError::InvalidPayload { .. })); -} - -#[test_log::test(tokio::test)] -async fn test_it_consumes_events() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - // Produce multiple events directly in Redis - const N: usize = 3; - for i in 0..N { - let id = format!("1-{}", i); - let data = format!(r#"{{"data":"{}"}}"#, i); - let _: String = state - .conn - .xadd(STREAM_KEY, id, &[("payload", data)]) - .await - .expect("failed to add events"); - } - // Consume events using the Broker struct - let mut broker = state.create_broker().await; - let mut last_id = INITIAL_ID.to_owned(); - for i in 0..N { - let event = broker - .consume_blocking(&MockStream {}, &last_id) - .await - .expect("failed to consume"); - assert_eq!(event.id, format!("1-{}", i)); - assert_eq!(event.payload.data, i.to_string()); - last_id = event.id; - } -} - -#[test_log::test(tokio::test)] -async fn test_it_blocks_until_event_is_produced() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - // Spawn another thread that sends the event after a few ms - let handler = { - let mut conn = state.conn.clone(); - tokio::spawn(async move { - let duration = std::time::Duration::from_millis(10); - tokio::time::sleep(duration).await; - let _: String = conn - .xadd(STREAM_KEY, "1-0", &[("payload", r#"{"data":"0"}"#)]) - .await - .expect("failed to write event"); - }) - }; - // In the main thread, wait for the expected event - let mut broker = state.create_broker().await; - let event = broker - .consume_blocking(&MockStream {}, "0") - .await - .expect("failed to consume event"); - assert_eq!(event.id, "1-0"); - assert_eq!(event.payload.data, "0"); - handler.await.expect("failed to wait handler"); -} - -#[test_log::test(tokio::test)] -async fn test_it_consumes_events_without_blocking() { - let docker = Cli::default(); - let mut state = TestState::setup(&docker).await; - // Produce multiple events directly in Redis - const N: usize = 3; - for i in 0..N { - let id = format!("1-{}", i); - let data = format!(r#"{{"data":"{}"}}"#, i); - let _: String = state - .conn - .xadd(STREAM_KEY, id, &[("payload", data)]) - .await - .expect("failed to add events"); - } - // Consume events using the Broker struct - let mut broker = state.create_broker().await; - let mut last_id = INITIAL_ID.to_owned(); - for i in 0..N { - let event = broker - .consume_nonblocking(&MockStream {}, &last_id) - .await - .expect("failed to consume") - .expect("expected event, got None"); - assert_eq!(event.id, format!("1-{}", i)); - assert_eq!(event.payload.data, i.to_string()); - last_id = event.id; - } -} - -#[test_log::test(tokio::test)] -async fn test_it_does_not_block_when_consuming_empty_stream() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - let mut broker = state.create_broker().await; - let event = broker - .consume_nonblocking(&MockStream {}, INITIAL_ID) - .await - .expect("failed to peek"); - assert!(matches!(event, None)); -} - -// ------------------------------------------------------------------------------------------------ - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct AnotherMockStream { - key: String, - a: u8, - b: u8, -} - -impl AnotherMockStream { - fn new(a: u8, b: u8) -> Self { - let key = format!("{{a-{}:b-{}}}:{}", a, b, STREAM_KEY); - Self { key, a, b } - } -} - -impl BrokerStream for AnotherMockStream { - type Payload = MockPayload; - - fn key(&self) -> &str { - &self.key - } -} - -impl BrokerMultiStream for AnotherMockStream { - fn from_key(key: String) -> Self { - let re = r"^\{a-([^:]+):b-([^}]+)\}:test-stream$".to_string(); - let re = regex::Regex::new(&re).unwrap(); - let caps = re.captures(&key).unwrap(); - - let a = caps - .get(1) - .unwrap() - .as_str() - .to_string() - .parse::() - .unwrap(); - let b = caps - .get(2) - .unwrap() - .as_str() - .to_string() - .parse::() - .unwrap(); - - Self { key, a, b } - } -} - -#[test_log::test(tokio::test)] -async fn test_it_consumes_from_multiple_streams() { - let docker = Cli::default(); - let state = TestState::setup(&docker).await; - let mut broker = state.create_broker().await; - - // Creates the map of streams to last-consumed-ids. - let mut streams = HashMap::new(); - let initial_id = INITIAL_ID.to_string(); - streams.insert(AnotherMockStream::new(1, 2), initial_id.clone()); - streams.insert(AnotherMockStream::new(3, 4), initial_id.clone()); - streams.insert(AnotherMockStream::new(5, 6), initial_id.clone()); - - // Produces N events for each stream using the broker struct. - const N: usize = 3; - for stream in streams.keys() { - for i in 0..N { - let data = format!("{}{}{}", stream.a, stream.b, i); - let payload = MockPayload { data }; - let _ = broker - .produce(stream, payload) - .await - .expect("failed to produce events"); - } - } - - // Consumes all events using the broker struct. - let mut counters = HashMap::new(); - for _ in 0..streams.len() { - let streams_and_events = broker - .consume_blocking_from_multiple_streams(streams.clone()) - .await - .expect("failed to consume"); - - for (stream, event) in streams_and_events { - let i = counters - .entry(stream.clone()) - .and_modify(|n| *n += 1) - .or_insert(0) - .clone(); - - // Asserts that the payload is correct. - let expected = format!("{}{}{}", stream.a, stream.b, i); - assert_eq!(expected, event.payload.data); - - // Updates the map of streams with the last consumed id. - let replaced = streams.insert(stream, event.id); - // And asserts that the key from the map was indeed overwritten. - assert!(replaced.is_some()); - } - } - - // Asserts that N events were consumed from each stream. - for counter in counters.values() { - assert_eq!(N - 1, *counter); - } - - // Gets one of the streams. - let stream = streams.clone().into_keys().next().unwrap(); - let expected_stream = stream.clone(); - - // Produces the final event. - let data = "final event".to_string(); - let payload = MockPayload { data }; - let _ = broker - .produce(&stream, payload) - .await - .expect("failed to produce the final event"); - - // Consumes the final event. - let mut streams_and_events = broker - .consume_blocking_from_multiple_streams(streams) - .await - .expect("failed to consume the final event"); - assert_eq!(1, streams_and_events.len()); - let (final_stream, _) = streams_and_events.pop().unwrap(); - - // Asserts that the event came from the correct stream. - assert_eq!(expected_stream, final_stream); -} diff --git a/offchain/rollups-http-client/Cargo.toml b/offchain/rollups-http-client/Cargo.toml deleted file mode 100644 index 0e2364060..000000000 --- a/offchain/rollups-http-client/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "rollups-http-client" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -hyper = { workspace = true, features = ["http1", "runtime", "client"] } -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -tracing.workspace = true diff --git a/offchain/rollups-http-client/README.md b/offchain/rollups-http-client/README.md deleted file mode 100644 index 8a5710642..000000000 --- a/offchain/rollups-http-client/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Rollups HTTP Client - -Library crate that offers an HTTP client compatible with the Rollups HTTP server API (see [Rollups OpenAPI interfaces](https://github.com/cartesi/openapi-interfaces/blob/main/rollup.yaml)). diff --git a/offchain/rollups-http-client/src/client.rs b/offchain/rollups-http-client/src/client.rs deleted file mode 100644 index 63165091e..000000000 --- a/offchain/rollups-http-client/src/client.rs +++ /dev/null @@ -1,212 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::rollup::{ - AdvanceRequest, Exception, IndexResponse, InspectRequest, Notice, Report, - RollupRequest, RollupResponse, Voucher, -}; -use serde::{Deserialize, Serialize}; -use std::io::ErrorKind; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "request_type")] -enum RollupHttpRequest { - #[serde(rename = "advance_state")] - Advance { data: AdvanceRequest }, - #[serde(rename = "inspect_state")] - Inspect { data: InspectRequest }, -} - -pub async fn send_voucher(rollup_http_server_addr: &str, voucher: Voucher) { - tracing::debug!("sending voucher request to {}", rollup_http_server_addr); - let client = hyper::Client::new(); - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(rollup_http_server_addr.to_string() + "/voucher") - .body(hyper::Body::from(serde_json::to_string(&voucher).unwrap())) - .expect("voucher request"); - match client.request(req).await { - Ok(res) => { - let id_response = serde_json::from_slice::( - &hyper::body::to_bytes(res) - .await - .expect("error in voucher in response handling"), - ); - tracing::debug!("voucher generated: {:?}", &id_response); - } - Err(e) => { - tracing::error!( - "failed to send voucher request to rollup http server: {}", - e - ); - } - } -} - -pub async fn send_notice(rollup_http_server_addr: &str, notice: Notice) { - tracing::debug!("sending notice request to {}", rollup_http_server_addr); - let client = hyper::Client::new(); - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(rollup_http_server_addr.to_string() + "/notice") - .body(hyper::Body::from(serde_json::to_string(¬ice).unwrap())) - .expect("notice request"); - match client.request(req).await { - Ok(res) => { - let id_response = serde_json::from_slice::( - &hyper::body::to_bytes(res) - .await - .expect("error in notice id response handling"), - ); - tracing::debug!("notice generated: {:?}", &id_response); - } - Err(e) => { - tracing::error!( - "failed to send notice request to rollup http server: {}", - e - ); - } - } -} - -pub async fn send_report(rollup_http_server_addr: &str, report: Report) { - tracing::debug!("sending report request to {}", rollup_http_server_addr); - let client = hyper::Client::new(); - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(rollup_http_server_addr.to_string() + "/report") - .body(hyper::Body::from(serde_json::to_string(&report).unwrap())) - .expect("report request"); - if let Err(e) = client.request(req).await { - tracing::error!( - "failed to send report request to rollup http server: {}", - e - ); - } -} - -pub async fn throw_exception( - rollup_http_server_addr: &str, - exception: Exception, -) { - tracing::debug!( - "throwing exception request to {}", - rollup_http_server_addr - ); - let client = hyper::Client::new(); - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(rollup_http_server_addr.to_string() + "/exception") - .body(hyper::Body::from( - serde_json::to_string(&exception).unwrap(), - )) - .expect("exception request"); - if let Err(e) = client.request(req).await { - tracing::error!( - "failed to send exception throw request to rollup http server : {}", - e - ); - } - // Here it doesn't matter what application does, as server manager - // will terminate machine execution - #[cfg(target_arch = "riscv64")] - { - panic!("exception happened due to exception parameter!"); - } -} - -pub async fn send_finish_request( - rollup_http_server_addr: &str, - result: &RollupResponse, -) -> Result { - // Application advance request resulting status - let status = match result { - RollupResponse::Finish(value) => { - if *value { - "accept" - } else { - "reject" - } - } - }; - // Reconstruct http dispatcher finish target endpoint - let rollup_http_server_endpoint = - format!("{}/finish", rollup_http_server_addr); - tracing::debug!( - "sending finish request to {}", - rollup_http_server_endpoint - ); - // Send finish request to rollup http server - { - let mut json_status = std::collections::HashMap::new(); - json_status.insert("status", status); - let client = hyper::Client::new(); - // Prepare http request - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(rollup_http_server_endpoint) - .body(hyper::Body::from( - serde_json::to_string(&json_status).expect("status json"), - )) - .expect("finish request"); - - // Send http request targeting target-proxy /finish endpoint - // And parse response with the new advance/inspect request - match client.request(req).await { - Ok(res) => { - if res.status().is_success() { - // Handle Rollup Http Request received in json body - let buf = hyper::body::to_bytes(res) - .await - .expect("error in rollup http server response handling") - .to_vec(); - let finish_response = serde_json::from_slice::< - RollupHttpRequest, - >(&buf) - .expect( - "rollup http server response deserialization failed", - ); - tracing::debug!( - "rollup http request finish response: {:?}", - &finish_response - ); - - match finish_response { - RollupHttpRequest::Advance { - data: advance_request, - } => Ok(RollupRequest::Advance(advance_request)), - RollupHttpRequest::Inspect { - data: inspect_request, - } => Ok(RollupRequest::Inspect(inspect_request)), - } - } else { - // Rollup http server returned error on finish request - // Handle error message received in plain http response - let finish_error = String::from_utf8( - hyper::body::to_bytes(res) - .await - .expect("error in rollup http server finish response handling") - .into_iter() - .collect(), - ) - .expect("failed to decode message"); - - Err(std::io::Error::new(ErrorKind::Other, finish_error)) - } - } - Err(e) => { - tracing::error!( - "Failed to send `{}` response to the server: {}", - status, - e - ); - Err(std::io::Error::new(ErrorKind::Other, e.to_string())) - } - } - } -} diff --git a/offchain/rollups-http-client/src/lib.rs b/offchain/rollups-http-client/src/lib.rs deleted file mode 100644 index c3d11944f..000000000 --- a/offchain/rollups-http-client/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod client; -pub mod rollup; diff --git a/offchain/rollups-http-client/src/rollup.rs b/offchain/rollups-http-client/src/rollup.rs deleted file mode 100644 index 51c5c72b2..000000000 --- a/offchain/rollups-http-client/src/rollup.rs +++ /dev/null @@ -1,75 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use serde::{Deserialize, Serialize}; -use std::error::Error; -use std::fmt; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AdvanceMetadata { - pub msg_sender: String, - pub epoch_index: u64, - pub input_index: u64, - pub block_number: u64, - pub timestamp: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AdvanceRequest { - pub metadata: AdvanceMetadata, - pub payload: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct InspectRequest { - pub payload: String, -} - -#[derive(Debug, PartialEq, Eq)] -pub struct RollupRequestError { - pub cause: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Notice { - pub payload: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Voucher { - pub destination: String, - pub payload: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Report { - pub payload: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IndexResponse { - index: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Exception { - pub payload: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum RollupRequest { - Inspect(InspectRequest), - Advance(AdvanceRequest), -} - -pub enum RollupResponse { - Finish(bool), -} - -impl Error for RollupRequestError {} - -impl fmt::Display for RollupRequestError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "failed to execute rollup request ({})", self.cause) - } -} diff --git a/offchain/rust-toolchain.toml b/offchain/rust-toolchain.toml deleted file mode 100644 index 51985806f..000000000 --- a/offchain/rust-toolchain.toml +++ /dev/null @@ -1,2 +0,0 @@ -[toolchain] -channel = "1.78.0" diff --git a/offchain/state-server/Cargo.toml b/offchain/state-server/Cargo.toml deleted file mode 100644 index 5fb700f4b..000000000 --- a/offchain/state-server/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "state-server" -edition.workspace = true -license.workspace = true -version.workspace = true - -[[bin]] -name = "cartesi-rollups-state-server" -path = "src/main.rs" - -[dependencies] -log = { path = "../log" } -types = { path = "../types" } - -clap = { workspace = true, features = ["derive", "env"] } -eth-block-history.workspace = true -eth-state-fold-types.workspace = true -eth-state-fold.workspace = true -eth-state-server-lib.workspace = true -serde.workspace = true -snafu.workspace = true -tokio = { workspace = true, features = ["macros", "sync", "rt-multi-thread"] } -tonic.workspace = true -tracing.workspace = true -url.workspace = true diff --git a/offchain/state-server/README.md b/offchain/state-server/README.md deleted file mode 100644 index bd0598c10..000000000 --- a/offchain/state-server/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# State-fold server - -Service based on the [State-fold library](https://github.com/cartesi/state-fold) used to: - -- Detect state changes in the blockchain to generate rollups input events to be processed by the Cartesi Node. diff --git a/offchain/state-server/src/config.rs b/offchain/state-server/src/config.rs deleted file mode 100644 index 3101ea8d7..000000000 --- a/offchain/state-server/src/config.rs +++ /dev/null @@ -1,43 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::Parser; -use eth_state_server_lib::config::{ - Result, StateServerConfig, StateServerEnvCLIConfig, -}; -use log::{LogConfig, LogEnvCliConfig}; - -#[derive(Parser)] -#[command(name = "state_server_config")] -#[command(about = "Configuration for state-server")] -pub struct EnvCLIConfig { - #[command(flatten)] - pub state_server_config: StateServerEnvCLIConfig, - - #[command(flatten)] - pub log_config: LogEnvCliConfig, -} - -#[derive(Debug, Clone)] -pub struct Config { - pub state_server_config: StateServerConfig, - pub log_config: LogConfig, -} - -impl Config { - pub fn initialize(env_cli_config: EnvCLIConfig) -> Result { - let state_server_config = - StateServerConfig::initialize(env_cli_config.state_server_config); - let log_config = LogConfig::initialize(env_cli_config.log_config); - - Ok(Self { - state_server_config: state_server_config?, - log_config, - }) - } - - pub fn initialize_from_args() -> Result { - let env_cli_config = EnvCLIConfig::parse(); - Self::initialize(env_cli_config) - } -} diff --git a/offchain/state-server/src/error.rs b/offchain/state-server/src/error.rs deleted file mode 100644 index 0dc5b9414..000000000 --- a/offchain/state-server/src/error.rs +++ /dev/null @@ -1,28 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use eth_block_history::BlockArchiveError; -use eth_state_fold_types::ethers::providers::{Http, RetryClient}; -use snafu::Snafu; -use tonic::transport::Error as TonicError; -use url::ParseError; - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -#[allow(clippy::enum_variant_names)] -pub enum StateServerError { - #[snafu(display("tonic error"))] - TonicError { source: TonicError }, - - #[snafu(display("parser error"))] - ParserError { source: ParseError }, - - #[snafu(display("block archive error"))] - BlockArchiveError { - source: BlockArchiveError< - eth_state_fold_types::ethers::providers::Provider< - RetryClient, - >, - >, - }, -} diff --git a/offchain/state-server/src/lib.rs b/offchain/state-server/src/lib.rs deleted file mode 100644 index 9e342e058..000000000 --- a/offchain/state-server/src/lib.rs +++ /dev/null @@ -1,116 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use eth_state_fold::{Foldable, StateFoldEnvironment}; -use eth_state_fold_types::ethers::providers::{ - Http, HttpRateLimitRetryPolicy, Provider, RetryClient, -}; -use eth_state_server_lib::{ - config, - grpc_server::StateServer, - utils::{start_server, wait_for_signal}, -}; -use snafu::ResultExt; -use std::sync::{Arc, Mutex}; -use tokio::sync::oneshot; -use types::UserData; -use url::Url; - -use crate::error::{ - BlockArchiveSnafu, ParserSnafu, StateServerError, TonicSnafu, -}; - -mod error; - -const MAX_RETRIES: u32 = 10; -const INITIAL_BACKOFF: u64 = 1000; - -#[tracing::instrument(level = "trace")] -pub async fn run_server> + 'static>( - config: config::StateServerConfig, -) -> Result<(), StateServerError> -where - ::InitialState: serde::de::DeserializeOwned, - F: serde::ser::Serialize, -{ - let provider = create_provider(&config)?; - let block_subscriber = - create_block_subscriber(&config, Arc::clone(&provider)).await?; - let env = create_env( - &config, - Arc::clone(&provider), - Arc::clone(&block_subscriber.block_archive), - )?; - - let server = StateServer::<_, _, F>::new(block_subscriber, env); - - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - - tokio::spawn(async { wait_for_signal(shutdown_tx).await }); - - start_server(&config, server, shutdown_rx) - .await - .context(TonicSnafu) -} - -type ServerProvider = Provider>; - -fn create_provider( - config: &config::StateServerConfig, -) -> Result, StateServerError> { - let http = Http::new( - Url::parse(&config.block_history.http_endpoint).context(ParserSnafu)?, - ); - - let retry_client = RetryClient::new( - http, - Box::new(HttpRateLimitRetryPolicy), - MAX_RETRIES, - INITIAL_BACKOFF, - ); - - let provider = Provider::new(retry_client); - - Ok(Arc::new(provider)) -} - -fn create_env( - config: &config::StateServerConfig, - provider: Arc, - block_archive: Arc>, -) -> Result< - Arc>>, - StateServerError, -> { - let env = StateFoldEnvironment::new( - provider, - Some(block_archive), - config.state_fold.safety_margin, - config.state_fold.genesis_block, - config.state_fold.query_limit_error_codes.clone(), - config.state_fold.concurrent_events_fetch, - 10000, - Mutex::new(UserData::default()), - ); - - Ok(Arc::new(env)) -} - -async fn create_block_subscriber( - config: &config::StateServerConfig, - provider: Arc, -) -> Result< - Arc>, - StateServerError, -> { - let block_subscriber = eth_block_history::BlockSubscriber::start( - Arc::clone(&provider), - config.block_history.ws_endpoint.to_owned(), - config.block_history.block_timeout, - config.block_history.max_depth, - ) - .await - .context(BlockArchiveSnafu)?; - - Ok(Arc::new(block_subscriber)) -} diff --git a/offchain/state-server/src/main.rs b/offchain/state-server/src/main.rs deleted file mode 100644 index b6afd84af..000000000 --- a/offchain/state-server/src/main.rs +++ /dev/null @@ -1,18 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) -mod config; -use config::Config; -use types::foldables::InputBox; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let config: Config = Config::initialize_from_args()?; - - log::configure(&config.log_config); - - log::log_service_start(&config, "State Server"); - - state_server::run_server::(config.state_server_config) - .await - .map_err(|e| e.into()) -} diff --git a/offchain/test-fixtures/Cargo.toml b/offchain/test-fixtures/Cargo.toml deleted file mode 100644 index 245d93bfe..000000000 --- a/offchain/test-fixtures/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "test-fixtures" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -grpc-interfaces = { path = "../grpc-interfaces" } -rollups-data = { path = "../data" } -rollups-events = { path = "../rollups-events" } - -anyhow.workspace = true -backoff = { workspace = true, features = ["tokio"] } -hyper = { workspace = true, features = ["http1", "runtime", "client"] } -json.workspace = true -tempfile.workspace = true -testcontainers.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -tonic.workspace = true -tracing.workspace = true -users.workspace = true diff --git a/offchain/test-fixtures/README.md b/offchain/test-fixtures/README.md deleted file mode 100644 index d44628125..000000000 --- a/offchain/test-fixtures/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Test Fixtures - -Library crate that define text fixtures used by tests included in the services that comprise the Cartesi Node. diff --git a/offchain/test-fixtures/docker/server_manager_nonroot.Dockerfile b/offchain/test-fixtures/docker/server_manager_nonroot.Dockerfile deleted file mode 100644 index d53233d57..000000000 --- a/offchain/test-fixtures/docker/server_manager_nonroot.Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -# (c) Cartesi and individual authors (see AUTHORS) -# SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -FROM cartesi/server-manager:0.9.1 - -ARG user -ARG group -ARG uid -ARG gid - -USER root - -RUN if ! getent group ${gid}; then \ - groupadd -g ${gid} ${group}; \ - fi - -RUN useradd -u ${uid} -g ${gid} -s /bin/sh -m ${user} - -USER ${uid}:${gid} diff --git a/offchain/test-fixtures/src/broker.rs b/offchain/test-fixtures/src/broker.rs deleted file mode 100644 index a384b757f..000000000 --- a/offchain/test-fixtures/src/broker.rs +++ /dev/null @@ -1,354 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use std::collections::HashMap; - -use backoff::ExponentialBackoff; -use rollups_events::{ - Address, Broker, BrokerConfig, BrokerEndpoint, DAppMetadata, Event, - RedactedUrl, RollupsClaim, RollupsClaimsStream, RollupsData, RollupsInput, - RollupsInputsStream, RollupsOutput, RollupsOutputsStream, Url, - ADDRESS_SIZE, INITIAL_ID, -}; -use testcontainers::{ - clients::Cli, core::WaitFor, images::generic::GenericImage, Container, -}; -use tokio::sync::Mutex; - -const CHAIN_ID: u64 = 0; -const DAPP_ADDRESS: Address = Address::new([0xfa; ADDRESS_SIZE]); -const CONSUME_TIMEOUT: usize = 10_000; // ms - -async fn start_redis( - docker: &Cli, -) -> (Container, BrokerEndpoint, Mutex) { - tracing::trace!("starting redis docker container"); - let image = GenericImage::new("redis", "6.2").with_wait_for( - WaitFor::message_on_stdout("Ready to accept connections"), - ); - let node = docker.run(image); - let port = node.get_host_port_ipv4(6379); - let endpoint = BrokerEndpoint::Single( - Url::parse(&format!("redis://127.0.0.1:{}", port)) - .map(RedactedUrl::new) - .expect("failed to parse Redis Url"), - ); - - let backoff = ExponentialBackoff::default(); - let config = BrokerConfig { - redis_endpoint: endpoint.clone(), - consume_timeout: CONSUME_TIMEOUT, - backoff, - }; - - tracing::trace!(?endpoint, "connecting to redis with rollups_events crate"); - let client = Mutex::new( - Broker::new(config) - .await - .expect("failed to connect to broker"), - ); - - (node, endpoint, client) -} - -// ------------------------------------------------------------------------------------------------ - -pub struct BrokerFixture<'d> { - _node: Container<'d, GenericImage>, - client: Mutex, - inputs_stream: RollupsInputsStream, - claims_stream: RollupsClaimsStream, - outputs_stream: RollupsOutputsStream, - redis_endpoint: BrokerEndpoint, - chain_id: u64, - dapp_address: Address, -} - -impl BrokerFixture<'_> { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn setup(docker: &Cli) -> BrokerFixture<'_> { - tracing::info!("setting up redis fixture"); - - let (redis_node, redis_endpoint, redis_client) = - start_redis(&docker).await; - - let metadata = DAppMetadata { - chain_id: CHAIN_ID, - dapp_address: DAPP_ADDRESS.clone(), - }; - - BrokerFixture { - _node: redis_node, - client: redis_client, - inputs_stream: RollupsInputsStream::new(&metadata), - claims_stream: RollupsClaimsStream::new(&metadata), - outputs_stream: RollupsOutputsStream::new(&metadata), - redis_endpoint, - chain_id: CHAIN_ID, - dapp_address: DAPP_ADDRESS, - } - } - - pub fn redis_endpoint(&self) -> &BrokerEndpoint { - &self.redis_endpoint - } - - pub fn chain_id(&self) -> u64 { - self.chain_id - } - - pub fn dapp_address(&self) -> &Address { - &self.dapp_address - } - - pub fn dapp_metadata(&self) -> DAppMetadata { - DAppMetadata { - chain_id: self.chain_id, - dapp_address: self.dapp_address.clone(), - } - } - - /// Obtain the latest event from the rollups inputs stream - #[tracing::instrument(level = "trace", skip_all)] - pub async fn get_latest_input_event(&self) -> Option> { - tracing::trace!("getting latest input event"); - self.client - .lock() - .await - .peek_latest(&self.inputs_stream) - .await - .expect("failed to get latest input event") - } - - /// Produce the input event given the data - /// Return the produced event id - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce_input_event(&self, data: RollupsData) -> String { - tracing::trace!(?data, "producing rollups-input event"); - let last_event = self.get_latest_input_event().await; - let epoch_index = match last_event.as_ref() { - Some(event) => match event.payload.data { - RollupsData::AdvanceStateInput { .. } => { - event.payload.epoch_index - } - RollupsData::FinishEpoch {} => event.payload.epoch_index + 1, - }, - None => 0, - }; - let previous_inputs_sent_count = match last_event.as_ref() { - Some(event) => event.payload.inputs_sent_count, - None => 0, - }; - let inputs_sent_count = match data { - RollupsData::AdvanceStateInput { .. } => { - previous_inputs_sent_count + 1 - } - RollupsData::FinishEpoch {} => previous_inputs_sent_count, - }; - let parent_id = match last_event { - Some(event) => event.id, - None => INITIAL_ID.to_owned(), - }; - let input = RollupsInput { - parent_id, - epoch_index, - inputs_sent_count, - data, - }; - self.produce_raw_input_event(input).await - } - - /// Produce the input event given the input - /// This may produce inconsistent inputs - /// Return the produced event id - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce_raw_input_event(&self, input: RollupsInput) -> String { - tracing::trace!(?input, "producing rollups-input raw event"); - self.client - .lock() - .await - .produce(&self.inputs_stream, input) - .await - .expect("failed to produce event") - } - - /// Produce the claim given the hash - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce_rollups_claim(&self, rollups_claim: RollupsClaim) { - tracing::trace!(?rollups_claim.epoch_hash, "producing rollups-claim event"); - { - let last_claim = self - .client - .lock() - .await - .peek_latest(&self.claims_stream) - .await - .expect("failed to get latest claim"); - let epoch_index = match last_claim { - Some(event) => event.payload.epoch_index + 1, - None => 0, - }; - assert_eq!( - rollups_claim.epoch_index, epoch_index, - "invalid epoch index", - ); - } - self.client - .lock() - .await - .produce(&self.claims_stream, rollups_claim) - .await - .expect("failed to produce claim"); - } - - /// Obtain all produced claims - #[tracing::instrument(level = "trace", skip_all)] - pub async fn consume_all_claims(&self) -> Vec { - tracing::trace!("consuming all rollups-claims events"); - let mut claims = vec![]; - let mut last_id = INITIAL_ID.to_owned(); - while let Some(event) = self - .client - .lock() - .await - .consume_nonblocking(&self.claims_stream, &last_id) - .await - .expect("failed to consume claim") - { - claims.push(event.payload); - last_id = event.id; - } - claims - } - - /// Obtain the first n produced claims - /// Panic in case of timeout - #[tracing::instrument(level = "trace", skip_all)] - pub async fn consume_n_claims(&self, n: usize) -> Vec { - tracing::trace!(n, "consuming n rollups-claims events"); - let mut claims = vec![]; - let mut last_id = INITIAL_ID.to_owned(); - for _ in 0..n { - let event = self - .client - .lock() - .await - .consume_blocking(&self.claims_stream, &last_id) - .await - .expect("failed to consume claim"); - claims.push(event.payload); - last_id = event.id - } - claims - } - - /// Produce an output event - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce_output(&self, output: RollupsOutput) { - tracing::trace!(?output, "producing rollups-outputs event"); - self.client - .lock() - .await - .produce(&self.outputs_stream, output) - .await - .expect("failed to produce output"); - } -} - -// ------------------------------------------------------------------------------------------------ - -pub struct ClaimerMultidappBrokerFixture<'d> { - _node: Container<'d, GenericImage>, - client: Mutex, - redis_endpoint: BrokerEndpoint, - claims_streams: HashMap, -} - -impl ClaimerMultidappBrokerFixture<'_> { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn setup( - docker: &Cli, - dapps: Vec<(u64, Address)>, - ) -> ClaimerMultidappBrokerFixture<'_> { - let (redis_node, redis_endpoint, redis_client) = - start_redis(&docker).await; - - let claims_streams = dapps - .into_iter() - .map(|(chain_id, dapp_address)| { - let dapp_metadata = DAppMetadata { - chain_id, - dapp_address: dapp_address.clone(), - }; - let stream = RollupsClaimsStream::new(&dapp_metadata); - (dapp_address, stream) - }) - .collect::>(); - let claims_streams = HashMap::from_iter(claims_streams); - - ClaimerMultidappBrokerFixture { - _node: redis_node, - client: redis_client, - redis_endpoint, - claims_streams, - } - } - - pub fn redis_endpoint(&self) -> &BrokerEndpoint { - &self.redis_endpoint - } - - #[tracing::instrument(level = "trace", skip_all)] - pub async fn dapps_set(&self, dapps: Vec

) { - self.client.lock().await.dapps_set(dapps).await - } - - #[tracing::instrument(level = "trace", skip_all)] - pub async fn dapps_add(&self, dapp: String) { - self.client.lock().await.dapps_add(dapp).await - } - - #[tracing::instrument(level = "trace", skip_all)] - pub async fn dapps_members(&self) -> Vec { - self.client.lock().await.dapps_members().await - } - - // Different from the default function, - // this one requires `rollups_claim.dapp_address` to be set, - // and to match one of the addresses from the streams. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn produce_rollups_claim(&self, rollups_claim: RollupsClaim) { - tracing::trace!(?rollups_claim.epoch_hash, "producing rollups-claim event"); - - let stream = self - .claims_streams - .get(&rollups_claim.dapp_address) - .unwrap() - .clone(); - - { - let last_claim = self - .client - .lock() - .await - .peek_latest(&stream) - .await - .expect("failed to get latest claim"); - let epoch_index = match last_claim { - Some(event) => event.payload.epoch_index + 1, - None => 0, - }; - assert_eq!( - rollups_claim.epoch_index, epoch_index, - "invalid epoch index", - ); - } - - self.client - .lock() - .await - .produce(&stream, rollups_claim) - .await - .expect("failed to produce claim"); - } -} diff --git a/offchain/test-fixtures/src/data.rs b/offchain/test-fixtures/src/data.rs deleted file mode 100644 index 3e8fab6c4..000000000 --- a/offchain/test-fixtures/src/data.rs +++ /dev/null @@ -1,64 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use rollups_data::run_migrations; -use testcontainers::{clients::Cli, images::postgres::Postgres, Container}; - -pub const POSTGRES_DB: &str = "postgres"; -pub const POSTGRES_USER: &str = "postgres"; -pub const POSTGRES_PASSWORD: &str = "pw"; -pub const POSTGRES_HOST: &str = "localhost"; - -pub struct DataFixture<'d> { - _node: Container<'d, Postgres>, - pub user: String, - pub password: String, - pub hostname: String, - pub port: u16, - pub db: String, - pub endpoint: String, -} - -impl DataFixture<'_> { - #[tracing::instrument(level = "trace", skip_all)] - pub fn setup(docker: &Cli) -> DataFixture<'_> { - tracing::info!("setting up postgres fixture"); - - tracing::trace!("starting postgres docker container"); - - let image = testcontainers::RunnableImage::from( - testcontainers::images::postgres::Postgres::default(), - ) - .with_env_var(("POSTGRES_DB".to_owned(), POSTGRES_DB)) - .with_env_var(("POSTGRES_USER".to_owned(), POSTGRES_USER)) - .with_env_var(("POSTGRES_PASSWORD".to_owned(), POSTGRES_PASSWORD)) - .with_tag("13-alpine"); - - let node = docker.run(image); - let port = node.get_host_port_ipv4(5432); - let pg_endpoint = format!( - "postgres://{}:{}@{}:{}/{}", - POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, port, POSTGRES_DB - ); - - run_migrations(&pg_endpoint).unwrap(); - - DataFixture { - _node: node, - user: POSTGRES_USER.to_string(), - password: POSTGRES_PASSWORD.to_string(), - hostname: POSTGRES_HOST.to_string(), - port, - db: POSTGRES_DB.to_string(), - endpoint: pg_endpoint, - } - } - - pub fn port(&self) -> u16 { - self.port - } - - pub fn endpoint(&self) -> &str { - &self.endpoint - } -} diff --git a/offchain/test-fixtures/src/docker_cli.rs b/offchain/test-fixtures/src/docker_cli.rs deleted file mode 100644 index f387baae6..000000000 --- a/offchain/test-fixtures/src/docker_cli.rs +++ /dev/null @@ -1,47 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use std::process::Command; - -pub fn build(dockerfile: &str, tag: &str, build_args: &[(&str, &str)]) { - let build_args: Vec = build_args - .iter() - .map(|(key, value)| format!("--build-arg={}={}", key, value)) - .collect(); - let mut args = vec!["build", "-f", dockerfile, "-t", tag]; - for build_arg in build_args.iter() { - args.push(build_arg); - } - args.push("."); - docker_run(&args); -} - -pub fn create(tag: &str) -> String { - let mut id = docker_run(&["create", tag]); - id.pop().expect("failed to remove new line"); - String::from_utf8_lossy(&id).to_string() -} - -pub fn cp(from: &str, to: &str) { - docker_run(&["cp", from, to]); -} - -pub fn rm(id: &str) { - docker_run(&["rm", "-v", id]); -} - -#[tracing::instrument(level = "trace", skip_all)] -fn docker_run(args: &[&str]) -> Vec { - tracing::trace!("running docker command 'docker {}'", args.join(" ")); - let output = Command::new("docker") - .args(args) - .output() - .expect("failed to docker_run docker command"); - assert!( - output.status.success(), - "failed to docker_run command 'docker {}'\n{}", - args.join(" "), - String::from_utf8_lossy(&output.stderr) - ); - output.stdout -} diff --git a/offchain/test-fixtures/src/echo_dapp.rs b/offchain/test-fixtures/src/echo_dapp.rs deleted file mode 100644 index 9e74f8dfc..000000000 --- a/offchain/test-fixtures/src/echo_dapp.rs +++ /dev/null @@ -1,127 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use json::{object, JsonValue}; - -async fn print_response( - response: hyper::Response, - endpoint: &str, -) -> Result<(), Box> -where - ::Error: 'static, - ::Error: std::error::Error, -{ - let response_status = response.status().as_u16(); - let response_body = hyper::body::to_bytes(response).await?; - tracing::info!( - "Received {} status {} body {}", - endpoint, - response_status, - std::str::from_utf8(&response_body)? - ); - Ok(()) -} - -async fn handle_advance( - client: &hyper::Client, - server_addr: &str, - request: JsonValue, -) -> Result<&'static str, Box> { - tracing::info!("Received advance request data {}", &request); - let payload = request["data"]["payload"] - .as_str() - .ok_or("Missing payload")?; - tracing::info!("Adding notice"); - let notice = object! {"payload" => payload}; - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(format!("{}/notice", server_addr)) - .body(hyper::Body::from(notice.dump()))?; - let response = client.request(req).await?; - print_response(response, "notice").await?; - - let rollup_address = request["data"]["metadata"]["msg_sender"] - .as_str() - .ok_or("Missing msg_sender")?; - tracing::info!("Adding voucher"); - let voucher = object! { "address" => rollup_address, "payload" => payload}; - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(format!("{}/voucher", server_addr)) - .body(hyper::Body::from(voucher.dump()))?; - let response = client.request(req).await?; - print_response(response, "voucher").await?; - - Ok("accept") -} - -async fn handle_inspect( - client: &hyper::Client, - server_addr: &str, - request: JsonValue, -) -> Result<&'static str, Box> { - tracing::info!("Received inspect request data {}", &request); - let payload = request["data"]["payload"] - .as_str() - .ok_or("Missing payload")?; - tracing::info!("Adding report"); - let report = object! {"payload" => payload}; - let req = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(format!("{}/report", server_addr)) - .body(hyper::Body::from(report.dump()))?; - let response = client.request(req).await?; - print_response(response, "report").await?; - Ok("accept") -} - -pub struct EchoDAppFixture {} - -impl EchoDAppFixture { - pub async fn start_echo_dapp( - server_addr: String, - ) -> Result<(), Box> { - let client = hyper::Client::new(); - - let mut status = "accept"; - loop { - tracing::info!("Sending finish"); - - let response = object! {"status" => status}; - let request = hyper::Request::builder() - .method(hyper::Method::POST) - .header(hyper::header::CONTENT_TYPE, "application/json") - .uri(format!("{}/finish", &server_addr)) - .body(hyper::Body::from(response.dump()))?; - let response = client.request(request).await?; - tracing::info!("Received finish status {}", response.status()); - - if response.status() == hyper::StatusCode::ACCEPTED { - tracing::info!("No pending rollup request, trying again"); - } else { - let body = hyper::body::to_bytes(response).await?; - let utf = std::str::from_utf8(&body)?; - let req = json::parse(utf)?; - - let request_type = req["request_type"] - .as_str() - .ok_or("request_type is not a string")?; - status = match request_type { - "advance_state" => { - handle_advance(&client, &server_addr[..], req).await? - } - "inspect_state" => { - handle_inspect(&client, &server_addr[..], req).await? - } - &_ => { - tracing::info!("Unknown request type"); - "reject" - } - }; - } - } - } -} diff --git a/offchain/test-fixtures/src/host_server_manager.rs b/offchain/test-fixtures/src/host_server_manager.rs deleted file mode 100644 index 648a24347..000000000 --- a/offchain/test-fixtures/src/host_server_manager.rs +++ /dev/null @@ -1,200 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use anyhow::{anyhow, Context}; -use backoff::{future::retry, ExponentialBackoff, ExponentialBackoffBuilder}; -use grpc_interfaces::cartesi_server_manager::{ - processed_input::ProcessedInputOneOf, - server_manager_client::ServerManagerClient, EpochState, - GetEpochStatusRequest, GetEpochStatusResponse, GetSessionStatusRequest, -}; -use rollups_events::Payload; -use std::time::Duration; -use testcontainers::{ - clients::Cli, core::WaitFor, images::generic::GenericImage, Container, -}; -use tokio::sync::Mutex; -use tonic::transport::Channel; - -const SESSION_ID: &str = "default-session-id"; -const RETRY_MAX_ELAPSED_TIME: u64 = 120; - -macro_rules! grpc_call { - ($self: ident, $method: ident, $request: expr) => { - $self - .client - .lock() - .await - .$method($request) - .await - .map(|v| v.into_inner()) - .context("grpc call failed") - }; -} - -pub struct HostServerManagerFixture<'d> { - _node: Container<'d, GenericImage>, - client: Mutex>, - session_id: String, - backoff: ExponentialBackoff, - grpc_endpoint: String, - http_endpoint: String, -} - -impl HostServerManagerFixture<'_> { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn setup(docker: &Cli) -> HostServerManagerFixture<'_> { - tracing::info!("setting up host-server-manager fixture"); - tracing::trace!("starting host-server-manager docker container"); - let image = GenericImage::new("cartesi/host-server-manager", "0.9.0") - .with_wait_for(WaitFor::message_on_stderr( - "starting in Actix runtime", - )) - .with_exposed_port(5001) - .with_exposed_port(5004); - let node = docker.run(image); - let grpc_endpoint = - format!("http://127.0.0.1:{}", node.get_host_port_ipv4(5001)); - let http_endpoint = - format!("http://127.0.0.1:{}", node.get_host_port_ipv4(5004)); - tracing::trace!(grpc_endpoint, "connecting to host-server-manager"); - let client = Mutex::new( - ServerManagerClient::connect(grpc_endpoint.clone()) - .await - .expect("failed to connect to host server manager"), - ); - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(Duration::from_secs( - RETRY_MAX_ELAPSED_TIME, - ))) - .build(); - - HostServerManagerFixture { - _node: node, - client, - session_id: SESSION_ID.to_owned(), - backoff, - grpc_endpoint, - http_endpoint, - } - } - - pub fn grpc_endpoint(&self) -> &str { - &self.grpc_endpoint - } - - pub fn http_endpoint(&self) -> &str { - &self.http_endpoint - } - - pub fn session_id(&self) -> &str { - &self.session_id - } - - /// Wait until the session is ready - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_session_ready(&self) { - tracing::trace!("asserting whether session is ready"); - retry(self.backoff.clone(), || async { - let request = GetSessionStatusRequest { - session_id: self.session_id.clone(), - }; - grpc_call!(self, get_session_status, request)?; - Ok(()) - }) - .await - .expect("failed to wait for session"); - } - - /// Wait until there is the required amount of processed inputs - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_epoch_status( - &self, - epoch_index: u64, - expected_processed: usize, - ) -> GetEpochStatusResponse { - tracing::trace!( - epoch_index, - expected_processed, - "asserting epoch status" - ); - retry(self.backoff.clone(), || async { - let request = GetEpochStatusRequest { - session_id: self.session_id.clone(), - epoch_index, - }; - let response = grpc_call!(self, get_epoch_status, request)?; - if response.processed_inputs.len() != expected_processed { - Err(anyhow!( - "processed_inputs_count fail got={} expected={}", - response.processed_inputs.len(), - expected_processed - ))?; - } - Ok(response) - }) - .await - .expect("failed to wait for epoch status") - } - - /// Wait until there is the required amount of processed inputs - /// Then, compare the obtained output payloads with the expected ones - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_epoch_status_payloads( - &self, - epoch_index: u64, - expected_payloads: &[Payload], - ) { - tracing::trace!( - epoch_index, - ?expected_payloads, - "asserting epoch status payloads" - ); - let epoch_status = self - .assert_epoch_status(epoch_index, expected_payloads.len()) - .await; - - assert_eq!( - expected_payloads.len(), - epoch_status.processed_inputs.len() - ); - for (processed_input, expected_payload) in - epoch_status.processed_inputs.iter().zip(expected_payloads) - { - let oneof = - processed_input.processed_input_one_of.as_ref().unwrap(); - match oneof { - ProcessedInputOneOf::AcceptedData(accepted_data) => { - assert_eq!(accepted_data.notices.len(), 1); - assert_eq!( - &accepted_data.notices[0].payload, - expected_payload.inner() - ); - } - ProcessedInputOneOf::ExceptionData(_) => { - panic!("unexpected exception data"); - } - } - } - } - - /// Wait until the given epoch is finished. - /// Raises error if the epoch is not finished after the backoff timeout. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_epoch_finished(&self, epoch_index: u64) { - tracing::trace!(epoch_index, "asserting epoch finished"); - retry(self.backoff.clone(), || async { - let request = GetEpochStatusRequest { - session_id: self.session_id.clone(), - epoch_index, - }; - let response = grpc_call!(self, get_epoch_status, request)?; - if response.state() == EpochState::Active { - Err(anyhow!("epoch {} is not finished", epoch_index))?; - } - Ok(()) - }) - .await - .expect("failed to wait for epoch status") - } -} diff --git a/offchain/test-fixtures/src/lib.rs b/offchain/test-fixtures/src/lib.rs deleted file mode 100644 index bf775307f..000000000 --- a/offchain/test-fixtures/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod broker; -pub mod data; -pub mod docker_cli; -pub mod echo_dapp; -pub mod host_server_manager; -pub mod machine_snapshots; -pub mod repository; -pub mod server_manager; - -pub use broker::BrokerFixture; -pub use data::DataFixture; -pub use echo_dapp::EchoDAppFixture; -pub use host_server_manager::HostServerManagerFixture; -pub use machine_snapshots::MachineSnapshotsFixture; -pub use repository::RepositoryFixture; -pub use server_manager::ServerManagerFixture; diff --git a/offchain/test-fixtures/src/machine_snapshots.rs b/offchain/test-fixtures/src/machine_snapshots.rs deleted file mode 100644 index 9fd37c49d..000000000 --- a/offchain/test-fixtures/src/machine_snapshots.rs +++ /dev/null @@ -1,36 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use std::path::PathBuf; -use tempfile::TempDir; - -use crate::docker_cli; - -const TAG: &str = "cartesi/rollups-node-snapshot:devel"; -const CONTAINER_SNAPSHOT_DIR: &str = "/usr/share/cartesi/snapshot"; - -pub struct MachineSnapshotsFixture { - dir: TempDir, -} - -impl MachineSnapshotsFixture { - #[tracing::instrument(level = "trace", skip_all)] - pub fn setup() -> Self { - tracing::info!("setting up machine snapshots fixture"); - - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let id = docker_cli::create(TAG); - let from_container = format!("{}:{}", id, CONTAINER_SNAPSHOT_DIR); - docker_cli::cp(&from_container, dir.path().to_str().unwrap()); - docker_cli::rm(&id); - Self { dir } - } - - /// Return the path of directory that contains the snapshot - pub fn path(&self) -> PathBuf { - let snapshot_dir = PathBuf::from(CONTAINER_SNAPSHOT_DIR); - self.dir - .path() - .join(snapshot_dir.file_name().expect("impossible")) - } -} diff --git a/offchain/test-fixtures/src/repository.rs b/offchain/test-fixtures/src/repository.rs deleted file mode 100644 index db6bb87ff..000000000 --- a/offchain/test-fixtures/src/repository.rs +++ /dev/null @@ -1,87 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use backoff::ExponentialBackoffBuilder; -use rollups_data::{RedactedUrl, Repository, RepositoryConfig, Url}; -use std::time::Duration; -use testcontainers::clients::Cli; - -use crate::data::DataFixture; - -const REPOSITORY_MAX_ELAPSED_TIME: u64 = 10; - -/// Fixture that creates a database and connects to it using the -/// rollups_data::Repository struct. -pub struct RepositoryFixture<'d> { - data: DataFixture<'d>, - repository: Repository, -} - -impl RepositoryFixture<'_> { - pub fn setup(docker: &Cli) -> RepositoryFixture { - let data = DataFixture::setup(docker); - let config = create_repository_config(data.port()); - let repository = - Repository::new(config).expect("failed to create repository"); - RepositoryFixture { data, repository } - } - - pub fn config(&self) -> RepositoryConfig { - create_repository_config(self.data.port()) - } - - pub fn repository(&self) -> &Repository { - &self.repository - } - - /// Calls f until it returns Ok or an error different from ItemNotFound. - /// This function is async to allow other services to run in background. - pub async fn retry(&self, mut f: F) -> T - where - F: FnMut(&Repository) -> Result - + Send - + 'static, - T: Send + 'static, - { - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(Duration::from_secs( - REPOSITORY_MAX_ELAPSED_TIME, - ))) - .build(); - let repository = self.repository.clone(); - tokio::task::spawn_blocking(move || { - backoff::retry(backoff, || { - f(&repository).map_err(|e| match &e { - rollups_data::Error::ItemNotFound { item_type } => { - tracing::info!("{} not found", item_type); - backoff::Error::transient(e) - } - _ => backoff::Error::permanent(e), - }) - }) - .expect("failed to get input from DB") - }) - .await - .expect("failed to wait for task") - } -} - -fn create_repository_config(postgres_port: u16) -> RepositoryConfig { - use crate::data::*; - let redacted_endpoint = Some(RedactedUrl::new( - Url::parse(&format!( - "postgres://{}:{}@{}:{}/{}", - POSTGRES_USER, - POSTGRES_PASSWORD, - POSTGRES_HOST, - postgres_port, - POSTGRES_DB, - )) - .expect("failed to generate Postgres endpoint"), - )); - RepositoryConfig { - redacted_endpoint, - connection_pool_size: 1, - backoff: Default::default(), - } -} diff --git a/offchain/test-fixtures/src/server_manager.rs b/offchain/test-fixtures/src/server_manager.rs deleted file mode 100644 index c8bd5ca5d..000000000 --- a/offchain/test-fixtures/src/server_manager.rs +++ /dev/null @@ -1,214 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::docker_cli; -use anyhow::{anyhow, Context}; -use backoff::{future::retry, ExponentialBackoff, ExponentialBackoffBuilder}; -use grpc_interfaces::cartesi_server_manager::{ - processed_input::ProcessedInputOneOf, - server_manager_client::ServerManagerClient, EpochState, - GetEpochStatusRequest, GetEpochStatusResponse, GetSessionStatusRequest, -}; -use rollups_events::Payload; -use std::path::Path; -use std::time::Duration; -use testcontainers::{ - clients::Cli, core::WaitFor, images::generic::GenericImage, Container, -}; -use tokio::sync::Mutex; -use tonic::transport::Channel; - -const DOCKERFILE: &str = - "../test-fixtures/docker/server_manager_nonroot.Dockerfile"; -const DOCKER_TAG: &str = "cartesi/test-server-manager-nonroot"; -const SESSION_ID: &str = "default-session-id"; -const RETRY_MAX_ELAPSED_TIME: u64 = 120; - -macro_rules! grpc_call { - ($self: ident, $method: ident, $request: expr) => { - $self - .client - .lock() - .await - .$method($request) - .await - .map(|v| v.into_inner()) - .context("grpc call failed") - }; -} - -pub struct ServerManagerFixture<'d> { - _node: Container<'d, GenericImage>, - client: Mutex>, - endpoint: String, - session_id: String, - backoff: ExponentialBackoff, -} - -impl ServerManagerFixture<'_> { - #[tracing::instrument(level = "trace", skip_all)] - pub async fn setup<'d>( - docker: &'d Cli, - snapshot_dir: &Path, - ) -> ServerManagerFixture<'d> { - tracing::info!("setting up server-manager fixture"); - - tracing::trace!("generating {} docker image", DOCKER_TAG); - let user = users::get_current_username().unwrap(); - let uid = users::get_current_uid().to_string(); - let group = users::get_current_groupname().unwrap(); - let gid = users::get_current_gid().to_string(); - let build_args = vec![ - ("user", user.to_str().unwrap()), - ("uid", &uid), - ("group", group.to_str().unwrap()), - ("gid", &gid), - ]; - docker_cli::build(DOCKERFILE, DOCKER_TAG, &build_args); - - tracing::trace!("starting server manager container"); - let snapshot_dir = snapshot_dir.to_str().unwrap(); - let image = GenericImage::new(DOCKER_TAG, "latest") - .with_wait_for(WaitFor::message_on_stderr("manager version is")) - .with_volume(snapshot_dir, snapshot_dir) - .with_exposed_port(5001); - let node = docker.run(image); - let endpoint = - format!("http://127.0.0.1:{}", node.get_host_port_ipv4(5001)); - - tracing::trace!(endpoint, "connecting to server manager"); - let client = Mutex::new( - ServerManagerClient::connect(endpoint.clone()) - .await - .expect("failed to connect to server manager"), - ); - - let backoff = ExponentialBackoffBuilder::new() - .with_max_elapsed_time(Some(Duration::from_secs( - RETRY_MAX_ELAPSED_TIME, - ))) - .build(); - - ServerManagerFixture { - _node: node, - client, - endpoint, - session_id: SESSION_ID.to_owned(), - backoff, - } - } - - pub fn endpoint(&self) -> &str { - &self.endpoint - } - - pub fn session_id(&self) -> &str { - &self.session_id - } - - /// Wait until the session is ready - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_session_ready(&self) { - tracing::trace!("asserting whether session is ready"); - retry(self.backoff.clone(), || async { - let request = GetSessionStatusRequest { - session_id: self.session_id.clone(), - }; - grpc_call!(self, get_session_status, request)?; - Ok(()) - }) - .await - .expect("failed to wait for session"); - } - - /// Wait until there is the required amount of processed inputs - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_epoch_status( - &self, - epoch_index: u64, - expected_processed: usize, - ) -> GetEpochStatusResponse { - tracing::trace!( - epoch_index, - expected_processed, - "asserting epoch status" - ); - retry(self.backoff.clone(), || async { - let request = GetEpochStatusRequest { - session_id: self.session_id.clone(), - epoch_index, - }; - let response = grpc_call!(self, get_epoch_status, request)?; - if response.processed_inputs.len() != expected_processed { - Err(anyhow!( - "processed_inputs_count fail got={} expected={}", - response.processed_inputs.len(), - expected_processed - ))?; - } - Ok(response) - }) - .await - .expect("failed to wait for epoch status") - } - - /// Wait until there is the required amount of processed inputs - /// Then, compare the obtained output payloads with the expected ones - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_epoch_status_payloads( - &self, - epoch_index: u64, - expected_payloads: &[Payload], - ) { - tracing::trace!( - epoch_index, - ?expected_payloads, - "asserting epoch status payloads" - ); - let epoch_status = self - .assert_epoch_status(epoch_index, expected_payloads.len()) - .await; - assert_eq!( - expected_payloads.len(), - epoch_status.processed_inputs.len() - ); - for (processed_input, expected_payload) in - epoch_status.processed_inputs.iter().zip(expected_payloads) - { - let oneof = - processed_input.processed_input_one_of.as_ref().unwrap(); - match oneof { - ProcessedInputOneOf::AcceptedData(accepted_data) => { - assert_eq!(accepted_data.notices.len(), 1); - assert_eq!( - &accepted_data.notices[0].payload, - expected_payload.inner() - ); - } - ProcessedInputOneOf::ExceptionData(_) => { - panic!("unexpected exception data"); - } - } - } - } - - /// Wait until the given epoch is finished. - /// Raises error if the epoch is not finished after the backoff timeout. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn assert_epoch_finished(&self, epoch_index: u64) { - tracing::trace!(epoch_index, "asserting epoch finished"); - retry(self.backoff.clone(), || async { - let request = GetEpochStatusRequest { - session_id: self.session_id.clone(), - epoch_index, - }; - let response = grpc_call!(self, get_epoch_status, request)?; - if response.state() == EpochState::Active { - Err(anyhow!("epoch {} is not finished", epoch_index))?; - } - Ok(()) - }) - .await - .expect("failed to wait for epoch status") - } -} diff --git a/offchain/types/Cargo.toml b/offchain/types/Cargo.toml deleted file mode 100644 index b9fa6c141..000000000 --- a/offchain/types/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "types" -edition.workspace = true -license.workspace = true -version.workspace = true - -[dependencies] -contracts = { path = "../contracts" } -rollups-events = { path = "../rollups-events" } - -anyhow.workspace = true -async-trait.workspace = true -clap = { workspace = true, features = ["derive", "env"] } -eth-state-fold-types = { workspace = true, features = ["ethers"] } -eth-state-fold.workspace = true -im = { workspace = true, features = ["serde"] } -serde = { workspace = true, features = ["rc"] } -serde_json.workspace = true -snafu.workspace = true - -[dev-dependencies] -serde_json.workspace = true diff --git a/offchain/types/README.md b/offchain/types/README.md deleted file mode 100644 index 4a4c179a4..000000000 --- a/offchain/types/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Types - -Library crate that define types used by the Cartesi Node services. diff --git a/offchain/types/src/blockchain_config.rs b/offchain/types/src/blockchain_config.rs deleted file mode 100644 index e6250a0a3..000000000 --- a/offchain/types/src/blockchain_config.rs +++ /dev/null @@ -1,239 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use clap::{command, Parser}; -use rollups_events::Address; -use serde::{de::DeserializeOwned, Deserialize}; -use snafu::{ResultExt, Snafu}; -use std::{fs::File, io::BufReader, path::PathBuf}; - -#[derive(Debug, Snafu)] -pub enum BlockchainConfigError { - #[snafu(display("Json deserialize error"))] - JsonDeserializeError { source: serde_json::Error }, - - #[snafu(display("Json read error ({})", path.display()))] - JsonReadError { - path: PathBuf, - source: serde_json::Error, - }, - - #[snafu(display("Read file error ({})", path.display()))] - ReadFileError { - path: PathBuf, - source: std::io::Error, - }, - - #[snafu(display("Missing configuration: ({})", name))] - MissingConfig { name: String }, -} - -#[derive(Debug, Parser)] -#[command(name = "blockchain_config")] -pub struct BlockchainCLIConfig { - /// DApp address - #[arg(long, env)] - pub dapp_address: Option, - - /// Input box deployment block number - #[arg(long, env)] - pub input_box_deployment_block_number: Option, - - /// History contract address - #[arg(long, env)] - pub history_address: Option, - - /// Authority contract address - #[arg(long, env)] - pub authority_address: Option, - - /// Input Box contract address - #[arg(long, env)] - pub input_box_address: Option, - - /// Path to a file with the deployment json of the dapp - #[arg(long, env)] - pub dapp_deployment_file: Option, - - /// Path to file with deployment json of the rollups - #[arg(long, env)] - pub rollups_deployment_file: Option, -} - -#[derive(Clone, Debug)] -pub struct BlockchainConfig { - pub dapp_address: Address, - pub input_box_deployment_block_number: u64, - pub history_address: Address, - pub authority_address: Address, - pub input_box_address: Address, -} - -fn deserialize( - s: String, -) -> Result { - serde_json::from_value(serde_json::Value::String(s)) - .context(JsonDeserializeSnafu) -} - -macro_rules! check_missing { - ($var_name: ident) => { - match $var_name { - Some(v) => v, - None => { - return Err(BlockchainConfigError::MissingConfig { - name: stringify!($var_name).to_string(), - }) - } - } - }; -} - -impl TryFrom for BlockchainConfig { - type Error = BlockchainConfigError; - - fn try_from(cli: BlockchainCLIConfig) -> Result { - // try to get the values from the environment values - let mut dapp_address = - cli.dapp_address.map(deserialize::
).transpose()?; - let input_box_deployment_block_number = - cli.input_box_deployment_block_number; - let mut history_address = cli - .history_address - .map(deserialize::
) - .transpose()?; - let mut authority_address = cli - .authority_address - .map(deserialize::
) - .transpose()?; - let mut input_box_address = cli - .input_box_address - .map(deserialize::
) - .transpose()?; - - // read files and replace values if they are not set - if let Some(file) = - cli.dapp_deployment_file.map(read::).transpose()? - { - dapp_address = dapp_address.or(file.address); - } - if let Some(file) = cli - .rollups_deployment_file - .map(read::) - .transpose()? - { - history_address = history_address.or(file - .contracts - .history - .map(|c| c.address) - .flatten()); - authority_address = authority_address.or(file - .contracts - .authority - .map(|c| c.address) - .flatten()); - input_box_address = input_box_address.or(file - .contracts - .input_box - .map(|c| c.address) - .flatten()); - } - - Ok(BlockchainConfig { - dapp_address: check_missing!(dapp_address), - input_box_deployment_block_number: check_missing!( - input_box_deployment_block_number - ), - history_address: check_missing!(history_address), - authority_address: check_missing!(authority_address), - input_box_address: check_missing!(input_box_address), - }) - } -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Contract { - #[serde(rename = "address")] - pub address: Option
, - - #[serde(rename = "blockNumber")] - pub block_number: Option, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct RollupsContracts { - #[serde(rename = "History")] - pub history: Option, - - #[serde(rename = "Authority")] - pub authority: Option, - - #[serde(rename = "InputBox")] - pub input_box: Option, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct RollupsDeployment { - pub contracts: RollupsContracts, -} - -fn read( - path: PathBuf, -) -> Result { - let file = - File::open(&path).context(ReadFileSnafu { path: path.clone() })?; - let reader = BufReader::new(file); - serde_json::from_reader(reader).context(JsonReadSnafu { path }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse() { - let history_address = deserialize( - "0xb6Eb78277C8a96Fb3f55BABef25eD0Bc5E5c95Fb".to_string(), - ) - .unwrap(); - let authority_address = deserialize( - "0xf3D8ce181a502B54512908a32780eaa9183Ef31a".to_string(), - ) - .unwrap(); - let input_box_address = deserialize( - "0x10dc33852b996A4C8A391d6Ed224FD89A3aD1ceE".to_string(), - ) - .unwrap(); - - let data = r#"{ - "contracts": { - "History": { - "address": "0xb6Eb78277C8a96Fb3f55BABef25eD0Bc5E5c95Fb" - }, - - "Authority": { - "address": "0xf3D8ce181a502B54512908a32780eaa9183Ef31a" - }, - - "InputBox": { - "address": "0x10dc33852b996A4C8A391d6Ed224FD89A3aD1ceE" - } - } - }"#; - - let deployment: RollupsDeployment = serde_json::from_str(data).unwrap(); - - assert_eq!( - deployment.contracts.history.unwrap().address, - history_address - ); - assert_eq!( - deployment.contracts.authority.unwrap().address, - authority_address - ); - assert_eq!( - deployment.contracts.input_box.unwrap().address, - input_box_address - ); - } -} diff --git a/offchain/types/src/error.rs b/offchain/types/src/error.rs deleted file mode 100644 index 4e29c11c9..000000000 --- a/offchain/types/src/error.rs +++ /dev/null @@ -1,42 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use anyhow::Error; -use eth_state_fold::Foldable; -use eth_state_fold_types::ethers::prelude::{ContractError, Middleware}; -use std::fmt::{Display, Formatter}; - -#[derive(Debug)] -pub struct FoldableError(Error); - -impl Display for FoldableError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl std::error::Error for FoldableError {} - -impl From for FoldableError { - fn from(error: Error) -> Self { - Self(error) - } -} - -impl From> for FoldableError { - fn from(contract_error: ContractError) -> Self { - FoldableError(contract_error.into()) - } -} - -impl - From> for FoldableError -where - ::Error: Send + Sync, -{ - fn from( - contract_error: eth_state_fold::error::FoldableError, - ) -> Self { - FoldableError(contract_error.into()) - } -} diff --git a/offchain/types/src/foldables.rs b/offchain/types/src/foldables.rs deleted file mode 100644 index 71d7cb3e1..000000000 --- a/offchain/types/src/foldables.rs +++ /dev/null @@ -1,240 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use crate::{FoldableError, UserData}; - -use eth_state_fold::{ - utils as fold_utils, FoldMiddleware, Foldable, StateFoldEnvironment, - SyncMiddleware, -}; -use eth_state_fold_types::{ - ethers::{ - contract::LogMeta, - prelude::EthEvent, - providers::Middleware, - types::{Address, TxHash}, - }, - Block, -}; - -use anyhow::{ensure, Context}; -use async_trait::async_trait; -use im::{HashMap, Vector}; -use serde::{Deserialize, Serialize}; -use std::sync::{Arc, Mutex}; - -#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] -pub struct InputBoxInitialState { - pub dapp_address: Arc
, - pub input_box_address: Arc
, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Input { - pub sender: Arc
, - pub payload: Vec, - pub block_added: Arc, - pub dapp: Arc
, - pub tx_hash: Arc, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct DAppInputBox { - pub inputs: Vector>, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct InputBox { - pub dapp_address: Arc
, - pub input_box_address: Arc
, - pub dapp_input_boxes: Arc, Arc>>, -} - -#[async_trait] -impl Foldable for InputBox { - type InitialState = InputBoxInitialState; - type Error = FoldableError; - type UserData = Mutex; - - async fn sync( - initial_state: &Self::InitialState, - _block: &Block, - env: &StateFoldEnvironment, - access: Arc>, - ) -> Result { - let dapp_address = Arc::clone(&initial_state.dapp_address); - let input_box_address = Arc::clone(&initial_state.input_box_address); - - Ok(Self { - dapp_input_boxes: updated_inputs( - None, - access, - env, - &input_box_address, - &dapp_address, - None, - ) - .await?, - dapp_address, - input_box_address, - }) - } - - async fn fold( - previous_state: &Self, - block: &Block, // TODO: when new version of state-fold gets released, change this to Arc - // and save on cloning. - env: &StateFoldEnvironment, - access: Arc>, - ) -> Result { - let dapp_address = Arc::clone(&previous_state.dapp_address); - let input_box_address = Arc::clone(&previous_state.input_box_address); - - if !fold_utils::contains_address(&block.logs_bloom, &input_box_address) - || !fold_utils::contains_topic(&block.logs_bloom, &*dapp_address) - || !fold_utils::contains_topic( - &block.logs_bloom, - &contracts::input_box::InputAddedFilter::signature(), - ) - { - return Ok(previous_state.clone()); - } - - Ok(Self { - dapp_input_boxes: updated_inputs( - Some(&previous_state.dapp_input_boxes), - access, - env, - &input_box_address, - &dapp_address, - None, - ) - .await?, - dapp_address, - input_box_address, - }) - } -} - -async fn updated_inputs( - previous_input_boxes: Option<&HashMap, Arc>>, - provider: Arc, - env: &StateFoldEnvironment::UserData>, - contract_address: &Address, - dapp_address: &Address, - block_opt: Option, // TODO: Option>, -) -> Result, Arc>>, FoldableError> { - let mut input_boxes = - previous_input_boxes.cloned().unwrap_or(HashMap::new()); - - let new_inputs = fetch_all_new_inputs( - provider, - env, - contract_address, - dapp_address, - block_opt, - ) - .await?; - - for input in new_inputs { - let dapp = input.dapp.clone(); - let input = Arc::new(input); - - input_boxes - .entry(dapp) - .and_modify(|i| { - let mut new_input_box = (**i).clone(); - new_input_box.inputs.push_back(input.clone()); - *i = Arc::new(new_input_box); - }) - .or_insert_with(|| { - Arc::new(DAppInputBox { - inputs: im::vector![input], - }) - }); - } - - Ok(Arc::new(input_boxes)) -} - -async fn fetch_all_new_inputs< - M1: Middleware + 'static, - M2: Middleware + 'static, ->( - provider: Arc, - env: &StateFoldEnvironment::UserData>, - contract_address: &Address, - dapp_address: &Address, - block_opt: Option, // TODO: Option>, -) -> Result, FoldableError> { - use contracts::input_box::*; - let contract = InputBox::new(*contract_address, Arc::clone(&provider)); - - // Retrieve `InputAdded` events - let input_events = contract - .input_added_filter() - .topic1(*dapp_address) - .query_with_meta() - .await - .context("Error querying for input added events")?; - - let mut inputs = Vec::with_capacity(input_events.len()); - for (event, meta) in input_events { - inputs.push(Input::build_input(env, event, meta, &block_opt).await?); - } - - Ok(inputs) -} - -impl Input { - async fn build_input( - env: &StateFoldEnvironment::UserData>, - event: contracts::input_box::InputAddedFilter, - meta: LogMeta, - block_opt: &Option, // TODO: &Option> - ) -> Result { - let block = - match block_opt { - Some(ref b) => Arc::new(b.clone()), // TODO: remove Arc::new - - None => env.block_with_hash(&meta.block_hash).await.context( - format!("Could not query block `{:?}`", meta.block_hash), - )?, - }; - - meta_consistent_with_block(&meta, &block)?; - - let mut user_data = env - .user_data() - .lock() - .expect("Mutex should never be poisoned"); - - let sender = user_data.get(event.sender); - let dapp = user_data.get(event.dapp); - - Ok(Self { - sender, - payload: event.input.to_vec(), - dapp, - block_added: block, - tx_hash: Arc::new(meta.transaction_hash), - }) - } -} - -fn meta_consistent_with_block( - meta: &LogMeta, - block: &Block, -) -> Result<(), anyhow::Error> { - ensure!( - meta.block_hash == block.hash, - "Sanity check failed: meta and block `block_hash` do not match" - ); - - ensure!( - meta.block_number == block.number, - "Sanity check failed: meta and block `block_number` do not match" - ); - - Ok(()) -} diff --git a/offchain/types/src/lib.rs b/offchain/types/src/lib.rs deleted file mode 100644 index d8b8674de..000000000 --- a/offchain/types/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -pub mod blockchain_config; -pub mod error; -pub use error::*; - -pub mod foldables; - -pub mod user_data; -pub mod utils; - -pub use user_data::UserData; diff --git a/offchain/types/src/user_data.rs b/offchain/types/src/user_data.rs deleted file mode 100644 index 9990a27dc..000000000 --- a/offchain/types/src/user_data.rs +++ /dev/null @@ -1,25 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use eth_state_fold_types::ethers::types::Address; - -use std::{collections::HashSet, sync::Arc}; - -#[derive(Debug, Default)] -pub struct UserData { - addresses: HashSet>, -} - -impl UserData { - pub fn get(&mut self, address: Address) -> Arc
{ - // Method `get_or_insert` of HashSet is still unstable - match self.addresses.get(&address) { - Some(s) => Arc::clone(s), - None => { - let s = Arc::new(address); - assert!(self.addresses.insert(s.clone())); - s - } - } - } -} diff --git a/offchain/types/src/utils.rs b/offchain/types/src/utils.rs deleted file mode 100644 index 44d11d711..000000000 --- a/offchain/types/src/utils.rs +++ /dev/null @@ -1,65 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -use std::cmp::Ordering; -use std::iter::Peekable; - -pub struct MergeAscending -where - L: Iterator, - R: Iterator, -{ - left: Peekable, - right: Peekable, -} - -impl MergeAscending -where - L: Iterator, - R: Iterator, -{ - pub fn new(left: L, right: R) -> Self { - MergeAscending { - left: left.peekable(), - right: right.peekable(), - } - } -} - -impl Iterator for MergeAscending -where - L: Iterator, - R: Iterator, - L::Item: Ord, -{ - type Item = L::Item; - - fn next(&mut self) -> Option { - let which = match (self.left.peek(), self.right.peek()) { - (Some(l), Some(r)) => Some(l.cmp(r)), - (Some(_), None) => Some(Ordering::Less), - (None, Some(_)) => Some(Ordering::Greater), - (None, None) => None, - }; - - match which { - Some(Ordering::Less) => self.left.next(), - Some(Ordering::Equal) => self.left.next(), - Some(Ordering::Greater) => self.right.next(), - None => None, - } - } - - fn size_hint(&self) -> (usize, Option) { - let (ll, lu) = self.left.size_hint(); - let (rl, ru) = self.right.size_hint(); - - let l = ll + rl; - let u = match (lu, ru) { - (Some(l), Some(r)) => Some(l + r), - _ => None, - }; - - (l, u) - } -} diff --git a/pkg/addresses/addresses.go b/pkg/addresses/addresses.go deleted file mode 100644 index 8989e65b6..000000000 --- a/pkg/addresses/addresses.go +++ /dev/null @@ -1,67 +0,0 @@ -// (c) Cartesi and individual authors (see AUTHORS) -// SPDX-License-Identifier: Apache-2.0 (see LICENSE) - -// This package manages the contract addresses. -// -// The addresses depend on the deployment of the contracts and should be provided by the node user. -// This module offers an option to load these addresses from a config file, compatible with the -// output of `sunodo address-book --json`. -// This package also contain the addresses for the test environment as hard-coded values. -package addresses - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/ethereum/go-ethereum/common" -) - -// List of contract addresses. -type Book struct { - AuthorityHistoryPairFactory common.Address - CartesiDAppFactory common.Address - DAppAddressRelay common.Address - ERC1155BatchPortal common.Address - ERC1155SinglePortal common.Address - ERC20Portal common.Address - ERC721Portal common.Address - EtherPortal common.Address - InputBox common.Address - CartesiDApp common.Address - HistoryAddress common.Address - AuthorityAddress common.Address -} - -// Get the addresses for the test environment. -func GetTestBook() *Book { - return &Book{ - AuthorityHistoryPairFactory: common. - HexToAddress("0x3890A047Cf9Af60731E80B2105362BbDCD70142D"), - CartesiDAppFactory: common.HexToAddress("0x7122cd1221C20892234186facfE8615e6743Ab02"), - DAppAddressRelay: common.HexToAddress("0xF5DE34d6BbC0446E2a45719E718efEbaaE179daE"), - ERC1155BatchPortal: common.HexToAddress("0xedB53860A6B52bbb7561Ad596416ee9965B055Aa"), - ERC1155SinglePortal: common.HexToAddress("0x7CFB0193Ca87eB6e48056885E026552c3A941FC4"), - ERC20Portal: common.HexToAddress("0x9C21AEb2093C32DDbC53eEF24B873BDCd1aDa1DB"), - ERC721Portal: common.HexToAddress("0x237F8DD094C0e47f4236f12b4Fa01d6Dae89fb87"), - EtherPortal: common.HexToAddress("0xFfdbe43d4c855BF7e0f105c400A50857f53AB044"), - InputBox: common.HexToAddress("0x59b22D57D4f067708AB0c00552767405926dc768"), - CartesiDApp: common.HexToAddress("0x7C54E3f7A8070a54223469965A871fB8f6f88c22"), - HistoryAddress: common.HexToAddress("0x325272217ae6815b494bF38cED004c5Eb8a7CdA7"), - AuthorityAddress: common.HexToAddress("0x58c93F83fb3304730C95aad2E360cdb88b782010"), - } -} - -// Get the address book from json File. -func GetBookFromFile(path string) (*Book, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read address book file: %v", err) - } - var book Book - err = json.Unmarshal(data, &book) - if err != nil { - return nil, fmt.Errorf("parse address book json: %v", err) - } - return &book, nil -} diff --git a/pkg/contracts/authority.go b/pkg/contracts/authority.go deleted file mode 100644 index e584ecce6..000000000 --- a/pkg/contracts/authority.go +++ /dev/null @@ -1,844 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package contracts - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription - _ = abi.ConvertType -) - -// AuthorityMetaData contains all meta data concerning the Authority contract. -var AuthorityMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_owner\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"AuthorityWithdrawalFailed\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"application\",\"type\":\"address\"}],\"name\":\"ApplicationJoined\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"contractIHistory\",\"name\":\"history\",\"type\":\"address\"}],\"name\":\"NewHistory\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_dapp\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"_proofContext\",\"type\":\"bytes\"}],\"name\":\"getClaim\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getHistory\",\"outputs\":[{\"internalType\":\"contractIHistory\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"join\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_consensus\",\"type\":\"address\"}],\"name\":\"migrateHistoryToConsensus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractIHistory\",\"name\":\"_history\",\"type\":\"address\"}],\"name\":\"setHistory\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"_claimData\",\"type\":\"bytes\"}],\"name\":\"submitClaim\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractIERC20\",\"name\":\"_token\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_recipient\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"withdrawERC20Tokens\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", -} - -// AuthorityABI is the input ABI used to generate the binding from. -// Deprecated: Use AuthorityMetaData.ABI instead. -var AuthorityABI = AuthorityMetaData.ABI - -// Authority is an auto generated Go binding around an Ethereum contract. -type Authority struct { - AuthorityCaller // Read-only binding to the contract - AuthorityTransactor // Write-only binding to the contract - AuthorityFilterer // Log filterer for contract events -} - -// AuthorityCaller is an auto generated read-only Go binding around an Ethereum contract. -type AuthorityCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// AuthorityTransactor is an auto generated write-only Go binding around an Ethereum contract. -type AuthorityTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// AuthorityFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type AuthorityFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// AuthoritySession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type AuthoritySession struct { - Contract *Authority // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// AuthorityCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type AuthorityCallerSession struct { - Contract *AuthorityCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// AuthorityTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type AuthorityTransactorSession struct { - Contract *AuthorityTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// AuthorityRaw is an auto generated low-level Go binding around an Ethereum contract. -type AuthorityRaw struct { - Contract *Authority // Generic contract binding to access the raw methods on -} - -// AuthorityCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type AuthorityCallerRaw struct { - Contract *AuthorityCaller // Generic read-only contract binding to access the raw methods on -} - -// AuthorityTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type AuthorityTransactorRaw struct { - Contract *AuthorityTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewAuthority creates a new instance of Authority, bound to a specific deployed contract. -func NewAuthority(address common.Address, backend bind.ContractBackend) (*Authority, error) { - contract, err := bindAuthority(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &Authority{AuthorityCaller: AuthorityCaller{contract: contract}, AuthorityTransactor: AuthorityTransactor{contract: contract}, AuthorityFilterer: AuthorityFilterer{contract: contract}}, nil -} - -// NewAuthorityCaller creates a new read-only instance of Authority, bound to a specific deployed contract. -func NewAuthorityCaller(address common.Address, caller bind.ContractCaller) (*AuthorityCaller, error) { - contract, err := bindAuthority(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &AuthorityCaller{contract: contract}, nil -} - -// NewAuthorityTransactor creates a new write-only instance of Authority, bound to a specific deployed contract. -func NewAuthorityTransactor(address common.Address, transactor bind.ContractTransactor) (*AuthorityTransactor, error) { - contract, err := bindAuthority(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &AuthorityTransactor{contract: contract}, nil -} - -// NewAuthorityFilterer creates a new log filterer instance of Authority, bound to a specific deployed contract. -func NewAuthorityFilterer(address common.Address, filterer bind.ContractFilterer) (*AuthorityFilterer, error) { - contract, err := bindAuthority(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &AuthorityFilterer{contract: contract}, nil -} - -// bindAuthority binds a generic wrapper to an already deployed contract. -func bindAuthority(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := AuthorityMetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_Authority *AuthorityRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _Authority.Contract.AuthorityCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_Authority *AuthorityRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Authority.Contract.AuthorityTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_Authority *AuthorityRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _Authority.Contract.AuthorityTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_Authority *AuthorityCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _Authority.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_Authority *AuthorityTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Authority.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_Authority *AuthorityTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _Authority.Contract.contract.Transact(opts, method, params...) -} - -// GetClaim is a free data retrieval call binding the contract method 0xd79a8240. -// -// Solidity: function getClaim(address _dapp, bytes _proofContext) view returns(bytes32, uint256, uint256) -func (_Authority *AuthorityCaller) GetClaim(opts *bind.CallOpts, _dapp common.Address, _proofContext []byte) ([32]byte, *big.Int, *big.Int, error) { - var out []interface{} - err := _Authority.contract.Call(opts, &out, "getClaim", _dapp, _proofContext) - - if err != nil { - return *new([32]byte), *new(*big.Int), *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - out1 := *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) - out2 := *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) - - return out0, out1, out2, err - -} - -// GetClaim is a free data retrieval call binding the contract method 0xd79a8240. -// -// Solidity: function getClaim(address _dapp, bytes _proofContext) view returns(bytes32, uint256, uint256) -func (_Authority *AuthoritySession) GetClaim(_dapp common.Address, _proofContext []byte) ([32]byte, *big.Int, *big.Int, error) { - return _Authority.Contract.GetClaim(&_Authority.CallOpts, _dapp, _proofContext) -} - -// GetClaim is a free data retrieval call binding the contract method 0xd79a8240. -// -// Solidity: function getClaim(address _dapp, bytes _proofContext) view returns(bytes32, uint256, uint256) -func (_Authority *AuthorityCallerSession) GetClaim(_dapp common.Address, _proofContext []byte) ([32]byte, *big.Int, *big.Int, error) { - return _Authority.Contract.GetClaim(&_Authority.CallOpts, _dapp, _proofContext) -} - -// GetHistory is a free data retrieval call binding the contract method 0xaa15efc8. -// -// Solidity: function getHistory() view returns(address) -func (_Authority *AuthorityCaller) GetHistory(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _Authority.contract.Call(opts, &out, "getHistory") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// GetHistory is a free data retrieval call binding the contract method 0xaa15efc8. -// -// Solidity: function getHistory() view returns(address) -func (_Authority *AuthoritySession) GetHistory() (common.Address, error) { - return _Authority.Contract.GetHistory(&_Authority.CallOpts) -} - -// GetHistory is a free data retrieval call binding the contract method 0xaa15efc8. -// -// Solidity: function getHistory() view returns(address) -func (_Authority *AuthorityCallerSession) GetHistory() (common.Address, error) { - return _Authority.Contract.GetHistory(&_Authority.CallOpts) -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_Authority *AuthorityCaller) Owner(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _Authority.contract.Call(opts, &out, "owner") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_Authority *AuthoritySession) Owner() (common.Address, error) { - return _Authority.Contract.Owner(&_Authority.CallOpts) -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_Authority *AuthorityCallerSession) Owner() (common.Address, error) { - return _Authority.Contract.Owner(&_Authority.CallOpts) -} - -// Join is a paid mutator transaction binding the contract method 0xb688a363. -// -// Solidity: function join() returns() -func (_Authority *AuthorityTransactor) Join(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Authority.contract.Transact(opts, "join") -} - -// Join is a paid mutator transaction binding the contract method 0xb688a363. -// -// Solidity: function join() returns() -func (_Authority *AuthoritySession) Join() (*types.Transaction, error) { - return _Authority.Contract.Join(&_Authority.TransactOpts) -} - -// Join is a paid mutator transaction binding the contract method 0xb688a363. -// -// Solidity: function join() returns() -func (_Authority *AuthorityTransactorSession) Join() (*types.Transaction, error) { - return _Authority.Contract.Join(&_Authority.TransactOpts) -} - -// MigrateHistoryToConsensus is a paid mutator transaction binding the contract method 0x9368a3d3. -// -// Solidity: function migrateHistoryToConsensus(address _consensus) returns() -func (_Authority *AuthorityTransactor) MigrateHistoryToConsensus(opts *bind.TransactOpts, _consensus common.Address) (*types.Transaction, error) { - return _Authority.contract.Transact(opts, "migrateHistoryToConsensus", _consensus) -} - -// MigrateHistoryToConsensus is a paid mutator transaction binding the contract method 0x9368a3d3. -// -// Solidity: function migrateHistoryToConsensus(address _consensus) returns() -func (_Authority *AuthoritySession) MigrateHistoryToConsensus(_consensus common.Address) (*types.Transaction, error) { - return _Authority.Contract.MigrateHistoryToConsensus(&_Authority.TransactOpts, _consensus) -} - -// MigrateHistoryToConsensus is a paid mutator transaction binding the contract method 0x9368a3d3. -// -// Solidity: function migrateHistoryToConsensus(address _consensus) returns() -func (_Authority *AuthorityTransactorSession) MigrateHistoryToConsensus(_consensus common.Address) (*types.Transaction, error) { - return _Authority.Contract.MigrateHistoryToConsensus(&_Authority.TransactOpts, _consensus) -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_Authority *AuthorityTransactor) RenounceOwnership(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Authority.contract.Transact(opts, "renounceOwnership") -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_Authority *AuthoritySession) RenounceOwnership() (*types.Transaction, error) { - return _Authority.Contract.RenounceOwnership(&_Authority.TransactOpts) -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_Authority *AuthorityTransactorSession) RenounceOwnership() (*types.Transaction, error) { - return _Authority.Contract.RenounceOwnership(&_Authority.TransactOpts) -} - -// SetHistory is a paid mutator transaction binding the contract method 0x159c5ea1. -// -// Solidity: function setHistory(address _history) returns() -func (_Authority *AuthorityTransactor) SetHistory(opts *bind.TransactOpts, _history common.Address) (*types.Transaction, error) { - return _Authority.contract.Transact(opts, "setHistory", _history) -} - -// SetHistory is a paid mutator transaction binding the contract method 0x159c5ea1. -// -// Solidity: function setHistory(address _history) returns() -func (_Authority *AuthoritySession) SetHistory(_history common.Address) (*types.Transaction, error) { - return _Authority.Contract.SetHistory(&_Authority.TransactOpts, _history) -} - -// SetHistory is a paid mutator transaction binding the contract method 0x159c5ea1. -// -// Solidity: function setHistory(address _history) returns() -func (_Authority *AuthorityTransactorSession) SetHistory(_history common.Address) (*types.Transaction, error) { - return _Authority.Contract.SetHistory(&_Authority.TransactOpts, _history) -} - -// SubmitClaim is a paid mutator transaction binding the contract method 0xddfdfbb0. -// -// Solidity: function submitClaim(bytes _claimData) returns() -func (_Authority *AuthorityTransactor) SubmitClaim(opts *bind.TransactOpts, _claimData []byte) (*types.Transaction, error) { - return _Authority.contract.Transact(opts, "submitClaim", _claimData) -} - -// SubmitClaim is a paid mutator transaction binding the contract method 0xddfdfbb0. -// -// Solidity: function submitClaim(bytes _claimData) returns() -func (_Authority *AuthoritySession) SubmitClaim(_claimData []byte) (*types.Transaction, error) { - return _Authority.Contract.SubmitClaim(&_Authority.TransactOpts, _claimData) -} - -// SubmitClaim is a paid mutator transaction binding the contract method 0xddfdfbb0. -// -// Solidity: function submitClaim(bytes _claimData) returns() -func (_Authority *AuthorityTransactorSession) SubmitClaim(_claimData []byte) (*types.Transaction, error) { - return _Authority.Contract.SubmitClaim(&_Authority.TransactOpts, _claimData) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_Authority *AuthorityTransactor) TransferOwnership(opts *bind.TransactOpts, newOwner common.Address) (*types.Transaction, error) { - return _Authority.contract.Transact(opts, "transferOwnership", newOwner) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_Authority *AuthoritySession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { - return _Authority.Contract.TransferOwnership(&_Authority.TransactOpts, newOwner) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_Authority *AuthorityTransactorSession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { - return _Authority.Contract.TransferOwnership(&_Authority.TransactOpts, newOwner) -} - -// WithdrawERC20Tokens is a paid mutator transaction binding the contract method 0xbcdd1e13. -// -// Solidity: function withdrawERC20Tokens(address _token, address _recipient, uint256 _amount) returns() -func (_Authority *AuthorityTransactor) WithdrawERC20Tokens(opts *bind.TransactOpts, _token common.Address, _recipient common.Address, _amount *big.Int) (*types.Transaction, error) { - return _Authority.contract.Transact(opts, "withdrawERC20Tokens", _token, _recipient, _amount) -} - -// WithdrawERC20Tokens is a paid mutator transaction binding the contract method 0xbcdd1e13. -// -// Solidity: function withdrawERC20Tokens(address _token, address _recipient, uint256 _amount) returns() -func (_Authority *AuthoritySession) WithdrawERC20Tokens(_token common.Address, _recipient common.Address, _amount *big.Int) (*types.Transaction, error) { - return _Authority.Contract.WithdrawERC20Tokens(&_Authority.TransactOpts, _token, _recipient, _amount) -} - -// WithdrawERC20Tokens is a paid mutator transaction binding the contract method 0xbcdd1e13. -// -// Solidity: function withdrawERC20Tokens(address _token, address _recipient, uint256 _amount) returns() -func (_Authority *AuthorityTransactorSession) WithdrawERC20Tokens(_token common.Address, _recipient common.Address, _amount *big.Int) (*types.Transaction, error) { - return _Authority.Contract.WithdrawERC20Tokens(&_Authority.TransactOpts, _token, _recipient, _amount) -} - -// AuthorityApplicationJoinedIterator is returned from FilterApplicationJoined and is used to iterate over the raw logs and unpacked data for ApplicationJoined events raised by the Authority contract. -type AuthorityApplicationJoinedIterator struct { - Event *AuthorityApplicationJoined // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AuthorityApplicationJoinedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AuthorityApplicationJoined) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AuthorityApplicationJoined) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AuthorityApplicationJoinedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AuthorityApplicationJoinedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AuthorityApplicationJoined represents a ApplicationJoined event raised by the Authority contract. -type AuthorityApplicationJoined struct { - Application common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterApplicationJoined is a free log retrieval operation binding the contract event 0x27c2b702d3bff195a18baca2daf00b20a986177c5f1449af4e2d46a3c3e02ce5. -// -// Solidity: event ApplicationJoined(address application) -func (_Authority *AuthorityFilterer) FilterApplicationJoined(opts *bind.FilterOpts) (*AuthorityApplicationJoinedIterator, error) { - - logs, sub, err := _Authority.contract.FilterLogs(opts, "ApplicationJoined") - if err != nil { - return nil, err - } - return &AuthorityApplicationJoinedIterator{contract: _Authority.contract, event: "ApplicationJoined", logs: logs, sub: sub}, nil -} - -// WatchApplicationJoined is a free log subscription operation binding the contract event 0x27c2b702d3bff195a18baca2daf00b20a986177c5f1449af4e2d46a3c3e02ce5. -// -// Solidity: event ApplicationJoined(address application) -func (_Authority *AuthorityFilterer) WatchApplicationJoined(opts *bind.WatchOpts, sink chan<- *AuthorityApplicationJoined) (event.Subscription, error) { - - logs, sub, err := _Authority.contract.WatchLogs(opts, "ApplicationJoined") - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AuthorityApplicationJoined) - if err := _Authority.contract.UnpackLog(event, "ApplicationJoined", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseApplicationJoined is a log parse operation binding the contract event 0x27c2b702d3bff195a18baca2daf00b20a986177c5f1449af4e2d46a3c3e02ce5. -// -// Solidity: event ApplicationJoined(address application) -func (_Authority *AuthorityFilterer) ParseApplicationJoined(log types.Log) (*AuthorityApplicationJoined, error) { - event := new(AuthorityApplicationJoined) - if err := _Authority.contract.UnpackLog(event, "ApplicationJoined", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AuthorityNewHistoryIterator is returned from FilterNewHistory and is used to iterate over the raw logs and unpacked data for NewHistory events raised by the Authority contract. -type AuthorityNewHistoryIterator struct { - Event *AuthorityNewHistory // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AuthorityNewHistoryIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AuthorityNewHistory) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AuthorityNewHistory) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AuthorityNewHistoryIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AuthorityNewHistoryIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AuthorityNewHistory represents a NewHistory event raised by the Authority contract. -type AuthorityNewHistory struct { - History common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterNewHistory is a free log retrieval operation binding the contract event 0x2bcd43869347a1d42f97ac6042f3d129817abd05a6125f9750fe3724e321d23e. -// -// Solidity: event NewHistory(address history) -func (_Authority *AuthorityFilterer) FilterNewHistory(opts *bind.FilterOpts) (*AuthorityNewHistoryIterator, error) { - - logs, sub, err := _Authority.contract.FilterLogs(opts, "NewHistory") - if err != nil { - return nil, err - } - return &AuthorityNewHistoryIterator{contract: _Authority.contract, event: "NewHistory", logs: logs, sub: sub}, nil -} - -// WatchNewHistory is a free log subscription operation binding the contract event 0x2bcd43869347a1d42f97ac6042f3d129817abd05a6125f9750fe3724e321d23e. -// -// Solidity: event NewHistory(address history) -func (_Authority *AuthorityFilterer) WatchNewHistory(opts *bind.WatchOpts, sink chan<- *AuthorityNewHistory) (event.Subscription, error) { - - logs, sub, err := _Authority.contract.WatchLogs(opts, "NewHistory") - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AuthorityNewHistory) - if err := _Authority.contract.UnpackLog(event, "NewHistory", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseNewHistory is a log parse operation binding the contract event 0x2bcd43869347a1d42f97ac6042f3d129817abd05a6125f9750fe3724e321d23e. -// -// Solidity: event NewHistory(address history) -func (_Authority *AuthorityFilterer) ParseNewHistory(log types.Log) (*AuthorityNewHistory, error) { - event := new(AuthorityNewHistory) - if err := _Authority.contract.UnpackLog(event, "NewHistory", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AuthorityOwnershipTransferredIterator is returned from FilterOwnershipTransferred and is used to iterate over the raw logs and unpacked data for OwnershipTransferred events raised by the Authority contract. -type AuthorityOwnershipTransferredIterator struct { - Event *AuthorityOwnershipTransferred // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AuthorityOwnershipTransferredIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AuthorityOwnershipTransferred) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AuthorityOwnershipTransferred) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AuthorityOwnershipTransferredIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AuthorityOwnershipTransferredIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AuthorityOwnershipTransferred represents a OwnershipTransferred event raised by the Authority contract. -type AuthorityOwnershipTransferred struct { - PreviousOwner common.Address - NewOwner common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterOwnershipTransferred is a free log retrieval operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_Authority *AuthorityFilterer) FilterOwnershipTransferred(opts *bind.FilterOpts, previousOwner []common.Address, newOwner []common.Address) (*AuthorityOwnershipTransferredIterator, error) { - - var previousOwnerRule []interface{} - for _, previousOwnerItem := range previousOwner { - previousOwnerRule = append(previousOwnerRule, previousOwnerItem) - } - var newOwnerRule []interface{} - for _, newOwnerItem := range newOwner { - newOwnerRule = append(newOwnerRule, newOwnerItem) - } - - logs, sub, err := _Authority.contract.FilterLogs(opts, "OwnershipTransferred", previousOwnerRule, newOwnerRule) - if err != nil { - return nil, err - } - return &AuthorityOwnershipTransferredIterator{contract: _Authority.contract, event: "OwnershipTransferred", logs: logs, sub: sub}, nil -} - -// WatchOwnershipTransferred is a free log subscription operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_Authority *AuthorityFilterer) WatchOwnershipTransferred(opts *bind.WatchOpts, sink chan<- *AuthorityOwnershipTransferred, previousOwner []common.Address, newOwner []common.Address) (event.Subscription, error) { - - var previousOwnerRule []interface{} - for _, previousOwnerItem := range previousOwner { - previousOwnerRule = append(previousOwnerRule, previousOwnerItem) - } - var newOwnerRule []interface{} - for _, newOwnerItem := range newOwner { - newOwnerRule = append(newOwnerRule, newOwnerItem) - } - - logs, sub, err := _Authority.contract.WatchLogs(opts, "OwnershipTransferred", previousOwnerRule, newOwnerRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AuthorityOwnershipTransferred) - if err := _Authority.contract.UnpackLog(event, "OwnershipTransferred", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseOwnershipTransferred is a log parse operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_Authority *AuthorityFilterer) ParseOwnershipTransferred(log types.Log) (*AuthorityOwnershipTransferred, error) { - event := new(AuthorityOwnershipTransferred) - if err := _Authority.contract.UnpackLog(event, "OwnershipTransferred", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} diff --git a/pkg/contracts/cartesi_dapp.go b/pkg/contracts/cartesi_dapp.go deleted file mode 100644 index cb9c0cfe6..000000000 --- a/pkg/contracts/cartesi_dapp.go +++ /dev/null @@ -1,995 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package contracts - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription - _ = abi.ConvertType -) - -// OutputValidityProof is an auto generated low-level Go binding around an user-defined struct. -type OutputValidityProof struct { - InputIndexWithinEpoch uint64 - OutputIndexWithinInput uint64 - OutputHashesRootHash [32]byte - VouchersEpochRootHash [32]byte - NoticesEpochRootHash [32]byte - MachineStateHash [32]byte - OutputHashInOutputHashesSiblings [][32]byte - OutputHashesInEpochSiblings [][32]byte -} - -// Proof is an auto generated low-level Go binding around an user-defined struct. -type Proof struct { - Validity OutputValidityProof - Context []byte -} - -// CartesiDAppMetaData contains all meta data concerning the CartesiDApp contract. -var CartesiDAppMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"contractIConsensus\",\"name\":\"_consensus\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_owner\",\"type\":\"address\"},{\"internalType\":\"bytes32\",\"name\":\"_templateHash\",\"type\":\"bytes32\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"EtherTransferFailed\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"IncorrectEpochHash\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"IncorrectOutputHashesRootHash\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"IncorrectOutputsEpochRootHash\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InputIndexOutOfClaimBounds\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"OnlyDApp\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"VoucherReexecutionNotAllowed\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"contractIConsensus\",\"name\":\"newConsensus\",\"type\":\"address\"}],\"name\":\"NewConsensus\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"voucherId\",\"type\":\"uint256\"}],\"name\":\"VoucherExecuted\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_destination\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"_payload\",\"type\":\"bytes\"},{\"components\":[{\"components\":[{\"internalType\":\"uint64\",\"name\":\"inputIndexWithinEpoch\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"outputIndexWithinInput\",\"type\":\"uint64\"},{\"internalType\":\"bytes32\",\"name\":\"outputHashesRootHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"vouchersEpochRootHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"noticesEpochRootHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"machineStateHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32[]\",\"name\":\"outputHashInOutputHashesSiblings\",\"type\":\"bytes32[]\"},{\"internalType\":\"bytes32[]\",\"name\":\"outputHashesInEpochSiblings\",\"type\":\"bytes32[]\"}],\"internalType\":\"structOutputValidityProof\",\"name\":\"validity\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structProof\",\"name\":\"_proof\",\"type\":\"tuple\"}],\"name\":\"executeVoucher\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getConsensus\",\"outputs\":[{\"internalType\":\"contractIConsensus\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getTemplateHash\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractIConsensus\",\"name\":\"_newConsensus\",\"type\":\"address\"}],\"name\":\"migrateToConsensus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"uint256[]\",\"name\":\"\",\"type\":\"uint256[]\"},{\"internalType\":\"uint256[]\",\"name\":\"\",\"type\":\"uint256[]\"},{\"internalType\":\"bytes\",\"name\":\"\",\"type\":\"bytes\"}],\"name\":\"onERC1155BatchReceived\",\"outputs\":[{\"internalType\":\"bytes4\",\"name\":\"\",\"type\":\"bytes4\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"\",\"type\":\"bytes\"}],\"name\":\"onERC1155Received\",\"outputs\":[{\"internalType\":\"bytes4\",\"name\":\"\",\"type\":\"bytes4\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"\",\"type\":\"bytes\"}],\"name\":\"onERC721Received\",\"outputs\":[{\"internalType\":\"bytes4\",\"name\":\"\",\"type\":\"bytes4\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes4\",\"name\":\"interfaceId\",\"type\":\"bytes4\"}],\"name\":\"supportsInterface\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"_notice\",\"type\":\"bytes\"},{\"components\":[{\"components\":[{\"internalType\":\"uint64\",\"name\":\"inputIndexWithinEpoch\",\"type\":\"uint64\"},{\"internalType\":\"uint64\",\"name\":\"outputIndexWithinInput\",\"type\":\"uint64\"},{\"internalType\":\"bytes32\",\"name\":\"outputHashesRootHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"vouchersEpochRootHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"noticesEpochRootHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"machineStateHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32[]\",\"name\":\"outputHashInOutputHashesSiblings\",\"type\":\"bytes32[]\"},{\"internalType\":\"bytes32[]\",\"name\":\"outputHashesInEpochSiblings\",\"type\":\"bytes32[]\"}],\"internalType\":\"structOutputValidityProof\",\"name\":\"validity\",\"type\":\"tuple\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structProof\",\"name\":\"_proof\",\"type\":\"tuple\"}],\"name\":\"validateNotice\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_inputIndex\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_outputIndexWithinInput\",\"type\":\"uint256\"}],\"name\":\"wasVoucherExecuted\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_receiver\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_value\",\"type\":\"uint256\"}],\"name\":\"withdrawEther\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"stateMutability\":\"payable\",\"type\":\"receive\"}]", -} - -// CartesiDAppABI is the input ABI used to generate the binding from. -// Deprecated: Use CartesiDAppMetaData.ABI instead. -var CartesiDAppABI = CartesiDAppMetaData.ABI - -// CartesiDApp is an auto generated Go binding around an Ethereum contract. -type CartesiDApp struct { - CartesiDAppCaller // Read-only binding to the contract - CartesiDAppTransactor // Write-only binding to the contract - CartesiDAppFilterer // Log filterer for contract events -} - -// CartesiDAppCaller is an auto generated read-only Go binding around an Ethereum contract. -type CartesiDAppCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// CartesiDAppTransactor is an auto generated write-only Go binding around an Ethereum contract. -type CartesiDAppTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// CartesiDAppFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type CartesiDAppFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// CartesiDAppSession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type CartesiDAppSession struct { - Contract *CartesiDApp // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// CartesiDAppCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type CartesiDAppCallerSession struct { - Contract *CartesiDAppCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// CartesiDAppTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type CartesiDAppTransactorSession struct { - Contract *CartesiDAppTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// CartesiDAppRaw is an auto generated low-level Go binding around an Ethereum contract. -type CartesiDAppRaw struct { - Contract *CartesiDApp // Generic contract binding to access the raw methods on -} - -// CartesiDAppCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type CartesiDAppCallerRaw struct { - Contract *CartesiDAppCaller // Generic read-only contract binding to access the raw methods on -} - -// CartesiDAppTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type CartesiDAppTransactorRaw struct { - Contract *CartesiDAppTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewCartesiDApp creates a new instance of CartesiDApp, bound to a specific deployed contract. -func NewCartesiDApp(address common.Address, backend bind.ContractBackend) (*CartesiDApp, error) { - contract, err := bindCartesiDApp(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &CartesiDApp{CartesiDAppCaller: CartesiDAppCaller{contract: contract}, CartesiDAppTransactor: CartesiDAppTransactor{contract: contract}, CartesiDAppFilterer: CartesiDAppFilterer{contract: contract}}, nil -} - -// NewCartesiDAppCaller creates a new read-only instance of CartesiDApp, bound to a specific deployed contract. -func NewCartesiDAppCaller(address common.Address, caller bind.ContractCaller) (*CartesiDAppCaller, error) { - contract, err := bindCartesiDApp(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &CartesiDAppCaller{contract: contract}, nil -} - -// NewCartesiDAppTransactor creates a new write-only instance of CartesiDApp, bound to a specific deployed contract. -func NewCartesiDAppTransactor(address common.Address, transactor bind.ContractTransactor) (*CartesiDAppTransactor, error) { - contract, err := bindCartesiDApp(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &CartesiDAppTransactor{contract: contract}, nil -} - -// NewCartesiDAppFilterer creates a new log filterer instance of CartesiDApp, bound to a specific deployed contract. -func NewCartesiDAppFilterer(address common.Address, filterer bind.ContractFilterer) (*CartesiDAppFilterer, error) { - contract, err := bindCartesiDApp(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &CartesiDAppFilterer{contract: contract}, nil -} - -// bindCartesiDApp binds a generic wrapper to an already deployed contract. -func bindCartesiDApp(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := CartesiDAppMetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_CartesiDApp *CartesiDAppRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _CartesiDApp.Contract.CartesiDAppCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_CartesiDApp *CartesiDAppRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _CartesiDApp.Contract.CartesiDAppTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_CartesiDApp *CartesiDAppRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _CartesiDApp.Contract.CartesiDAppTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_CartesiDApp *CartesiDAppCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _CartesiDApp.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_CartesiDApp *CartesiDAppTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _CartesiDApp.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_CartesiDApp *CartesiDAppTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _CartesiDApp.Contract.contract.Transact(opts, method, params...) -} - -// GetConsensus is a free data retrieval call binding the contract method 0x179e740b. -// -// Solidity: function getConsensus() view returns(address) -func (_CartesiDApp *CartesiDAppCaller) GetConsensus(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _CartesiDApp.contract.Call(opts, &out, "getConsensus") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// GetConsensus is a free data retrieval call binding the contract method 0x179e740b. -// -// Solidity: function getConsensus() view returns(address) -func (_CartesiDApp *CartesiDAppSession) GetConsensus() (common.Address, error) { - return _CartesiDApp.Contract.GetConsensus(&_CartesiDApp.CallOpts) -} - -// GetConsensus is a free data retrieval call binding the contract method 0x179e740b. -// -// Solidity: function getConsensus() view returns(address) -func (_CartesiDApp *CartesiDAppCallerSession) GetConsensus() (common.Address, error) { - return _CartesiDApp.Contract.GetConsensus(&_CartesiDApp.CallOpts) -} - -// GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. -// -// Solidity: function getTemplateHash() view returns(bytes32) -func (_CartesiDApp *CartesiDAppCaller) GetTemplateHash(opts *bind.CallOpts) ([32]byte, error) { - var out []interface{} - err := _CartesiDApp.contract.Call(opts, &out, "getTemplateHash") - - if err != nil { - return *new([32]byte), err - } - - out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - - return out0, err - -} - -// GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. -// -// Solidity: function getTemplateHash() view returns(bytes32) -func (_CartesiDApp *CartesiDAppSession) GetTemplateHash() ([32]byte, error) { - return _CartesiDApp.Contract.GetTemplateHash(&_CartesiDApp.CallOpts) -} - -// GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. -// -// Solidity: function getTemplateHash() view returns(bytes32) -func (_CartesiDApp *CartesiDAppCallerSession) GetTemplateHash() ([32]byte, error) { - return _CartesiDApp.Contract.GetTemplateHash(&_CartesiDApp.CallOpts) -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_CartesiDApp *CartesiDAppCaller) Owner(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _CartesiDApp.contract.Call(opts, &out, "owner") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_CartesiDApp *CartesiDAppSession) Owner() (common.Address, error) { - return _CartesiDApp.Contract.Owner(&_CartesiDApp.CallOpts) -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_CartesiDApp *CartesiDAppCallerSession) Owner() (common.Address, error) { - return _CartesiDApp.Contract.Owner(&_CartesiDApp.CallOpts) -} - -// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. -// -// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) -func (_CartesiDApp *CartesiDAppCaller) SupportsInterface(opts *bind.CallOpts, interfaceId [4]byte) (bool, error) { - var out []interface{} - err := _CartesiDApp.contract.Call(opts, &out, "supportsInterface", interfaceId) - - if err != nil { - return *new(bool), err - } - - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - - return out0, err - -} - -// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. -// -// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) -func (_CartesiDApp *CartesiDAppSession) SupportsInterface(interfaceId [4]byte) (bool, error) { - return _CartesiDApp.Contract.SupportsInterface(&_CartesiDApp.CallOpts, interfaceId) -} - -// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. -// -// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) -func (_CartesiDApp *CartesiDAppCallerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { - return _CartesiDApp.Contract.SupportsInterface(&_CartesiDApp.CallOpts, interfaceId) -} - -// ValidateNotice is a free data retrieval call binding the contract method 0x96487d46. -// -// Solidity: function validateNotice(bytes _notice, ((uint64,uint64,bytes32,bytes32,bytes32,bytes32,bytes32[],bytes32[]),bytes) _proof) view returns(bool) -func (_CartesiDApp *CartesiDAppCaller) ValidateNotice(opts *bind.CallOpts, _notice []byte, _proof Proof) (bool, error) { - var out []interface{} - err := _CartesiDApp.contract.Call(opts, &out, "validateNotice", _notice, _proof) - - if err != nil { - return *new(bool), err - } - - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - - return out0, err - -} - -// ValidateNotice is a free data retrieval call binding the contract method 0x96487d46. -// -// Solidity: function validateNotice(bytes _notice, ((uint64,uint64,bytes32,bytes32,bytes32,bytes32,bytes32[],bytes32[]),bytes) _proof) view returns(bool) -func (_CartesiDApp *CartesiDAppSession) ValidateNotice(_notice []byte, _proof Proof) (bool, error) { - return _CartesiDApp.Contract.ValidateNotice(&_CartesiDApp.CallOpts, _notice, _proof) -} - -// ValidateNotice is a free data retrieval call binding the contract method 0x96487d46. -// -// Solidity: function validateNotice(bytes _notice, ((uint64,uint64,bytes32,bytes32,bytes32,bytes32,bytes32[],bytes32[]),bytes) _proof) view returns(bool) -func (_CartesiDApp *CartesiDAppCallerSession) ValidateNotice(_notice []byte, _proof Proof) (bool, error) { - return _CartesiDApp.Contract.ValidateNotice(&_CartesiDApp.CallOpts, _notice, _proof) -} - -// WasVoucherExecuted is a free data retrieval call binding the contract method 0x9d9b1145. -// -// Solidity: function wasVoucherExecuted(uint256 _inputIndex, uint256 _outputIndexWithinInput) view returns(bool) -func (_CartesiDApp *CartesiDAppCaller) WasVoucherExecuted(opts *bind.CallOpts, _inputIndex *big.Int, _outputIndexWithinInput *big.Int) (bool, error) { - var out []interface{} - err := _CartesiDApp.contract.Call(opts, &out, "wasVoucherExecuted", _inputIndex, _outputIndexWithinInput) - - if err != nil { - return *new(bool), err - } - - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - - return out0, err - -} - -// WasVoucherExecuted is a free data retrieval call binding the contract method 0x9d9b1145. -// -// Solidity: function wasVoucherExecuted(uint256 _inputIndex, uint256 _outputIndexWithinInput) view returns(bool) -func (_CartesiDApp *CartesiDAppSession) WasVoucherExecuted(_inputIndex *big.Int, _outputIndexWithinInput *big.Int) (bool, error) { - return _CartesiDApp.Contract.WasVoucherExecuted(&_CartesiDApp.CallOpts, _inputIndex, _outputIndexWithinInput) -} - -// WasVoucherExecuted is a free data retrieval call binding the contract method 0x9d9b1145. -// -// Solidity: function wasVoucherExecuted(uint256 _inputIndex, uint256 _outputIndexWithinInput) view returns(bool) -func (_CartesiDApp *CartesiDAppCallerSession) WasVoucherExecuted(_inputIndex *big.Int, _outputIndexWithinInput *big.Int) (bool, error) { - return _CartesiDApp.Contract.WasVoucherExecuted(&_CartesiDApp.CallOpts, _inputIndex, _outputIndexWithinInput) -} - -// ExecuteVoucher is a paid mutator transaction binding the contract method 0x1250482f. -// -// Solidity: function executeVoucher(address _destination, bytes _payload, ((uint64,uint64,bytes32,bytes32,bytes32,bytes32,bytes32[],bytes32[]),bytes) _proof) returns(bool) -func (_CartesiDApp *CartesiDAppTransactor) ExecuteVoucher(opts *bind.TransactOpts, _destination common.Address, _payload []byte, _proof Proof) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "executeVoucher", _destination, _payload, _proof) -} - -// ExecuteVoucher is a paid mutator transaction binding the contract method 0x1250482f. -// -// Solidity: function executeVoucher(address _destination, bytes _payload, ((uint64,uint64,bytes32,bytes32,bytes32,bytes32,bytes32[],bytes32[]),bytes) _proof) returns(bool) -func (_CartesiDApp *CartesiDAppSession) ExecuteVoucher(_destination common.Address, _payload []byte, _proof Proof) (*types.Transaction, error) { - return _CartesiDApp.Contract.ExecuteVoucher(&_CartesiDApp.TransactOpts, _destination, _payload, _proof) -} - -// ExecuteVoucher is a paid mutator transaction binding the contract method 0x1250482f. -// -// Solidity: function executeVoucher(address _destination, bytes _payload, ((uint64,uint64,bytes32,bytes32,bytes32,bytes32,bytes32[],bytes32[]),bytes) _proof) returns(bool) -func (_CartesiDApp *CartesiDAppTransactorSession) ExecuteVoucher(_destination common.Address, _payload []byte, _proof Proof) (*types.Transaction, error) { - return _CartesiDApp.Contract.ExecuteVoucher(&_CartesiDApp.TransactOpts, _destination, _payload, _proof) -} - -// MigrateToConsensus is a paid mutator transaction binding the contract method 0xfc411683. -// -// Solidity: function migrateToConsensus(address _newConsensus) returns() -func (_CartesiDApp *CartesiDAppTransactor) MigrateToConsensus(opts *bind.TransactOpts, _newConsensus common.Address) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "migrateToConsensus", _newConsensus) -} - -// MigrateToConsensus is a paid mutator transaction binding the contract method 0xfc411683. -// -// Solidity: function migrateToConsensus(address _newConsensus) returns() -func (_CartesiDApp *CartesiDAppSession) MigrateToConsensus(_newConsensus common.Address) (*types.Transaction, error) { - return _CartesiDApp.Contract.MigrateToConsensus(&_CartesiDApp.TransactOpts, _newConsensus) -} - -// MigrateToConsensus is a paid mutator transaction binding the contract method 0xfc411683. -// -// Solidity: function migrateToConsensus(address _newConsensus) returns() -func (_CartesiDApp *CartesiDAppTransactorSession) MigrateToConsensus(_newConsensus common.Address) (*types.Transaction, error) { - return _CartesiDApp.Contract.MigrateToConsensus(&_CartesiDApp.TransactOpts, _newConsensus) -} - -// OnERC1155BatchReceived is a paid mutator transaction binding the contract method 0xbc197c81. -// -// Solidity: function onERC1155BatchReceived(address , address , uint256[] , uint256[] , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppTransactor) OnERC1155BatchReceived(opts *bind.TransactOpts, arg0 common.Address, arg1 common.Address, arg2 []*big.Int, arg3 []*big.Int, arg4 []byte) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "onERC1155BatchReceived", arg0, arg1, arg2, arg3, arg4) -} - -// OnERC1155BatchReceived is a paid mutator transaction binding the contract method 0xbc197c81. -// -// Solidity: function onERC1155BatchReceived(address , address , uint256[] , uint256[] , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppSession) OnERC1155BatchReceived(arg0 common.Address, arg1 common.Address, arg2 []*big.Int, arg3 []*big.Int, arg4 []byte) (*types.Transaction, error) { - return _CartesiDApp.Contract.OnERC1155BatchReceived(&_CartesiDApp.TransactOpts, arg0, arg1, arg2, arg3, arg4) -} - -// OnERC1155BatchReceived is a paid mutator transaction binding the contract method 0xbc197c81. -// -// Solidity: function onERC1155BatchReceived(address , address , uint256[] , uint256[] , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppTransactorSession) OnERC1155BatchReceived(arg0 common.Address, arg1 common.Address, arg2 []*big.Int, arg3 []*big.Int, arg4 []byte) (*types.Transaction, error) { - return _CartesiDApp.Contract.OnERC1155BatchReceived(&_CartesiDApp.TransactOpts, arg0, arg1, arg2, arg3, arg4) -} - -// OnERC1155Received is a paid mutator transaction binding the contract method 0xf23a6e61. -// -// Solidity: function onERC1155Received(address , address , uint256 , uint256 , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppTransactor) OnERC1155Received(opts *bind.TransactOpts, arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 *big.Int, arg4 []byte) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "onERC1155Received", arg0, arg1, arg2, arg3, arg4) -} - -// OnERC1155Received is a paid mutator transaction binding the contract method 0xf23a6e61. -// -// Solidity: function onERC1155Received(address , address , uint256 , uint256 , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppSession) OnERC1155Received(arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 *big.Int, arg4 []byte) (*types.Transaction, error) { - return _CartesiDApp.Contract.OnERC1155Received(&_CartesiDApp.TransactOpts, arg0, arg1, arg2, arg3, arg4) -} - -// OnERC1155Received is a paid mutator transaction binding the contract method 0xf23a6e61. -// -// Solidity: function onERC1155Received(address , address , uint256 , uint256 , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppTransactorSession) OnERC1155Received(arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 *big.Int, arg4 []byte) (*types.Transaction, error) { - return _CartesiDApp.Contract.OnERC1155Received(&_CartesiDApp.TransactOpts, arg0, arg1, arg2, arg3, arg4) -} - -// OnERC721Received is a paid mutator transaction binding the contract method 0x150b7a02. -// -// Solidity: function onERC721Received(address , address , uint256 , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppTransactor) OnERC721Received(opts *bind.TransactOpts, arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 []byte) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "onERC721Received", arg0, arg1, arg2, arg3) -} - -// OnERC721Received is a paid mutator transaction binding the contract method 0x150b7a02. -// -// Solidity: function onERC721Received(address , address , uint256 , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppSession) OnERC721Received(arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 []byte) (*types.Transaction, error) { - return _CartesiDApp.Contract.OnERC721Received(&_CartesiDApp.TransactOpts, arg0, arg1, arg2, arg3) -} - -// OnERC721Received is a paid mutator transaction binding the contract method 0x150b7a02. -// -// Solidity: function onERC721Received(address , address , uint256 , bytes ) returns(bytes4) -func (_CartesiDApp *CartesiDAppTransactorSession) OnERC721Received(arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 []byte) (*types.Transaction, error) { - return _CartesiDApp.Contract.OnERC721Received(&_CartesiDApp.TransactOpts, arg0, arg1, arg2, arg3) -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_CartesiDApp *CartesiDAppTransactor) RenounceOwnership(opts *bind.TransactOpts) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "renounceOwnership") -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_CartesiDApp *CartesiDAppSession) RenounceOwnership() (*types.Transaction, error) { - return _CartesiDApp.Contract.RenounceOwnership(&_CartesiDApp.TransactOpts) -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_CartesiDApp *CartesiDAppTransactorSession) RenounceOwnership() (*types.Transaction, error) { - return _CartesiDApp.Contract.RenounceOwnership(&_CartesiDApp.TransactOpts) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_CartesiDApp *CartesiDAppTransactor) TransferOwnership(opts *bind.TransactOpts, newOwner common.Address) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "transferOwnership", newOwner) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_CartesiDApp *CartesiDAppSession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { - return _CartesiDApp.Contract.TransferOwnership(&_CartesiDApp.TransactOpts, newOwner) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_CartesiDApp *CartesiDAppTransactorSession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { - return _CartesiDApp.Contract.TransferOwnership(&_CartesiDApp.TransactOpts, newOwner) -} - -// WithdrawEther is a paid mutator transaction binding the contract method 0x522f6815. -// -// Solidity: function withdrawEther(address _receiver, uint256 _value) returns() -func (_CartesiDApp *CartesiDAppTransactor) WithdrawEther(opts *bind.TransactOpts, _receiver common.Address, _value *big.Int) (*types.Transaction, error) { - return _CartesiDApp.contract.Transact(opts, "withdrawEther", _receiver, _value) -} - -// WithdrawEther is a paid mutator transaction binding the contract method 0x522f6815. -// -// Solidity: function withdrawEther(address _receiver, uint256 _value) returns() -func (_CartesiDApp *CartesiDAppSession) WithdrawEther(_receiver common.Address, _value *big.Int) (*types.Transaction, error) { - return _CartesiDApp.Contract.WithdrawEther(&_CartesiDApp.TransactOpts, _receiver, _value) -} - -// WithdrawEther is a paid mutator transaction binding the contract method 0x522f6815. -// -// Solidity: function withdrawEther(address _receiver, uint256 _value) returns() -func (_CartesiDApp *CartesiDAppTransactorSession) WithdrawEther(_receiver common.Address, _value *big.Int) (*types.Transaction, error) { - return _CartesiDApp.Contract.WithdrawEther(&_CartesiDApp.TransactOpts, _receiver, _value) -} - -// Receive is a paid mutator transaction binding the contract receive function. -// -// Solidity: receive() payable returns() -func (_CartesiDApp *CartesiDAppTransactor) Receive(opts *bind.TransactOpts) (*types.Transaction, error) { - return _CartesiDApp.contract.RawTransact(opts, nil) // calldata is disallowed for receive function -} - -// Receive is a paid mutator transaction binding the contract receive function. -// -// Solidity: receive() payable returns() -func (_CartesiDApp *CartesiDAppSession) Receive() (*types.Transaction, error) { - return _CartesiDApp.Contract.Receive(&_CartesiDApp.TransactOpts) -} - -// Receive is a paid mutator transaction binding the contract receive function. -// -// Solidity: receive() payable returns() -func (_CartesiDApp *CartesiDAppTransactorSession) Receive() (*types.Transaction, error) { - return _CartesiDApp.Contract.Receive(&_CartesiDApp.TransactOpts) -} - -// CartesiDAppNewConsensusIterator is returned from FilterNewConsensus and is used to iterate over the raw logs and unpacked data for NewConsensus events raised by the CartesiDApp contract. -type CartesiDAppNewConsensusIterator struct { - Event *CartesiDAppNewConsensus // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *CartesiDAppNewConsensusIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(CartesiDAppNewConsensus) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(CartesiDAppNewConsensus) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *CartesiDAppNewConsensusIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *CartesiDAppNewConsensusIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// CartesiDAppNewConsensus represents a NewConsensus event raised by the CartesiDApp contract. -type CartesiDAppNewConsensus struct { - NewConsensus common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterNewConsensus is a free log retrieval operation binding the contract event 0x4991c6f37185659e276ff918a96f3e20e6c5abcd8c9aab450dc19c2f7ad35cb5. -// -// Solidity: event NewConsensus(address newConsensus) -func (_CartesiDApp *CartesiDAppFilterer) FilterNewConsensus(opts *bind.FilterOpts) (*CartesiDAppNewConsensusIterator, error) { - - logs, sub, err := _CartesiDApp.contract.FilterLogs(opts, "NewConsensus") - if err != nil { - return nil, err - } - return &CartesiDAppNewConsensusIterator{contract: _CartesiDApp.contract, event: "NewConsensus", logs: logs, sub: sub}, nil -} - -// WatchNewConsensus is a free log subscription operation binding the contract event 0x4991c6f37185659e276ff918a96f3e20e6c5abcd8c9aab450dc19c2f7ad35cb5. -// -// Solidity: event NewConsensus(address newConsensus) -func (_CartesiDApp *CartesiDAppFilterer) WatchNewConsensus(opts *bind.WatchOpts, sink chan<- *CartesiDAppNewConsensus) (event.Subscription, error) { - - logs, sub, err := _CartesiDApp.contract.WatchLogs(opts, "NewConsensus") - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(CartesiDAppNewConsensus) - if err := _CartesiDApp.contract.UnpackLog(event, "NewConsensus", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseNewConsensus is a log parse operation binding the contract event 0x4991c6f37185659e276ff918a96f3e20e6c5abcd8c9aab450dc19c2f7ad35cb5. -// -// Solidity: event NewConsensus(address newConsensus) -func (_CartesiDApp *CartesiDAppFilterer) ParseNewConsensus(log types.Log) (*CartesiDAppNewConsensus, error) { - event := new(CartesiDAppNewConsensus) - if err := _CartesiDApp.contract.UnpackLog(event, "NewConsensus", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// CartesiDAppOwnershipTransferredIterator is returned from FilterOwnershipTransferred and is used to iterate over the raw logs and unpacked data for OwnershipTransferred events raised by the CartesiDApp contract. -type CartesiDAppOwnershipTransferredIterator struct { - Event *CartesiDAppOwnershipTransferred // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *CartesiDAppOwnershipTransferredIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(CartesiDAppOwnershipTransferred) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(CartesiDAppOwnershipTransferred) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *CartesiDAppOwnershipTransferredIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *CartesiDAppOwnershipTransferredIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// CartesiDAppOwnershipTransferred represents a OwnershipTransferred event raised by the CartesiDApp contract. -type CartesiDAppOwnershipTransferred struct { - PreviousOwner common.Address - NewOwner common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterOwnershipTransferred is a free log retrieval operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_CartesiDApp *CartesiDAppFilterer) FilterOwnershipTransferred(opts *bind.FilterOpts, previousOwner []common.Address, newOwner []common.Address) (*CartesiDAppOwnershipTransferredIterator, error) { - - var previousOwnerRule []interface{} - for _, previousOwnerItem := range previousOwner { - previousOwnerRule = append(previousOwnerRule, previousOwnerItem) - } - var newOwnerRule []interface{} - for _, newOwnerItem := range newOwner { - newOwnerRule = append(newOwnerRule, newOwnerItem) - } - - logs, sub, err := _CartesiDApp.contract.FilterLogs(opts, "OwnershipTransferred", previousOwnerRule, newOwnerRule) - if err != nil { - return nil, err - } - return &CartesiDAppOwnershipTransferredIterator{contract: _CartesiDApp.contract, event: "OwnershipTransferred", logs: logs, sub: sub}, nil -} - -// WatchOwnershipTransferred is a free log subscription operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_CartesiDApp *CartesiDAppFilterer) WatchOwnershipTransferred(opts *bind.WatchOpts, sink chan<- *CartesiDAppOwnershipTransferred, previousOwner []common.Address, newOwner []common.Address) (event.Subscription, error) { - - var previousOwnerRule []interface{} - for _, previousOwnerItem := range previousOwner { - previousOwnerRule = append(previousOwnerRule, previousOwnerItem) - } - var newOwnerRule []interface{} - for _, newOwnerItem := range newOwner { - newOwnerRule = append(newOwnerRule, newOwnerItem) - } - - logs, sub, err := _CartesiDApp.contract.WatchLogs(opts, "OwnershipTransferred", previousOwnerRule, newOwnerRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(CartesiDAppOwnershipTransferred) - if err := _CartesiDApp.contract.UnpackLog(event, "OwnershipTransferred", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseOwnershipTransferred is a log parse operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_CartesiDApp *CartesiDAppFilterer) ParseOwnershipTransferred(log types.Log) (*CartesiDAppOwnershipTransferred, error) { - event := new(CartesiDAppOwnershipTransferred) - if err := _CartesiDApp.contract.UnpackLog(event, "OwnershipTransferred", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// CartesiDAppVoucherExecutedIterator is returned from FilterVoucherExecuted and is used to iterate over the raw logs and unpacked data for VoucherExecuted events raised by the CartesiDApp contract. -type CartesiDAppVoucherExecutedIterator struct { - Event *CartesiDAppVoucherExecuted // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *CartesiDAppVoucherExecutedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(CartesiDAppVoucherExecuted) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(CartesiDAppVoucherExecuted) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *CartesiDAppVoucherExecutedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *CartesiDAppVoucherExecutedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// CartesiDAppVoucherExecuted represents a VoucherExecuted event raised by the CartesiDApp contract. -type CartesiDAppVoucherExecuted struct { - VoucherId *big.Int - Raw types.Log // Blockchain specific contextual infos -} - -// FilterVoucherExecuted is a free log retrieval operation binding the contract event 0x0eb7ee080f865f1cadc4f54daf58cc3b8879e888832867d13351edcec0fbdc54. -// -// Solidity: event VoucherExecuted(uint256 voucherId) -func (_CartesiDApp *CartesiDAppFilterer) FilterVoucherExecuted(opts *bind.FilterOpts) (*CartesiDAppVoucherExecutedIterator, error) { - - logs, sub, err := _CartesiDApp.contract.FilterLogs(opts, "VoucherExecuted") - if err != nil { - return nil, err - } - return &CartesiDAppVoucherExecutedIterator{contract: _CartesiDApp.contract, event: "VoucherExecuted", logs: logs, sub: sub}, nil -} - -// WatchVoucherExecuted is a free log subscription operation binding the contract event 0x0eb7ee080f865f1cadc4f54daf58cc3b8879e888832867d13351edcec0fbdc54. -// -// Solidity: event VoucherExecuted(uint256 voucherId) -func (_CartesiDApp *CartesiDAppFilterer) WatchVoucherExecuted(opts *bind.WatchOpts, sink chan<- *CartesiDAppVoucherExecuted) (event.Subscription, error) { - - logs, sub, err := _CartesiDApp.contract.WatchLogs(opts, "VoucherExecuted") - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(CartesiDAppVoucherExecuted) - if err := _CartesiDApp.contract.UnpackLog(event, "VoucherExecuted", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseVoucherExecuted is a log parse operation binding the contract event 0x0eb7ee080f865f1cadc4f54daf58cc3b8879e888832867d13351edcec0fbdc54. -// -// Solidity: event VoucherExecuted(uint256 voucherId) -func (_CartesiDApp *CartesiDAppFilterer) ParseVoucherExecuted(log types.Log) (*CartesiDAppVoucherExecuted, error) { - event := new(CartesiDAppVoucherExecuted) - if err := _CartesiDApp.contract.UnpackLog(event, "VoucherExecuted", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} diff --git a/pkg/contracts/cartesi_dapp_factory.go b/pkg/contracts/cartesi_dapp_factory.go deleted file mode 100644 index fb6b2be18..000000000 --- a/pkg/contracts/cartesi_dapp_factory.go +++ /dev/null @@ -1,401 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package contracts - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription - _ = abi.ConvertType -) - -// CartesiDAppFactoryMetaData contains all meta data concerning the CartesiDAppFactory contract. -var CartesiDAppFactoryMetaData = &bind.MetaData{ - ABI: "[{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"contractIConsensus\",\"name\":\"consensus\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"dappOwner\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"templateHash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"contractCartesiDApp\",\"name\":\"application\",\"type\":\"address\"}],\"name\":\"ApplicationCreated\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"contractIConsensus\",\"name\":\"_consensus\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_dappOwner\",\"type\":\"address\"},{\"internalType\":\"bytes32\",\"name\":\"_templateHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"_salt\",\"type\":\"bytes32\"}],\"name\":\"calculateApplicationAddress\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractIConsensus\",\"name\":\"_consensus\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_dappOwner\",\"type\":\"address\"},{\"internalType\":\"bytes32\",\"name\":\"_templateHash\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"_salt\",\"type\":\"bytes32\"}],\"name\":\"newApplication\",\"outputs\":[{\"internalType\":\"contractCartesiDApp\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contractIConsensus\",\"name\":\"_consensus\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_dappOwner\",\"type\":\"address\"},{\"internalType\":\"bytes32\",\"name\":\"_templateHash\",\"type\":\"bytes32\"}],\"name\":\"newApplication\",\"outputs\":[{\"internalType\":\"contractCartesiDApp\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", -} - -// CartesiDAppFactoryABI is the input ABI used to generate the binding from. -// Deprecated: Use CartesiDAppFactoryMetaData.ABI instead. -var CartesiDAppFactoryABI = CartesiDAppFactoryMetaData.ABI - -// CartesiDAppFactory is an auto generated Go binding around an Ethereum contract. -type CartesiDAppFactory struct { - CartesiDAppFactoryCaller // Read-only binding to the contract - CartesiDAppFactoryTransactor // Write-only binding to the contract - CartesiDAppFactoryFilterer // Log filterer for contract events -} - -// CartesiDAppFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. -type CartesiDAppFactoryCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// CartesiDAppFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. -type CartesiDAppFactoryTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// CartesiDAppFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type CartesiDAppFactoryFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// CartesiDAppFactorySession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type CartesiDAppFactorySession struct { - Contract *CartesiDAppFactory // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// CartesiDAppFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type CartesiDAppFactoryCallerSession struct { - Contract *CartesiDAppFactoryCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// CartesiDAppFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type CartesiDAppFactoryTransactorSession struct { - Contract *CartesiDAppFactoryTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// CartesiDAppFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. -type CartesiDAppFactoryRaw struct { - Contract *CartesiDAppFactory // Generic contract binding to access the raw methods on -} - -// CartesiDAppFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type CartesiDAppFactoryCallerRaw struct { - Contract *CartesiDAppFactoryCaller // Generic read-only contract binding to access the raw methods on -} - -// CartesiDAppFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type CartesiDAppFactoryTransactorRaw struct { - Contract *CartesiDAppFactoryTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewCartesiDAppFactory creates a new instance of CartesiDAppFactory, bound to a specific deployed contract. -func NewCartesiDAppFactory(address common.Address, backend bind.ContractBackend) (*CartesiDAppFactory, error) { - contract, err := bindCartesiDAppFactory(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &CartesiDAppFactory{CartesiDAppFactoryCaller: CartesiDAppFactoryCaller{contract: contract}, CartesiDAppFactoryTransactor: CartesiDAppFactoryTransactor{contract: contract}, CartesiDAppFactoryFilterer: CartesiDAppFactoryFilterer{contract: contract}}, nil -} - -// NewCartesiDAppFactoryCaller creates a new read-only instance of CartesiDAppFactory, bound to a specific deployed contract. -func NewCartesiDAppFactoryCaller(address common.Address, caller bind.ContractCaller) (*CartesiDAppFactoryCaller, error) { - contract, err := bindCartesiDAppFactory(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &CartesiDAppFactoryCaller{contract: contract}, nil -} - -// NewCartesiDAppFactoryTransactor creates a new write-only instance of CartesiDAppFactory, bound to a specific deployed contract. -func NewCartesiDAppFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*CartesiDAppFactoryTransactor, error) { - contract, err := bindCartesiDAppFactory(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &CartesiDAppFactoryTransactor{contract: contract}, nil -} - -// NewCartesiDAppFactoryFilterer creates a new log filterer instance of CartesiDAppFactory, bound to a specific deployed contract. -func NewCartesiDAppFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*CartesiDAppFactoryFilterer, error) { - contract, err := bindCartesiDAppFactory(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &CartesiDAppFactoryFilterer{contract: contract}, nil -} - -// bindCartesiDAppFactory binds a generic wrapper to an already deployed contract. -func bindCartesiDAppFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := CartesiDAppFactoryMetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_CartesiDAppFactory *CartesiDAppFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _CartesiDAppFactory.Contract.CartesiDAppFactoryCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_CartesiDAppFactory *CartesiDAppFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.CartesiDAppFactoryTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_CartesiDAppFactory *CartesiDAppFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.CartesiDAppFactoryTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_CartesiDAppFactory *CartesiDAppFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _CartesiDAppFactory.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_CartesiDAppFactory *CartesiDAppFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_CartesiDAppFactory *CartesiDAppFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.contract.Transact(opts, method, params...) -} - -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xbd4f1219. -// -// Solidity: function calculateApplicationAddress(address _consensus, address _dappOwner, bytes32 _templateHash, bytes32 _salt) view returns(address) -func (_CartesiDAppFactory *CartesiDAppFactoryCaller) CalculateApplicationAddress(opts *bind.CallOpts, _consensus common.Address, _dappOwner common.Address, _templateHash [32]byte, _salt [32]byte) (common.Address, error) { - var out []interface{} - err := _CartesiDAppFactory.contract.Call(opts, &out, "calculateApplicationAddress", _consensus, _dappOwner, _templateHash, _salt) - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xbd4f1219. -// -// Solidity: function calculateApplicationAddress(address _consensus, address _dappOwner, bytes32 _templateHash, bytes32 _salt) view returns(address) -func (_CartesiDAppFactory *CartesiDAppFactorySession) CalculateApplicationAddress(_consensus common.Address, _dappOwner common.Address, _templateHash [32]byte, _salt [32]byte) (common.Address, error) { - return _CartesiDAppFactory.Contract.CalculateApplicationAddress(&_CartesiDAppFactory.CallOpts, _consensus, _dappOwner, _templateHash, _salt) -} - -// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xbd4f1219. -// -// Solidity: function calculateApplicationAddress(address _consensus, address _dappOwner, bytes32 _templateHash, bytes32 _salt) view returns(address) -func (_CartesiDAppFactory *CartesiDAppFactoryCallerSession) CalculateApplicationAddress(_consensus common.Address, _dappOwner common.Address, _templateHash [32]byte, _salt [32]byte) (common.Address, error) { - return _CartesiDAppFactory.Contract.CalculateApplicationAddress(&_CartesiDAppFactory.CallOpts, _consensus, _dappOwner, _templateHash, _salt) -} - -// NewApplication is a paid mutator transaction binding the contract method 0x0e1a07f5. -// -// Solidity: function newApplication(address _consensus, address _dappOwner, bytes32 _templateHash, bytes32 _salt) returns(address) -func (_CartesiDAppFactory *CartesiDAppFactoryTransactor) NewApplication(opts *bind.TransactOpts, _consensus common.Address, _dappOwner common.Address, _templateHash [32]byte, _salt [32]byte) (*types.Transaction, error) { - return _CartesiDAppFactory.contract.Transact(opts, "newApplication", _consensus, _dappOwner, _templateHash, _salt) -} - -// NewApplication is a paid mutator transaction binding the contract method 0x0e1a07f5. -// -// Solidity: function newApplication(address _consensus, address _dappOwner, bytes32 _templateHash, bytes32 _salt) returns(address) -func (_CartesiDAppFactory *CartesiDAppFactorySession) NewApplication(_consensus common.Address, _dappOwner common.Address, _templateHash [32]byte, _salt [32]byte) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.NewApplication(&_CartesiDAppFactory.TransactOpts, _consensus, _dappOwner, _templateHash, _salt) -} - -// NewApplication is a paid mutator transaction binding the contract method 0x0e1a07f5. -// -// Solidity: function newApplication(address _consensus, address _dappOwner, bytes32 _templateHash, bytes32 _salt) returns(address) -func (_CartesiDAppFactory *CartesiDAppFactoryTransactorSession) NewApplication(_consensus common.Address, _dappOwner common.Address, _templateHash [32]byte, _salt [32]byte) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.NewApplication(&_CartesiDAppFactory.TransactOpts, _consensus, _dappOwner, _templateHash, _salt) -} - -// NewApplication0 is a paid mutator transaction binding the contract method 0x3648bfb5. -// -// Solidity: function newApplication(address _consensus, address _dappOwner, bytes32 _templateHash) returns(address) -func (_CartesiDAppFactory *CartesiDAppFactoryTransactor) NewApplication0(opts *bind.TransactOpts, _consensus common.Address, _dappOwner common.Address, _templateHash [32]byte) (*types.Transaction, error) { - return _CartesiDAppFactory.contract.Transact(opts, "newApplication0", _consensus, _dappOwner, _templateHash) -} - -// NewApplication0 is a paid mutator transaction binding the contract method 0x3648bfb5. -// -// Solidity: function newApplication(address _consensus, address _dappOwner, bytes32 _templateHash) returns(address) -func (_CartesiDAppFactory *CartesiDAppFactorySession) NewApplication0(_consensus common.Address, _dappOwner common.Address, _templateHash [32]byte) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.NewApplication0(&_CartesiDAppFactory.TransactOpts, _consensus, _dappOwner, _templateHash) -} - -// NewApplication0 is a paid mutator transaction binding the contract method 0x3648bfb5. -// -// Solidity: function newApplication(address _consensus, address _dappOwner, bytes32 _templateHash) returns(address) -func (_CartesiDAppFactory *CartesiDAppFactoryTransactorSession) NewApplication0(_consensus common.Address, _dappOwner common.Address, _templateHash [32]byte) (*types.Transaction, error) { - return _CartesiDAppFactory.Contract.NewApplication0(&_CartesiDAppFactory.TransactOpts, _consensus, _dappOwner, _templateHash) -} - -// CartesiDAppFactoryApplicationCreatedIterator is returned from FilterApplicationCreated and is used to iterate over the raw logs and unpacked data for ApplicationCreated events raised by the CartesiDAppFactory contract. -type CartesiDAppFactoryApplicationCreatedIterator struct { - Event *CartesiDAppFactoryApplicationCreated // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *CartesiDAppFactoryApplicationCreatedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(CartesiDAppFactoryApplicationCreated) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(CartesiDAppFactoryApplicationCreated) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *CartesiDAppFactoryApplicationCreatedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *CartesiDAppFactoryApplicationCreatedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// CartesiDAppFactoryApplicationCreated represents a ApplicationCreated event raised by the CartesiDAppFactory contract. -type CartesiDAppFactoryApplicationCreated struct { - Consensus common.Address - DappOwner common.Address - TemplateHash [32]byte - Application common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterApplicationCreated is a free log retrieval operation binding the contract event 0xe73165c2d277daf8713fd08b40845cb6bb7a20b2b543f3d35324a475660fcebd. -// -// Solidity: event ApplicationCreated(address indexed consensus, address dappOwner, bytes32 templateHash, address application) -func (_CartesiDAppFactory *CartesiDAppFactoryFilterer) FilterApplicationCreated(opts *bind.FilterOpts, consensus []common.Address) (*CartesiDAppFactoryApplicationCreatedIterator, error) { - - var consensusRule []interface{} - for _, consensusItem := range consensus { - consensusRule = append(consensusRule, consensusItem) - } - - logs, sub, err := _CartesiDAppFactory.contract.FilterLogs(opts, "ApplicationCreated", consensusRule) - if err != nil { - return nil, err - } - return &CartesiDAppFactoryApplicationCreatedIterator{contract: _CartesiDAppFactory.contract, event: "ApplicationCreated", logs: logs, sub: sub}, nil -} - -// WatchApplicationCreated is a free log subscription operation binding the contract event 0xe73165c2d277daf8713fd08b40845cb6bb7a20b2b543f3d35324a475660fcebd. -// -// Solidity: event ApplicationCreated(address indexed consensus, address dappOwner, bytes32 templateHash, address application) -func (_CartesiDAppFactory *CartesiDAppFactoryFilterer) WatchApplicationCreated(opts *bind.WatchOpts, sink chan<- *CartesiDAppFactoryApplicationCreated, consensus []common.Address) (event.Subscription, error) { - - var consensusRule []interface{} - for _, consensusItem := range consensus { - consensusRule = append(consensusRule, consensusItem) - } - - logs, sub, err := _CartesiDAppFactory.contract.WatchLogs(opts, "ApplicationCreated", consensusRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(CartesiDAppFactoryApplicationCreated) - if err := _CartesiDAppFactory.contract.UnpackLog(event, "ApplicationCreated", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseApplicationCreated is a log parse operation binding the contract event 0xe73165c2d277daf8713fd08b40845cb6bb7a20b2b543f3d35324a475660fcebd. -// -// Solidity: event ApplicationCreated(address indexed consensus, address dappOwner, bytes32 templateHash, address application) -func (_CartesiDAppFactory *CartesiDAppFactoryFilterer) ParseApplicationCreated(log types.Log) (*CartesiDAppFactoryApplicationCreated, error) { - event := new(CartesiDAppFactoryApplicationCreated) - if err := _CartesiDAppFactory.contract.UnpackLog(event, "ApplicationCreated", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} diff --git a/pkg/contracts/cartesistatetransition/cartesistatetransition.go b/pkg/contracts/cartesistatetransition/cartesistatetransition.go new file mode 100644 index 000000000..d8c3ab8d7 --- /dev/null +++ b/pkg/contracts/cartesistatetransition/cartesistatetransition.go @@ -0,0 +1,278 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package cartesistatetransition + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// CartesiStateTransitionMetaData contains all meta data concerning the CartesiStateTransition contract. +var CartesiStateTransitionMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"CM_MARCHID\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"UARCH_PRISTINE_STATE_HASH\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transitionState\",\"inputs\":[{\"name\":\"machineState\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"counter\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"proofs\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"CounterOutsideEpoch\",\"inputs\":[{\"name\":\"counter\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InputBytesWithoutInput\",\"inputs\":[{\"name\":\"inputLength\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"InvalidAccessLogProofLength\",\"inputs\":[{\"name\":\"consumed\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"provided\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"SafeCastOverflowedUintDowncast\",\"inputs\":[{\"name\":\"bits\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", +} + +// CartesiStateTransitionABI is the input ABI used to generate the binding from. +// Deprecated: Use CartesiStateTransitionMetaData.ABI instead. +var CartesiStateTransitionABI = CartesiStateTransitionMetaData.ABI + +// CartesiStateTransition is an auto generated Go binding around an Ethereum contract. +type CartesiStateTransition struct { + CartesiStateTransitionCaller // Read-only binding to the contract + CartesiStateTransitionTransactor // Write-only binding to the contract + CartesiStateTransitionFilterer // Log filterer for contract events +} + +// CartesiStateTransitionCaller is an auto generated read-only Go binding around an Ethereum contract. +type CartesiStateTransitionCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// CartesiStateTransitionTransactor is an auto generated write-only Go binding around an Ethereum contract. +type CartesiStateTransitionTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// CartesiStateTransitionFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type CartesiStateTransitionFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// CartesiStateTransitionSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type CartesiStateTransitionSession struct { + Contract *CartesiStateTransition // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// CartesiStateTransitionCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type CartesiStateTransitionCallerSession struct { + Contract *CartesiStateTransitionCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// CartesiStateTransitionTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type CartesiStateTransitionTransactorSession struct { + Contract *CartesiStateTransitionTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// CartesiStateTransitionRaw is an auto generated low-level Go binding around an Ethereum contract. +type CartesiStateTransitionRaw struct { + Contract *CartesiStateTransition // Generic contract binding to access the raw methods on +} + +// CartesiStateTransitionCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type CartesiStateTransitionCallerRaw struct { + Contract *CartesiStateTransitionCaller // Generic read-only contract binding to access the raw methods on +} + +// CartesiStateTransitionTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type CartesiStateTransitionTransactorRaw struct { + Contract *CartesiStateTransitionTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewCartesiStateTransition creates a new instance of CartesiStateTransition, bound to a specific deployed contract. +func NewCartesiStateTransition(address common.Address, backend bind.ContractBackend) (*CartesiStateTransition, error) { + contract, err := bindCartesiStateTransition(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &CartesiStateTransition{CartesiStateTransitionCaller: CartesiStateTransitionCaller{contract: contract}, CartesiStateTransitionTransactor: CartesiStateTransitionTransactor{contract: contract}, CartesiStateTransitionFilterer: CartesiStateTransitionFilterer{contract: contract}}, nil +} + +// NewCartesiStateTransitionCaller creates a new read-only instance of CartesiStateTransition, bound to a specific deployed contract. +func NewCartesiStateTransitionCaller(address common.Address, caller bind.ContractCaller) (*CartesiStateTransitionCaller, error) { + contract, err := bindCartesiStateTransition(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &CartesiStateTransitionCaller{contract: contract}, nil +} + +// NewCartesiStateTransitionTransactor creates a new write-only instance of CartesiStateTransition, bound to a specific deployed contract. +func NewCartesiStateTransitionTransactor(address common.Address, transactor bind.ContractTransactor) (*CartesiStateTransitionTransactor, error) { + contract, err := bindCartesiStateTransition(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &CartesiStateTransitionTransactor{contract: contract}, nil +} + +// NewCartesiStateTransitionFilterer creates a new log filterer instance of CartesiStateTransition, bound to a specific deployed contract. +func NewCartesiStateTransitionFilterer(address common.Address, filterer bind.ContractFilterer) (*CartesiStateTransitionFilterer, error) { + contract, err := bindCartesiStateTransition(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &CartesiStateTransitionFilterer{contract: contract}, nil +} + +// bindCartesiStateTransition binds a generic wrapper to an already deployed contract. +func bindCartesiStateTransition(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := CartesiStateTransitionMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_CartesiStateTransition *CartesiStateTransitionRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _CartesiStateTransition.Contract.CartesiStateTransitionCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_CartesiStateTransition *CartesiStateTransitionRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _CartesiStateTransition.Contract.CartesiStateTransitionTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_CartesiStateTransition *CartesiStateTransitionRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _CartesiStateTransition.Contract.CartesiStateTransitionTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_CartesiStateTransition *CartesiStateTransitionCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _CartesiStateTransition.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_CartesiStateTransition *CartesiStateTransitionTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _CartesiStateTransition.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_CartesiStateTransition *CartesiStateTransitionTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _CartesiStateTransition.Contract.contract.Transact(opts, method, params...) +} + +// CMMARCHID is a free data retrieval call binding the contract method 0xae99a0db. +// +// Solidity: function CM_MARCHID() view returns(uint64) +func (_CartesiStateTransition *CartesiStateTransitionCaller) CMMARCHID(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _CartesiStateTransition.contract.Call(opts, &out, "CM_MARCHID") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// CMMARCHID is a free data retrieval call binding the contract method 0xae99a0db. +// +// Solidity: function CM_MARCHID() view returns(uint64) +func (_CartesiStateTransition *CartesiStateTransitionSession) CMMARCHID() (uint64, error) { + return _CartesiStateTransition.Contract.CMMARCHID(&_CartesiStateTransition.CallOpts) +} + +// CMMARCHID is a free data retrieval call binding the contract method 0xae99a0db. +// +// Solidity: function CM_MARCHID() view returns(uint64) +func (_CartesiStateTransition *CartesiStateTransitionCallerSession) CMMARCHID() (uint64, error) { + return _CartesiStateTransition.Contract.CMMARCHID(&_CartesiStateTransition.CallOpts) +} + +// UARCHPRISTINESTATEHASH is a free data retrieval call binding the contract method 0x7587c22c. +// +// Solidity: function UARCH_PRISTINE_STATE_HASH() view returns(bytes32) +func (_CartesiStateTransition *CartesiStateTransitionCaller) UARCHPRISTINESTATEHASH(opts *bind.CallOpts) ([32]byte, error) { + var out []interface{} + err := _CartesiStateTransition.contract.Call(opts, &out, "UARCH_PRISTINE_STATE_HASH") + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// UARCHPRISTINESTATEHASH is a free data retrieval call binding the contract method 0x7587c22c. +// +// Solidity: function UARCH_PRISTINE_STATE_HASH() view returns(bytes32) +func (_CartesiStateTransition *CartesiStateTransitionSession) UARCHPRISTINESTATEHASH() ([32]byte, error) { + return _CartesiStateTransition.Contract.UARCHPRISTINESTATEHASH(&_CartesiStateTransition.CallOpts) +} + +// UARCHPRISTINESTATEHASH is a free data retrieval call binding the contract method 0x7587c22c. +// +// Solidity: function UARCH_PRISTINE_STATE_HASH() view returns(bytes32) +func (_CartesiStateTransition *CartesiStateTransitionCallerSession) UARCHPRISTINESTATEHASH() ([32]byte, error) { + return _CartesiStateTransition.Contract.UARCHPRISTINESTATEHASH(&_CartesiStateTransition.CallOpts) +} + +// TransitionState is a free data retrieval call binding the contract method 0x115d0779. +// +// Solidity: function transitionState(bytes32 machineState, uint256 counter, bytes proofs, address provider) view returns(bytes32) +func (_CartesiStateTransition *CartesiStateTransitionCaller) TransitionState(opts *bind.CallOpts, machineState [32]byte, counter *big.Int, proofs []byte, provider common.Address) ([32]byte, error) { + var out []interface{} + err := _CartesiStateTransition.contract.Call(opts, &out, "transitionState", machineState, counter, proofs, provider) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// TransitionState is a free data retrieval call binding the contract method 0x115d0779. +// +// Solidity: function transitionState(bytes32 machineState, uint256 counter, bytes proofs, address provider) view returns(bytes32) +func (_CartesiStateTransition *CartesiStateTransitionSession) TransitionState(machineState [32]byte, counter *big.Int, proofs []byte, provider common.Address) ([32]byte, error) { + return _CartesiStateTransition.Contract.TransitionState(&_CartesiStateTransition.CallOpts, machineState, counter, proofs, provider) +} + +// TransitionState is a free data retrieval call binding the contract method 0x115d0779. +// +// Solidity: function transitionState(bytes32 machineState, uint256 counter, bytes proofs, address provider) view returns(bytes32) +func (_CartesiStateTransition *CartesiStateTransitionCallerSession) TransitionState(machineState [32]byte, counter *big.Int, proofs []byte, provider common.Address) ([32]byte, error) { + return _CartesiStateTransition.Contract.TransitionState(&_CartesiStateTransition.CallOpts, machineState, counter, proofs, provider) +} diff --git a/pkg/contracts/generate/main.go b/pkg/contracts/generate/main.go index a0dba4861..fd5f8e859 100644 --- a/pkg/contracts/generate/main.go +++ b/pkg/contracts/generate/main.go @@ -3,72 +3,120 @@ // This binary generates the Go bindings for the Cartesi Rollups contracts. // This binary should be called with `go generate` in the parent dir. -// First, it downloads the Cartesi Rollups npm package containing the contracts. -// Then, it generates the bindings using abi-gen. -// Finally, it stores the bindings in the current directory. +// The Makefile extracts the release artifacts before this generator runs. +// It reads their ABIs and generates the Go bindings with bind.Bind. +// It stores the bindings in the current directory. package main import ( - "archive/tar" - "compress/gzip" "encoding/json" - "io" + "errors" + "io/fs" "log" - "net/http" "os" + "path/filepath" + "strings" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) -const rollupsContractsUrl = "https://registry.npmjs.org/@cartesi/rollups/-/rollups-1.2.0.tgz" -const baseContractsPath = "package/export/artifacts/contracts/" -const bindingPkg = "contracts" +const rollupsContractsPath = "../../rollups-contracts/" +const rollupsPrtContractsPath = "../../rollups-prt-contracts/" type contractBinding struct { jsonPath string typeName string - outFile string } var bindings = []contractBinding{ { - jsonPath: baseContractsPath + "inputs/InputBox.sol/InputBox.json", - typeName: "InputBox", - outFile: "input_box.go", + jsonPath: rollupsContractsPath + "IAuthorityFactory.sol/IAuthorityFactory.json", + typeName: "IAuthorityFactory", }, { - jsonPath: baseContractsPath + "dapp/CartesiDAppFactory.sol/CartesiDAppFactory.json", - typeName: "CartesiDAppFactory", - outFile: "cartesi_dapp_factory.go", + jsonPath: rollupsContractsPath + "IAuthority.sol/IAuthority.json", + typeName: "IAuthority", }, { - jsonPath: baseContractsPath + "dapp/CartesiDApp.sol/CartesiDApp.json", - typeName: "CartesiDApp", - outFile: "cartesi_dapp.go", + jsonPath: rollupsContractsPath + "IConsensus.sol/IConsensus.json", + typeName: "IConsensus", }, { - jsonPath: baseContractsPath + "consensus/authority/Authority.sol/Authority.json", - typeName: "Authority", - outFile: "authority.go", + jsonPath: rollupsContractsPath + "IQuorum.sol/IQuorum.json", + typeName: "IQuorum", }, { - jsonPath: baseContractsPath + "history/History.sol/History.json", - typeName: "History", - outFile: "history.go", + jsonPath: rollupsContractsPath + "IQuorumFactory.sol/IQuorumFactory.json", + typeName: "IQuorumFactory", + }, + { + jsonPath: rollupsContractsPath + "IApplication.sol/IApplication.json", + typeName: "IApplication", + }, + { + jsonPath: rollupsContractsPath + "IApplicationFactory.sol/IApplicationFactory.json", + typeName: "IApplicationFactory", + }, + { + jsonPath: rollupsContractsPath + "ISelfHostedApplicationFactory.sol/ISelfHostedApplicationFactory.json", + typeName: "ISelfHostedApplicationFactory", + }, + { + jsonPath: rollupsContractsPath + "IInputBox.sol/IInputBox.json", + typeName: "IInputBox", + }, + { + jsonPath: rollupsContractsPath + "Inputs.sol/Inputs.json", + typeName: "Inputs", + }, + { + jsonPath: rollupsContractsPath + "Outputs.sol/Outputs.json", + typeName: "Outputs", + }, + { + jsonPath: rollupsContractsPath + "IUsdWithdrawalOutputBuilder.sol/IUsdWithdrawalOutputBuilder.json", + typeName: "IUsdWithdrawalOutputBuilder", + }, + { + jsonPath: rollupsContractsPath + "IERC20Metadata.sol/IERC20Metadata.json", + typeName: "IERC20Metadata", + }, + { + jsonPath: rollupsContractsPath + "IErc20Portal.sol/IErc20Portal.json", + typeName: "IErc20Portal", + }, + { + jsonPath: rollupsContractsPath + "draft-IERC6093.sol/IERC20Errors.json", + typeName: "IERC20Errors", + }, + { + jsonPath: rollupsPrtContractsPath + "prt/contracts/out/ITournament.sol/ITournament.json", + typeName: "ITournament", + }, + { + jsonPath: rollupsPrtContractsPath + "prt/contracts/out/IMultiLevelTournamentFactory.sol/IMultiLevelTournamentFactory.json", + typeName: "IMultiLevelTournamentFactory", + }, + { + jsonPath: rollupsPrtContractsPath + "prt/contracts/out/CartesiStateTransition.sol/CartesiStateTransition.json", + typeName: "CartesiStateTransition", + }, + { + jsonPath: rollupsPrtContractsPath + "cartesi-rollups/contracts/out/IDaveConsensus.sol/IDaveConsensus.json", + typeName: "IDaveConsensus", + }, + { + jsonPath: rollupsPrtContractsPath + "cartesi-rollups/contracts/out/IDaveAppFactory.sol/IDaveAppFactory.json", + typeName: "IDaveAppFactory", }, } func main() { - contractsZip := downloadContracts(rollupsContractsUrl) - defer contractsZip.Close() - contractsTar := unzip(contractsZip) - defer contractsTar.Close() - files := make(map[string]bool) for _, b := range bindings { files[b.jsonPath] = true } - contents := readFilesFromTar(contractsTar, files) + contents := readFilesFromDir(files) for _, b := range bindings { content := contents[b.jsonPath] @@ -86,42 +134,18 @@ func checkErr(context string, err any) { } } -// Download the contracts from rollupsContractsUrl. -// Return the buffer with the contracts. -func downloadContracts(url string) io.ReadCloser { - log.Print("downloading contracts from ", url) - response, err := http.Get(url) - checkErr("download tgz", err) - if response.StatusCode != http.StatusOK { - response.Body.Close() - log.Fatal("invalid status: ", response.Status) - } - return response.Body -} - -// Decompress the buffer with the contracts. -func unzip(r io.Reader) io.ReadCloser { - log.Print("unziping contracts") - gzipReader, err := gzip.NewReader(r) - checkErr("unziping", err) - return gzipReader -} - -// Read the required files from the tar. +// Read the required files from the directory. // Return a map with the file contents. -func readFilesFromTar(r io.Reader, files map[string]bool) map[string][]byte { +func readFilesFromDir(files map[string]bool) map[string][]byte { contents := make(map[string][]byte) - tarReader := tar.NewReader(r) - for { - header, err := tarReader.Next() - if err == io.EOF { - break // End of archive - } - checkErr("read tar", err) - if files[header.Name] { - contents[header.Name], err = io.ReadAll(tarReader) - checkErr("read tar", err) + for fileName := range files { + fileFullPath, err := filepath.Abs(fileName) + if err != nil { + log.Fatal(err) } + data, err := os.ReadFile(fileFullPath) + checkErr("read file", err) + contents[fileName] = data } return contents } @@ -136,9 +160,16 @@ func getAbi(rawJson []byte) []byte { return contents.Abi } +// Check whether file exists. +func fileExists(filePath string) bool { + _, err := os.Stat(filePath) + return !errors.Is(err, fs.ErrNotExist) +} + // Generate the Go bindings for the contracts. func generateBinding(b contractBinding, content []byte) { var ( + pkg = strings.ToLower(b.typeName) sigs []map[string]string abis = []string{string(getAbi(content))} bins = []string{""} @@ -146,10 +177,28 @@ func generateBinding(b contractBinding, content []byte) { libs = make(map[string]string) aliases = make(map[string]string) ) - code, err := bind.Bind(types, abis, bins, sigs, bindingPkg, bind.LangGo, libs, aliases) + code, err := bind.Bind(types, abis, bins, sigs, pkg, libs, aliases) checkErr("generate binding", err) - const fileMode = 0600 - err = os.WriteFile(b.outFile, []byte(code), fileMode) + + if fileExists(pkg) { + err := os.RemoveAll(pkg) + checkErr("removing dir", err) + } + + // Generated source is shared by developers in the same group. Set the + // final modes explicitly so the caller's umask does not remove group write. + const dirMode = 0775 + err = os.Mkdir(pkg, dirMode) + checkErr("creating dir", err) + err = os.Chmod(pkg, dirMode) + checkErr("setting dir permissions", err) + + const fileMode = 0664 + filePath := pkg + "/" + pkg + ".go" + err = os.WriteFile(filePath, []byte(code), fileMode) checkErr("write binding file", err) - log.Print("generated binding ", b.outFile) + err = os.Chmod(filePath, fileMode) + checkErr("setting binding permissions", err) + + log.Print("generated binding for ", filePath) } diff --git a/pkg/contracts/generate/main_test.go b/pkg/contracts/generate/main_test.go new file mode 100644 index 000000000..2cb9c7a27 --- /dev/null +++ b/pkg/contracts/generate/main_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build unix + +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateBinding_SharedWorktreePermissions(t *testing.T) { + t.Chdir(t.TempDir()) + // Keep this test serial: both Chdir and Umask change process-wide state. + // The usual umask removes group write during creation. Generation must + // restore it so another user in the same group can regenerate the package. + const sourceCreationMask = 0022 + previousMask := syscall.Umask(sourceCreationMask) + t.Cleanup(func() { syscall.Umask(previousMask) }) + const artifact = `{ + "abi": [{ + "type": "function", + "name": "value", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" + }] + }` + + for _, name := range []string{"first generation", "regeneration"} { + t.Run(name, func(t *testing.T) { + generateBinding(contractBinding{typeName: "TestContract"}, []byte(artifact)) + + directory, err := os.Stat("testcontract") + require.NoError(t, err) + require.True(t, directory.IsDir()) + const sharedDirectoryBits os.FileMode = 0775 + require.Equal(t, sharedDirectoryBits, directory.Mode().Perm()&sharedDirectoryBits, + "group members must be able to regenerate the publicly readable package") + + binding, err := os.Stat(filepath.Join("testcontract", "testcontract.go")) + require.NoError(t, err) + require.True(t, binding.Mode().IsRegular()) + const sharedSourceBits os.FileMode = 0664 + require.Equal(t, sharedSourceBits, binding.Mode().Perm()&sharedSourceBits, + "group members must be able to edit the publicly readable Go source") + const executeBits os.FileMode = 0111 + require.Zero(t, binding.Mode().Perm()&executeBits, "generated Go source must not be executable") + }) + } +} diff --git a/pkg/contracts/history.go b/pkg/contracts/history.go deleted file mode 100644 index 47489c82e..000000000 --- a/pkg/contracts/history.go +++ /dev/null @@ -1,634 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package contracts - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription - _ = abi.ConvertType -) - -// HistoryClaim is an auto generated low-level Go binding around an user-defined struct. -type HistoryClaim struct { - EpochHash [32]byte - FirstIndex *big.Int - LastIndex *big.Int -} - -// HistoryMetaData contains all meta data concerning the History contract. -var HistoryMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_owner\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"InvalidClaimIndex\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidInputIndices\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"UnclaimedInputs\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"dapp\",\"type\":\"address\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"epochHash\",\"type\":\"bytes32\"},{\"internalType\":\"uint128\",\"name\":\"firstIndex\",\"type\":\"uint128\"},{\"internalType\":\"uint128\",\"name\":\"lastIndex\",\"type\":\"uint128\"}],\"indexed\":false,\"internalType\":\"structHistory.Claim\",\"name\":\"claim\",\"type\":\"tuple\"}],\"name\":\"NewClaimToHistory\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_dapp\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"_proofContext\",\"type\":\"bytes\"}],\"name\":\"getClaim\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_consensus\",\"type\":\"address\"}],\"name\":\"migrateToConsensus\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"_claimData\",\"type\":\"bytes\"}],\"name\":\"submitClaim\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", -} - -// HistoryABI is the input ABI used to generate the binding from. -// Deprecated: Use HistoryMetaData.ABI instead. -var HistoryABI = HistoryMetaData.ABI - -// History is an auto generated Go binding around an Ethereum contract. -type History struct { - HistoryCaller // Read-only binding to the contract - HistoryTransactor // Write-only binding to the contract - HistoryFilterer // Log filterer for contract events -} - -// HistoryCaller is an auto generated read-only Go binding around an Ethereum contract. -type HistoryCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// HistoryTransactor is an auto generated write-only Go binding around an Ethereum contract. -type HistoryTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// HistoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type HistoryFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// HistorySession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type HistorySession struct { - Contract *History // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// HistoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type HistoryCallerSession struct { - Contract *HistoryCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// HistoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type HistoryTransactorSession struct { - Contract *HistoryTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// HistoryRaw is an auto generated low-level Go binding around an Ethereum contract. -type HistoryRaw struct { - Contract *History // Generic contract binding to access the raw methods on -} - -// HistoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type HistoryCallerRaw struct { - Contract *HistoryCaller // Generic read-only contract binding to access the raw methods on -} - -// HistoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type HistoryTransactorRaw struct { - Contract *HistoryTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewHistory creates a new instance of History, bound to a specific deployed contract. -func NewHistory(address common.Address, backend bind.ContractBackend) (*History, error) { - contract, err := bindHistory(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &History{HistoryCaller: HistoryCaller{contract: contract}, HistoryTransactor: HistoryTransactor{contract: contract}, HistoryFilterer: HistoryFilterer{contract: contract}}, nil -} - -// NewHistoryCaller creates a new read-only instance of History, bound to a specific deployed contract. -func NewHistoryCaller(address common.Address, caller bind.ContractCaller) (*HistoryCaller, error) { - contract, err := bindHistory(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &HistoryCaller{contract: contract}, nil -} - -// NewHistoryTransactor creates a new write-only instance of History, bound to a specific deployed contract. -func NewHistoryTransactor(address common.Address, transactor bind.ContractTransactor) (*HistoryTransactor, error) { - contract, err := bindHistory(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &HistoryTransactor{contract: contract}, nil -} - -// NewHistoryFilterer creates a new log filterer instance of History, bound to a specific deployed contract. -func NewHistoryFilterer(address common.Address, filterer bind.ContractFilterer) (*HistoryFilterer, error) { - contract, err := bindHistory(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &HistoryFilterer{contract: contract}, nil -} - -// bindHistory binds a generic wrapper to an already deployed contract. -func bindHistory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := HistoryMetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_History *HistoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _History.Contract.HistoryCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_History *HistoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _History.Contract.HistoryTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_History *HistoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _History.Contract.HistoryTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_History *HistoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _History.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_History *HistoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _History.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_History *HistoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _History.Contract.contract.Transact(opts, method, params...) -} - -// GetClaim is a free data retrieval call binding the contract method 0xd79a8240. -// -// Solidity: function getClaim(address _dapp, bytes _proofContext) view returns(bytes32, uint256, uint256) -func (_History *HistoryCaller) GetClaim(opts *bind.CallOpts, _dapp common.Address, _proofContext []byte) ([32]byte, *big.Int, *big.Int, error) { - var out []interface{} - err := _History.contract.Call(opts, &out, "getClaim", _dapp, _proofContext) - - if err != nil { - return *new([32]byte), *new(*big.Int), *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - out1 := *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) - out2 := *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) - - return out0, out1, out2, err - -} - -// GetClaim is a free data retrieval call binding the contract method 0xd79a8240. -// -// Solidity: function getClaim(address _dapp, bytes _proofContext) view returns(bytes32, uint256, uint256) -func (_History *HistorySession) GetClaim(_dapp common.Address, _proofContext []byte) ([32]byte, *big.Int, *big.Int, error) { - return _History.Contract.GetClaim(&_History.CallOpts, _dapp, _proofContext) -} - -// GetClaim is a free data retrieval call binding the contract method 0xd79a8240. -// -// Solidity: function getClaim(address _dapp, bytes _proofContext) view returns(bytes32, uint256, uint256) -func (_History *HistoryCallerSession) GetClaim(_dapp common.Address, _proofContext []byte) ([32]byte, *big.Int, *big.Int, error) { - return _History.Contract.GetClaim(&_History.CallOpts, _dapp, _proofContext) -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_History *HistoryCaller) Owner(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _History.contract.Call(opts, &out, "owner") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_History *HistorySession) Owner() (common.Address, error) { - return _History.Contract.Owner(&_History.CallOpts) -} - -// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (_History *HistoryCallerSession) Owner() (common.Address, error) { - return _History.Contract.Owner(&_History.CallOpts) -} - -// MigrateToConsensus is a paid mutator transaction binding the contract method 0xfc411683. -// -// Solidity: function migrateToConsensus(address _consensus) returns() -func (_History *HistoryTransactor) MigrateToConsensus(opts *bind.TransactOpts, _consensus common.Address) (*types.Transaction, error) { - return _History.contract.Transact(opts, "migrateToConsensus", _consensus) -} - -// MigrateToConsensus is a paid mutator transaction binding the contract method 0xfc411683. -// -// Solidity: function migrateToConsensus(address _consensus) returns() -func (_History *HistorySession) MigrateToConsensus(_consensus common.Address) (*types.Transaction, error) { - return _History.Contract.MigrateToConsensus(&_History.TransactOpts, _consensus) -} - -// MigrateToConsensus is a paid mutator transaction binding the contract method 0xfc411683. -// -// Solidity: function migrateToConsensus(address _consensus) returns() -func (_History *HistoryTransactorSession) MigrateToConsensus(_consensus common.Address) (*types.Transaction, error) { - return _History.Contract.MigrateToConsensus(&_History.TransactOpts, _consensus) -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_History *HistoryTransactor) RenounceOwnership(opts *bind.TransactOpts) (*types.Transaction, error) { - return _History.contract.Transact(opts, "renounceOwnership") -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_History *HistorySession) RenounceOwnership() (*types.Transaction, error) { - return _History.Contract.RenounceOwnership(&_History.TransactOpts) -} - -// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. -// -// Solidity: function renounceOwnership() returns() -func (_History *HistoryTransactorSession) RenounceOwnership() (*types.Transaction, error) { - return _History.Contract.RenounceOwnership(&_History.TransactOpts) -} - -// SubmitClaim is a paid mutator transaction binding the contract method 0xddfdfbb0. -// -// Solidity: function submitClaim(bytes _claimData) returns() -func (_History *HistoryTransactor) SubmitClaim(opts *bind.TransactOpts, _claimData []byte) (*types.Transaction, error) { - return _History.contract.Transact(opts, "submitClaim", _claimData) -} - -// SubmitClaim is a paid mutator transaction binding the contract method 0xddfdfbb0. -// -// Solidity: function submitClaim(bytes _claimData) returns() -func (_History *HistorySession) SubmitClaim(_claimData []byte) (*types.Transaction, error) { - return _History.Contract.SubmitClaim(&_History.TransactOpts, _claimData) -} - -// SubmitClaim is a paid mutator transaction binding the contract method 0xddfdfbb0. -// -// Solidity: function submitClaim(bytes _claimData) returns() -func (_History *HistoryTransactorSession) SubmitClaim(_claimData []byte) (*types.Transaction, error) { - return _History.Contract.SubmitClaim(&_History.TransactOpts, _claimData) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_History *HistoryTransactor) TransferOwnership(opts *bind.TransactOpts, newOwner common.Address) (*types.Transaction, error) { - return _History.contract.Transact(opts, "transferOwnership", newOwner) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_History *HistorySession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { - return _History.Contract.TransferOwnership(&_History.TransactOpts, newOwner) -} - -// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (_History *HistoryTransactorSession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { - return _History.Contract.TransferOwnership(&_History.TransactOpts, newOwner) -} - -// HistoryNewClaimToHistoryIterator is returned from FilterNewClaimToHistory and is used to iterate over the raw logs and unpacked data for NewClaimToHistory events raised by the History contract. -type HistoryNewClaimToHistoryIterator struct { - Event *HistoryNewClaimToHistory // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *HistoryNewClaimToHistoryIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(HistoryNewClaimToHistory) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(HistoryNewClaimToHistory) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *HistoryNewClaimToHistoryIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *HistoryNewClaimToHistoryIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// HistoryNewClaimToHistory represents a NewClaimToHistory event raised by the History contract. -type HistoryNewClaimToHistory struct { - Dapp common.Address - Claim HistoryClaim - Raw types.Log // Blockchain specific contextual infos -} - -// FilterNewClaimToHistory is a free log retrieval operation binding the contract event 0xb71880d7a0c514d48c0296b2721b0a4f9641a45117960f2ca86b5b7873c4ab2f. -// -// Solidity: event NewClaimToHistory(address indexed dapp, (bytes32,uint128,uint128) claim) -func (_History *HistoryFilterer) FilterNewClaimToHistory(opts *bind.FilterOpts, dapp []common.Address) (*HistoryNewClaimToHistoryIterator, error) { - - var dappRule []interface{} - for _, dappItem := range dapp { - dappRule = append(dappRule, dappItem) - } - - logs, sub, err := _History.contract.FilterLogs(opts, "NewClaimToHistory", dappRule) - if err != nil { - return nil, err - } - return &HistoryNewClaimToHistoryIterator{contract: _History.contract, event: "NewClaimToHistory", logs: logs, sub: sub}, nil -} - -// WatchNewClaimToHistory is a free log subscription operation binding the contract event 0xb71880d7a0c514d48c0296b2721b0a4f9641a45117960f2ca86b5b7873c4ab2f. -// -// Solidity: event NewClaimToHistory(address indexed dapp, (bytes32,uint128,uint128) claim) -func (_History *HistoryFilterer) WatchNewClaimToHistory(opts *bind.WatchOpts, sink chan<- *HistoryNewClaimToHistory, dapp []common.Address) (event.Subscription, error) { - - var dappRule []interface{} - for _, dappItem := range dapp { - dappRule = append(dappRule, dappItem) - } - - logs, sub, err := _History.contract.WatchLogs(opts, "NewClaimToHistory", dappRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(HistoryNewClaimToHistory) - if err := _History.contract.UnpackLog(event, "NewClaimToHistory", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseNewClaimToHistory is a log parse operation binding the contract event 0xb71880d7a0c514d48c0296b2721b0a4f9641a45117960f2ca86b5b7873c4ab2f. -// -// Solidity: event NewClaimToHistory(address indexed dapp, (bytes32,uint128,uint128) claim) -func (_History *HistoryFilterer) ParseNewClaimToHistory(log types.Log) (*HistoryNewClaimToHistory, error) { - event := new(HistoryNewClaimToHistory) - if err := _History.contract.UnpackLog(event, "NewClaimToHistory", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// HistoryOwnershipTransferredIterator is returned from FilterOwnershipTransferred and is used to iterate over the raw logs and unpacked data for OwnershipTransferred events raised by the History contract. -type HistoryOwnershipTransferredIterator struct { - Event *HistoryOwnershipTransferred // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *HistoryOwnershipTransferredIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(HistoryOwnershipTransferred) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(HistoryOwnershipTransferred) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *HistoryOwnershipTransferredIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *HistoryOwnershipTransferredIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// HistoryOwnershipTransferred represents a OwnershipTransferred event raised by the History contract. -type HistoryOwnershipTransferred struct { - PreviousOwner common.Address - NewOwner common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterOwnershipTransferred is a free log retrieval operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_History *HistoryFilterer) FilterOwnershipTransferred(opts *bind.FilterOpts, previousOwner []common.Address, newOwner []common.Address) (*HistoryOwnershipTransferredIterator, error) { - - var previousOwnerRule []interface{} - for _, previousOwnerItem := range previousOwner { - previousOwnerRule = append(previousOwnerRule, previousOwnerItem) - } - var newOwnerRule []interface{} - for _, newOwnerItem := range newOwner { - newOwnerRule = append(newOwnerRule, newOwnerItem) - } - - logs, sub, err := _History.contract.FilterLogs(opts, "OwnershipTransferred", previousOwnerRule, newOwnerRule) - if err != nil { - return nil, err - } - return &HistoryOwnershipTransferredIterator{contract: _History.contract, event: "OwnershipTransferred", logs: logs, sub: sub}, nil -} - -// WatchOwnershipTransferred is a free log subscription operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_History *HistoryFilterer) WatchOwnershipTransferred(opts *bind.WatchOpts, sink chan<- *HistoryOwnershipTransferred, previousOwner []common.Address, newOwner []common.Address) (event.Subscription, error) { - - var previousOwnerRule []interface{} - for _, previousOwnerItem := range previousOwner { - previousOwnerRule = append(previousOwnerRule, previousOwnerItem) - } - var newOwnerRule []interface{} - for _, newOwnerItem := range newOwner { - newOwnerRule = append(newOwnerRule, newOwnerItem) - } - - logs, sub, err := _History.contract.WatchLogs(opts, "OwnershipTransferred", previousOwnerRule, newOwnerRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(HistoryOwnershipTransferred) - if err := _History.contract.UnpackLog(event, "OwnershipTransferred", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseOwnershipTransferred is a log parse operation binding the contract event 0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (_History *HistoryFilterer) ParseOwnershipTransferred(log types.Log) (*HistoryOwnershipTransferred, error) { - event := new(HistoryOwnershipTransferred) - if err := _History.contract.UnpackLog(event, "OwnershipTransferred", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} diff --git a/pkg/contracts/iapplication/iapplication.go b/pkg/contracts/iapplication/iapplication.go new file mode 100644 index 000000000..305b5bb9d --- /dev/null +++ b/pkg/contracts/iapplication/iapplication.go @@ -0,0 +1,2125 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iapplication + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// AccountValidityProof is an auto generated low-level Go binding around an user-defined struct. +type AccountValidityProof struct { + AccountIndex uint64 + AccountRootSiblings [][32]byte +} + +// OutputValidityProof is an auto generated low-level Go binding around an user-defined struct. +type OutputValidityProof struct { + OutputIndex uint64 + OutputHashesSiblings [][32]byte +} + +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + +// IApplicationMetaData contains all meta data concerning the IApplication contract. +var IApplicationMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"executeOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"foreclose\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getAccountsDriveMerkleRoot\",\"inputs\":[],\"outputs\":[{\"name\":\"wasAccountsDriveMerkleRootProved\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAccountsDriveStartIndex\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getGuardian\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2LeavesPerAccount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLog2MaxNumOfAccounts\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfExecutedOutputs\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfIssuedRefunds\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfWithdrawals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getOutputsMerkleRootValidator\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getRefundOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIRefundOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTemplateHash\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalConfig\",\"inputs\":[],\"outputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getWithdrawalOutputBuilder\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isForeclosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"issueRefund\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"migrateToOutputsMerkleRootValidator\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"proveAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"validateAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateAccountMerkleRoot\",\"inputs\":[{\"name\":\"accountMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateInput\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputSender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputPayload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateInputHash\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutput\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validateOutputHash\",\"inputs\":[{\"name\":\"outputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structOutputValidityProof\",\"components\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"outputHashesSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasOutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasRefundForInputIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wereAccountFundsWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdraw\",\"inputs\":[{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structAccountValidityProof\",\"components\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"accountRootSiblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AccountsDriveMerkleRootProved\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Foreclosure\",\"inputs\":[],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputExecuted\",\"inputs\":[{\"name\":\"outputIndex\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutputsMerkleRootValidatorChanged\",\"inputs\":[{\"name\":\"newOutputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIOutputsMerkleRootValidator\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RefundIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawal\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"},{\"name\":\"account\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"},{\"name\":\"output\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccountFundsAlreadyWithdrawn\",\"inputs\":[{\"name\":\"accountIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootAlreadyProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccountsDriveMerkleRootNotProved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CannotRefundFinalizedInput\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Foreclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IllFormedInput\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientFunds\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountRootSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAccountSize\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"accountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRoot\",\"inputs\":[{\"name\":\"accountsDriveMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidAccountsDriveMerkleRootProofSize\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidInputHash\",\"inputs\":[{\"name\":\"storedInputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"invalidInputHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidInputIndex\",\"inputs\":[{\"name\":\"invalidInputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"numOfInputs\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleRoot\",\"inputs\":[{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidOutputHashesSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidOutputsMerkleRoot\",\"inputs\":[{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"NotDeploymentBlock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotForeclosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotGuardian\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OutputNotExecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"OutputNotReexecutable\",\"inputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"RefundAlreadyIssued\",\"inputs\":[{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TargetHasNoCode\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"UnknownInputSender\",\"inputs\":[{\"name\":\"inputSender\",\"type\":\"address\",\"internalType\":\"address\"}]}]", +} + +// IApplicationABI is the input ABI used to generate the binding from. +// Deprecated: Use IApplicationMetaData.ABI instead. +var IApplicationABI = IApplicationMetaData.ABI + +// IApplication is an auto generated Go binding around an Ethereum contract. +type IApplication struct { + IApplicationCaller // Read-only binding to the contract + IApplicationTransactor // Write-only binding to the contract + IApplicationFilterer // Log filterer for contract events +} + +// IApplicationCaller is an auto generated read-only Go binding around an Ethereum contract. +type IApplicationCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IApplicationTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IApplicationTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IApplicationFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IApplicationFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IApplicationSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IApplicationSession struct { + Contract *IApplication // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IApplicationCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IApplicationCallerSession struct { + Contract *IApplicationCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IApplicationTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IApplicationTransactorSession struct { + Contract *IApplicationTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IApplicationRaw is an auto generated low-level Go binding around an Ethereum contract. +type IApplicationRaw struct { + Contract *IApplication // Generic contract binding to access the raw methods on +} + +// IApplicationCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IApplicationCallerRaw struct { + Contract *IApplicationCaller // Generic read-only contract binding to access the raw methods on +} + +// IApplicationTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IApplicationTransactorRaw struct { + Contract *IApplicationTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIApplication creates a new instance of IApplication, bound to a specific deployed contract. +func NewIApplication(address common.Address, backend bind.ContractBackend) (*IApplication, error) { + contract, err := bindIApplication(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IApplication{IApplicationCaller: IApplicationCaller{contract: contract}, IApplicationTransactor: IApplicationTransactor{contract: contract}, IApplicationFilterer: IApplicationFilterer{contract: contract}}, nil +} + +// NewIApplicationCaller creates a new read-only instance of IApplication, bound to a specific deployed contract. +func NewIApplicationCaller(address common.Address, caller bind.ContractCaller) (*IApplicationCaller, error) { + contract, err := bindIApplication(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IApplicationCaller{contract: contract}, nil +} + +// NewIApplicationTransactor creates a new write-only instance of IApplication, bound to a specific deployed contract. +func NewIApplicationTransactor(address common.Address, transactor bind.ContractTransactor) (*IApplicationTransactor, error) { + contract, err := bindIApplication(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IApplicationTransactor{contract: contract}, nil +} + +// NewIApplicationFilterer creates a new log filterer instance of IApplication, bound to a specific deployed contract. +func NewIApplicationFilterer(address common.Address, filterer bind.ContractFilterer) (*IApplicationFilterer, error) { + contract, err := bindIApplication(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IApplicationFilterer{contract: contract}, nil +} + +// bindIApplication binds a generic wrapper to an already deployed contract. +func bindIApplication(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IApplicationMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IApplication *IApplicationRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IApplication.Contract.IApplicationCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IApplication *IApplicationRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IApplication.Contract.IApplicationTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IApplication *IApplicationRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IApplication.Contract.IApplicationTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IApplication *IApplicationCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IApplication.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IApplication *IApplicationTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IApplication.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IApplication *IApplicationTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IApplication.Contract.contract.Transact(opts, method, params...) +} + +// GetAccountsDriveMerkleRoot is a free data retrieval call binding the contract method 0xf04ba871. +// +// Solidity: function getAccountsDriveMerkleRoot() view returns(bool wasAccountsDriveMerkleRootProved, bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationCaller) GetAccountsDriveMerkleRoot(opts *bind.CallOpts) (struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte +}, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getAccountsDriveMerkleRoot") + + outstruct := new(struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.WasAccountsDriveMerkleRootProved = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.AccountsDriveMerkleRoot = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// GetAccountsDriveMerkleRoot is a free data retrieval call binding the contract method 0xf04ba871. +// +// Solidity: function getAccountsDriveMerkleRoot() view returns(bool wasAccountsDriveMerkleRootProved, bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationSession) GetAccountsDriveMerkleRoot() (struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte +}, error) { + return _IApplication.Contract.GetAccountsDriveMerkleRoot(&_IApplication.CallOpts) +} + +// GetAccountsDriveMerkleRoot is a free data retrieval call binding the contract method 0xf04ba871. +// +// Solidity: function getAccountsDriveMerkleRoot() view returns(bool wasAccountsDriveMerkleRootProved, bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationCallerSession) GetAccountsDriveMerkleRoot() (struct { + WasAccountsDriveMerkleRootProved bool + AccountsDriveMerkleRoot [32]byte +}, error) { + return _IApplication.Contract.GetAccountsDriveMerkleRoot(&_IApplication.CallOpts) +} + +// GetAccountsDriveStartIndex is a free data retrieval call binding the contract method 0xab2423ad. +// +// Solidity: function getAccountsDriveStartIndex() view returns(uint64) +func (_IApplication *IApplicationCaller) GetAccountsDriveStartIndex(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getAccountsDriveStartIndex") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// GetAccountsDriveStartIndex is a free data retrieval call binding the contract method 0xab2423ad. +// +// Solidity: function getAccountsDriveStartIndex() view returns(uint64) +func (_IApplication *IApplicationSession) GetAccountsDriveStartIndex() (uint64, error) { + return _IApplication.Contract.GetAccountsDriveStartIndex(&_IApplication.CallOpts) +} + +// GetAccountsDriveStartIndex is a free data retrieval call binding the contract method 0xab2423ad. +// +// Solidity: function getAccountsDriveStartIndex() view returns(uint64) +func (_IApplication *IApplicationCallerSession) GetAccountsDriveStartIndex() (uint64, error) { + return _IApplication.Contract.GetAccountsDriveStartIndex(&_IApplication.CallOpts) +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IApplication *IApplicationCaller) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getDeploymentBlockNumber") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IApplication *IApplicationSession) GetDeploymentBlockNumber() (*big.Int, error) { + return _IApplication.Contract.GetDeploymentBlockNumber(&_IApplication.CallOpts) +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IApplication *IApplicationCallerSession) GetDeploymentBlockNumber() (*big.Int, error) { + return _IApplication.Contract.GetDeploymentBlockNumber(&_IApplication.CallOpts) +} + +// GetGuardian is a free data retrieval call binding the contract method 0xa75b87d2. +// +// Solidity: function getGuardian() view returns(address) +func (_IApplication *IApplicationCaller) GetGuardian(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getGuardian") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetGuardian is a free data retrieval call binding the contract method 0xa75b87d2. +// +// Solidity: function getGuardian() view returns(address) +func (_IApplication *IApplicationSession) GetGuardian() (common.Address, error) { + return _IApplication.Contract.GetGuardian(&_IApplication.CallOpts) +} + +// GetGuardian is a free data retrieval call binding the contract method 0xa75b87d2. +// +// Solidity: function getGuardian() view returns(address) +func (_IApplication *IApplicationCallerSession) GetGuardian() (common.Address, error) { + return _IApplication.Contract.GetGuardian(&_IApplication.CallOpts) +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationCaller) GetInputBox(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getInputBox") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationSession) GetInputBox() (common.Address, error) { + return _IApplication.Contract.GetInputBox(&_IApplication.CallOpts) +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IApplication *IApplicationCallerSession) GetInputBox() (common.Address, error) { + return _IApplication.Contract.GetInputBox(&_IApplication.CallOpts) +} + +// GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. +// +// Solidity: function getLog2LeavesPerAccount() view returns(uint8) +func (_IApplication *IApplicationCaller) GetLog2LeavesPerAccount(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getLog2LeavesPerAccount") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. +// +// Solidity: function getLog2LeavesPerAccount() view returns(uint8) +func (_IApplication *IApplicationSession) GetLog2LeavesPerAccount() (uint8, error) { + return _IApplication.Contract.GetLog2LeavesPerAccount(&_IApplication.CallOpts) +} + +// GetLog2LeavesPerAccount is a free data retrieval call binding the contract method 0x28a0e3c5. +// +// Solidity: function getLog2LeavesPerAccount() view returns(uint8) +func (_IApplication *IApplicationCallerSession) GetLog2LeavesPerAccount() (uint8, error) { + return _IApplication.Contract.GetLog2LeavesPerAccount(&_IApplication.CallOpts) +} + +// GetLog2MaxNumOfAccounts is a free data retrieval call binding the contract method 0xfc39b736. +// +// Solidity: function getLog2MaxNumOfAccounts() view returns(uint8) +func (_IApplication *IApplicationCaller) GetLog2MaxNumOfAccounts(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getLog2MaxNumOfAccounts") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// GetLog2MaxNumOfAccounts is a free data retrieval call binding the contract method 0xfc39b736. +// +// Solidity: function getLog2MaxNumOfAccounts() view returns(uint8) +func (_IApplication *IApplicationSession) GetLog2MaxNumOfAccounts() (uint8, error) { + return _IApplication.Contract.GetLog2MaxNumOfAccounts(&_IApplication.CallOpts) +} + +// GetLog2MaxNumOfAccounts is a free data retrieval call binding the contract method 0xfc39b736. +// +// Solidity: function getLog2MaxNumOfAccounts() view returns(uint8) +func (_IApplication *IApplicationCallerSession) GetLog2MaxNumOfAccounts() (uint8, error) { + return _IApplication.Contract.GetLog2MaxNumOfAccounts(&_IApplication.CallOpts) +} + +// GetNumberOfExecutedOutputs is a free data retrieval call binding the contract method 0xe64fab4d. +// +// Solidity: function getNumberOfExecutedOutputs() view returns(uint256) +func (_IApplication *IApplicationCaller) GetNumberOfExecutedOutputs(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getNumberOfExecutedOutputs") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfExecutedOutputs is a free data retrieval call binding the contract method 0xe64fab4d. +// +// Solidity: function getNumberOfExecutedOutputs() view returns(uint256) +func (_IApplication *IApplicationSession) GetNumberOfExecutedOutputs() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfExecutedOutputs(&_IApplication.CallOpts) +} + +// GetNumberOfExecutedOutputs is a free data retrieval call binding the contract method 0xe64fab4d. +// +// Solidity: function getNumberOfExecutedOutputs() view returns(uint256) +func (_IApplication *IApplicationCallerSession) GetNumberOfExecutedOutputs() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfExecutedOutputs(&_IApplication.CallOpts) +} + +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationCaller) GetNumberOfIssuedRefunds(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getNumberOfIssuedRefunds") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationSession) GetNumberOfIssuedRefunds() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfIssuedRefunds(&_IApplication.CallOpts) +} + +// GetNumberOfIssuedRefunds is a free data retrieval call binding the contract method 0x1118be66. +// +// Solidity: function getNumberOfIssuedRefunds() view returns(uint256) +func (_IApplication *IApplicationCallerSession) GetNumberOfIssuedRefunds() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfIssuedRefunds(&_IApplication.CallOpts) +} + +// GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. +// +// Solidity: function getNumberOfWithdrawals() view returns(uint256) +func (_IApplication *IApplicationCaller) GetNumberOfWithdrawals(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getNumberOfWithdrawals") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. +// +// Solidity: function getNumberOfWithdrawals() view returns(uint256) +func (_IApplication *IApplicationSession) GetNumberOfWithdrawals() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfWithdrawals(&_IApplication.CallOpts) +} + +// GetNumberOfWithdrawals is a free data retrieval call binding the contract method 0x0e70381b. +// +// Solidity: function getNumberOfWithdrawals() view returns(uint256) +func (_IApplication *IApplicationCallerSession) GetNumberOfWithdrawals() (*big.Int, error) { + return _IApplication.Contract.GetNumberOfWithdrawals(&_IApplication.CallOpts) +} + +// GetOutputsMerkleRootValidator is a free data retrieval call binding the contract method 0xa94dfc5a. +// +// Solidity: function getOutputsMerkleRootValidator() view returns(address) +func (_IApplication *IApplicationCaller) GetOutputsMerkleRootValidator(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getOutputsMerkleRootValidator") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetOutputsMerkleRootValidator is a free data retrieval call binding the contract method 0xa94dfc5a. +// +// Solidity: function getOutputsMerkleRootValidator() view returns(address) +func (_IApplication *IApplicationSession) GetOutputsMerkleRootValidator() (common.Address, error) { + return _IApplication.Contract.GetOutputsMerkleRootValidator(&_IApplication.CallOpts) +} + +// GetOutputsMerkleRootValidator is a free data retrieval call binding the contract method 0xa94dfc5a. +// +// Solidity: function getOutputsMerkleRootValidator() view returns(address) +func (_IApplication *IApplicationCallerSession) GetOutputsMerkleRootValidator() (common.Address, error) { + return _IApplication.Contract.GetOutputsMerkleRootValidator(&_IApplication.CallOpts) +} + +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationCaller) GetRefundOutputBuilder(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getRefundOutputBuilder") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationSession) GetRefundOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetRefundOutputBuilder(&_IApplication.CallOpts) +} + +// GetRefundOutputBuilder is a free data retrieval call binding the contract method 0x8cd92074. +// +// Solidity: function getRefundOutputBuilder() view returns(address) +func (_IApplication *IApplicationCallerSession) GetRefundOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetRefundOutputBuilder(&_IApplication.CallOpts) +} + +// GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. +// +// Solidity: function getTemplateHash() view returns(bytes32) +func (_IApplication *IApplicationCaller) GetTemplateHash(opts *bind.CallOpts) ([32]byte, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getTemplateHash") + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. +// +// Solidity: function getTemplateHash() view returns(bytes32) +func (_IApplication *IApplicationSession) GetTemplateHash() ([32]byte, error) { + return _IApplication.Contract.GetTemplateHash(&_IApplication.CallOpts) +} + +// GetTemplateHash is a free data retrieval call binding the contract method 0x61b12c66. +// +// Solidity: function getTemplateHash() view returns(bytes32) +func (_IApplication *IApplicationCallerSession) GetTemplateHash() ([32]byte, error) { + return _IApplication.Contract.GetTemplateHash(&_IApplication.CallOpts) +} + +// GetWithdrawalConfig is a free data retrieval call binding the contract method 0x65d0c9ce. +// +// Solidity: function getWithdrawalConfig() view returns((address,uint8,uint8,uint64,address) withdrawalConfig) +func (_IApplication *IApplicationCaller) GetWithdrawalConfig(opts *bind.CallOpts) (WithdrawalConfig, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getWithdrawalConfig") + + if err != nil { + return *new(WithdrawalConfig), err + } + + out0 := *abi.ConvertType(out[0], new(WithdrawalConfig)).(*WithdrawalConfig) + + return out0, err + +} + +// GetWithdrawalConfig is a free data retrieval call binding the contract method 0x65d0c9ce. +// +// Solidity: function getWithdrawalConfig() view returns((address,uint8,uint8,uint64,address) withdrawalConfig) +func (_IApplication *IApplicationSession) GetWithdrawalConfig() (WithdrawalConfig, error) { + return _IApplication.Contract.GetWithdrawalConfig(&_IApplication.CallOpts) +} + +// GetWithdrawalConfig is a free data retrieval call binding the contract method 0x65d0c9ce. +// +// Solidity: function getWithdrawalConfig() view returns((address,uint8,uint8,uint64,address) withdrawalConfig) +func (_IApplication *IApplicationCallerSession) GetWithdrawalConfig() (WithdrawalConfig, error) { + return _IApplication.Contract.GetWithdrawalConfig(&_IApplication.CallOpts) +} + +// GetWithdrawalOutputBuilder is a free data retrieval call binding the contract method 0x92ab68d0. +// +// Solidity: function getWithdrawalOutputBuilder() view returns(address) +func (_IApplication *IApplicationCaller) GetWithdrawalOutputBuilder(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "getWithdrawalOutputBuilder") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetWithdrawalOutputBuilder is a free data retrieval call binding the contract method 0x92ab68d0. +// +// Solidity: function getWithdrawalOutputBuilder() view returns(address) +func (_IApplication *IApplicationSession) GetWithdrawalOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetWithdrawalOutputBuilder(&_IApplication.CallOpts) +} + +// GetWithdrawalOutputBuilder is a free data retrieval call binding the contract method 0x92ab68d0. +// +// Solidity: function getWithdrawalOutputBuilder() view returns(address) +func (_IApplication *IApplicationCallerSession) GetWithdrawalOutputBuilder() (common.Address, error) { + return _IApplication.Contract.GetWithdrawalOutputBuilder(&_IApplication.CallOpts) +} + +// IsForeclosed is a free data retrieval call binding the contract method 0x83e4fbcd. +// +// Solidity: function isForeclosed() view returns(bool) +func (_IApplication *IApplicationCaller) IsForeclosed(opts *bind.CallOpts) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "isForeclosed") + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsForeclosed is a free data retrieval call binding the contract method 0x83e4fbcd. +// +// Solidity: function isForeclosed() view returns(bool) +func (_IApplication *IApplicationSession) IsForeclosed() (bool, error) { + return _IApplication.Contract.IsForeclosed(&_IApplication.CallOpts) +} + +// IsForeclosed is a free data retrieval call binding the contract method 0x83e4fbcd. +// +// Solidity: function isForeclosed() view returns(bool) +func (_IApplication *IApplicationCallerSession) IsForeclosed() (bool, error) { + return _IApplication.Contract.IsForeclosed(&_IApplication.CallOpts) +} + +// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (_IApplication *IApplicationCaller) Owner(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "owner") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (_IApplication *IApplicationSession) Owner() (common.Address, error) { + return _IApplication.Contract.Owner(&_IApplication.CallOpts) +} + +// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (_IApplication *IApplicationCallerSession) Owner() (common.Address, error) { + return _IApplication.Contract.Owner(&_IApplication.CallOpts) +} + +// ValidateAccount is a free data retrieval call binding the contract method 0x2b639720. +// +// Solidity: function validateAccount(bytes account, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCaller) ValidateAccount(opts *bind.CallOpts, account []byte, proof AccountValidityProof) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateAccount", account, proof) + + if err != nil { + return err + } + + return err + +} + +// ValidateAccount is a free data retrieval call binding the contract method 0x2b639720. +// +// Solidity: function validateAccount(bytes account, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationSession) ValidateAccount(account []byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccount(&_IApplication.CallOpts, account, proof) +} + +// ValidateAccount is a free data retrieval call binding the contract method 0x2b639720. +// +// Solidity: function validateAccount(bytes account, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCallerSession) ValidateAccount(account []byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccount(&_IApplication.CallOpts, account, proof) +} + +// ValidateAccountMerkleRoot is a free data retrieval call binding the contract method 0x63b9c3b2. +// +// Solidity: function validateAccountMerkleRoot(bytes32 accountMerkleRoot, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCaller) ValidateAccountMerkleRoot(opts *bind.CallOpts, accountMerkleRoot [32]byte, proof AccountValidityProof) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateAccountMerkleRoot", accountMerkleRoot, proof) + + if err != nil { + return err + } + + return err + +} + +// ValidateAccountMerkleRoot is a free data retrieval call binding the contract method 0x63b9c3b2. +// +// Solidity: function validateAccountMerkleRoot(bytes32 accountMerkleRoot, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationSession) ValidateAccountMerkleRoot(accountMerkleRoot [32]byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccountMerkleRoot(&_IApplication.CallOpts, accountMerkleRoot, proof) +} + +// ValidateAccountMerkleRoot is a free data retrieval call binding the contract method 0x63b9c3b2. +// +// Solidity: function validateAccountMerkleRoot(bytes32 accountMerkleRoot, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCallerSession) ValidateAccountMerkleRoot(accountMerkleRoot [32]byte, proof AccountValidityProof) error { + return _IApplication.Contract.ValidateAccountMerkleRoot(&_IApplication.CallOpts, accountMerkleRoot, proof) +} + +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationCaller) ValidateInput(opts *bind.CallOpts, inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateInput", inputIndex, input) + + outstruct := new(struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.BlockNumber = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.InputSender = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.InputPayload = *abi.ConvertType(out[2], new([]byte)).(*[]byte) + + return *outstruct, err + +} + +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationSession) ValidateInput(inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + return _IApplication.Contract.ValidateInput(&_IApplication.CallOpts, inputIndex, input) +} + +// ValidateInput is a free data retrieval call binding the contract method 0xc59319c2. +// +// Solidity: function validateInput(uint256 inputIndex, bytes input) view returns(uint256 blockNumber, address inputSender, bytes inputPayload) +func (_IApplication *IApplicationCallerSession) ValidateInput(inputIndex *big.Int, input []byte) (struct { + BlockNumber *big.Int + InputSender common.Address + InputPayload []byte +}, error) { + return _IApplication.Contract.ValidateInput(&_IApplication.CallOpts, inputIndex, input) +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationCaller) ValidateInputHash(opts *bind.CallOpts, inputIndex *big.Int, inputHash [32]byte) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateInputHash", inputIndex, inputHash) + + if err != nil { + return err + } + + return err + +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationSession) ValidateInputHash(inputIndex *big.Int, inputHash [32]byte) error { + return _IApplication.Contract.ValidateInputHash(&_IApplication.CallOpts, inputIndex, inputHash) +} + +// ValidateInputHash is a free data retrieval call binding the contract method 0x191f84a8. +// +// Solidity: function validateInputHash(uint256 inputIndex, bytes32 inputHash) view returns() +func (_IApplication *IApplicationCallerSession) ValidateInputHash(inputIndex *big.Int, inputHash [32]byte) error { + return _IApplication.Contract.ValidateInputHash(&_IApplication.CallOpts, inputIndex, inputHash) +} + +// ValidateOutput is a free data retrieval call binding the contract method 0xe88d39c0. +// +// Solidity: function validateOutput(bytes output, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCaller) ValidateOutput(opts *bind.CallOpts, output []byte, proof OutputValidityProof) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateOutput", output, proof) + + if err != nil { + return err + } + + return err + +} + +// ValidateOutput is a free data retrieval call binding the contract method 0xe88d39c0. +// +// Solidity: function validateOutput(bytes output, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationSession) ValidateOutput(output []byte, proof OutputValidityProof) error { + return _IApplication.Contract.ValidateOutput(&_IApplication.CallOpts, output, proof) +} + +// ValidateOutput is a free data retrieval call binding the contract method 0xe88d39c0. +// +// Solidity: function validateOutput(bytes output, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCallerSession) ValidateOutput(output []byte, proof OutputValidityProof) error { + return _IApplication.Contract.ValidateOutput(&_IApplication.CallOpts, output, proof) +} + +// ValidateOutputHash is a free data retrieval call binding the contract method 0x08eb89ab. +// +// Solidity: function validateOutputHash(bytes32 outputHash, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCaller) ValidateOutputHash(opts *bind.CallOpts, outputHash [32]byte, proof OutputValidityProof) error { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "validateOutputHash", outputHash, proof) + + if err != nil { + return err + } + + return err + +} + +// ValidateOutputHash is a free data retrieval call binding the contract method 0x08eb89ab. +// +// Solidity: function validateOutputHash(bytes32 outputHash, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationSession) ValidateOutputHash(outputHash [32]byte, proof OutputValidityProof) error { + return _IApplication.Contract.ValidateOutputHash(&_IApplication.CallOpts, outputHash, proof) +} + +// ValidateOutputHash is a free data retrieval call binding the contract method 0x08eb89ab. +// +// Solidity: function validateOutputHash(bytes32 outputHash, (uint64,bytes32[]) proof) view returns() +func (_IApplication *IApplicationCallerSession) ValidateOutputHash(outputHash [32]byte, proof OutputValidityProof) error { + return _IApplication.Contract.ValidateOutputHash(&_IApplication.CallOpts, outputHash, proof) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IApplication *IApplicationCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IApplication *IApplicationSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplication.Contract.Version(&_IApplication.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IApplication *IApplicationCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplication.Contract.Version(&_IApplication.CallOpts) +} + +// WasOutputExecuted is a free data retrieval call binding the contract method 0x71891db0. +// +// Solidity: function wasOutputExecuted(uint256 outputIndex) view returns(bool) +func (_IApplication *IApplicationCaller) WasOutputExecuted(opts *bind.CallOpts, outputIndex *big.Int) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "wasOutputExecuted", outputIndex) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasOutputExecuted is a free data retrieval call binding the contract method 0x71891db0. +// +// Solidity: function wasOutputExecuted(uint256 outputIndex) view returns(bool) +func (_IApplication *IApplicationSession) WasOutputExecuted(outputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasOutputExecuted(&_IApplication.CallOpts, outputIndex) +} + +// WasOutputExecuted is a free data retrieval call binding the contract method 0x71891db0. +// +// Solidity: function wasOutputExecuted(uint256 outputIndex) view returns(bool) +func (_IApplication *IApplicationCallerSession) WasOutputExecuted(outputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasOutputExecuted(&_IApplication.CallOpts, outputIndex) +} + +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationCaller) WasRefundForInputIssued(opts *bind.CallOpts, inputIndex *big.Int) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "wasRefundForInputIssued", inputIndex) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationSession) WasRefundForInputIssued(inputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasRefundForInputIssued(&_IApplication.CallOpts, inputIndex) +} + +// WasRefundForInputIssued is a free data retrieval call binding the contract method 0xcce91060. +// +// Solidity: function wasRefundForInputIssued(uint256 inputIndex) view returns(bool) +func (_IApplication *IApplicationCallerSession) WasRefundForInputIssued(inputIndex *big.Int) (bool, error) { + return _IApplication.Contract.WasRefundForInputIssued(&_IApplication.CallOpts, inputIndex) +} + +// WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. +// +// Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) +func (_IApplication *IApplicationCaller) WereAccountFundsWithdrawn(opts *bind.CallOpts, accountIndex *big.Int) (bool, error) { + var out []interface{} + err := _IApplication.contract.Call(opts, &out, "wereAccountFundsWithdrawn", accountIndex) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. +// +// Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) +func (_IApplication *IApplicationSession) WereAccountFundsWithdrawn(accountIndex *big.Int) (bool, error) { + return _IApplication.Contract.WereAccountFundsWithdrawn(&_IApplication.CallOpts, accountIndex) +} + +// WereAccountFundsWithdrawn is a free data retrieval call binding the contract method 0x8272a6aa. +// +// Solidity: function wereAccountFundsWithdrawn(uint256 accountIndex) view returns(bool) +func (_IApplication *IApplicationCallerSession) WereAccountFundsWithdrawn(accountIndex *big.Int) (bool, error) { + return _IApplication.Contract.WereAccountFundsWithdrawn(&_IApplication.CallOpts, accountIndex) +} + +// ExecuteOutput is a paid mutator transaction binding the contract method 0x33137b76. +// +// Solidity: function executeOutput(bytes output, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationTransactor) ExecuteOutput(opts *bind.TransactOpts, output []byte, proof OutputValidityProof) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "executeOutput", output, proof) +} + +// ExecuteOutput is a paid mutator transaction binding the contract method 0x33137b76. +// +// Solidity: function executeOutput(bytes output, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationSession) ExecuteOutput(output []byte, proof OutputValidityProof) (*types.Transaction, error) { + return _IApplication.Contract.ExecuteOutput(&_IApplication.TransactOpts, output, proof) +} + +// ExecuteOutput is a paid mutator transaction binding the contract method 0x33137b76. +// +// Solidity: function executeOutput(bytes output, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationTransactorSession) ExecuteOutput(output []byte, proof OutputValidityProof) (*types.Transaction, error) { + return _IApplication.Contract.ExecuteOutput(&_IApplication.TransactOpts, output, proof) +} + +// Foreclose is a paid mutator transaction binding the contract method 0xeb6266e2. +// +// Solidity: function foreclose() returns() +func (_IApplication *IApplicationTransactor) Foreclose(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "foreclose") +} + +// Foreclose is a paid mutator transaction binding the contract method 0xeb6266e2. +// +// Solidity: function foreclose() returns() +func (_IApplication *IApplicationSession) Foreclose() (*types.Transaction, error) { + return _IApplication.Contract.Foreclose(&_IApplication.TransactOpts) +} + +// Foreclose is a paid mutator transaction binding the contract method 0xeb6266e2. +// +// Solidity: function foreclose() returns() +func (_IApplication *IApplicationTransactorSession) Foreclose() (*types.Transaction, error) { + return _IApplication.Contract.Foreclose(&_IApplication.TransactOpts) +} + +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationTransactor) IssueRefund(opts *bind.TransactOpts, inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "issueRefund", inputIndex, input) +} + +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationSession) IssueRefund(inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.Contract.IssueRefund(&_IApplication.TransactOpts, inputIndex, input) +} + +// IssueRefund is a paid mutator transaction binding the contract method 0xd4c4646a. +// +// Solidity: function issueRefund(uint256 inputIndex, bytes input) returns() +func (_IApplication *IApplicationTransactorSession) IssueRefund(inputIndex *big.Int, input []byte) (*types.Transaction, error) { + return _IApplication.Contract.IssueRefund(&_IApplication.TransactOpts, inputIndex, input) +} + +// MigrateToOutputsMerkleRootValidator is a paid mutator transaction binding the contract method 0xbf8abff8. +// +// Solidity: function migrateToOutputsMerkleRootValidator(address newOutputsMerkleRootValidator) returns() +func (_IApplication *IApplicationTransactor) MigrateToOutputsMerkleRootValidator(opts *bind.TransactOpts, newOutputsMerkleRootValidator common.Address) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "migrateToOutputsMerkleRootValidator", newOutputsMerkleRootValidator) +} + +// MigrateToOutputsMerkleRootValidator is a paid mutator transaction binding the contract method 0xbf8abff8. +// +// Solidity: function migrateToOutputsMerkleRootValidator(address newOutputsMerkleRootValidator) returns() +func (_IApplication *IApplicationSession) MigrateToOutputsMerkleRootValidator(newOutputsMerkleRootValidator common.Address) (*types.Transaction, error) { + return _IApplication.Contract.MigrateToOutputsMerkleRootValidator(&_IApplication.TransactOpts, newOutputsMerkleRootValidator) +} + +// MigrateToOutputsMerkleRootValidator is a paid mutator transaction binding the contract method 0xbf8abff8. +// +// Solidity: function migrateToOutputsMerkleRootValidator(address newOutputsMerkleRootValidator) returns() +func (_IApplication *IApplicationTransactorSession) MigrateToOutputsMerkleRootValidator(newOutputsMerkleRootValidator common.Address) (*types.Transaction, error) { + return _IApplication.Contract.MigrateToOutputsMerkleRootValidator(&_IApplication.TransactOpts, newOutputsMerkleRootValidator) +} + +// ProveAccountsDriveMerkleRoot is a paid mutator transaction binding the contract method 0xbe77e2c4. +// +// Solidity: function proveAccountsDriveMerkleRoot(bytes32 accountsDriveMerkleRoot, bytes32[] proof) returns() +func (_IApplication *IApplicationTransactor) ProveAccountsDriveMerkleRoot(opts *bind.TransactOpts, accountsDriveMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "proveAccountsDriveMerkleRoot", accountsDriveMerkleRoot, proof) +} + +// ProveAccountsDriveMerkleRoot is a paid mutator transaction binding the contract method 0xbe77e2c4. +// +// Solidity: function proveAccountsDriveMerkleRoot(bytes32 accountsDriveMerkleRoot, bytes32[] proof) returns() +func (_IApplication *IApplicationSession) ProveAccountsDriveMerkleRoot(accountsDriveMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { + return _IApplication.Contract.ProveAccountsDriveMerkleRoot(&_IApplication.TransactOpts, accountsDriveMerkleRoot, proof) +} + +// ProveAccountsDriveMerkleRoot is a paid mutator transaction binding the contract method 0xbe77e2c4. +// +// Solidity: function proveAccountsDriveMerkleRoot(bytes32 accountsDriveMerkleRoot, bytes32[] proof) returns() +func (_IApplication *IApplicationTransactorSession) ProveAccountsDriveMerkleRoot(accountsDriveMerkleRoot [32]byte, proof [][32]byte) (*types.Transaction, error) { + return _IApplication.Contract.ProveAccountsDriveMerkleRoot(&_IApplication.TransactOpts, accountsDriveMerkleRoot, proof) +} + +// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. +// +// Solidity: function renounceOwnership() returns() +func (_IApplication *IApplicationTransactor) RenounceOwnership(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "renounceOwnership") +} + +// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. +// +// Solidity: function renounceOwnership() returns() +func (_IApplication *IApplicationSession) RenounceOwnership() (*types.Transaction, error) { + return _IApplication.Contract.RenounceOwnership(&_IApplication.TransactOpts) +} + +// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. +// +// Solidity: function renounceOwnership() returns() +func (_IApplication *IApplicationTransactorSession) RenounceOwnership() (*types.Transaction, error) { + return _IApplication.Contract.RenounceOwnership(&_IApplication.TransactOpts) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (_IApplication *IApplicationTransactor) TransferOwnership(opts *bind.TransactOpts, newOwner common.Address) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "transferOwnership", newOwner) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (_IApplication *IApplicationSession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { + return _IApplication.Contract.TransferOwnership(&_IApplication.TransactOpts, newOwner) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (_IApplication *IApplicationTransactorSession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { + return _IApplication.Contract.TransferOwnership(&_IApplication.TransactOpts, newOwner) +} + +// Withdraw is a paid mutator transaction binding the contract method 0xbcf8023f. +// +// Solidity: function withdraw(bytes account, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationTransactor) Withdraw(opts *bind.TransactOpts, account []byte, proof AccountValidityProof) (*types.Transaction, error) { + return _IApplication.contract.Transact(opts, "withdraw", account, proof) +} + +// Withdraw is a paid mutator transaction binding the contract method 0xbcf8023f. +// +// Solidity: function withdraw(bytes account, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationSession) Withdraw(account []byte, proof AccountValidityProof) (*types.Transaction, error) { + return _IApplication.Contract.Withdraw(&_IApplication.TransactOpts, account, proof) +} + +// Withdraw is a paid mutator transaction binding the contract method 0xbcf8023f. +// +// Solidity: function withdraw(bytes account, (uint64,bytes32[]) proof) returns() +func (_IApplication *IApplicationTransactorSession) Withdraw(account []byte, proof AccountValidityProof) (*types.Transaction, error) { + return _IApplication.Contract.Withdraw(&_IApplication.TransactOpts, account, proof) +} + +// IApplicationAccountsDriveMerkleRootProvedIterator is returned from FilterAccountsDriveMerkleRootProved and is used to iterate over the raw logs and unpacked data for AccountsDriveMerkleRootProved events raised by the IApplication contract. +type IApplicationAccountsDriveMerkleRootProvedIterator struct { + Event *IApplicationAccountsDriveMerkleRootProved // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationAccountsDriveMerkleRootProvedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationAccountsDriveMerkleRootProved) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationAccountsDriveMerkleRootProved) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationAccountsDriveMerkleRootProvedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationAccountsDriveMerkleRootProvedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationAccountsDriveMerkleRootProved represents a AccountsDriveMerkleRootProved event raised by the IApplication contract. +type IApplicationAccountsDriveMerkleRootProved struct { + AccountsDriveMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAccountsDriveMerkleRootProved is a free log retrieval operation binding the contract event 0x421863fbad9f3586640ffad00109861693263a28f6e97c679f45c3cbf5263594. +// +// Solidity: event AccountsDriveMerkleRootProved(bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationFilterer) FilterAccountsDriveMerkleRootProved(opts *bind.FilterOpts) (*IApplicationAccountsDriveMerkleRootProvedIterator, error) { + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "AccountsDriveMerkleRootProved") + if err != nil { + return nil, err + } + return &IApplicationAccountsDriveMerkleRootProvedIterator{contract: _IApplication.contract, event: "AccountsDriveMerkleRootProved", logs: logs, sub: sub}, nil +} + +// WatchAccountsDriveMerkleRootProved is a free log subscription operation binding the contract event 0x421863fbad9f3586640ffad00109861693263a28f6e97c679f45c3cbf5263594. +// +// Solidity: event AccountsDriveMerkleRootProved(bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationFilterer) WatchAccountsDriveMerkleRootProved(opts *bind.WatchOpts, sink chan<- *IApplicationAccountsDriveMerkleRootProved) (event.Subscription, error) { + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "AccountsDriveMerkleRootProved") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationAccountsDriveMerkleRootProved) + if err := _IApplication.contract.UnpackLog(event, "AccountsDriveMerkleRootProved", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseAccountsDriveMerkleRootProved is a log parse operation binding the contract event 0x421863fbad9f3586640ffad00109861693263a28f6e97c679f45c3cbf5263594. +// +// Solidity: event AccountsDriveMerkleRootProved(bytes32 accountsDriveMerkleRoot) +func (_IApplication *IApplicationFilterer) ParseAccountsDriveMerkleRootProved(log types.Log) (*IApplicationAccountsDriveMerkleRootProved, error) { + event := new(IApplicationAccountsDriveMerkleRootProved) + if err := _IApplication.contract.UnpackLog(event, "AccountsDriveMerkleRootProved", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IApplicationForeclosureIterator is returned from FilterForeclosure and is used to iterate over the raw logs and unpacked data for Foreclosure events raised by the IApplication contract. +type IApplicationForeclosureIterator struct { + Event *IApplicationForeclosure // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationForeclosureIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationForeclosure) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationForeclosure) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationForeclosureIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationForeclosureIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationForeclosure represents a Foreclosure event raised by the IApplication contract. +type IApplicationForeclosure struct { + Raw types.Log // Blockchain specific contextual infos +} + +// FilterForeclosure is a free log retrieval operation binding the contract event 0xd10ac0ca4adfcec0fa841963d75fefd049b7cd20555173c0673d9b2bfdb9d3ac. +// +// Solidity: event Foreclosure() +func (_IApplication *IApplicationFilterer) FilterForeclosure(opts *bind.FilterOpts) (*IApplicationForeclosureIterator, error) { + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "Foreclosure") + if err != nil { + return nil, err + } + return &IApplicationForeclosureIterator{contract: _IApplication.contract, event: "Foreclosure", logs: logs, sub: sub}, nil +} + +// WatchForeclosure is a free log subscription operation binding the contract event 0xd10ac0ca4adfcec0fa841963d75fefd049b7cd20555173c0673d9b2bfdb9d3ac. +// +// Solidity: event Foreclosure() +func (_IApplication *IApplicationFilterer) WatchForeclosure(opts *bind.WatchOpts, sink chan<- *IApplicationForeclosure) (event.Subscription, error) { + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "Foreclosure") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationForeclosure) + if err := _IApplication.contract.UnpackLog(event, "Foreclosure", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseForeclosure is a log parse operation binding the contract event 0xd10ac0ca4adfcec0fa841963d75fefd049b7cd20555173c0673d9b2bfdb9d3ac. +// +// Solidity: event Foreclosure() +func (_IApplication *IApplicationFilterer) ParseForeclosure(log types.Log) (*IApplicationForeclosure, error) { + event := new(IApplicationForeclosure) + if err := _IApplication.contract.UnpackLog(event, "Foreclosure", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IApplicationOutputExecutedIterator is returned from FilterOutputExecuted and is used to iterate over the raw logs and unpacked data for OutputExecuted events raised by the IApplication contract. +type IApplicationOutputExecutedIterator struct { + Event *IApplicationOutputExecuted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationOutputExecutedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationOutputExecuted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationOutputExecuted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationOutputExecutedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationOutputExecutedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationOutputExecuted represents a OutputExecuted event raised by the IApplication contract. +type IApplicationOutputExecuted struct { + OutputIndex uint64 + Output []byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOutputExecuted is a free log retrieval operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. +// +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) +func (_IApplication *IApplicationFilterer) FilterOutputExecuted(opts *bind.FilterOpts, outputIndex []uint64) (*IApplicationOutputExecutedIterator, error) { + + var outputIndexRule []interface{} + for _, outputIndexItem := range outputIndex { + outputIndexRule = append(outputIndexRule, outputIndexItem) + } + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "OutputExecuted", outputIndexRule) + if err != nil { + return nil, err + } + return &IApplicationOutputExecutedIterator{contract: _IApplication.contract, event: "OutputExecuted", logs: logs, sub: sub}, nil +} + +// WatchOutputExecuted is a free log subscription operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. +// +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) +func (_IApplication *IApplicationFilterer) WatchOutputExecuted(opts *bind.WatchOpts, sink chan<- *IApplicationOutputExecuted, outputIndex []uint64) (event.Subscription, error) { + + var outputIndexRule []interface{} + for _, outputIndexItem := range outputIndex { + outputIndexRule = append(outputIndexRule, outputIndexItem) + } + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "OutputExecuted", outputIndexRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationOutputExecuted) + if err := _IApplication.contract.UnpackLog(event, "OutputExecuted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOutputExecuted is a log parse operation binding the contract event 0xcad1f361c6e84664e892230291c8e8eb9555683e0a6a5ce8ea7b204ac0ac3676. +// +// Solidity: event OutputExecuted(uint64 indexed outputIndex, bytes output) +func (_IApplication *IApplicationFilterer) ParseOutputExecuted(log types.Log) (*IApplicationOutputExecuted, error) { + event := new(IApplicationOutputExecuted) + if err := _IApplication.contract.UnpackLog(event, "OutputExecuted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IApplicationOutputsMerkleRootValidatorChangedIterator is returned from FilterOutputsMerkleRootValidatorChanged and is used to iterate over the raw logs and unpacked data for OutputsMerkleRootValidatorChanged events raised by the IApplication contract. +type IApplicationOutputsMerkleRootValidatorChangedIterator struct { + Event *IApplicationOutputsMerkleRootValidatorChanged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationOutputsMerkleRootValidatorChangedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationOutputsMerkleRootValidatorChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationOutputsMerkleRootValidatorChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationOutputsMerkleRootValidatorChangedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationOutputsMerkleRootValidatorChangedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationOutputsMerkleRootValidatorChanged represents a OutputsMerkleRootValidatorChanged event raised by the IApplication contract. +type IApplicationOutputsMerkleRootValidatorChanged struct { + NewOutputsMerkleRootValidator common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterOutputsMerkleRootValidatorChanged is a free log retrieval operation binding the contract event 0x6ad3188ba8f430fba0656cb0a7e839ab2020d5586ba11a1477d18f7092f8bece. +// +// Solidity: event OutputsMerkleRootValidatorChanged(address newOutputsMerkleRootValidator) +func (_IApplication *IApplicationFilterer) FilterOutputsMerkleRootValidatorChanged(opts *bind.FilterOpts) (*IApplicationOutputsMerkleRootValidatorChangedIterator, error) { + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "OutputsMerkleRootValidatorChanged") + if err != nil { + return nil, err + } + return &IApplicationOutputsMerkleRootValidatorChangedIterator{contract: _IApplication.contract, event: "OutputsMerkleRootValidatorChanged", logs: logs, sub: sub}, nil +} + +// WatchOutputsMerkleRootValidatorChanged is a free log subscription operation binding the contract event 0x6ad3188ba8f430fba0656cb0a7e839ab2020d5586ba11a1477d18f7092f8bece. +// +// Solidity: event OutputsMerkleRootValidatorChanged(address newOutputsMerkleRootValidator) +func (_IApplication *IApplicationFilterer) WatchOutputsMerkleRootValidatorChanged(opts *bind.WatchOpts, sink chan<- *IApplicationOutputsMerkleRootValidatorChanged) (event.Subscription, error) { + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "OutputsMerkleRootValidatorChanged") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationOutputsMerkleRootValidatorChanged) + if err := _IApplication.contract.UnpackLog(event, "OutputsMerkleRootValidatorChanged", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseOutputsMerkleRootValidatorChanged is a log parse operation binding the contract event 0x6ad3188ba8f430fba0656cb0a7e839ab2020d5586ba11a1477d18f7092f8bece. +// +// Solidity: event OutputsMerkleRootValidatorChanged(address newOutputsMerkleRootValidator) +func (_IApplication *IApplicationFilterer) ParseOutputsMerkleRootValidatorChanged(log types.Log) (*IApplicationOutputsMerkleRootValidatorChanged, error) { + event := new(IApplicationOutputsMerkleRootValidatorChanged) + if err := _IApplication.contract.UnpackLog(event, "OutputsMerkleRootValidatorChanged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IApplicationRefundIssuedIterator is returned from FilterRefundIssued and is used to iterate over the raw logs and unpacked data for RefundIssued events raised by the IApplication contract. +type IApplicationRefundIssuedIterator struct { + Event *IApplicationRefundIssued // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationRefundIssuedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationRefundIssued) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationRefundIssued) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationRefundIssuedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationRefundIssuedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationRefundIssued represents a RefundIssued event raised by the IApplication contract. +type IApplicationRefundIssued struct { + InputIndex *big.Int + Input []byte + Output []byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRefundIssued is a free log retrieval operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) FilterRefundIssued(opts *bind.FilterOpts, inputIndex []*big.Int) (*IApplicationRefundIssuedIterator, error) { + + var inputIndexRule []interface{} + for _, inputIndexItem := range inputIndex { + inputIndexRule = append(inputIndexRule, inputIndexItem) + } + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "RefundIssued", inputIndexRule) + if err != nil { + return nil, err + } + return &IApplicationRefundIssuedIterator{contract: _IApplication.contract, event: "RefundIssued", logs: logs, sub: sub}, nil +} + +// WatchRefundIssued is a free log subscription operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) WatchRefundIssued(opts *bind.WatchOpts, sink chan<- *IApplicationRefundIssued, inputIndex []*big.Int) (event.Subscription, error) { + + var inputIndexRule []interface{} + for _, inputIndexItem := range inputIndex { + inputIndexRule = append(inputIndexRule, inputIndexItem) + } + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "RefundIssued", inputIndexRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationRefundIssued) + if err := _IApplication.contract.UnpackLog(event, "RefundIssued", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRefundIssued is a log parse operation binding the contract event 0x1e7ce3d695b800137b84540cdf13216ec4aa4f00374482b89ab552368e800dd9. +// +// Solidity: event RefundIssued(uint256 indexed inputIndex, bytes input, bytes output) +func (_IApplication *IApplicationFilterer) ParseRefundIssued(log types.Log) (*IApplicationRefundIssued, error) { + event := new(IApplicationRefundIssued) + if err := _IApplication.contract.UnpackLog(event, "RefundIssued", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IApplicationWithdrawalIterator is returned from FilterWithdrawal and is used to iterate over the raw logs and unpacked data for Withdrawal events raised by the IApplication contract. +type IApplicationWithdrawalIterator struct { + Event *IApplicationWithdrawal // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationWithdrawalIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationWithdrawal) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationWithdrawal) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationWithdrawalIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationWithdrawalIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationWithdrawal represents a Withdrawal event raised by the IApplication contract. +type IApplicationWithdrawal struct { + AccountIndex uint64 + Account []byte + Output []byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterWithdrawal is a free log retrieval operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. +// +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) +func (_IApplication *IApplicationFilterer) FilterWithdrawal(opts *bind.FilterOpts, accountIndex []uint64) (*IApplicationWithdrawalIterator, error) { + + var accountIndexRule []interface{} + for _, accountIndexItem := range accountIndex { + accountIndexRule = append(accountIndexRule, accountIndexItem) + } + + logs, sub, err := _IApplication.contract.FilterLogs(opts, "Withdrawal", accountIndexRule) + if err != nil { + return nil, err + } + return &IApplicationWithdrawalIterator{contract: _IApplication.contract, event: "Withdrawal", logs: logs, sub: sub}, nil +} + +// WatchWithdrawal is a free log subscription operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. +// +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) +func (_IApplication *IApplicationFilterer) WatchWithdrawal(opts *bind.WatchOpts, sink chan<- *IApplicationWithdrawal, accountIndex []uint64) (event.Subscription, error) { + + var accountIndexRule []interface{} + for _, accountIndexItem := range accountIndex { + accountIndexRule = append(accountIndexRule, accountIndexItem) + } + + logs, sub, err := _IApplication.contract.WatchLogs(opts, "Withdrawal", accountIndexRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationWithdrawal) + if err := _IApplication.contract.UnpackLog(event, "Withdrawal", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseWithdrawal is a log parse operation binding the contract event 0xde17c4fe795586e35da70cf61f10d8b19542b1eaf30daf7670e8ae438908ba59. +// +// Solidity: event Withdrawal(uint64 indexed accountIndex, bytes account, bytes output) +func (_IApplication *IApplicationFilterer) ParseWithdrawal(log types.Log) (*IApplicationWithdrawal, error) { + event := new(IApplicationWithdrawal) + if err := _IApplication.contract.UnpackLog(event, "Withdrawal", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iapplicationfactory/iapplicationfactory.go b/pkg/contracts/iapplicationfactory/iapplicationfactory.go new file mode 100644 index 000000000..2b5dc6bb5 --- /dev/null +++ b/pkg/contracts/iapplicationfactory/iapplicationfactory.go @@ -0,0 +1,480 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iapplicationfactory + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + +// IApplicationFactoryMetaData contains all meta data concerning the IApplicationFactory contract. +var IApplicationFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"calculateApplicationAddress\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newApplication\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ApplicationCreated\",\"inputs\":[{\"name\":\"outputsMerkleRootValidator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractIOutputsMerkleRootValidator\"},{\"name\":\"appOwner\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]}]", +} + +// IApplicationFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IApplicationFactoryMetaData.ABI instead. +var IApplicationFactoryABI = IApplicationFactoryMetaData.ABI + +// IApplicationFactory is an auto generated Go binding around an Ethereum contract. +type IApplicationFactory struct { + IApplicationFactoryCaller // Read-only binding to the contract + IApplicationFactoryTransactor // Write-only binding to the contract + IApplicationFactoryFilterer // Log filterer for contract events +} + +// IApplicationFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IApplicationFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IApplicationFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IApplicationFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IApplicationFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IApplicationFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IApplicationFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IApplicationFactorySession struct { + Contract *IApplicationFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IApplicationFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IApplicationFactoryCallerSession struct { + Contract *IApplicationFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IApplicationFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IApplicationFactoryTransactorSession struct { + Contract *IApplicationFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IApplicationFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IApplicationFactoryRaw struct { + Contract *IApplicationFactory // Generic contract binding to access the raw methods on +} + +// IApplicationFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IApplicationFactoryCallerRaw struct { + Contract *IApplicationFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IApplicationFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IApplicationFactoryTransactorRaw struct { + Contract *IApplicationFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIApplicationFactory creates a new instance of IApplicationFactory, bound to a specific deployed contract. +func NewIApplicationFactory(address common.Address, backend bind.ContractBackend) (*IApplicationFactory, error) { + contract, err := bindIApplicationFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IApplicationFactory{IApplicationFactoryCaller: IApplicationFactoryCaller{contract: contract}, IApplicationFactoryTransactor: IApplicationFactoryTransactor{contract: contract}, IApplicationFactoryFilterer: IApplicationFactoryFilterer{contract: contract}}, nil +} + +// NewIApplicationFactoryCaller creates a new read-only instance of IApplicationFactory, bound to a specific deployed contract. +func NewIApplicationFactoryCaller(address common.Address, caller bind.ContractCaller) (*IApplicationFactoryCaller, error) { + contract, err := bindIApplicationFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IApplicationFactoryCaller{contract: contract}, nil +} + +// NewIApplicationFactoryTransactor creates a new write-only instance of IApplicationFactory, bound to a specific deployed contract. +func NewIApplicationFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IApplicationFactoryTransactor, error) { + contract, err := bindIApplicationFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IApplicationFactoryTransactor{contract: contract}, nil +} + +// NewIApplicationFactoryFilterer creates a new log filterer instance of IApplicationFactory, bound to a specific deployed contract. +func NewIApplicationFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IApplicationFactoryFilterer, error) { + contract, err := bindIApplicationFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IApplicationFactoryFilterer{contract: contract}, nil +} + +// bindIApplicationFactory binds a generic wrapper to an already deployed contract. +func bindIApplicationFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IApplicationFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IApplicationFactory *IApplicationFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IApplicationFactory.Contract.IApplicationFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IApplicationFactory *IApplicationFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IApplicationFactory.Contract.IApplicationFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IApplicationFactory *IApplicationFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IApplicationFactory.Contract.IApplicationFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IApplicationFactory *IApplicationFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IApplicationFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IApplicationFactory *IApplicationFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IApplicationFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IApplicationFactory *IApplicationFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IApplicationFactory.Contract.contract.Transact(opts, method, params...) +} + +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. +// +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactoryCaller) CalculateApplicationAddress(opts *bind.CallOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { + var out []interface{} + err := _IApplicationFactory.contract.Call(opts, &out, "calculateApplicationAddress", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. +// +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactorySession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { + return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) +} + +// CalculateApplicationAddress is a free data retrieval call binding the contract method 0xb32a00a4. +// +// Solidity: function calculateApplicationAddress(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address) +func (_IApplicationFactory *IApplicationFactoryCallerSession) CalculateApplicationAddress(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, error) { + return _IApplicationFactory.Contract.CalculateApplicationAddress(&_IApplicationFactory.CallOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IApplicationFactory *IApplicationFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IApplicationFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IApplicationFactory *IApplicationFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplicationFactory.Contract.Version(&_IApplicationFactory.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IApplicationFactory *IApplicationFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IApplicationFactory.Contract.Version(&_IApplicationFactory.CallOpts) +} + +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. +// +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.contract.Transact(opts, "newApplication", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) +} + +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. +// +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactorySession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) +} + +// NewApplication is a paid mutator transaction binding the contract method 0x0e116170. +// +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig) +} + +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. +// +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactor) NewApplication0(opts *bind.TransactOpts, outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.contract.Transact(opts, "newApplication0", outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) +} + +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. +// +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactorySession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) +} + +// NewApplication0 is a paid mutator transaction binding the contract method 0x62da0d80. +// +// Solidity: function newApplication(address outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address) +func (_IApplicationFactory *IApplicationFactoryTransactorSession) NewApplication0(outputsMerkleRootValidator common.Address, appOwner common.Address, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IApplicationFactory.Contract.NewApplication0(&_IApplicationFactory.TransactOpts, outputsMerkleRootValidator, appOwner, templateHash, inputBox, withdrawalConfig, salt) +} + +// IApplicationFactoryApplicationCreatedIterator is returned from FilterApplicationCreated and is used to iterate over the raw logs and unpacked data for ApplicationCreated events raised by the IApplicationFactory contract. +type IApplicationFactoryApplicationCreatedIterator struct { + Event *IApplicationFactoryApplicationCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IApplicationFactoryApplicationCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IApplicationFactoryApplicationCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IApplicationFactoryApplicationCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IApplicationFactoryApplicationCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IApplicationFactoryApplicationCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IApplicationFactoryApplicationCreated represents a ApplicationCreated event raised by the IApplicationFactory contract. +type IApplicationFactoryApplicationCreated struct { + OutputsMerkleRootValidator common.Address + AppOwner common.Address + TemplateHash [32]byte + InputBox common.Address + WithdrawalConfig WithdrawalConfig + AppContract common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterApplicationCreated is a free log retrieval operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. +// +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +func (_IApplicationFactory *IApplicationFactoryFilterer) FilterApplicationCreated(opts *bind.FilterOpts, outputsMerkleRootValidator []common.Address) (*IApplicationFactoryApplicationCreatedIterator, error) { + + var outputsMerkleRootValidatorRule []interface{} + for _, outputsMerkleRootValidatorItem := range outputsMerkleRootValidator { + outputsMerkleRootValidatorRule = append(outputsMerkleRootValidatorRule, outputsMerkleRootValidatorItem) + } + + logs, sub, err := _IApplicationFactory.contract.FilterLogs(opts, "ApplicationCreated", outputsMerkleRootValidatorRule) + if err != nil { + return nil, err + } + return &IApplicationFactoryApplicationCreatedIterator{contract: _IApplicationFactory.contract, event: "ApplicationCreated", logs: logs, sub: sub}, nil +} + +// WatchApplicationCreated is a free log subscription operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. +// +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +func (_IApplicationFactory *IApplicationFactoryFilterer) WatchApplicationCreated(opts *bind.WatchOpts, sink chan<- *IApplicationFactoryApplicationCreated, outputsMerkleRootValidator []common.Address) (event.Subscription, error) { + + var outputsMerkleRootValidatorRule []interface{} + for _, outputsMerkleRootValidatorItem := range outputsMerkleRootValidator { + outputsMerkleRootValidatorRule = append(outputsMerkleRootValidatorRule, outputsMerkleRootValidatorItem) + } + + logs, sub, err := _IApplicationFactory.contract.WatchLogs(opts, "ApplicationCreated", outputsMerkleRootValidatorRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IApplicationFactoryApplicationCreated) + if err := _IApplicationFactory.contract.UnpackLog(event, "ApplicationCreated", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseApplicationCreated is a log parse operation binding the contract event 0x4486f86034ea9f9960840c03ca21cd7dae1f2d5ef3ddc06067ea9352a49a40d2. +// +// Solidity: event ApplicationCreated(address indexed outputsMerkleRootValidator, address appOwner, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, address appContract) +func (_IApplicationFactory *IApplicationFactoryFilterer) ParseApplicationCreated(log types.Log) (*IApplicationFactoryApplicationCreated, error) { + event := new(IApplicationFactoryApplicationCreated) + if err := _IApplicationFactory.contract.UnpackLog(event, "ApplicationCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iauthority/iauthority.go b/pkg/contracts/iauthority/iauthority.go new file mode 100644 index 000000000..8d8d5be6d --- /dev/null +++ b/pkg/contracts/iauthority/iauthority.go @@ -0,0 +1,1152 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iauthority + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IConsensusClaim is an auto generated low-level Go binding around an user-defined struct. +type IConsensusClaim struct { + Status uint8 + StagingBlockNumber *big.Int + StagedOutputsMerkleRoot [32]byte +} + +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + +// IAuthorityMetaData contains all meta data concerning the IAuthority contract. +var IAuthorityMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", +} + +// IAuthorityABI is the input ABI used to generate the binding from. +// Deprecated: Use IAuthorityMetaData.ABI instead. +var IAuthorityABI = IAuthorityMetaData.ABI + +// IAuthority is an auto generated Go binding around an Ethereum contract. +type IAuthority struct { + IAuthorityCaller // Read-only binding to the contract + IAuthorityTransactor // Write-only binding to the contract + IAuthorityFilterer // Log filterer for contract events +} + +// IAuthorityCaller is an auto generated read-only Go binding around an Ethereum contract. +type IAuthorityCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IAuthorityTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IAuthorityTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IAuthorityFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IAuthorityFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IAuthoritySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IAuthoritySession struct { + Contract *IAuthority // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IAuthorityCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IAuthorityCallerSession struct { + Contract *IAuthorityCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IAuthorityTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IAuthorityTransactorSession struct { + Contract *IAuthorityTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IAuthorityRaw is an auto generated low-level Go binding around an Ethereum contract. +type IAuthorityRaw struct { + Contract *IAuthority // Generic contract binding to access the raw methods on +} + +// IAuthorityCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IAuthorityCallerRaw struct { + Contract *IAuthorityCaller // Generic read-only contract binding to access the raw methods on +} + +// IAuthorityTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IAuthorityTransactorRaw struct { + Contract *IAuthorityTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIAuthority creates a new instance of IAuthority, bound to a specific deployed contract. +func NewIAuthority(address common.Address, backend bind.ContractBackend) (*IAuthority, error) { + contract, err := bindIAuthority(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IAuthority{IAuthorityCaller: IAuthorityCaller{contract: contract}, IAuthorityTransactor: IAuthorityTransactor{contract: contract}, IAuthorityFilterer: IAuthorityFilterer{contract: contract}}, nil +} + +// NewIAuthorityCaller creates a new read-only instance of IAuthority, bound to a specific deployed contract. +func NewIAuthorityCaller(address common.Address, caller bind.ContractCaller) (*IAuthorityCaller, error) { + contract, err := bindIAuthority(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IAuthorityCaller{contract: contract}, nil +} + +// NewIAuthorityTransactor creates a new write-only instance of IAuthority, bound to a specific deployed contract. +func NewIAuthorityTransactor(address common.Address, transactor bind.ContractTransactor) (*IAuthorityTransactor, error) { + contract, err := bindIAuthority(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IAuthorityTransactor{contract: contract}, nil +} + +// NewIAuthorityFilterer creates a new log filterer instance of IAuthority, bound to a specific deployed contract. +func NewIAuthorityFilterer(address common.Address, filterer bind.ContractFilterer) (*IAuthorityFilterer, error) { + contract, err := bindIAuthority(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IAuthorityFilterer{contract: contract}, nil +} + +// bindIAuthority binds a generic wrapper to an already deployed contract. +func bindIAuthority(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IAuthorityMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IAuthority *IAuthorityRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IAuthority.Contract.IAuthorityCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IAuthority *IAuthorityRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IAuthority.Contract.IAuthorityTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IAuthority *IAuthorityRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IAuthority.Contract.IAuthorityTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IAuthority *IAuthorityCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IAuthority.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IAuthority *IAuthorityTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IAuthority.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IAuthority *IAuthorityTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IAuthority.Contract.contract.Transact(opts, method, params...) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IAuthority *IAuthorityCaller) GetClaim(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) + + if err != nil { + return *new(IConsensusClaim), err + } + + out0 := *abi.ConvertType(out[0], new(IConsensusClaim)).(*IConsensusClaim) + + return out0, err + +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IAuthority *IAuthoritySession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IAuthority.Contract.GetClaim(&_IAuthority.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IAuthority *IAuthorityCallerSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IAuthority.Contract.GetClaim(&_IAuthority.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getClaimStagingPeriod") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IAuthority *IAuthoritySession) GetClaimStagingPeriod() (*big.Int, error) { + return _IAuthority.Contract.GetClaimStagingPeriod(&_IAuthority.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IAuthority.Contract.GetClaimStagingPeriod(&_IAuthority.CallOpts) +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetEpochLength(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getEpochLength") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IAuthority *IAuthoritySession) GetEpochLength() (*big.Int, error) { + return _IAuthority.Contract.GetEpochLength(&_IAuthority.CallOpts) +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetEpochLength() (*big.Int, error) { + return _IAuthority.Contract.GetEpochLength(&_IAuthority.CallOpts) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IAuthority *IAuthorityCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IAuthority *IAuthoritySession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IAuthority.Contract.GetLastFinalizedMachineMerkleRoot(&_IAuthority.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IAuthority *IAuthorityCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IAuthority.Contract.GetLastFinalizedMachineMerkleRoot(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getNumberOfAcceptedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthoritySession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfAcceptedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfAcceptedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetNumberOfStagedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getNumberOfStagedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthoritySession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfStagedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfStagedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCaller) GetNumberOfSubmittedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "getNumberOfSubmittedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthoritySession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfSubmittedClaims(&_IAuthority.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IAuthority *IAuthorityCallerSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IAuthority.Contract.GetNumberOfSubmittedClaims(&_IAuthority.CallOpts, appContract) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IAuthority *IAuthorityCaller) IsOutputsMerkleRootValid(opts *bind.CallOpts, appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "isOutputsMerkleRootValid", appContract, outputsMerkleRoot) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IAuthority *IAuthoritySession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IAuthority.Contract.IsOutputsMerkleRootValid(&_IAuthority.CallOpts, appContract, outputsMerkleRoot) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IAuthority *IAuthorityCallerSession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IAuthority.Contract.IsOutputsMerkleRootValid(&_IAuthority.CallOpts, appContract, outputsMerkleRoot) +} + +// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (_IAuthority *IAuthorityCaller) Owner(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "owner") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (_IAuthority *IAuthoritySession) Owner() (common.Address, error) { + return _IAuthority.Contract.Owner(&_IAuthority.CallOpts) +} + +// Owner is a free data retrieval call binding the contract method 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (_IAuthority *IAuthorityCallerSession) Owner() (common.Address, error) { + return _IAuthority.Contract.Owner(&_IAuthority.CallOpts) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IAuthority *IAuthorityCaller) SupportsInterface(opts *bind.CallOpts, interfaceId [4]byte) (bool, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "supportsInterface", interfaceId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IAuthority *IAuthoritySession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IAuthority.Contract.SupportsInterface(&_IAuthority.CallOpts, interfaceId) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IAuthority *IAuthorityCallerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IAuthority.Contract.SupportsInterface(&_IAuthority.CallOpts, interfaceId) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IAuthority *IAuthorityCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IAuthority *IAuthoritySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthority.Contract.Version(&_IAuthority.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IAuthority *IAuthorityCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthority.Contract.Version(&_IAuthority.CallOpts) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthorityCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IAuthority.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthoritySession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IAuthority.Contract.WasInputFinalized(&_IAuthority.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IAuthority *IAuthorityCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IAuthority.Contract.WasInputFinalized(&_IAuthority.CallOpts, appContract, inputIndex, blockNumber) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IAuthority *IAuthorityTransactor) AcceptClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IAuthority.contract.Transact(opts, "acceptClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IAuthority *IAuthoritySession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IAuthority.Contract.AcceptClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IAuthority *IAuthorityTransactorSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IAuthority.Contract.AcceptClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. +// +// Solidity: function renounceOwnership() returns() +func (_IAuthority *IAuthorityTransactor) RenounceOwnership(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IAuthority.contract.Transact(opts, "renounceOwnership") +} + +// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. +// +// Solidity: function renounceOwnership() returns() +func (_IAuthority *IAuthoritySession) RenounceOwnership() (*types.Transaction, error) { + return _IAuthority.Contract.RenounceOwnership(&_IAuthority.TransactOpts) +} + +// RenounceOwnership is a paid mutator transaction binding the contract method 0x715018a6. +// +// Solidity: function renounceOwnership() returns() +func (_IAuthority *IAuthorityTransactorSession) RenounceOwnership() (*types.Transaction, error) { + return _IAuthority.Contract.RenounceOwnership(&_IAuthority.TransactOpts) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthorityTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthoritySession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IAuthority *IAuthorityTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IAuthority.Contract.SubmitClaim(&_IAuthority.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (_IAuthority *IAuthorityTransactor) TransferOwnership(opts *bind.TransactOpts, newOwner common.Address) (*types.Transaction, error) { + return _IAuthority.contract.Transact(opts, "transferOwnership", newOwner) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (_IAuthority *IAuthoritySession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { + return _IAuthority.Contract.TransferOwnership(&_IAuthority.TransactOpts, newOwner) +} + +// TransferOwnership is a paid mutator transaction binding the contract method 0xf2fde38b. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (_IAuthority *IAuthorityTransactorSession) TransferOwnership(newOwner common.Address) (*types.Transaction, error) { + return _IAuthority.Contract.TransferOwnership(&_IAuthority.TransactOpts, newOwner) +} + +// IAuthorityClaimAcceptedIterator is returned from FilterClaimAccepted and is used to iterate over the raw logs and unpacked data for ClaimAccepted events raised by the IAuthority contract. +type IAuthorityClaimAcceptedIterator struct { + Event *IAuthorityClaimAccepted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IAuthorityClaimAcceptedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IAuthorityClaimAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IAuthorityClaimAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IAuthorityClaimAcceptedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IAuthorityClaimAcceptedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IAuthorityClaimAccepted represents a ClaimAccepted event raised by the IAuthority contract. +type IAuthorityClaimAccepted struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) FilterClaimAccepted(opts *bind.FilterOpts, appContract []common.Address) (*IAuthorityClaimAcceptedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.FilterLogs(opts, "ClaimAccepted", appContractRule) + if err != nil { + return nil, err + } + return &IAuthorityClaimAcceptedIterator{contract: _IAuthority.contract, event: "ClaimAccepted", logs: logs, sub: sub}, nil +} + +// WatchClaimAccepted is a free log subscription operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) WatchClaimAccepted(opts *bind.WatchOpts, sink chan<- *IAuthorityClaimAccepted, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.WatchLogs(opts, "ClaimAccepted", appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IAuthorityClaimAccepted) + if err := _IAuthority.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimAccepted is a log parse operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) ParseClaimAccepted(log types.Log) (*IAuthorityClaimAccepted, error) { + event := new(IAuthorityClaimAccepted) + if err := _IAuthority.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IAuthorityClaimStagedIterator is returned from FilterClaimStaged and is used to iterate over the raw logs and unpacked data for ClaimStaged events raised by the IAuthority contract. +type IAuthorityClaimStagedIterator struct { + Event *IAuthorityClaimStaged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IAuthorityClaimStagedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IAuthorityClaimStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IAuthorityClaimStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IAuthorityClaimStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IAuthorityClaimStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IAuthorityClaimStaged represents a ClaimStaged event raised by the IAuthority contract. +type IAuthorityClaimStaged struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimStaged is a free log retrieval operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) FilterClaimStaged(opts *bind.FilterOpts, appContract []common.Address) (*IAuthorityClaimStagedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.FilterLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return &IAuthorityClaimStagedIterator{contract: _IAuthority.contract, event: "ClaimStaged", logs: logs, sub: sub}, nil +} + +// WatchClaimStaged is a free log subscription operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) WatchClaimStaged(opts *bind.WatchOpts, sink chan<- *IAuthorityClaimStaged, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.WatchLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IAuthorityClaimStaged) + if err := _IAuthority.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimStaged is a log parse operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) ParseClaimStaged(log types.Log) (*IAuthorityClaimStaged, error) { + event := new(IAuthorityClaimStaged) + if err := _IAuthority.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IAuthorityClaimSubmittedIterator is returned from FilterClaimSubmitted and is used to iterate over the raw logs and unpacked data for ClaimSubmitted events raised by the IAuthority contract. +type IAuthorityClaimSubmittedIterator struct { + Event *IAuthorityClaimSubmitted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IAuthorityClaimSubmittedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IAuthorityClaimSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IAuthorityClaimSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IAuthorityClaimSubmittedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IAuthorityClaimSubmittedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IAuthorityClaimSubmitted represents a ClaimSubmitted event raised by the IAuthority contract. +type IAuthorityClaimSubmitted struct { + Submitter common.Address + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) FilterClaimSubmitted(opts *bind.FilterOpts, submitter []common.Address, appContract []common.Address) (*IAuthorityClaimSubmittedIterator, error) { + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.FilterLogs(opts, "ClaimSubmitted", submitterRule, appContractRule) + if err != nil { + return nil, err + } + return &IAuthorityClaimSubmittedIterator{contract: _IAuthority.contract, event: "ClaimSubmitted", logs: logs, sub: sub}, nil +} + +// WatchClaimSubmitted is a free log subscription operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, sink chan<- *IAuthorityClaimSubmitted, submitter []common.Address, appContract []common.Address) (event.Subscription, error) { + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IAuthority.contract.WatchLogs(opts, "ClaimSubmitted", submitterRule, appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IAuthorityClaimSubmitted) + if err := _IAuthority.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimSubmitted is a log parse operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IAuthority *IAuthorityFilterer) ParseClaimSubmitted(log types.Log) (*IAuthorityClaimSubmitted, error) { + event := new(IAuthorityClaimSubmitted) + if err := _IAuthority.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iauthorityfactory/iauthorityfactory.go b/pkg/contracts/iauthorityfactory/iauthorityfactory.go new file mode 100644 index 000000000..842eb6177 --- /dev/null +++ b/pkg/contracts/iauthorityfactory/iauthorityfactory.go @@ -0,0 +1,456 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iauthorityfactory + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IAuthorityFactoryMetaData contains all meta data concerning the IAuthorityFactory contract. +var IAuthorityFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"calculateAuthorityAddress\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newAuthority\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newAuthority\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"AuthorityCreated\",\"inputs\":[{\"name\":\"authority\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIAuthority\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", +} + +// IAuthorityFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IAuthorityFactoryMetaData.ABI instead. +var IAuthorityFactoryABI = IAuthorityFactoryMetaData.ABI + +// IAuthorityFactory is an auto generated Go binding around an Ethereum contract. +type IAuthorityFactory struct { + IAuthorityFactoryCaller // Read-only binding to the contract + IAuthorityFactoryTransactor // Write-only binding to the contract + IAuthorityFactoryFilterer // Log filterer for contract events +} + +// IAuthorityFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IAuthorityFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IAuthorityFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IAuthorityFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IAuthorityFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IAuthorityFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IAuthorityFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IAuthorityFactorySession struct { + Contract *IAuthorityFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IAuthorityFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IAuthorityFactoryCallerSession struct { + Contract *IAuthorityFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IAuthorityFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IAuthorityFactoryTransactorSession struct { + Contract *IAuthorityFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IAuthorityFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IAuthorityFactoryRaw struct { + Contract *IAuthorityFactory // Generic contract binding to access the raw methods on +} + +// IAuthorityFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IAuthorityFactoryCallerRaw struct { + Contract *IAuthorityFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IAuthorityFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IAuthorityFactoryTransactorRaw struct { + Contract *IAuthorityFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIAuthorityFactory creates a new instance of IAuthorityFactory, bound to a specific deployed contract. +func NewIAuthorityFactory(address common.Address, backend bind.ContractBackend) (*IAuthorityFactory, error) { + contract, err := bindIAuthorityFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IAuthorityFactory{IAuthorityFactoryCaller: IAuthorityFactoryCaller{contract: contract}, IAuthorityFactoryTransactor: IAuthorityFactoryTransactor{contract: contract}, IAuthorityFactoryFilterer: IAuthorityFactoryFilterer{contract: contract}}, nil +} + +// NewIAuthorityFactoryCaller creates a new read-only instance of IAuthorityFactory, bound to a specific deployed contract. +func NewIAuthorityFactoryCaller(address common.Address, caller bind.ContractCaller) (*IAuthorityFactoryCaller, error) { + contract, err := bindIAuthorityFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IAuthorityFactoryCaller{contract: contract}, nil +} + +// NewIAuthorityFactoryTransactor creates a new write-only instance of IAuthorityFactory, bound to a specific deployed contract. +func NewIAuthorityFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IAuthorityFactoryTransactor, error) { + contract, err := bindIAuthorityFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IAuthorityFactoryTransactor{contract: contract}, nil +} + +// NewIAuthorityFactoryFilterer creates a new log filterer instance of IAuthorityFactory, bound to a specific deployed contract. +func NewIAuthorityFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IAuthorityFactoryFilterer, error) { + contract, err := bindIAuthorityFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IAuthorityFactoryFilterer{contract: contract}, nil +} + +// bindIAuthorityFactory binds a generic wrapper to an already deployed contract. +func bindIAuthorityFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IAuthorityFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IAuthorityFactory *IAuthorityFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IAuthorityFactory.Contract.IAuthorityFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IAuthorityFactory *IAuthorityFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.IAuthorityFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IAuthorityFactory *IAuthorityFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.IAuthorityFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IAuthorityFactory *IAuthorityFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IAuthorityFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IAuthorityFactory *IAuthorityFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IAuthorityFactory *IAuthorityFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.contract.Transact(opts, method, params...) +} + +// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0xe771e61b. +// +// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IAuthorityFactory *IAuthorityFactoryCaller) CalculateAuthorityAddress(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + var out []interface{} + err := _IAuthorityFactory.contract.Call(opts, &out, "calculateAuthorityAddress", authorityOwner, epochLength, claimStagingPeriod, salt) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0xe771e61b. +// +// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IAuthorityFactory *IAuthorityFactorySession) CalculateAuthorityAddress(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IAuthorityFactory.Contract.CalculateAuthorityAddress(&_IAuthorityFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, salt) +} + +// CalculateAuthorityAddress is a free data retrieval call binding the contract method 0xe771e61b. +// +// Solidity: function calculateAuthorityAddress(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IAuthorityFactory *IAuthorityFactoryCallerSession) CalculateAuthorityAddress(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IAuthorityFactory.Contract.CalculateAuthorityAddress(&_IAuthorityFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, salt) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IAuthorityFactory *IAuthorityFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IAuthorityFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IAuthorityFactory *IAuthorityFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthorityFactory.Contract.Version(&_IAuthorityFactory.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IAuthorityFactory *IAuthorityFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IAuthorityFactory.Contract.Version(&_IAuthorityFactory.CallOpts) +} + +// NewAuthority is a paid mutator transaction binding the contract method 0x6dbc5ab0. +// +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactor) NewAuthority(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IAuthorityFactory.contract.Transact(opts, "newAuthority", authorityOwner, epochLength, claimStagingPeriod) +} + +// NewAuthority is a paid mutator transaction binding the contract method 0x6dbc5ab0. +// +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IAuthorityFactory *IAuthorityFactorySession) NewAuthority(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod) +} + +// NewAuthority is a paid mutator transaction binding the contract method 0x6dbc5ab0. +// +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactorSession) NewAuthority(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod) +} + +// NewAuthority0 is a paid mutator transaction binding the contract method 0x75855f0d. +// +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactor) NewAuthority0(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IAuthorityFactory.contract.Transact(opts, "newAuthority0", authorityOwner, epochLength, claimStagingPeriod, salt) +} + +// NewAuthority0 is a paid mutator transaction binding the contract method 0x75855f0d. +// +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IAuthorityFactory *IAuthorityFactorySession) NewAuthority0(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority0(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, salt) +} + +// NewAuthority0 is a paid mutator transaction binding the contract method 0x75855f0d. +// +// Solidity: function newAuthority(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IAuthorityFactory *IAuthorityFactoryTransactorSession) NewAuthority0(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IAuthorityFactory.Contract.NewAuthority0(&_IAuthorityFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, salt) +} + +// IAuthorityFactoryAuthorityCreatedIterator is returned from FilterAuthorityCreated and is used to iterate over the raw logs and unpacked data for AuthorityCreated events raised by the IAuthorityFactory contract. +type IAuthorityFactoryAuthorityCreatedIterator struct { + Event *IAuthorityFactoryAuthorityCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IAuthorityFactoryAuthorityCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IAuthorityFactoryAuthorityCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IAuthorityFactoryAuthorityCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IAuthorityFactoryAuthorityCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IAuthorityFactoryAuthorityCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IAuthorityFactoryAuthorityCreated represents a AuthorityCreated event raised by the IAuthorityFactory contract. +type IAuthorityFactoryAuthorityCreated struct { + Authority common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterAuthorityCreated is a free log retrieval operation binding the contract event 0xdca1fad70bee4ba7a4e17a1c6e99e657d2251af7a279124758bc01588abe2d2f. +// +// Solidity: event AuthorityCreated(address authority) +func (_IAuthorityFactory *IAuthorityFactoryFilterer) FilterAuthorityCreated(opts *bind.FilterOpts) (*IAuthorityFactoryAuthorityCreatedIterator, error) { + + logs, sub, err := _IAuthorityFactory.contract.FilterLogs(opts, "AuthorityCreated") + if err != nil { + return nil, err + } + return &IAuthorityFactoryAuthorityCreatedIterator{contract: _IAuthorityFactory.contract, event: "AuthorityCreated", logs: logs, sub: sub}, nil +} + +// WatchAuthorityCreated is a free log subscription operation binding the contract event 0xdca1fad70bee4ba7a4e17a1c6e99e657d2251af7a279124758bc01588abe2d2f. +// +// Solidity: event AuthorityCreated(address authority) +func (_IAuthorityFactory *IAuthorityFactoryFilterer) WatchAuthorityCreated(opts *bind.WatchOpts, sink chan<- *IAuthorityFactoryAuthorityCreated) (event.Subscription, error) { + + logs, sub, err := _IAuthorityFactory.contract.WatchLogs(opts, "AuthorityCreated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IAuthorityFactoryAuthorityCreated) + if err := _IAuthorityFactory.contract.UnpackLog(event, "AuthorityCreated", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseAuthorityCreated is a log parse operation binding the contract event 0xdca1fad70bee4ba7a4e17a1c6e99e657d2251af7a279124758bc01588abe2d2f. +// +// Solidity: event AuthorityCreated(address authority) +func (_IAuthorityFactory *IAuthorityFactoryFilterer) ParseAuthorityCreated(log types.Log) (*IAuthorityFactoryAuthorityCreated, error) { + event := new(IAuthorityFactoryAuthorityCreated) + if err := _IAuthorityFactory.contract.UnpackLog(event, "AuthorityCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iconsensus/iconsensus.go b/pkg/contracts/iconsensus/iconsensus.go new file mode 100644 index 000000000..f609bd510 --- /dev/null +++ b/pkg/contracts/iconsensus/iconsensus.go @@ -0,0 +1,1079 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iconsensus + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IConsensusClaim is an auto generated low-level Go binding around an user-defined struct. +type IConsensusClaim struct { + Status uint8 + StagingBlockNumber *big.Int + StagedOutputsMerkleRoot [32]byte +} + +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + +// IConsensusMetaData contains all meta data concerning the IConsensus contract. +var IConsensusMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", +} + +// IConsensusABI is the input ABI used to generate the binding from. +// Deprecated: Use IConsensusMetaData.ABI instead. +var IConsensusABI = IConsensusMetaData.ABI + +// IConsensus is an auto generated Go binding around an Ethereum contract. +type IConsensus struct { + IConsensusCaller // Read-only binding to the contract + IConsensusTransactor // Write-only binding to the contract + IConsensusFilterer // Log filterer for contract events +} + +// IConsensusCaller is an auto generated read-only Go binding around an Ethereum contract. +type IConsensusCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IConsensusTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IConsensusTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IConsensusFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IConsensusFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IConsensusSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IConsensusSession struct { + Contract *IConsensus // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IConsensusCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IConsensusCallerSession struct { + Contract *IConsensusCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IConsensusTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IConsensusTransactorSession struct { + Contract *IConsensusTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IConsensusRaw is an auto generated low-level Go binding around an Ethereum contract. +type IConsensusRaw struct { + Contract *IConsensus // Generic contract binding to access the raw methods on +} + +// IConsensusCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IConsensusCallerRaw struct { + Contract *IConsensusCaller // Generic read-only contract binding to access the raw methods on +} + +// IConsensusTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IConsensusTransactorRaw struct { + Contract *IConsensusTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIConsensus creates a new instance of IConsensus, bound to a specific deployed contract. +func NewIConsensus(address common.Address, backend bind.ContractBackend) (*IConsensus, error) { + contract, err := bindIConsensus(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IConsensus{IConsensusCaller: IConsensusCaller{contract: contract}, IConsensusTransactor: IConsensusTransactor{contract: contract}, IConsensusFilterer: IConsensusFilterer{contract: contract}}, nil +} + +// NewIConsensusCaller creates a new read-only instance of IConsensus, bound to a specific deployed contract. +func NewIConsensusCaller(address common.Address, caller bind.ContractCaller) (*IConsensusCaller, error) { + contract, err := bindIConsensus(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IConsensusCaller{contract: contract}, nil +} + +// NewIConsensusTransactor creates a new write-only instance of IConsensus, bound to a specific deployed contract. +func NewIConsensusTransactor(address common.Address, transactor bind.ContractTransactor) (*IConsensusTransactor, error) { + contract, err := bindIConsensus(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IConsensusTransactor{contract: contract}, nil +} + +// NewIConsensusFilterer creates a new log filterer instance of IConsensus, bound to a specific deployed contract. +func NewIConsensusFilterer(address common.Address, filterer bind.ContractFilterer) (*IConsensusFilterer, error) { + contract, err := bindIConsensus(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IConsensusFilterer{contract: contract}, nil +} + +// bindIConsensus binds a generic wrapper to an already deployed contract. +func bindIConsensus(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IConsensusMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IConsensus *IConsensusRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IConsensus.Contract.IConsensusCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IConsensus *IConsensusRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IConsensus.Contract.IConsensusTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IConsensus *IConsensusRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IConsensus.Contract.IConsensusTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IConsensus *IConsensusCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IConsensus.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IConsensus *IConsensusTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IConsensus.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IConsensus *IConsensusTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IConsensus.Contract.contract.Transact(opts, method, params...) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IConsensus *IConsensusCaller) GetClaim(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) + + if err != nil { + return *new(IConsensusClaim), err + } + + out0 := *abi.ConvertType(out[0], new(IConsensusClaim)).(*IConsensusClaim) + + return out0, err + +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IConsensus *IConsensusSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IConsensus.Contract.GetClaim(&_IConsensus.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IConsensus *IConsensusCallerSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IConsensus.Contract.GetClaim(&_IConsensus.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IConsensus *IConsensusCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getClaimStagingPeriod") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IConsensus *IConsensusSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IConsensus.Contract.GetClaimStagingPeriod(&_IConsensus.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IConsensus.Contract.GetClaimStagingPeriod(&_IConsensus.CallOpts) +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IConsensus *IConsensusCaller) GetEpochLength(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getEpochLength") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IConsensus *IConsensusSession) GetEpochLength() (*big.Int, error) { + return _IConsensus.Contract.GetEpochLength(&_IConsensus.CallOpts) +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetEpochLength() (*big.Int, error) { + return _IConsensus.Contract.GetEpochLength(&_IConsensus.CallOpts) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IConsensus *IConsensusCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IConsensus *IConsensusSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IConsensus.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IConsensus *IConsensusCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getNumberOfAcceptedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfAcceptedClaims(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfAcceptedClaims(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCaller) GetNumberOfStagedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getNumberOfStagedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfStagedClaims(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfStagedClaims(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCaller) GetNumberOfSubmittedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "getNumberOfSubmittedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfSubmittedClaims(&_IConsensus.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IConsensus *IConsensusCallerSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IConsensus.Contract.GetNumberOfSubmittedClaims(&_IConsensus.CallOpts, appContract) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IConsensus *IConsensusCaller) IsOutputsMerkleRootValid(opts *bind.CallOpts, appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "isOutputsMerkleRootValid", appContract, outputsMerkleRoot) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IConsensus *IConsensusSession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IConsensus.Contract.IsOutputsMerkleRootValid(&_IConsensus.CallOpts, appContract, outputsMerkleRoot) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IConsensus *IConsensusCallerSession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IConsensus.Contract.IsOutputsMerkleRootValid(&_IConsensus.CallOpts, appContract, outputsMerkleRoot) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IConsensus *IConsensusCaller) SupportsInterface(opts *bind.CallOpts, interfaceId [4]byte) (bool, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "supportsInterface", interfaceId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IConsensus *IConsensusSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IConsensus.Contract.SupportsInterface(&_IConsensus.CallOpts, interfaceId) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IConsensus *IConsensusCallerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IConsensus.Contract.SupportsInterface(&_IConsensus.CallOpts, interfaceId) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IConsensus *IConsensusCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IConsensus *IConsensusSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IConsensus.Contract.Version(&_IConsensus.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IConsensus *IConsensusCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IConsensus.Contract.Version(&_IConsensus.CallOpts) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IConsensus.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IConsensus.Contract.WasInputFinalized(&_IConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IConsensus *IConsensusCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IConsensus.Contract.WasInputFinalized(&_IConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IConsensus *IConsensusTransactor) AcceptClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IConsensus.contract.Transact(opts, "acceptClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IConsensus *IConsensusSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IConsensus.Contract.AcceptClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IConsensus *IConsensusTransactorSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IConsensus.Contract.AcceptClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IConsensus *IConsensusTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IConsensus.Contract.SubmitClaim(&_IConsensus.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// IConsensusClaimAcceptedIterator is returned from FilterClaimAccepted and is used to iterate over the raw logs and unpacked data for ClaimAccepted events raised by the IConsensus contract. +type IConsensusClaimAcceptedIterator struct { + Event *IConsensusClaimAccepted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IConsensusClaimAcceptedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IConsensusClaimAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IConsensusClaimAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IConsensusClaimAcceptedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IConsensusClaimAcceptedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IConsensusClaimAccepted represents a ClaimAccepted event raised by the IConsensus contract. +type IConsensusClaimAccepted struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) FilterClaimAccepted(opts *bind.FilterOpts, appContract []common.Address) (*IConsensusClaimAcceptedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.FilterLogs(opts, "ClaimAccepted", appContractRule) + if err != nil { + return nil, err + } + return &IConsensusClaimAcceptedIterator{contract: _IConsensus.contract, event: "ClaimAccepted", logs: logs, sub: sub}, nil +} + +// WatchClaimAccepted is a free log subscription operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) WatchClaimAccepted(opts *bind.WatchOpts, sink chan<- *IConsensusClaimAccepted, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.WatchLogs(opts, "ClaimAccepted", appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IConsensusClaimAccepted) + if err := _IConsensus.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimAccepted is a log parse operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) ParseClaimAccepted(log types.Log) (*IConsensusClaimAccepted, error) { + event := new(IConsensusClaimAccepted) + if err := _IConsensus.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IConsensusClaimStagedIterator is returned from FilterClaimStaged and is used to iterate over the raw logs and unpacked data for ClaimStaged events raised by the IConsensus contract. +type IConsensusClaimStagedIterator struct { + Event *IConsensusClaimStaged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IConsensusClaimStagedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IConsensusClaimStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IConsensusClaimStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IConsensusClaimStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IConsensusClaimStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IConsensusClaimStaged represents a ClaimStaged event raised by the IConsensus contract. +type IConsensusClaimStaged struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimStaged is a free log retrieval operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) FilterClaimStaged(opts *bind.FilterOpts, appContract []common.Address) (*IConsensusClaimStagedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.FilterLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return &IConsensusClaimStagedIterator{contract: _IConsensus.contract, event: "ClaimStaged", logs: logs, sub: sub}, nil +} + +// WatchClaimStaged is a free log subscription operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) WatchClaimStaged(opts *bind.WatchOpts, sink chan<- *IConsensusClaimStaged, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.WatchLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IConsensusClaimStaged) + if err := _IConsensus.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimStaged is a log parse operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) ParseClaimStaged(log types.Log) (*IConsensusClaimStaged, error) { + event := new(IConsensusClaimStaged) + if err := _IConsensus.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IConsensusClaimSubmittedIterator is returned from FilterClaimSubmitted and is used to iterate over the raw logs and unpacked data for ClaimSubmitted events raised by the IConsensus contract. +type IConsensusClaimSubmittedIterator struct { + Event *IConsensusClaimSubmitted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IConsensusClaimSubmittedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IConsensusClaimSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IConsensusClaimSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IConsensusClaimSubmittedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IConsensusClaimSubmittedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IConsensusClaimSubmitted represents a ClaimSubmitted event raised by the IConsensus contract. +type IConsensusClaimSubmitted struct { + Submitter common.Address + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) FilterClaimSubmitted(opts *bind.FilterOpts, submitter []common.Address, appContract []common.Address) (*IConsensusClaimSubmittedIterator, error) { + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.FilterLogs(opts, "ClaimSubmitted", submitterRule, appContractRule) + if err != nil { + return nil, err + } + return &IConsensusClaimSubmittedIterator{contract: _IConsensus.contract, event: "ClaimSubmitted", logs: logs, sub: sub}, nil +} + +// WatchClaimSubmitted is a free log subscription operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, sink chan<- *IConsensusClaimSubmitted, submitter []common.Address, appContract []common.Address) (event.Subscription, error) { + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IConsensus.contract.WatchLogs(opts, "ClaimSubmitted", submitterRule, appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IConsensusClaimSubmitted) + if err := _IConsensus.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimSubmitted is a log parse operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IConsensus *IConsensusFilterer) ParseClaimSubmitted(log types.Log) (*IConsensusClaimSubmitted, error) { + event := new(IConsensusClaimSubmitted) + if err := _IConsensus.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/idaveappfactory/idaveappfactory.go b/pkg/contracts/idaveappfactory/idaveappfactory.go new file mode 100644 index 000000000..ca8f832c4 --- /dev/null +++ b/pkg/contracts/idaveappfactory/idaveappfactory.go @@ -0,0 +1,399 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package idaveappfactory + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + +// IDaveAppFactoryMetaData contains all meta data concerning the IDaveAppFactory contract. +var IDaveAppFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"calculateDaveAppAddress\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryManager\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"sentries\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContractAddress\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"daveConsensusAddress\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newDaveApp\",\"inputs\":[{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryManager\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"sentries\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"internalType\":\"contractIDaveConsensus\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"DaveAppCreated\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIApplication\"},{\"name\":\"daveConsensus\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIDaveConsensus\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"DuplicatedSentryAddress\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroSentryAddress\",\"inputs\":[]}]", +} + +// IDaveAppFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IDaveAppFactoryMetaData.ABI instead. +var IDaveAppFactoryABI = IDaveAppFactoryMetaData.ABI + +// IDaveAppFactory is an auto generated Go binding around an Ethereum contract. +type IDaveAppFactory struct { + IDaveAppFactoryCaller // Read-only binding to the contract + IDaveAppFactoryTransactor // Write-only binding to the contract + IDaveAppFactoryFilterer // Log filterer for contract events +} + +// IDaveAppFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IDaveAppFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDaveAppFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IDaveAppFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDaveAppFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IDaveAppFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDaveAppFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IDaveAppFactorySession struct { + Contract *IDaveAppFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IDaveAppFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IDaveAppFactoryCallerSession struct { + Contract *IDaveAppFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IDaveAppFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IDaveAppFactoryTransactorSession struct { + Contract *IDaveAppFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IDaveAppFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IDaveAppFactoryRaw struct { + Contract *IDaveAppFactory // Generic contract binding to access the raw methods on +} + +// IDaveAppFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IDaveAppFactoryCallerRaw struct { + Contract *IDaveAppFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IDaveAppFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IDaveAppFactoryTransactorRaw struct { + Contract *IDaveAppFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIDaveAppFactory creates a new instance of IDaveAppFactory, bound to a specific deployed contract. +func NewIDaveAppFactory(address common.Address, backend bind.ContractBackend) (*IDaveAppFactory, error) { + contract, err := bindIDaveAppFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IDaveAppFactory{IDaveAppFactoryCaller: IDaveAppFactoryCaller{contract: contract}, IDaveAppFactoryTransactor: IDaveAppFactoryTransactor{contract: contract}, IDaveAppFactoryFilterer: IDaveAppFactoryFilterer{contract: contract}}, nil +} + +// NewIDaveAppFactoryCaller creates a new read-only instance of IDaveAppFactory, bound to a specific deployed contract. +func NewIDaveAppFactoryCaller(address common.Address, caller bind.ContractCaller) (*IDaveAppFactoryCaller, error) { + contract, err := bindIDaveAppFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IDaveAppFactoryCaller{contract: contract}, nil +} + +// NewIDaveAppFactoryTransactor creates a new write-only instance of IDaveAppFactory, bound to a specific deployed contract. +func NewIDaveAppFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IDaveAppFactoryTransactor, error) { + contract, err := bindIDaveAppFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IDaveAppFactoryTransactor{contract: contract}, nil +} + +// NewIDaveAppFactoryFilterer creates a new log filterer instance of IDaveAppFactory, bound to a specific deployed contract. +func NewIDaveAppFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IDaveAppFactoryFilterer, error) { + contract, err := bindIDaveAppFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IDaveAppFactoryFilterer{contract: contract}, nil +} + +// bindIDaveAppFactory binds a generic wrapper to an already deployed contract. +func bindIDaveAppFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IDaveAppFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IDaveAppFactory *IDaveAppFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IDaveAppFactory.Contract.IDaveAppFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IDaveAppFactory *IDaveAppFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.IDaveAppFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IDaveAppFactory *IDaveAppFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.IDaveAppFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IDaveAppFactory *IDaveAppFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IDaveAppFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IDaveAppFactory *IDaveAppFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IDaveAppFactory *IDaveAppFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.contract.Transact(opts, method, params...) +} + +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. +// +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactoryCaller) CalculateDaveAppAddress(opts *bind.CallOpts, templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { + AppContractAddress common.Address + DaveConsensusAddress common.Address +}, error) { + var out []interface{} + err := _IDaveAppFactory.contract.Call(opts, &out, "calculateDaveAppAddress", templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) + + outstruct := new(struct { + AppContractAddress common.Address + DaveConsensusAddress common.Address + }) + if err != nil { + return *outstruct, err + } + + outstruct.AppContractAddress = *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + outstruct.DaveConsensusAddress = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + + return *outstruct, err + +} + +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. +// +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactorySession) CalculateDaveAppAddress(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { + AppContractAddress common.Address + DaveConsensusAddress common.Address +}, error) { + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) +} + +// CalculateDaveAppAddress is a free data retrieval call binding the contract method 0x5592ff8a. +// +// Solidity: function calculateDaveAppAddress(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address appContractAddress, address daveConsensusAddress) +func (_IDaveAppFactory *IDaveAppFactoryCallerSession) CalculateDaveAppAddress(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (struct { + AppContractAddress common.Address + DaveConsensusAddress common.Address +}, error) { + return _IDaveAppFactory.Contract.CalculateDaveAppAddress(&_IDaveAppFactory.CallOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) +} + +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. +// +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryTransactor) NewDaveApp(opts *bind.TransactOpts, templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.contract.Transact(opts, "newDaveApp", templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) +} + +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. +// +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactorySession) NewDaveApp(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) +} + +// NewDaveApp is a paid mutator transaction binding the contract method 0x7ea1763b. +// +// Solidity: function newDaveApp(bytes32 templateHash, uint256 claimStagingPeriod, address sentryManager, address[] sentries, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryTransactorSession) NewDaveApp(templateHash [32]byte, claimStagingPeriod *big.Int, sentryManager common.Address, sentries []common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _IDaveAppFactory.Contract.NewDaveApp(&_IDaveAppFactory.TransactOpts, templateHash, claimStagingPeriod, sentryManager, sentries, withdrawalConfig, salt) +} + +// IDaveAppFactoryDaveAppCreatedIterator is returned from FilterDaveAppCreated and is used to iterate over the raw logs and unpacked data for DaveAppCreated events raised by the IDaveAppFactory contract. +type IDaveAppFactoryDaveAppCreatedIterator struct { + Event *IDaveAppFactoryDaveAppCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveAppFactoryDaveAppCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveAppFactoryDaveAppCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveAppFactoryDaveAppCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveAppFactoryDaveAppCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveAppFactoryDaveAppCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveAppFactoryDaveAppCreated represents a DaveAppCreated event raised by the IDaveAppFactory contract. +type IDaveAppFactoryDaveAppCreated struct { + AppContract common.Address + DaveConsensus common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterDaveAppCreated is a free log retrieval operation binding the contract event 0xdf2ebeb5a7d7df0100c0274c7cee9570954d7bebeef37db55b27204a57f65602. +// +// Solidity: event DaveAppCreated(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryFilterer) FilterDaveAppCreated(opts *bind.FilterOpts) (*IDaveAppFactoryDaveAppCreatedIterator, error) { + + logs, sub, err := _IDaveAppFactory.contract.FilterLogs(opts, "DaveAppCreated") + if err != nil { + return nil, err + } + return &IDaveAppFactoryDaveAppCreatedIterator{contract: _IDaveAppFactory.contract, event: "DaveAppCreated", logs: logs, sub: sub}, nil +} + +// WatchDaveAppCreated is a free log subscription operation binding the contract event 0xdf2ebeb5a7d7df0100c0274c7cee9570954d7bebeef37db55b27204a57f65602. +// +// Solidity: event DaveAppCreated(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryFilterer) WatchDaveAppCreated(opts *bind.WatchOpts, sink chan<- *IDaveAppFactoryDaveAppCreated) (event.Subscription, error) { + + logs, sub, err := _IDaveAppFactory.contract.WatchLogs(opts, "DaveAppCreated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveAppFactoryDaveAppCreated) + if err := _IDaveAppFactory.contract.UnpackLog(event, "DaveAppCreated", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseDaveAppCreated is a log parse operation binding the contract event 0xdf2ebeb5a7d7df0100c0274c7cee9570954d7bebeef37db55b27204a57f65602. +// +// Solidity: event DaveAppCreated(address appContract, address daveConsensus) +func (_IDaveAppFactory *IDaveAppFactoryFilterer) ParseDaveAppCreated(log types.Log) (*IDaveAppFactoryDaveAppCreated, error) { + event := new(IDaveAppFactoryDaveAppCreated) + if err := _IDaveAppFactory.contract.UnpackLog(event, "DaveAppCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/idaveconsensus/idaveconsensus.go b/pkg/contracts/idaveconsensus/idaveconsensus.go new file mode 100644 index 000000000..33b0b9c9c --- /dev/null +++ b/pkg/contracts/idaveconsensus/idaveconsensus.go @@ -0,0 +1,1759 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package idaveconsensus + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + +// IDaveConsensusMetaData contains all meta data concerning the IDaveConsensus contract. +var IDaveConsensusMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"acceptStagedTournamentResult\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"canAcceptStagedTournamentResult\",\"inputs\":[],\"outputs\":[{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"doAllSentriesAgreeWithStagedTournamentResult\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isClaimStagingPeriodOver\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"canStageTournamentResult\",\"inputs\":[],\"outputs\":[{\"name\":\"isFinished\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isTournamentFailed\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"winnerCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winnerPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getApplicationContract\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getCurrentSealedEpoch\",\"inputs\":[],\"outputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"tournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"isTournamentResultStaged\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputBox\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSentries\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryById\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryClaimCount\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryId\",\"inputs\":[{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getSentryManager\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getTournamentFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournamentFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"hasSentryClaimedInEpoch\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"provideMerkleRootOfInput\",\"inputs\":[{\"name\":\"inputIndexWithinEpoch\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"rotateSentry\",\"inputs\":[{\"name\":\"currentSentry\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"newSentry\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stageTournamentResult\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"submitSentryClaim\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ConsensusCreation\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIInputBox\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"},{\"name\":\"tournamentFactory\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournamentFactory\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochSealed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"inputIndexLowerBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"inputIndexUpperBound\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"initialMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EpochStaged\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"stagedPostEpochMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"stagedPostEpochOutputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SentryClaim\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"postEpochMachineStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SentryRotation\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"oldSentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newSentry\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"received\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotSentry\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotSentryManager\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"CannotRotateNonSentry\",\"inputs\":[{\"name\":\"nonSentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DataBlockTooLarge\",\"inputs\":[{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanData\",\"inputs\":[{\"name\":\"driveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"dataSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveSmallerThanDataBlock\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2DataBlockSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DriveTooLarge\",\"inputs\":[{\"name\":\"log2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxLog2DriveSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"DuplicatedSentryAddress\",\"inputs\":[{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentry\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IncorrectEpochNumber\",\"inputs\":[{\"name\":\"received\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"actual\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InputHashMismatch\",\"inputs\":[{\"name\":\"fromReceivedInput\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"fromInputBox\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidNodeIndex\",\"inputs\":[{\"name\":\"nodeIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"height\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SentryAlreadyClaimed\",\"inputs\":[{\"name\":\"epochNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"sentryId\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"TournamentNotFinishedYet\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentResultAlreadyStaged\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentResultNotStaged\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"UnexpectedFinalStackDepth\",\"inputs\":[{\"name\":\"stackDepth\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ZeroSentryAddress\",\"inputs\":[]}]", +} + +// IDaveConsensusABI is the input ABI used to generate the binding from. +// Deprecated: Use IDaveConsensusMetaData.ABI instead. +var IDaveConsensusABI = IDaveConsensusMetaData.ABI + +// IDaveConsensus is an auto generated Go binding around an Ethereum contract. +type IDaveConsensus struct { + IDaveConsensusCaller // Read-only binding to the contract + IDaveConsensusTransactor // Write-only binding to the contract + IDaveConsensusFilterer // Log filterer for contract events +} + +// IDaveConsensusCaller is an auto generated read-only Go binding around an Ethereum contract. +type IDaveConsensusCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDaveConsensusTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IDaveConsensusTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDaveConsensusFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IDaveConsensusFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IDaveConsensusSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IDaveConsensusSession struct { + Contract *IDaveConsensus // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IDaveConsensusCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IDaveConsensusCallerSession struct { + Contract *IDaveConsensusCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IDaveConsensusTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IDaveConsensusTransactorSession struct { + Contract *IDaveConsensusTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IDaveConsensusRaw is an auto generated low-level Go binding around an Ethereum contract. +type IDaveConsensusRaw struct { + Contract *IDaveConsensus // Generic contract binding to access the raw methods on +} + +// IDaveConsensusCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IDaveConsensusCallerRaw struct { + Contract *IDaveConsensusCaller // Generic read-only contract binding to access the raw methods on +} + +// IDaveConsensusTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IDaveConsensusTransactorRaw struct { + Contract *IDaveConsensusTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIDaveConsensus creates a new instance of IDaveConsensus, bound to a specific deployed contract. +func NewIDaveConsensus(address common.Address, backend bind.ContractBackend) (*IDaveConsensus, error) { + contract, err := bindIDaveConsensus(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IDaveConsensus{IDaveConsensusCaller: IDaveConsensusCaller{contract: contract}, IDaveConsensusTransactor: IDaveConsensusTransactor{contract: contract}, IDaveConsensusFilterer: IDaveConsensusFilterer{contract: contract}}, nil +} + +// NewIDaveConsensusCaller creates a new read-only instance of IDaveConsensus, bound to a specific deployed contract. +func NewIDaveConsensusCaller(address common.Address, caller bind.ContractCaller) (*IDaveConsensusCaller, error) { + contract, err := bindIDaveConsensus(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IDaveConsensusCaller{contract: contract}, nil +} + +// NewIDaveConsensusTransactor creates a new write-only instance of IDaveConsensus, bound to a specific deployed contract. +func NewIDaveConsensusTransactor(address common.Address, transactor bind.ContractTransactor) (*IDaveConsensusTransactor, error) { + contract, err := bindIDaveConsensus(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IDaveConsensusTransactor{contract: contract}, nil +} + +// NewIDaveConsensusFilterer creates a new log filterer instance of IDaveConsensus, bound to a specific deployed contract. +func NewIDaveConsensusFilterer(address common.Address, filterer bind.ContractFilterer) (*IDaveConsensusFilterer, error) { + contract, err := bindIDaveConsensus(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IDaveConsensusFilterer{contract: contract}, nil +} + +// bindIDaveConsensus binds a generic wrapper to an already deployed contract. +func bindIDaveConsensus(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IDaveConsensusMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IDaveConsensus *IDaveConsensusRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IDaveConsensus.Contract.IDaveConsensusCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IDaveConsensus *IDaveConsensusRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IDaveConsensus.Contract.IDaveConsensusTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IDaveConsensus *IDaveConsensusRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IDaveConsensus.Contract.IDaveConsensusTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IDaveConsensus *IDaveConsensusCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IDaveConsensus.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IDaveConsensus *IDaveConsensusTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IDaveConsensus.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IDaveConsensus *IDaveConsensusTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IDaveConsensus.Contract.contract.Transact(opts, method, params...) +} + +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCaller) CanAcceptStagedTournamentResult(opts *bind.CallOpts) (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "canAcceptStagedTournamentResult") + + outstruct := new(struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.DoAllSentriesAgreeWithStagedTournamentResult = *abi.ConvertType(out[1], new(bool)).(*bool) + outstruct.IsClaimStagingPeriodOver = *abi.ConvertType(out[2], new(bool)).(*bool) + outstruct.EpochNumber = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + outstruct.StagedPostEpochMachineStateHash = *abi.ConvertType(out[4], new([32]byte)).(*[32]byte) + outstruct.StagedPostEpochOutputsMerkleRoot = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusSession) CanAcceptStagedTournamentResult() (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.CanAcceptStagedTournamentResult(&_IDaveConsensus.CallOpts) +} + +// CanAcceptStagedTournamentResult is a free data retrieval call binding the contract method 0xab36a12e. +// +// Solidity: function canAcceptStagedTournamentResult() view returns(bool isTournamentResultStaged, bool doAllSentriesAgreeWithStagedTournamentResult, bool isClaimStagingPeriodOver, uint256 epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCallerSession) CanAcceptStagedTournamentResult() (struct { + IsTournamentResultStaged bool + DoAllSentriesAgreeWithStagedTournamentResult bool + IsClaimStagingPeriodOver bool + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.CanAcceptStagedTournamentResult(&_IDaveConsensus.CallOpts) +} + +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. +// +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusCaller) CanStageTournamentResult(opts *bind.CallOpts) (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte +}, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "canStageTournamentResult") + + outstruct := new(struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.IsFinished = *abi.ConvertType(out[0], new(bool)).(*bool) + outstruct.IsTournamentFailed = *abi.ConvertType(out[1], new(bool)).(*bool) + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[2], new(bool)).(*bool) + outstruct.EpochNumber = *abi.ConvertType(out[3], new(*big.Int)).(**big.Int) + outstruct.WinnerCommitment = *abi.ConvertType(out[4], new([32]byte)).(*[32]byte) + outstruct.WinnerPostEpochMachineStateHash = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. +// +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusSession) CanStageTournamentResult() (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte +}, error) { + return _IDaveConsensus.Contract.CanStageTournamentResult(&_IDaveConsensus.CallOpts) +} + +// CanStageTournamentResult is a free data retrieval call binding the contract method 0x5c2694fd. +// +// Solidity: function canStageTournamentResult() view returns(bool isFinished, bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, bytes32 winnerCommitment, bytes32 winnerPostEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusCallerSession) CanStageTournamentResult() (struct { + IsFinished bool + IsTournamentFailed bool + IsTournamentResultStaged bool + EpochNumber *big.Int + WinnerCommitment [32]byte + WinnerPostEpochMachineStateHash [32]byte +}, error) { + return _IDaveConsensus.Contract.CanStageTournamentResult(&_IDaveConsensus.CallOpts) +} + +// GetApplicationContract is a free data retrieval call binding the contract method 0xc050be00. +// +// Solidity: function getApplicationContract() view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetApplicationContract(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getApplicationContract") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetApplicationContract is a free data retrieval call binding the contract method 0xc050be00. +// +// Solidity: function getApplicationContract() view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetApplicationContract() (common.Address, error) { + return _IDaveConsensus.Contract.GetApplicationContract(&_IDaveConsensus.CallOpts) +} + +// GetApplicationContract is a free data retrieval call binding the contract method 0xc050be00. +// +// Solidity: function getApplicationContract() view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetApplicationContract() (common.Address, error) { + return _IDaveConsensus.Contract.GetApplicationContract(&_IDaveConsensus.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getClaimStagingPeriod") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IDaveConsensus.Contract.GetClaimStagingPeriod(&_IDaveConsensus.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IDaveConsensus.Contract.GetClaimStagingPeriod(&_IDaveConsensus.CallOpts) +} + +// GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. +// +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCaller) GetCurrentSealedEpoch(opts *bind.CallOpts) (struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getCurrentSealedEpoch") + + outstruct := new(struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte + }) + if err != nil { + return *outstruct, err + } + + outstruct.EpochNumber = *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + outstruct.InputIndexLowerBound = *abi.ConvertType(out[1], new(*big.Int)).(**big.Int) + outstruct.InputIndexUpperBound = *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) + outstruct.Tournament = *abi.ConvertType(out[3], new(common.Address)).(*common.Address) + outstruct.IsTournamentResultStaged = *abi.ConvertType(out[4], new(bool)).(*bool) + outstruct.StagingBlockNumber = *abi.ConvertType(out[5], new(*big.Int)).(**big.Int) + outstruct.StagedPostEpochMachineStateHash = *abi.ConvertType(out[6], new([32]byte)).(*[32]byte) + outstruct.StagedPostEpochOutputsMerkleRoot = *abi.ConvertType(out[7], new([32]byte)).(*[32]byte) + + return *outstruct, err + +} + +// GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. +// +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusSession) GetCurrentSealedEpoch() (struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.GetCurrentSealedEpoch(&_IDaveConsensus.CallOpts) +} + +// GetCurrentSealedEpoch is a free data retrieval call binding the contract method 0x1239acd9. +// +// Solidity: function getCurrentSealedEpoch() view returns(uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, address tournament, bool isTournamentResultStaged, uint256 stagingBlockNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetCurrentSealedEpoch() (struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + Tournament common.Address + IsTournamentResultStaged bool + StagingBlockNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte +}, error) { + return _IDaveConsensus.Contract.GetCurrentSealedEpoch(&_IDaveConsensus.CallOpts) +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getDeploymentBlockNumber") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetDeploymentBlockNumber() (*big.Int, error) { + return _IDaveConsensus.Contract.GetDeploymentBlockNumber(&_IDaveConsensus.CallOpts) +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetDeploymentBlockNumber() (*big.Int, error) { + return _IDaveConsensus.Contract.GetDeploymentBlockNumber(&_IDaveConsensus.CallOpts) +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetInputBox(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getInputBox") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetInputBox() (common.Address, error) { + return _IDaveConsensus.Contract.GetInputBox(&_IDaveConsensus.CallOpts) +} + +// GetInputBox is a free data retrieval call binding the contract method 0x00aace9a. +// +// Solidity: function getInputBox() view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetInputBox() (common.Address, error) { + return _IDaveConsensus.Contract.GetInputBox(&_IDaveConsensus.CallOpts) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IDaveConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IDaveConsensus.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IDaveConsensus.Contract.GetLastFinalizedMachineMerkleRoot(&_IDaveConsensus.CallOpts, appContract) +} + +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetNumberOfSentries(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getNumberOfSentries") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetNumberOfSentries() (*big.Int, error) { + return _IDaveConsensus.Contract.GetNumberOfSentries(&_IDaveConsensus.CallOpts) +} + +// GetNumberOfSentries is a free data retrieval call binding the contract method 0xf2f0143d. +// +// Solidity: function getNumberOfSentries() view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetNumberOfSentries() (*big.Int, error) { + return _IDaveConsensus.Contract.GetNumberOfSentries(&_IDaveConsensus.CallOpts) +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryById(opts *bind.CallOpts, sentryId *big.Int) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryById", sentryId) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryById(sentryId *big.Int) (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryById(&_IDaveConsensus.CallOpts, sentryId) +} + +// GetSentryById is a free data retrieval call binding the contract method 0x50c8378d. +// +// Solidity: function getSentryById(uint256 sentryId) view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryById(sentryId *big.Int) (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryById(&_IDaveConsensus.CallOpts, sentryId) +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryClaimCount(opts *bind.CallOpts, epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryClaimCount", epochNumber, postEpochMachineStateHash) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryClaimCount(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryClaimCount(&_IDaveConsensus.CallOpts, epochNumber, postEpochMachineStateHash) +} + +// GetSentryClaimCount is a free data retrieval call binding the contract method 0x47d309f7. +// +// Solidity: function getSentryClaimCount(uint256 epochNumber, bytes32 postEpochMachineStateHash) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryClaimCount(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryClaimCount(&_IDaveConsensus.CallOpts, epochNumber, postEpochMachineStateHash) +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryId(opts *bind.CallOpts, sentry common.Address) (*big.Int, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryId", sentry) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryId(sentry common.Address) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryId(&_IDaveConsensus.CallOpts, sentry) +} + +// GetSentryId is a free data retrieval call binding the contract method 0x8bb6ae16. +// +// Solidity: function getSentryId(address sentry) view returns(uint256) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryId(sentry common.Address) (*big.Int, error) { + return _IDaveConsensus.Contract.GetSentryId(&_IDaveConsensus.CallOpts, sentry) +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetSentryManager(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getSentryManager") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetSentryManager() (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryManager(&_IDaveConsensus.CallOpts) +} + +// GetSentryManager is a free data retrieval call binding the contract method 0x0c2f5d7c. +// +// Solidity: function getSentryManager() view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetSentryManager() (common.Address, error) { + return _IDaveConsensus.Contract.GetSentryManager(&_IDaveConsensus.CallOpts) +} + +// GetTournamentFactory is a free data retrieval call binding the contract method 0x813a1aaf. +// +// Solidity: function getTournamentFactory() view returns(address) +func (_IDaveConsensus *IDaveConsensusCaller) GetTournamentFactory(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "getTournamentFactory") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetTournamentFactory is a free data retrieval call binding the contract method 0x813a1aaf. +// +// Solidity: function getTournamentFactory() view returns(address) +func (_IDaveConsensus *IDaveConsensusSession) GetTournamentFactory() (common.Address, error) { + return _IDaveConsensus.Contract.GetTournamentFactory(&_IDaveConsensus.CallOpts) +} + +// GetTournamentFactory is a free data retrieval call binding the contract method 0x813a1aaf. +// +// Solidity: function getTournamentFactory() view returns(address) +func (_IDaveConsensus *IDaveConsensusCallerSession) GetTournamentFactory() (common.Address, error) { + return _IDaveConsensus.Contract.GetTournamentFactory(&_IDaveConsensus.CallOpts) +} + +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) HasSentryClaimedInEpoch(opts *bind.CallOpts, epochNumber *big.Int, sentryId *big.Int) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "hasSentryClaimedInEpoch", epochNumber, sentryId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) HasSentryClaimedInEpoch(epochNumber *big.Int, sentryId *big.Int) (bool, error) { + return _IDaveConsensus.Contract.HasSentryClaimedInEpoch(&_IDaveConsensus.CallOpts, epochNumber, sentryId) +} + +// HasSentryClaimedInEpoch is a free data retrieval call binding the contract method 0xb55832c6. +// +// Solidity: function hasSentryClaimedInEpoch(uint256 epochNumber, uint256 sentryId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) HasSentryClaimedInEpoch(epochNumber *big.Int, sentryId *big.Int) (bool, error) { + return _IDaveConsensus.Contract.HasSentryClaimedInEpoch(&_IDaveConsensus.CallOpts, epochNumber, sentryId) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) IsOutputsMerkleRootValid(opts *bind.CallOpts, appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "isOutputsMerkleRootValid", appContract, outputsMerkleRoot) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IDaveConsensus.Contract.IsOutputsMerkleRootValid(&_IDaveConsensus.CallOpts, appContract, outputsMerkleRoot) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IDaveConsensus.Contract.IsOutputsMerkleRootValid(&_IDaveConsensus.CallOpts, appContract, outputsMerkleRoot) +} + +// ProvideMerkleRootOfInput is a free data retrieval call binding the contract method 0x7a96f480. +// +// Solidity: function provideMerkleRootOfInput(uint256 inputIndexWithinEpoch, bytes input) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusCaller) ProvideMerkleRootOfInput(opts *bind.CallOpts, inputIndexWithinEpoch *big.Int, input []byte) ([32]byte, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "provideMerkleRootOfInput", inputIndexWithinEpoch, input) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// ProvideMerkleRootOfInput is a free data retrieval call binding the contract method 0x7a96f480. +// +// Solidity: function provideMerkleRootOfInput(uint256 inputIndexWithinEpoch, bytes input) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusSession) ProvideMerkleRootOfInput(inputIndexWithinEpoch *big.Int, input []byte) ([32]byte, error) { + return _IDaveConsensus.Contract.ProvideMerkleRootOfInput(&_IDaveConsensus.CallOpts, inputIndexWithinEpoch, input) +} + +// ProvideMerkleRootOfInput is a free data retrieval call binding the contract method 0x7a96f480. +// +// Solidity: function provideMerkleRootOfInput(uint256 inputIndexWithinEpoch, bytes input) view returns(bytes32) +func (_IDaveConsensus *IDaveConsensusCallerSession) ProvideMerkleRootOfInput(inputIndexWithinEpoch *big.Int, input []byte) ([32]byte, error) { + return _IDaveConsensus.Contract.ProvideMerkleRootOfInput(&_IDaveConsensus.CallOpts, inputIndexWithinEpoch, input) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) SupportsInterface(opts *bind.CallOpts, interfaceId [4]byte) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "supportsInterface", interfaceId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IDaveConsensus.Contract.SupportsInterface(&_IDaveConsensus.CallOpts, interfaceId) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IDaveConsensus.Contract.SupportsInterface(&_IDaveConsensus.CallOpts, interfaceId) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IDaveConsensus.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IDaveConsensus.Contract.WasInputFinalized(&_IDaveConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IDaveConsensus *IDaveConsensusCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IDaveConsensus.Contract.WasInputFinalized(&_IDaveConsensus.CallOpts, appContract, inputIndex, blockNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) AcceptStagedTournamentResult(opts *bind.TransactOpts, epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "acceptStagedTournamentResult", epochNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusSession) AcceptStagedTournamentResult(epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.Contract.AcceptStagedTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber) +} + +// AcceptStagedTournamentResult is a paid mutator transaction binding the contract method 0x592b96c4. +// +// Solidity: function acceptStagedTournamentResult(uint256 epochNumber) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) AcceptStagedTournamentResult(epochNumber *big.Int) (*types.Transaction, error) { + return _IDaveConsensus.Contract.AcceptStagedTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) RotateSentry(opts *bind.TransactOpts, currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "rotateSentry", currentSentry, newSentry) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusSession) RotateSentry(currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.Contract.RotateSentry(&_IDaveConsensus.TransactOpts, currentSentry, newSentry) +} + +// RotateSentry is a paid mutator transaction binding the contract method 0xbf4e5230. +// +// Solidity: function rotateSentry(address currentSentry, address newSentry) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) RotateSentry(currentSentry common.Address, newSentry common.Address) (*types.Transaction, error) { + return _IDaveConsensus.Contract.RotateSentry(&_IDaveConsensus.TransactOpts, currentSentry, newSentry) +} + +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. +// +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) StageTournamentResult(opts *bind.TransactOpts, epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "stageTournamentResult", epochNumber, proof) +} + +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. +// +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusSession) StageTournamentResult(epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.Contract.StageTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber, proof) +} + +// StageTournamentResult is a paid mutator transaction binding the contract method 0xc1dd774d. +// +// Solidity: function stageTournamentResult(uint256 epochNumber, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) StageTournamentResult(epochNumber *big.Int, proof MachineValidityProof) (*types.Transaction, error) { + return _IDaveConsensus.Contract.StageTournamentResult(&_IDaveConsensus.TransactOpts, epochNumber, proof) +} + +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. +// +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusTransactor) SubmitSentryClaim(opts *bind.TransactOpts, epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.contract.Transact(opts, "submitSentryClaim", epochNumber, postEpochMachineStateHash) +} + +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. +// +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusSession) SubmitSentryClaim(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.Contract.SubmitSentryClaim(&_IDaveConsensus.TransactOpts, epochNumber, postEpochMachineStateHash) +} + +// SubmitSentryClaim is a paid mutator transaction binding the contract method 0x9dab5b26. +// +// Solidity: function submitSentryClaim(uint256 epochNumber, bytes32 postEpochMachineStateHash) returns() +func (_IDaveConsensus *IDaveConsensusTransactorSession) SubmitSentryClaim(epochNumber *big.Int, postEpochMachineStateHash [32]byte) (*types.Transaction, error) { + return _IDaveConsensus.Contract.SubmitSentryClaim(&_IDaveConsensus.TransactOpts, epochNumber, postEpochMachineStateHash) +} + +// IDaveConsensusConsensusCreationIterator is returned from FilterConsensusCreation and is used to iterate over the raw logs and unpacked data for ConsensusCreation events raised by the IDaveConsensus contract. +type IDaveConsensusConsensusCreationIterator struct { + Event *IDaveConsensusConsensusCreation // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusConsensusCreationIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusConsensusCreation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusConsensusCreation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusConsensusCreationIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusConsensusCreationIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusConsensusCreation represents a ConsensusCreation event raised by the IDaveConsensus contract. +type IDaveConsensusConsensusCreation struct { + InputBox common.Address + AppContract common.Address + TournamentFactory common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterConsensusCreation is a free log retrieval operation binding the contract event 0xaf68463e16cb5595a44214bea8d366ecf7cd3410269c50f92c104b50a7829daa. +// +// Solidity: event ConsensusCreation(address inputBox, address appContract, address tournamentFactory) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterConsensusCreation(opts *bind.FilterOpts) (*IDaveConsensusConsensusCreationIterator, error) { + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "ConsensusCreation") + if err != nil { + return nil, err + } + return &IDaveConsensusConsensusCreationIterator{contract: _IDaveConsensus.contract, event: "ConsensusCreation", logs: logs, sub: sub}, nil +} + +// WatchConsensusCreation is a free log subscription operation binding the contract event 0xaf68463e16cb5595a44214bea8d366ecf7cd3410269c50f92c104b50a7829daa. +// +// Solidity: event ConsensusCreation(address inputBox, address appContract, address tournamentFactory) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchConsensusCreation(opts *bind.WatchOpts, sink chan<- *IDaveConsensusConsensusCreation) (event.Subscription, error) { + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "ConsensusCreation") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusConsensusCreation) + if err := _IDaveConsensus.contract.UnpackLog(event, "ConsensusCreation", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseConsensusCreation is a log parse operation binding the contract event 0xaf68463e16cb5595a44214bea8d366ecf7cd3410269c50f92c104b50a7829daa. +// +// Solidity: event ConsensusCreation(address inputBox, address appContract, address tournamentFactory) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseConsensusCreation(log types.Log) (*IDaveConsensusConsensusCreation, error) { + event := new(IDaveConsensusConsensusCreation) + if err := _IDaveConsensus.contract.UnpackLog(event, "ConsensusCreation", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusEpochSealedIterator is returned from FilterEpochSealed and is used to iterate over the raw logs and unpacked data for EpochSealed events raised by the IDaveConsensus contract. +type IDaveConsensusEpochSealedIterator struct { + Event *IDaveConsensusEpochSealed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusEpochSealedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusEpochSealedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusEpochSealedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusEpochSealed represents a EpochSealed event raised by the IDaveConsensus contract. +type IDaveConsensusEpochSealed struct { + EpochNumber *big.Int + InputIndexLowerBound *big.Int + InputIndexUpperBound *big.Int + InitialMachineStateHash [32]byte + OutputsMerkleRoot [32]byte + Tournament common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterEpochSealed is a free log retrieval operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. +// +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochSealed(opts *bind.FilterOpts, epochNumber []*big.Int) (*IDaveConsensusEpochSealedIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochSealed", epochNumberRule) + if err != nil { + return nil, err + } + return &IDaveConsensusEpochSealedIterator{contract: _IDaveConsensus.contract, event: "EpochSealed", logs: logs, sub: sub}, nil +} + +// WatchEpochSealed is a free log subscription operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. +// +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochSealed(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochSealed, epochNumber []*big.Int) (event.Subscription, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochSealed", epochNumberRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusEpochSealed) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochSealed", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseEpochSealed is a log parse operation binding the contract event 0xa91d0b68c00a132585cc08007b46ff5f0abc622f5286b5701149b33784764ced. +// +// Solidity: event EpochSealed(uint256 indexed epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputsMerkleRoot, address tournament) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochSealed(log types.Log) (*IDaveConsensusEpochSealed, error) { + event := new(IDaveConsensusEpochSealed) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochSealed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusEpochStagedIterator is returned from FilterEpochStaged and is used to iterate over the raw logs and unpacked data for EpochStaged events raised by the IDaveConsensus contract. +type IDaveConsensusEpochStagedIterator struct { + Event *IDaveConsensusEpochStaged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusEpochStagedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusEpochStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusEpochStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusEpochStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusEpochStaged represents a EpochStaged event raised by the IDaveConsensus contract. +type IDaveConsensusEpochStaged struct { + EpochNumber *big.Int + StagedPostEpochMachineStateHash [32]byte + StagedPostEpochOutputsMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterEpochStaged is a free log retrieval operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterEpochStaged(opts *bind.FilterOpts, epochNumber []*big.Int) (*IDaveConsensusEpochStagedIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "EpochStaged", epochNumberRule) + if err != nil { + return nil, err + } + return &IDaveConsensusEpochStagedIterator{contract: _IDaveConsensus.contract, event: "EpochStaged", logs: logs, sub: sub}, nil +} + +// WatchEpochStaged is a free log subscription operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchEpochStaged(opts *bind.WatchOpts, sink chan<- *IDaveConsensusEpochStaged, epochNumber []*big.Int) (event.Subscription, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "EpochStaged", epochNumberRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusEpochStaged) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochStaged", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseEpochStaged is a log parse operation binding the contract event 0x13bd4fdfe8d8a96c44e1f8c899cde8f2ae549c60b4768631f1a88541f85bec62. +// +// Solidity: event EpochStaged(uint256 indexed epochNumber, bytes32 stagedPostEpochMachineStateHash, bytes32 stagedPostEpochOutputsMerkleRoot) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseEpochStaged(log types.Log) (*IDaveConsensusEpochStaged, error) { + event := new(IDaveConsensusEpochStaged) + if err := _IDaveConsensus.contract.UnpackLog(event, "EpochStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusSentryClaimIterator is returned from FilterSentryClaim and is used to iterate over the raw logs and unpacked data for SentryClaim events raised by the IDaveConsensus contract. +type IDaveConsensusSentryClaimIterator struct { + Event *IDaveConsensusSentryClaim // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusSentryClaimIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryClaim) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryClaim) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusSentryClaimIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusSentryClaimIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusSentryClaim represents a SentryClaim event raised by the IDaveConsensus contract. +type IDaveConsensusSentryClaim struct { + EpochNumber *big.Int + SentryId *big.Int + Sentry common.Address + PostEpochMachineStateHash [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSentryClaim is a free log retrieval operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterSentryClaim(opts *bind.FilterOpts, epochNumber []*big.Int, sentryId []*big.Int, sentry []common.Address) (*IDaveConsensusSentryClaimIterator, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var sentryRule []interface{} + for _, sentryItem := range sentry { + sentryRule = append(sentryRule, sentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "SentryClaim", epochNumberRule, sentryIdRule, sentryRule) + if err != nil { + return nil, err + } + return &IDaveConsensusSentryClaimIterator{contract: _IDaveConsensus.contract, event: "SentryClaim", logs: logs, sub: sub}, nil +} + +// WatchSentryClaim is a free log subscription operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchSentryClaim(opts *bind.WatchOpts, sink chan<- *IDaveConsensusSentryClaim, epochNumber []*big.Int, sentryId []*big.Int, sentry []common.Address) (event.Subscription, error) { + + var epochNumberRule []interface{} + for _, epochNumberItem := range epochNumber { + epochNumberRule = append(epochNumberRule, epochNumberItem) + } + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var sentryRule []interface{} + for _, sentryItem := range sentry { + sentryRule = append(sentryRule, sentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "SentryClaim", epochNumberRule, sentryIdRule, sentryRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusSentryClaim) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryClaim", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSentryClaim is a log parse operation binding the contract event 0x0a242da6706fab1ed52cfaf047d4939b8c7acac1fe8ff75d911758adf345bdda. +// +// Solidity: event SentryClaim(uint256 indexed epochNumber, uint256 indexed sentryId, address indexed sentry, bytes32 postEpochMachineStateHash) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseSentryClaim(log types.Log) (*IDaveConsensusSentryClaim, error) { + event := new(IDaveConsensusSentryClaim) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryClaim", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IDaveConsensusSentryRotationIterator is returned from FilterSentryRotation and is used to iterate over the raw logs and unpacked data for SentryRotation events raised by the IDaveConsensus contract. +type IDaveConsensusSentryRotationIterator struct { + Event *IDaveConsensusSentryRotation // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IDaveConsensusSentryRotationIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryRotation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IDaveConsensusSentryRotation) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IDaveConsensusSentryRotationIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IDaveConsensusSentryRotationIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IDaveConsensusSentryRotation represents a SentryRotation event raised by the IDaveConsensus contract. +type IDaveConsensusSentryRotation struct { + SentryId *big.Int + OldSentry common.Address + NewSentry common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSentryRotation is a free log retrieval operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) FilterSentryRotation(opts *bind.FilterOpts, sentryId []*big.Int, oldSentry []common.Address, newSentry []common.Address) (*IDaveConsensusSentryRotationIterator, error) { + + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var oldSentryRule []interface{} + for _, oldSentryItem := range oldSentry { + oldSentryRule = append(oldSentryRule, oldSentryItem) + } + var newSentryRule []interface{} + for _, newSentryItem := range newSentry { + newSentryRule = append(newSentryRule, newSentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.FilterLogs(opts, "SentryRotation", sentryIdRule, oldSentryRule, newSentryRule) + if err != nil { + return nil, err + } + return &IDaveConsensusSentryRotationIterator{contract: _IDaveConsensus.contract, event: "SentryRotation", logs: logs, sub: sub}, nil +} + +// WatchSentryRotation is a free log subscription operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) WatchSentryRotation(opts *bind.WatchOpts, sink chan<- *IDaveConsensusSentryRotation, sentryId []*big.Int, oldSentry []common.Address, newSentry []common.Address) (event.Subscription, error) { + + var sentryIdRule []interface{} + for _, sentryIdItem := range sentryId { + sentryIdRule = append(sentryIdRule, sentryIdItem) + } + var oldSentryRule []interface{} + for _, oldSentryItem := range oldSentry { + oldSentryRule = append(oldSentryRule, oldSentryItem) + } + var newSentryRule []interface{} + for _, newSentryItem := range newSentry { + newSentryRule = append(newSentryRule, newSentryItem) + } + + logs, sub, err := _IDaveConsensus.contract.WatchLogs(opts, "SentryRotation", sentryIdRule, oldSentryRule, newSentryRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IDaveConsensusSentryRotation) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryRotation", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSentryRotation is a log parse operation binding the contract event 0x1c5771374c7e30d72546ebada2373852b83c92f2e40da2e8987f059fa732682d. +// +// Solidity: event SentryRotation(uint256 indexed sentryId, address indexed oldSentry, address indexed newSentry) +func (_IDaveConsensus *IDaveConsensusFilterer) ParseSentryRotation(log types.Log) (*IDaveConsensusSentryRotation, error) { + event := new(IDaveConsensusSentryRotation) + if err := _IDaveConsensus.contract.UnpackLog(event, "SentryRotation", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/ierc20errors/ierc20errors.go b/pkg/contracts/ierc20errors/ierc20errors.go new file mode 100644 index 000000000..ba8a8c4f9 --- /dev/null +++ b/pkg/contracts/ierc20errors/ierc20errors.go @@ -0,0 +1,185 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package ierc20errors + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IERC20ErrorsMetaData contains all meta data concerning the IERC20Errors contract. +var IERC20ErrorsMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"error\",\"name\":\"ERC20InsufficientAllowance\",\"inputs\":[{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"allowance\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"needed\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ERC20InsufficientBalance\",\"inputs\":[{\"name\":\"sender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"needed\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ERC20InvalidApprover\",\"inputs\":[{\"name\":\"approver\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ERC20InvalidReceiver\",\"inputs\":[{\"name\":\"receiver\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ERC20InvalidSender\",\"inputs\":[{\"name\":\"sender\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ERC20InvalidSpender\",\"inputs\":[{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"}]}]", +} + +// IERC20ErrorsABI is the input ABI used to generate the binding from. +// Deprecated: Use IERC20ErrorsMetaData.ABI instead. +var IERC20ErrorsABI = IERC20ErrorsMetaData.ABI + +// IERC20Errors is an auto generated Go binding around an Ethereum contract. +type IERC20Errors struct { + IERC20ErrorsCaller // Read-only binding to the contract + IERC20ErrorsTransactor // Write-only binding to the contract + IERC20ErrorsFilterer // Log filterer for contract events +} + +// IERC20ErrorsCaller is an auto generated read-only Go binding around an Ethereum contract. +type IERC20ErrorsCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20ErrorsTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IERC20ErrorsTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20ErrorsFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IERC20ErrorsFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20ErrorsSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IERC20ErrorsSession struct { + Contract *IERC20Errors // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IERC20ErrorsCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IERC20ErrorsCallerSession struct { + Contract *IERC20ErrorsCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IERC20ErrorsTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IERC20ErrorsTransactorSession struct { + Contract *IERC20ErrorsTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IERC20ErrorsRaw is an auto generated low-level Go binding around an Ethereum contract. +type IERC20ErrorsRaw struct { + Contract *IERC20Errors // Generic contract binding to access the raw methods on +} + +// IERC20ErrorsCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IERC20ErrorsCallerRaw struct { + Contract *IERC20ErrorsCaller // Generic read-only contract binding to access the raw methods on +} + +// IERC20ErrorsTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IERC20ErrorsTransactorRaw struct { + Contract *IERC20ErrorsTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIERC20Errors creates a new instance of IERC20Errors, bound to a specific deployed contract. +func NewIERC20Errors(address common.Address, backend bind.ContractBackend) (*IERC20Errors, error) { + contract, err := bindIERC20Errors(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IERC20Errors{IERC20ErrorsCaller: IERC20ErrorsCaller{contract: contract}, IERC20ErrorsTransactor: IERC20ErrorsTransactor{contract: contract}, IERC20ErrorsFilterer: IERC20ErrorsFilterer{contract: contract}}, nil +} + +// NewIERC20ErrorsCaller creates a new read-only instance of IERC20Errors, bound to a specific deployed contract. +func NewIERC20ErrorsCaller(address common.Address, caller bind.ContractCaller) (*IERC20ErrorsCaller, error) { + contract, err := bindIERC20Errors(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IERC20ErrorsCaller{contract: contract}, nil +} + +// NewIERC20ErrorsTransactor creates a new write-only instance of IERC20Errors, bound to a specific deployed contract. +func NewIERC20ErrorsTransactor(address common.Address, transactor bind.ContractTransactor) (*IERC20ErrorsTransactor, error) { + contract, err := bindIERC20Errors(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IERC20ErrorsTransactor{contract: contract}, nil +} + +// NewIERC20ErrorsFilterer creates a new log filterer instance of IERC20Errors, bound to a specific deployed contract. +func NewIERC20ErrorsFilterer(address common.Address, filterer bind.ContractFilterer) (*IERC20ErrorsFilterer, error) { + contract, err := bindIERC20Errors(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IERC20ErrorsFilterer{contract: contract}, nil +} + +// bindIERC20Errors binds a generic wrapper to an already deployed contract. +func bindIERC20Errors(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IERC20ErrorsMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IERC20Errors *IERC20ErrorsRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IERC20Errors.Contract.IERC20ErrorsCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IERC20Errors *IERC20ErrorsRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IERC20Errors.Contract.IERC20ErrorsTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IERC20Errors *IERC20ErrorsRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IERC20Errors.Contract.IERC20ErrorsTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IERC20Errors *IERC20ErrorsCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IERC20Errors.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IERC20Errors *IERC20ErrorsTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IERC20Errors.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IERC20Errors *IERC20ErrorsTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IERC20Errors.Contract.contract.Transact(opts, method, params...) +} diff --git a/pkg/contracts/ierc20metadata/ierc20metadata.go b/pkg/contracts/ierc20metadata/ierc20metadata.go new file mode 100644 index 000000000..21dc1a2f6 --- /dev/null +++ b/pkg/contracts/ierc20metadata/ierc20metadata.go @@ -0,0 +1,750 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package ierc20metadata + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IERC20MetadataMetaData contains all meta data concerning the IERC20Metadata contract. +var IERC20MetadataMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"allowance\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"approve\",\"inputs\":[{\"name\":\"spender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"balanceOf\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"decimals\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"name\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"symbol\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"totalSupply\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transfer\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferFrom\",\"inputs\":[{\"name\":\"from\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"Approval\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"spender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Transfer\",\"inputs\":[{\"name\":\"from\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false}]", +} + +// IERC20MetadataABI is the input ABI used to generate the binding from. +// Deprecated: Use IERC20MetadataMetaData.ABI instead. +var IERC20MetadataABI = IERC20MetadataMetaData.ABI + +// IERC20Metadata is an auto generated Go binding around an Ethereum contract. +type IERC20Metadata struct { + IERC20MetadataCaller // Read-only binding to the contract + IERC20MetadataTransactor // Write-only binding to the contract + IERC20MetadataFilterer // Log filterer for contract events +} + +// IERC20MetadataCaller is an auto generated read-only Go binding around an Ethereum contract. +type IERC20MetadataCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20MetadataTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IERC20MetadataTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20MetadataFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IERC20MetadataFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IERC20MetadataSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IERC20MetadataSession struct { + Contract *IERC20Metadata // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IERC20MetadataCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IERC20MetadataCallerSession struct { + Contract *IERC20MetadataCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IERC20MetadataTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IERC20MetadataTransactorSession struct { + Contract *IERC20MetadataTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IERC20MetadataRaw is an auto generated low-level Go binding around an Ethereum contract. +type IERC20MetadataRaw struct { + Contract *IERC20Metadata // Generic contract binding to access the raw methods on +} + +// IERC20MetadataCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IERC20MetadataCallerRaw struct { + Contract *IERC20MetadataCaller // Generic read-only contract binding to access the raw methods on +} + +// IERC20MetadataTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IERC20MetadataTransactorRaw struct { + Contract *IERC20MetadataTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIERC20Metadata creates a new instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20Metadata(address common.Address, backend bind.ContractBackend) (*IERC20Metadata, error) { + contract, err := bindIERC20Metadata(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IERC20Metadata{IERC20MetadataCaller: IERC20MetadataCaller{contract: contract}, IERC20MetadataTransactor: IERC20MetadataTransactor{contract: contract}, IERC20MetadataFilterer: IERC20MetadataFilterer{contract: contract}}, nil +} + +// NewIERC20MetadataCaller creates a new read-only instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20MetadataCaller(address common.Address, caller bind.ContractCaller) (*IERC20MetadataCaller, error) { + contract, err := bindIERC20Metadata(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IERC20MetadataCaller{contract: contract}, nil +} + +// NewIERC20MetadataTransactor creates a new write-only instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20MetadataTransactor(address common.Address, transactor bind.ContractTransactor) (*IERC20MetadataTransactor, error) { + contract, err := bindIERC20Metadata(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IERC20MetadataTransactor{contract: contract}, nil +} + +// NewIERC20MetadataFilterer creates a new log filterer instance of IERC20Metadata, bound to a specific deployed contract. +func NewIERC20MetadataFilterer(address common.Address, filterer bind.ContractFilterer) (*IERC20MetadataFilterer, error) { + contract, err := bindIERC20Metadata(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IERC20MetadataFilterer{contract: contract}, nil +} + +// bindIERC20Metadata binds a generic wrapper to an already deployed contract. +func bindIERC20Metadata(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IERC20MetadataMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IERC20Metadata *IERC20MetadataRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IERC20Metadata.Contract.IERC20MetadataCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IERC20Metadata *IERC20MetadataRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IERC20Metadata.Contract.IERC20MetadataTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IERC20Metadata *IERC20MetadataRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IERC20Metadata.Contract.IERC20MetadataTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IERC20Metadata *IERC20MetadataCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IERC20Metadata.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IERC20Metadata *IERC20MetadataTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IERC20Metadata.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IERC20Metadata *IERC20MetadataTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IERC20Metadata.Contract.contract.Transact(opts, method, params...) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCaller) Allowance(opts *bind.CallOpts, owner common.Address, spender common.Address) (*big.Int, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "allowance", owner, spender) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataSession) Allowance(owner common.Address, spender common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.Allowance(&_IERC20Metadata.CallOpts, owner, spender) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address owner, address spender) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCallerSession) Allowance(owner common.Address, spender common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.Allowance(&_IERC20Metadata.CallOpts, owner, spender) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address account) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCaller) BalanceOf(opts *bind.CallOpts, account common.Address) (*big.Int, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "balanceOf", account) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address account) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataSession) BalanceOf(account common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.BalanceOf(&_IERC20Metadata.CallOpts, account) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address account) view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCallerSession) BalanceOf(account common.Address) (*big.Int, error) { + return _IERC20Metadata.Contract.BalanceOf(&_IERC20Metadata.CallOpts, account) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_IERC20Metadata *IERC20MetadataCaller) Decimals(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "decimals") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_IERC20Metadata *IERC20MetadataSession) Decimals() (uint8, error) { + return _IERC20Metadata.Contract.Decimals(&_IERC20Metadata.CallOpts) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_IERC20Metadata *IERC20MetadataCallerSession) Decimals() (uint8, error) { + return _IERC20Metadata.Contract.Decimals(&_IERC20Metadata.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_IERC20Metadata *IERC20MetadataCaller) Name(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "name") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_IERC20Metadata *IERC20MetadataSession) Name() (string, error) { + return _IERC20Metadata.Contract.Name(&_IERC20Metadata.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_IERC20Metadata *IERC20MetadataCallerSession) Name() (string, error) { + return _IERC20Metadata.Contract.Name(&_IERC20Metadata.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_IERC20Metadata *IERC20MetadataCaller) Symbol(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "symbol") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_IERC20Metadata *IERC20MetadataSession) Symbol() (string, error) { + return _IERC20Metadata.Contract.Symbol(&_IERC20Metadata.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_IERC20Metadata *IERC20MetadataCallerSession) Symbol() (string, error) { + return _IERC20Metadata.Contract.Symbol(&_IERC20Metadata.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCaller) TotalSupply(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IERC20Metadata.contract.Call(opts, &out, "totalSupply") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_IERC20Metadata *IERC20MetadataSession) TotalSupply() (*big.Int, error) { + return _IERC20Metadata.Contract.TotalSupply(&_IERC20Metadata.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_IERC20Metadata *IERC20MetadataCallerSession) TotalSupply() (*big.Int, error) { + return _IERC20Metadata.Contract.TotalSupply(&_IERC20Metadata.CallOpts) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactor) Approve(opts *bind.TransactOpts, spender common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.contract.Transact(opts, "approve", spender, value) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataSession) Approve(spender common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Approve(&_IERC20Metadata.TransactOpts, spender, value) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactorSession) Approve(spender common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Approve(&_IERC20Metadata.TransactOpts, spender, value) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactor) Transfer(opts *bind.TransactOpts, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.contract.Transact(opts, "transfer", to, value) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataSession) Transfer(to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Transfer(&_IERC20Metadata.TransactOpts, to, value) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactorSession) Transfer(to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.Transfer(&_IERC20Metadata.TransactOpts, to, value) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactor) TransferFrom(opts *bind.TransactOpts, from common.Address, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.contract.Transact(opts, "transferFrom", from, to, value) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataSession) TransferFrom(from common.Address, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.TransferFrom(&_IERC20Metadata.TransactOpts, from, to, value) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 value) returns(bool) +func (_IERC20Metadata *IERC20MetadataTransactorSession) TransferFrom(from common.Address, to common.Address, value *big.Int) (*types.Transaction, error) { + return _IERC20Metadata.Contract.TransferFrom(&_IERC20Metadata.TransactOpts, from, to, value) +} + +// IERC20MetadataApprovalIterator is returned from FilterApproval and is used to iterate over the raw logs and unpacked data for Approval events raised by the IERC20Metadata contract. +type IERC20MetadataApprovalIterator struct { + Event *IERC20MetadataApproval // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IERC20MetadataApprovalIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IERC20MetadataApproval) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IERC20MetadataApproval) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IERC20MetadataApprovalIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IERC20MetadataApprovalIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IERC20MetadataApproval represents a Approval event raised by the IERC20Metadata contract. +type IERC20MetadataApproval struct { + Owner common.Address + Spender common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterApproval is a free log retrieval operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) FilterApproval(opts *bind.FilterOpts, owner []common.Address, spender []common.Address) (*IERC20MetadataApprovalIterator, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _IERC20Metadata.contract.FilterLogs(opts, "Approval", ownerRule, spenderRule) + if err != nil { + return nil, err + } + return &IERC20MetadataApprovalIterator{contract: _IERC20Metadata.contract, event: "Approval", logs: logs, sub: sub}, nil +} + +// WatchApproval is a free log subscription operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) WatchApproval(opts *bind.WatchOpts, sink chan<- *IERC20MetadataApproval, owner []common.Address, spender []common.Address) (event.Subscription, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _IERC20Metadata.contract.WatchLogs(opts, "Approval", ownerRule, spenderRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IERC20MetadataApproval) + if err := _IERC20Metadata.contract.UnpackLog(event, "Approval", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseApproval is a log parse operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) ParseApproval(log types.Log) (*IERC20MetadataApproval, error) { + event := new(IERC20MetadataApproval) + if err := _IERC20Metadata.contract.UnpackLog(event, "Approval", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IERC20MetadataTransferIterator is returned from FilterTransfer and is used to iterate over the raw logs and unpacked data for Transfer events raised by the IERC20Metadata contract. +type IERC20MetadataTransferIterator struct { + Event *IERC20MetadataTransfer // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IERC20MetadataTransferIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IERC20MetadataTransfer) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IERC20MetadataTransfer) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IERC20MetadataTransferIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IERC20MetadataTransferIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IERC20MetadataTransfer represents a Transfer event raised by the IERC20Metadata contract. +type IERC20MetadataTransfer struct { + From common.Address + To common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTransfer is a free log retrieval operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) FilterTransfer(opts *bind.FilterOpts, from []common.Address, to []common.Address) (*IERC20MetadataTransferIterator, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _IERC20Metadata.contract.FilterLogs(opts, "Transfer", fromRule, toRule) + if err != nil { + return nil, err + } + return &IERC20MetadataTransferIterator{contract: _IERC20Metadata.contract, event: "Transfer", logs: logs, sub: sub}, nil +} + +// WatchTransfer is a free log subscription operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) WatchTransfer(opts *bind.WatchOpts, sink chan<- *IERC20MetadataTransfer, from []common.Address, to []common.Address) (event.Subscription, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _IERC20Metadata.contract.WatchLogs(opts, "Transfer", fromRule, toRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IERC20MetadataTransfer) + if err := _IERC20Metadata.contract.UnpackLog(event, "Transfer", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTransfer is a log parse operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_IERC20Metadata *IERC20MetadataFilterer) ParseTransfer(log types.Log) (*IERC20MetadataTransfer, error) { + event := new(IERC20MetadataTransfer) + if err := _IERC20Metadata.contract.UnpackLog(event, "Transfer", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/ierc20portal/ierc20portal.go b/pkg/contracts/ierc20portal/ierc20portal.go new file mode 100644 index 000000000..395ff386f --- /dev/null +++ b/pkg/contracts/ierc20portal/ierc20portal.go @@ -0,0 +1,266 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package ierc20portal + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IErc20PortalMetaData contains all meta data concerning the IErc20Portal contract. +var IErc20PortalMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"depositErc20Tokens\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"contractIERC20\"},{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"execLayerData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"Erc20TransferDecreasedApplicationBalance\",\"inputs\":[{\"name\":\"balanceBefore\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balanceAfter\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"Erc20TransferFailed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"Erc20TransferValueIsNotBalanceDelta\",\"inputs\":[{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"balanceDelta\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]}]", +} + +// IErc20PortalABI is the input ABI used to generate the binding from. +// Deprecated: Use IErc20PortalMetaData.ABI instead. +var IErc20PortalABI = IErc20PortalMetaData.ABI + +// IErc20Portal is an auto generated Go binding around an Ethereum contract. +type IErc20Portal struct { + IErc20PortalCaller // Read-only binding to the contract + IErc20PortalTransactor // Write-only binding to the contract + IErc20PortalFilterer // Log filterer for contract events +} + +// IErc20PortalCaller is an auto generated read-only Go binding around an Ethereum contract. +type IErc20PortalCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IErc20PortalTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IErc20PortalTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IErc20PortalFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IErc20PortalFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IErc20PortalSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IErc20PortalSession struct { + Contract *IErc20Portal // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IErc20PortalCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IErc20PortalCallerSession struct { + Contract *IErc20PortalCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IErc20PortalTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IErc20PortalTransactorSession struct { + Contract *IErc20PortalTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IErc20PortalRaw is an auto generated low-level Go binding around an Ethereum contract. +type IErc20PortalRaw struct { + Contract *IErc20Portal // Generic contract binding to access the raw methods on +} + +// IErc20PortalCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IErc20PortalCallerRaw struct { + Contract *IErc20PortalCaller // Generic read-only contract binding to access the raw methods on +} + +// IErc20PortalTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IErc20PortalTransactorRaw struct { + Contract *IErc20PortalTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIErc20Portal creates a new instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20Portal(address common.Address, backend bind.ContractBackend) (*IErc20Portal, error) { + contract, err := bindIErc20Portal(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IErc20Portal{IErc20PortalCaller: IErc20PortalCaller{contract: contract}, IErc20PortalTransactor: IErc20PortalTransactor{contract: contract}, IErc20PortalFilterer: IErc20PortalFilterer{contract: contract}}, nil +} + +// NewIErc20PortalCaller creates a new read-only instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalCaller(address common.Address, caller bind.ContractCaller) (*IErc20PortalCaller, error) { + contract, err := bindIErc20Portal(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IErc20PortalCaller{contract: contract}, nil +} + +// NewIErc20PortalTransactor creates a new write-only instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalTransactor(address common.Address, transactor bind.ContractTransactor) (*IErc20PortalTransactor, error) { + contract, err := bindIErc20Portal(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IErc20PortalTransactor{contract: contract}, nil +} + +// NewIErc20PortalFilterer creates a new log filterer instance of IErc20Portal, bound to a specific deployed contract. +func NewIErc20PortalFilterer(address common.Address, filterer bind.ContractFilterer) (*IErc20PortalFilterer, error) { + contract, err := bindIErc20Portal(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IErc20PortalFilterer{contract: contract}, nil +} + +// bindIErc20Portal binds a generic wrapper to an already deployed contract. +func bindIErc20Portal(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IErc20PortalMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IErc20Portal *IErc20PortalRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IErc20Portal.Contract.IErc20PortalCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IErc20Portal *IErc20PortalRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IErc20Portal.Contract.IErc20PortalTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IErc20Portal *IErc20PortalRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IErc20Portal.Contract.IErc20PortalTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IErc20Portal *IErc20PortalCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IErc20Portal.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IErc20Portal *IErc20PortalTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IErc20Portal.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IErc20Portal *IErc20PortalTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IErc20Portal.Contract.contract.Transact(opts, method, params...) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IErc20Portal *IErc20PortalCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IErc20Portal.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IErc20Portal *IErc20PortalSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IErc20Portal.Contract.Version(&_IErc20Portal.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IErc20Portal *IErc20PortalCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IErc20Portal.Contract.Version(&_IErc20Portal.CallOpts) +} + +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. +// +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalTransactor) DepositErc20Tokens(opts *bind.TransactOpts, token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.contract.Transact(opts, "depositErc20Tokens", token, appContract, value, execLayerData) +} + +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. +// +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalSession) DepositErc20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.Contract.DepositErc20Tokens(&_IErc20Portal.TransactOpts, token, appContract, value, execLayerData) +} + +// DepositErc20Tokens is a paid mutator transaction binding the contract method 0x766afa3a. +// +// Solidity: function depositErc20Tokens(address token, address appContract, uint256 value, bytes execLayerData) returns() +func (_IErc20Portal *IErc20PortalTransactorSession) DepositErc20Tokens(token common.Address, appContract common.Address, value *big.Int, execLayerData []byte) (*types.Transaction, error) { + return _IErc20Portal.Contract.DepositErc20Tokens(&_IErc20Portal.TransactOpts, token, appContract, value, execLayerData) +} diff --git a/pkg/contracts/iinputbox/iinputbox.go b/pkg/contracts/iinputbox/iinputbox.go new file mode 100644 index 000000000..3de190b5b --- /dev/null +++ b/pkg/contracts/iinputbox/iinputbox.go @@ -0,0 +1,517 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iinputbox + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IInputBoxMetaData contains all meta data concerning the IInputBox contract. +var IInputBoxMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"addInput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getDeploymentBlockNumber\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getInputHash\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfInputs\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"InputAdded\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"index\",\"type\":\"uint256\",\"indexed\":true,\"internalType\":\"uint256\"},{\"name\":\"input\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InputTooLarge\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxInputLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", +} + +// IInputBoxABI is the input ABI used to generate the binding from. +// Deprecated: Use IInputBoxMetaData.ABI instead. +var IInputBoxABI = IInputBoxMetaData.ABI + +// IInputBox is an auto generated Go binding around an Ethereum contract. +type IInputBox struct { + IInputBoxCaller // Read-only binding to the contract + IInputBoxTransactor // Write-only binding to the contract + IInputBoxFilterer // Log filterer for contract events +} + +// IInputBoxCaller is an auto generated read-only Go binding around an Ethereum contract. +type IInputBoxCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IInputBoxTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IInputBoxTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IInputBoxFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IInputBoxFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IInputBoxSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IInputBoxSession struct { + Contract *IInputBox // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IInputBoxCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IInputBoxCallerSession struct { + Contract *IInputBoxCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IInputBoxTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IInputBoxTransactorSession struct { + Contract *IInputBoxTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IInputBoxRaw is an auto generated low-level Go binding around an Ethereum contract. +type IInputBoxRaw struct { + Contract *IInputBox // Generic contract binding to access the raw methods on +} + +// IInputBoxCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IInputBoxCallerRaw struct { + Contract *IInputBoxCaller // Generic read-only contract binding to access the raw methods on +} + +// IInputBoxTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IInputBoxTransactorRaw struct { + Contract *IInputBoxTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIInputBox creates a new instance of IInputBox, bound to a specific deployed contract. +func NewIInputBox(address common.Address, backend bind.ContractBackend) (*IInputBox, error) { + contract, err := bindIInputBox(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IInputBox{IInputBoxCaller: IInputBoxCaller{contract: contract}, IInputBoxTransactor: IInputBoxTransactor{contract: contract}, IInputBoxFilterer: IInputBoxFilterer{contract: contract}}, nil +} + +// NewIInputBoxCaller creates a new read-only instance of IInputBox, bound to a specific deployed contract. +func NewIInputBoxCaller(address common.Address, caller bind.ContractCaller) (*IInputBoxCaller, error) { + contract, err := bindIInputBox(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IInputBoxCaller{contract: contract}, nil +} + +// NewIInputBoxTransactor creates a new write-only instance of IInputBox, bound to a specific deployed contract. +func NewIInputBoxTransactor(address common.Address, transactor bind.ContractTransactor) (*IInputBoxTransactor, error) { + contract, err := bindIInputBox(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IInputBoxTransactor{contract: contract}, nil +} + +// NewIInputBoxFilterer creates a new log filterer instance of IInputBox, bound to a specific deployed contract. +func NewIInputBoxFilterer(address common.Address, filterer bind.ContractFilterer) (*IInputBoxFilterer, error) { + contract, err := bindIInputBox(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IInputBoxFilterer{contract: contract}, nil +} + +// bindIInputBox binds a generic wrapper to an already deployed contract. +func bindIInputBox(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IInputBoxMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IInputBox *IInputBoxRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IInputBox.Contract.IInputBoxCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IInputBox *IInputBoxRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IInputBox.Contract.IInputBoxTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IInputBox *IInputBoxRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IInputBox.Contract.IInputBoxTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IInputBox *IInputBoxCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IInputBox.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IInputBox *IInputBoxTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IInputBox.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IInputBox *IInputBoxTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IInputBox.Contract.contract.Transact(opts, method, params...) +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IInputBox *IInputBoxCaller) GetDeploymentBlockNumber(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IInputBox.contract.Call(opts, &out, "getDeploymentBlockNumber") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IInputBox *IInputBoxSession) GetDeploymentBlockNumber() (*big.Int, error) { + return _IInputBox.Contract.GetDeploymentBlockNumber(&_IInputBox.CallOpts) +} + +// GetDeploymentBlockNumber is a free data retrieval call binding the contract method 0xb3a1acd8. +// +// Solidity: function getDeploymentBlockNumber() view returns(uint256) +func (_IInputBox *IInputBoxCallerSession) GetDeploymentBlockNumber() (*big.Int, error) { + return _IInputBox.Contract.GetDeploymentBlockNumber(&_IInputBox.CallOpts) +} + +// GetInputHash is a free data retrieval call binding the contract method 0x677087c9. +// +// Solidity: function getInputHash(address appContract, uint256 index) view returns(bytes32) +func (_IInputBox *IInputBoxCaller) GetInputHash(opts *bind.CallOpts, appContract common.Address, index *big.Int) ([32]byte, error) { + var out []interface{} + err := _IInputBox.contract.Call(opts, &out, "getInputHash", appContract, index) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetInputHash is a free data retrieval call binding the contract method 0x677087c9. +// +// Solidity: function getInputHash(address appContract, uint256 index) view returns(bytes32) +func (_IInputBox *IInputBoxSession) GetInputHash(appContract common.Address, index *big.Int) ([32]byte, error) { + return _IInputBox.Contract.GetInputHash(&_IInputBox.CallOpts, appContract, index) +} + +// GetInputHash is a free data retrieval call binding the contract method 0x677087c9. +// +// Solidity: function getInputHash(address appContract, uint256 index) view returns(bytes32) +func (_IInputBox *IInputBoxCallerSession) GetInputHash(appContract common.Address, index *big.Int) ([32]byte, error) { + return _IInputBox.Contract.GetInputHash(&_IInputBox.CallOpts, appContract, index) +} + +// GetNumberOfInputs is a free data retrieval call binding the contract method 0x61a93c87. +// +// Solidity: function getNumberOfInputs(address appContract) view returns(uint256) +func (_IInputBox *IInputBoxCaller) GetNumberOfInputs(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IInputBox.contract.Call(opts, &out, "getNumberOfInputs", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfInputs is a free data retrieval call binding the contract method 0x61a93c87. +// +// Solidity: function getNumberOfInputs(address appContract) view returns(uint256) +func (_IInputBox *IInputBoxSession) GetNumberOfInputs(appContract common.Address) (*big.Int, error) { + return _IInputBox.Contract.GetNumberOfInputs(&_IInputBox.CallOpts, appContract) +} + +// GetNumberOfInputs is a free data retrieval call binding the contract method 0x61a93c87. +// +// Solidity: function getNumberOfInputs(address appContract) view returns(uint256) +func (_IInputBox *IInputBoxCallerSession) GetNumberOfInputs(appContract common.Address) (*big.Int, error) { + return _IInputBox.Contract.GetNumberOfInputs(&_IInputBox.CallOpts, appContract) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IInputBox *IInputBoxCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IInputBox.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IInputBox *IInputBoxSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IInputBox.Contract.Version(&_IInputBox.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IInputBox *IInputBoxCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IInputBox.Contract.Version(&_IInputBox.CallOpts) +} + +// AddInput is a paid mutator transaction binding the contract method 0x1789cd63. +// +// Solidity: function addInput(address appContract, bytes payload) returns(bytes32) +func (_IInputBox *IInputBoxTransactor) AddInput(opts *bind.TransactOpts, appContract common.Address, payload []byte) (*types.Transaction, error) { + return _IInputBox.contract.Transact(opts, "addInput", appContract, payload) +} + +// AddInput is a paid mutator transaction binding the contract method 0x1789cd63. +// +// Solidity: function addInput(address appContract, bytes payload) returns(bytes32) +func (_IInputBox *IInputBoxSession) AddInput(appContract common.Address, payload []byte) (*types.Transaction, error) { + return _IInputBox.Contract.AddInput(&_IInputBox.TransactOpts, appContract, payload) +} + +// AddInput is a paid mutator transaction binding the contract method 0x1789cd63. +// +// Solidity: function addInput(address appContract, bytes payload) returns(bytes32) +func (_IInputBox *IInputBoxTransactorSession) AddInput(appContract common.Address, payload []byte) (*types.Transaction, error) { + return _IInputBox.Contract.AddInput(&_IInputBox.TransactOpts, appContract, payload) +} + +// IInputBoxInputAddedIterator is returned from FilterInputAdded and is used to iterate over the raw logs and unpacked data for InputAdded events raised by the IInputBox contract. +type IInputBoxInputAddedIterator struct { + Event *IInputBoxInputAdded // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IInputBoxInputAddedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IInputBoxInputAdded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IInputBoxInputAdded) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IInputBoxInputAddedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IInputBoxInputAddedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IInputBoxInputAdded represents a InputAdded event raised by the IInputBox contract. +type IInputBoxInputAdded struct { + AppContract common.Address + Index *big.Int + Input []byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterInputAdded is a free log retrieval operation binding the contract event 0xc05d337121a6e8605c6ec0b72aa29c4210ffe6e5b9cefdd6a7058188a8f66f98. +// +// Solidity: event InputAdded(address indexed appContract, uint256 indexed index, bytes input) +func (_IInputBox *IInputBoxFilterer) FilterInputAdded(opts *bind.FilterOpts, appContract []common.Address, index []*big.Int) (*IInputBoxInputAddedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + var indexRule []interface{} + for _, indexItem := range index { + indexRule = append(indexRule, indexItem) + } + + logs, sub, err := _IInputBox.contract.FilterLogs(opts, "InputAdded", appContractRule, indexRule) + if err != nil { + return nil, err + } + return &IInputBoxInputAddedIterator{contract: _IInputBox.contract, event: "InputAdded", logs: logs, sub: sub}, nil +} + +// WatchInputAdded is a free log subscription operation binding the contract event 0xc05d337121a6e8605c6ec0b72aa29c4210ffe6e5b9cefdd6a7058188a8f66f98. +// +// Solidity: event InputAdded(address indexed appContract, uint256 indexed index, bytes input) +func (_IInputBox *IInputBoxFilterer) WatchInputAdded(opts *bind.WatchOpts, sink chan<- *IInputBoxInputAdded, appContract []common.Address, index []*big.Int) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + var indexRule []interface{} + for _, indexItem := range index { + indexRule = append(indexRule, indexItem) + } + + logs, sub, err := _IInputBox.contract.WatchLogs(opts, "InputAdded", appContractRule, indexRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IInputBoxInputAdded) + if err := _IInputBox.contract.UnpackLog(event, "InputAdded", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseInputAdded is a log parse operation binding the contract event 0xc05d337121a6e8605c6ec0b72aa29c4210ffe6e5b9cefdd6a7058188a8f66f98. +// +// Solidity: event InputAdded(address indexed appContract, uint256 indexed index, bytes input) +func (_IInputBox *IInputBoxFilterer) ParseInputAdded(log types.Log) (*IInputBoxInputAdded, error) { + event := new(IInputBoxInputAdded) + if err := _IInputBox.contract.UnpackLog(event, "InputAdded", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go b/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go new file mode 100644 index 000000000..7253b1aba --- /dev/null +++ b/pkg/contracts/imultileveltournamentfactory/imultileveltournamentfactory.go @@ -0,0 +1,482 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package imultileveltournamentfactory + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// ITournamentTournamentDescriptor is an auto generated low-level Go binding around an user-defined struct. +type ITournamentTournamentDescriptor struct { + InitialHash [32]byte + BaseCycle *big.Int + Log2Stride uint64 + Height uint64 + Level uint64 + Kind uint8 + StartInstant uint64 + Allowance uint64 +} + +// TournamentParameters is an auto generated low-level Go binding around an user-defined struct. +type TournamentParameters struct { + Levels uint64 + Log2step uint64 + Height uint64 + ResponseBudget uint64 + CommitmentBudget uint64 + MaxAllowance uint64 +} + +// IMultiLevelTournamentFactoryMetaData contains all meta data concerning the IMultiLevelTournamentFactory contract. +var IMultiLevelTournamentFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"instantiate\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"instantiateInner\",\"inputs\":[{\"name\":\"_initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_contestedCommitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"_contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_contestedCommitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"_contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"_allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"_startCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"_level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"_provider\",\"type\":\"address\",\"internalType\":\"contractIDataProvider\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentDescriptor\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"baseCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2Stride\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentKind\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"stateTransition\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIStateTransition\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentLevelCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentParameters\",\"inputs\":[{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structTournamentParameters\",\"components\":[{\"name\":\"levels\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"log2step\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"responseBudget\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"commitmentBudget\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"},{\"name\":\"maxAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"TournamentCreated\",\"inputs\":[{\"name\":\"tournament\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractITournament\"},{\"name\":\"descriptor\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structITournament.TournamentDescriptor\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"baseCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2Stride\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentKind\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]},{\"name\":\"bondValue\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false}]", +} + +// IMultiLevelTournamentFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IMultiLevelTournamentFactoryMetaData.ABI instead. +var IMultiLevelTournamentFactoryABI = IMultiLevelTournamentFactoryMetaData.ABI + +// IMultiLevelTournamentFactory is an auto generated Go binding around an Ethereum contract. +type IMultiLevelTournamentFactory struct { + IMultiLevelTournamentFactoryCaller // Read-only binding to the contract + IMultiLevelTournamentFactoryTransactor // Write-only binding to the contract + IMultiLevelTournamentFactoryFilterer // Log filterer for contract events +} + +// IMultiLevelTournamentFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IMultiLevelTournamentFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IMultiLevelTournamentFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IMultiLevelTournamentFactorySession struct { + Contract *IMultiLevelTournamentFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IMultiLevelTournamentFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IMultiLevelTournamentFactoryCallerSession struct { + Contract *IMultiLevelTournamentFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IMultiLevelTournamentFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IMultiLevelTournamentFactoryTransactorSession struct { + Contract *IMultiLevelTournamentFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IMultiLevelTournamentFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryRaw struct { + Contract *IMultiLevelTournamentFactory // Generic contract binding to access the raw methods on +} + +// IMultiLevelTournamentFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryCallerRaw struct { + Contract *IMultiLevelTournamentFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IMultiLevelTournamentFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IMultiLevelTournamentFactoryTransactorRaw struct { + Contract *IMultiLevelTournamentFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIMultiLevelTournamentFactory creates a new instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactory(address common.Address, backend bind.ContractBackend) (*IMultiLevelTournamentFactory, error) { + contract, err := bindIMultiLevelTournamentFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactory{IMultiLevelTournamentFactoryCaller: IMultiLevelTournamentFactoryCaller{contract: contract}, IMultiLevelTournamentFactoryTransactor: IMultiLevelTournamentFactoryTransactor{contract: contract}, IMultiLevelTournamentFactoryFilterer: IMultiLevelTournamentFactoryFilterer{contract: contract}}, nil +} + +// NewIMultiLevelTournamentFactoryCaller creates a new read-only instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryCaller(address common.Address, caller bind.ContractCaller) (*IMultiLevelTournamentFactoryCaller, error) { + contract, err := bindIMultiLevelTournamentFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryCaller{contract: contract}, nil +} + +// NewIMultiLevelTournamentFactoryTransactor creates a new write-only instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IMultiLevelTournamentFactoryTransactor, error) { + contract, err := bindIMultiLevelTournamentFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryTransactor{contract: contract}, nil +} + +// NewIMultiLevelTournamentFactoryFilterer creates a new log filterer instance of IMultiLevelTournamentFactory, bound to a specific deployed contract. +func NewIMultiLevelTournamentFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IMultiLevelTournamentFactoryFilterer, error) { + contract, err := bindIMultiLevelTournamentFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryFilterer{contract: contract}, nil +} + +// bindIMultiLevelTournamentFactory binds a generic wrapper to an already deployed contract. +func bindIMultiLevelTournamentFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IMultiLevelTournamentFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.IMultiLevelTournamentFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IMultiLevelTournamentFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.contract.Transact(opts, method, params...) +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) StateTransition(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "stateTransition") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) StateTransition() (common.Address, error) { + return _IMultiLevelTournamentFactory.Contract.StateTransition(&_IMultiLevelTournamentFactory.CallOpts) +} + +// StateTransition is a free data retrieval call binding the contract method 0xc64c6601. +// +// Solidity: function stateTransition() view returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) StateTransition() (common.Address, error) { + return _IMultiLevelTournamentFactory.Contract.StateTransition(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) TournamentLevelCount(opts *bind.CallOpts) (uint64, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "tournamentLevelCount") + + if err != nil { + return *new(uint64), err + } + + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + + return out0, err + +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) TournamentLevelCount() (uint64, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentLevelCount(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentLevelCount is a free data retrieval call binding the contract method 0xd076fad5. +// +// Solidity: function tournamentLevelCount() view returns(uint64) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) TournamentLevelCount() (uint64, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentLevelCount(&_IMultiLevelTournamentFactory.CallOpts) +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCaller) TournamentParameters(opts *bind.CallOpts, level uint64) (TournamentParameters, error) { + var out []interface{} + err := _IMultiLevelTournamentFactory.contract.Call(opts, &out, "tournamentParameters", level) + + if err != nil { + return *new(TournamentParameters), err + } + + out0 := *abi.ConvertType(out[0], new(TournamentParameters)).(*TournamentParameters) + + return out0, err + +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) TournamentParameters(level uint64) (TournamentParameters, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentParameters(&_IMultiLevelTournamentFactory.CallOpts, level) +} + +// TournamentParameters is a free data retrieval call binding the contract method 0xe05cc93f. +// +// Solidity: function tournamentParameters(uint64 level) view returns((uint64,uint64,uint64,uint64,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryCallerSession) TournamentParameters(level uint64) (TournamentParameters, error) { + return _IMultiLevelTournamentFactory.Contract.TournamentParameters(&_IMultiLevelTournamentFactory.CallOpts, level) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactor) Instantiate(opts *bind.TransactOpts, initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.contract.Transact(opts, "instantiate", initialState, provider) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) Instantiate(initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.Instantiate(&_IMultiLevelTournamentFactory.TransactOpts, initialState, provider) +} + +// Instantiate is a paid mutator transaction binding the contract method 0x0b64d79b. +// +// Solidity: function instantiate(bytes32 initialState, address provider) returns(address) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorSession) Instantiate(initialState [32]byte, provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.Instantiate(&_IMultiLevelTournamentFactory.TransactOpts, initialState, provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactor) InstantiateInner(opts *bind.TransactOpts, _initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.contract.Transact(opts, "instantiateInner", _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactorySession) InstantiateInner(_initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.InstantiateInner(&_IMultiLevelTournamentFactory.TransactOpts, _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// InstantiateInner is a paid mutator transaction binding the contract method 0xf65e16e8. +// +// Solidity: function instantiateInner(bytes32 _initialHash, bytes32 _contestedCommitmentOne, bytes32 _contestedFinalStateOne, bytes32 _contestedCommitmentTwo, bytes32 _contestedFinalStateTwo, uint64 _allowance, uint256 _startCycle, uint64 _level, address _provider) returns(address, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryTransactorSession) InstantiateInner(_initialHash [32]byte, _contestedCommitmentOne [32]byte, _contestedFinalStateOne [32]byte, _contestedCommitmentTwo [32]byte, _contestedFinalStateTwo [32]byte, _allowance uint64, _startCycle *big.Int, _level uint64, _provider common.Address) (*types.Transaction, error) { + return _IMultiLevelTournamentFactory.Contract.InstantiateInner(&_IMultiLevelTournamentFactory.TransactOpts, _initialHash, _contestedCommitmentOne, _contestedFinalStateOne, _contestedCommitmentTwo, _contestedFinalStateTwo, _allowance, _startCycle, _level, _provider) +} + +// IMultiLevelTournamentFactoryTournamentCreatedIterator is returned from FilterTournamentCreated and is used to iterate over the raw logs and unpacked data for TournamentCreated events raised by the IMultiLevelTournamentFactory contract. +type IMultiLevelTournamentFactoryTournamentCreatedIterator struct { + Event *IMultiLevelTournamentFactoryTournamentCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IMultiLevelTournamentFactoryTournamentCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IMultiLevelTournamentFactoryTournamentCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IMultiLevelTournamentFactoryTournamentCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IMultiLevelTournamentFactoryTournamentCreated represents a TournamentCreated event raised by the IMultiLevelTournamentFactory contract. +type IMultiLevelTournamentFactoryTournamentCreated struct { + Tournament common.Address + Descriptor ITournamentTournamentDescriptor + BondValue *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTournamentCreated is a free log retrieval operation binding the contract event 0xb47d95f2cb8257143b2b67c1fc53e82db57c652e730c1fd41b2681642c29ecef. +// +// Solidity: event TournamentCreated(address tournament, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64) descriptor, uint256 bondValue) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) FilterTournamentCreated(opts *bind.FilterOpts) (*IMultiLevelTournamentFactoryTournamentCreatedIterator, error) { + + logs, sub, err := _IMultiLevelTournamentFactory.contract.FilterLogs(opts, "TournamentCreated") + if err != nil { + return nil, err + } + return &IMultiLevelTournamentFactoryTournamentCreatedIterator{contract: _IMultiLevelTournamentFactory.contract, event: "TournamentCreated", logs: logs, sub: sub}, nil +} + +// WatchTournamentCreated is a free log subscription operation binding the contract event 0xb47d95f2cb8257143b2b67c1fc53e82db57c652e730c1fd41b2681642c29ecef. +// +// Solidity: event TournamentCreated(address tournament, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64) descriptor, uint256 bondValue) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) WatchTournamentCreated(opts *bind.WatchOpts, sink chan<- *IMultiLevelTournamentFactoryTournamentCreated) (event.Subscription, error) { + + logs, sub, err := _IMultiLevelTournamentFactory.contract.WatchLogs(opts, "TournamentCreated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IMultiLevelTournamentFactoryTournamentCreated) + if err := _IMultiLevelTournamentFactory.contract.UnpackLog(event, "TournamentCreated", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTournamentCreated is a log parse operation binding the contract event 0xb47d95f2cb8257143b2b67c1fc53e82db57c652e730c1fd41b2681642c29ecef. +// +// Solidity: event TournamentCreated(address tournament, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64) descriptor, uint256 bondValue) +func (_IMultiLevelTournamentFactory *IMultiLevelTournamentFactoryFilterer) ParseTournamentCreated(log types.Log) (*IMultiLevelTournamentFactoryTournamentCreated, error) { + event := new(IMultiLevelTournamentFactoryTournamentCreated) + if err := _IMultiLevelTournamentFactory.contract.UnpackLog(event, "TournamentCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/input_box.go b/pkg/contracts/input_box.go deleted file mode 100644 index 494c9ed40..000000000 --- a/pkg/contracts/input_box.go +++ /dev/null @@ -1,419 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package contracts - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription - _ = abi.ConvertType -) - -// InputBoxMetaData contains all meta data concerning the InputBox contract. -var InputBoxMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[],\"name\":\"InputSizeExceedsLimit\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"dapp\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"inputIndex\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"sender\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"input\",\"type\":\"bytes\"}],\"name\":\"InputAdded\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_dapp\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"_input\",\"type\":\"bytes\"}],\"name\":\"addInput\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_dapp\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_index\",\"type\":\"uint256\"}],\"name\":\"getInputHash\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_dapp\",\"type\":\"address\"}],\"name\":\"getNumberOfInputs\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", -} - -// InputBoxABI is the input ABI used to generate the binding from. -// Deprecated: Use InputBoxMetaData.ABI instead. -var InputBoxABI = InputBoxMetaData.ABI - -// InputBox is an auto generated Go binding around an Ethereum contract. -type InputBox struct { - InputBoxCaller // Read-only binding to the contract - InputBoxTransactor // Write-only binding to the contract - InputBoxFilterer // Log filterer for contract events -} - -// InputBoxCaller is an auto generated read-only Go binding around an Ethereum contract. -type InputBoxCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// InputBoxTransactor is an auto generated write-only Go binding around an Ethereum contract. -type InputBoxTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// InputBoxFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type InputBoxFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// InputBoxSession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type InputBoxSession struct { - Contract *InputBox // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// InputBoxCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type InputBoxCallerSession struct { - Contract *InputBoxCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// InputBoxTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type InputBoxTransactorSession struct { - Contract *InputBoxTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// InputBoxRaw is an auto generated low-level Go binding around an Ethereum contract. -type InputBoxRaw struct { - Contract *InputBox // Generic contract binding to access the raw methods on -} - -// InputBoxCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type InputBoxCallerRaw struct { - Contract *InputBoxCaller // Generic read-only contract binding to access the raw methods on -} - -// InputBoxTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type InputBoxTransactorRaw struct { - Contract *InputBoxTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewInputBox creates a new instance of InputBox, bound to a specific deployed contract. -func NewInputBox(address common.Address, backend bind.ContractBackend) (*InputBox, error) { - contract, err := bindInputBox(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &InputBox{InputBoxCaller: InputBoxCaller{contract: contract}, InputBoxTransactor: InputBoxTransactor{contract: contract}, InputBoxFilterer: InputBoxFilterer{contract: contract}}, nil -} - -// NewInputBoxCaller creates a new read-only instance of InputBox, bound to a specific deployed contract. -func NewInputBoxCaller(address common.Address, caller bind.ContractCaller) (*InputBoxCaller, error) { - contract, err := bindInputBox(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &InputBoxCaller{contract: contract}, nil -} - -// NewInputBoxTransactor creates a new write-only instance of InputBox, bound to a specific deployed contract. -func NewInputBoxTransactor(address common.Address, transactor bind.ContractTransactor) (*InputBoxTransactor, error) { - contract, err := bindInputBox(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &InputBoxTransactor{contract: contract}, nil -} - -// NewInputBoxFilterer creates a new log filterer instance of InputBox, bound to a specific deployed contract. -func NewInputBoxFilterer(address common.Address, filterer bind.ContractFilterer) (*InputBoxFilterer, error) { - contract, err := bindInputBox(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &InputBoxFilterer{contract: contract}, nil -} - -// bindInputBox binds a generic wrapper to an already deployed contract. -func bindInputBox(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := InputBoxMetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_InputBox *InputBoxRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _InputBox.Contract.InputBoxCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_InputBox *InputBoxRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _InputBox.Contract.InputBoxTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_InputBox *InputBoxRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _InputBox.Contract.InputBoxTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_InputBox *InputBoxCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _InputBox.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_InputBox *InputBoxTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _InputBox.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_InputBox *InputBoxTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _InputBox.Contract.contract.Transact(opts, method, params...) -} - -// GetInputHash is a free data retrieval call binding the contract method 0x677087c9. -// -// Solidity: function getInputHash(address _dapp, uint256 _index) view returns(bytes32) -func (_InputBox *InputBoxCaller) GetInputHash(opts *bind.CallOpts, _dapp common.Address, _index *big.Int) ([32]byte, error) { - var out []interface{} - err := _InputBox.contract.Call(opts, &out, "getInputHash", _dapp, _index) - - if err != nil { - return *new([32]byte), err - } - - out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - - return out0, err - -} - -// GetInputHash is a free data retrieval call binding the contract method 0x677087c9. -// -// Solidity: function getInputHash(address _dapp, uint256 _index) view returns(bytes32) -func (_InputBox *InputBoxSession) GetInputHash(_dapp common.Address, _index *big.Int) ([32]byte, error) { - return _InputBox.Contract.GetInputHash(&_InputBox.CallOpts, _dapp, _index) -} - -// GetInputHash is a free data retrieval call binding the contract method 0x677087c9. -// -// Solidity: function getInputHash(address _dapp, uint256 _index) view returns(bytes32) -func (_InputBox *InputBoxCallerSession) GetInputHash(_dapp common.Address, _index *big.Int) ([32]byte, error) { - return _InputBox.Contract.GetInputHash(&_InputBox.CallOpts, _dapp, _index) -} - -// GetNumberOfInputs is a free data retrieval call binding the contract method 0x61a93c87. -// -// Solidity: function getNumberOfInputs(address _dapp) view returns(uint256) -func (_InputBox *InputBoxCaller) GetNumberOfInputs(opts *bind.CallOpts, _dapp common.Address) (*big.Int, error) { - var out []interface{} - err := _InputBox.contract.Call(opts, &out, "getNumberOfInputs", _dapp) - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// GetNumberOfInputs is a free data retrieval call binding the contract method 0x61a93c87. -// -// Solidity: function getNumberOfInputs(address _dapp) view returns(uint256) -func (_InputBox *InputBoxSession) GetNumberOfInputs(_dapp common.Address) (*big.Int, error) { - return _InputBox.Contract.GetNumberOfInputs(&_InputBox.CallOpts, _dapp) -} - -// GetNumberOfInputs is a free data retrieval call binding the contract method 0x61a93c87. -// -// Solidity: function getNumberOfInputs(address _dapp) view returns(uint256) -func (_InputBox *InputBoxCallerSession) GetNumberOfInputs(_dapp common.Address) (*big.Int, error) { - return _InputBox.Contract.GetNumberOfInputs(&_InputBox.CallOpts, _dapp) -} - -// AddInput is a paid mutator transaction binding the contract method 0x1789cd63. -// -// Solidity: function addInput(address _dapp, bytes _input) returns(bytes32) -func (_InputBox *InputBoxTransactor) AddInput(opts *bind.TransactOpts, _dapp common.Address, _input []byte) (*types.Transaction, error) { - return _InputBox.contract.Transact(opts, "addInput", _dapp, _input) -} - -// AddInput is a paid mutator transaction binding the contract method 0x1789cd63. -// -// Solidity: function addInput(address _dapp, bytes _input) returns(bytes32) -func (_InputBox *InputBoxSession) AddInput(_dapp common.Address, _input []byte) (*types.Transaction, error) { - return _InputBox.Contract.AddInput(&_InputBox.TransactOpts, _dapp, _input) -} - -// AddInput is a paid mutator transaction binding the contract method 0x1789cd63. -// -// Solidity: function addInput(address _dapp, bytes _input) returns(bytes32) -func (_InputBox *InputBoxTransactorSession) AddInput(_dapp common.Address, _input []byte) (*types.Transaction, error) { - return _InputBox.Contract.AddInput(&_InputBox.TransactOpts, _dapp, _input) -} - -// InputBoxInputAddedIterator is returned from FilterInputAdded and is used to iterate over the raw logs and unpacked data for InputAdded events raised by the InputBox contract. -type InputBoxInputAddedIterator struct { - Event *InputBoxInputAdded // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *InputBoxInputAddedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(InputBoxInputAdded) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(InputBoxInputAdded) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *InputBoxInputAddedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *InputBoxInputAddedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// InputBoxInputAdded represents a InputAdded event raised by the InputBox contract. -type InputBoxInputAdded struct { - Dapp common.Address - InputIndex *big.Int - Sender common.Address - Input []byte - Raw types.Log // Blockchain specific contextual infos -} - -// FilterInputAdded is a free log retrieval operation binding the contract event 0x6aaa400068bf4ca337265e2a1e1e841f66b8597fd5b452fdc52a44bed28a0784. -// -// Solidity: event InputAdded(address indexed dapp, uint256 indexed inputIndex, address sender, bytes input) -func (_InputBox *InputBoxFilterer) FilterInputAdded(opts *bind.FilterOpts, dapp []common.Address, inputIndex []*big.Int) (*InputBoxInputAddedIterator, error) { - - var dappRule []interface{} - for _, dappItem := range dapp { - dappRule = append(dappRule, dappItem) - } - var inputIndexRule []interface{} - for _, inputIndexItem := range inputIndex { - inputIndexRule = append(inputIndexRule, inputIndexItem) - } - - logs, sub, err := _InputBox.contract.FilterLogs(opts, "InputAdded", dappRule, inputIndexRule) - if err != nil { - return nil, err - } - return &InputBoxInputAddedIterator{contract: _InputBox.contract, event: "InputAdded", logs: logs, sub: sub}, nil -} - -// WatchInputAdded is a free log subscription operation binding the contract event 0x6aaa400068bf4ca337265e2a1e1e841f66b8597fd5b452fdc52a44bed28a0784. -// -// Solidity: event InputAdded(address indexed dapp, uint256 indexed inputIndex, address sender, bytes input) -func (_InputBox *InputBoxFilterer) WatchInputAdded(opts *bind.WatchOpts, sink chan<- *InputBoxInputAdded, dapp []common.Address, inputIndex []*big.Int) (event.Subscription, error) { - - var dappRule []interface{} - for _, dappItem := range dapp { - dappRule = append(dappRule, dappItem) - } - var inputIndexRule []interface{} - for _, inputIndexItem := range inputIndex { - inputIndexRule = append(inputIndexRule, inputIndexItem) - } - - logs, sub, err := _InputBox.contract.WatchLogs(opts, "InputAdded", dappRule, inputIndexRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(InputBoxInputAdded) - if err := _InputBox.contract.UnpackLog(event, "InputAdded", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseInputAdded is a log parse operation binding the contract event 0x6aaa400068bf4ca337265e2a1e1e841f66b8597fd5b452fdc52a44bed28a0784. -// -// Solidity: event InputAdded(address indexed dapp, uint256 indexed inputIndex, address sender, bytes input) -func (_InputBox *InputBoxFilterer) ParseInputAdded(log types.Log) (*InputBoxInputAdded, error) { - event := new(InputBoxInputAdded) - if err := _InputBox.contract.UnpackLog(event, "InputAdded", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} diff --git a/pkg/contracts/inputs/inputs.go b/pkg/contracts/inputs/inputs.go new file mode 100644 index 000000000..9a1c3770b --- /dev/null +++ b/pkg/contracts/inputs/inputs.go @@ -0,0 +1,206 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package inputs + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// InputsMetaData contains all meta data concerning the Inputs contract. +var InputsMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"EvmAdvance\",\"inputs\":[{\"name\":\"chainId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"msgSender\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockTimestamp\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"prevRandao\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"}]", +} + +// InputsABI is the input ABI used to generate the binding from. +// Deprecated: Use InputsMetaData.ABI instead. +var InputsABI = InputsMetaData.ABI + +// Inputs is an auto generated Go binding around an Ethereum contract. +type Inputs struct { + InputsCaller // Read-only binding to the contract + InputsTransactor // Write-only binding to the contract + InputsFilterer // Log filterer for contract events +} + +// InputsCaller is an auto generated read-only Go binding around an Ethereum contract. +type InputsCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// InputsTransactor is an auto generated write-only Go binding around an Ethereum contract. +type InputsTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// InputsFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type InputsFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// InputsSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type InputsSession struct { + Contract *Inputs // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// InputsCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type InputsCallerSession struct { + Contract *InputsCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// InputsTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type InputsTransactorSession struct { + Contract *InputsTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// InputsRaw is an auto generated low-level Go binding around an Ethereum contract. +type InputsRaw struct { + Contract *Inputs // Generic contract binding to access the raw methods on +} + +// InputsCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type InputsCallerRaw struct { + Contract *InputsCaller // Generic read-only contract binding to access the raw methods on +} + +// InputsTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type InputsTransactorRaw struct { + Contract *InputsTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewInputs creates a new instance of Inputs, bound to a specific deployed contract. +func NewInputs(address common.Address, backend bind.ContractBackend) (*Inputs, error) { + contract, err := bindInputs(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &Inputs{InputsCaller: InputsCaller{contract: contract}, InputsTransactor: InputsTransactor{contract: contract}, InputsFilterer: InputsFilterer{contract: contract}}, nil +} + +// NewInputsCaller creates a new read-only instance of Inputs, bound to a specific deployed contract. +func NewInputsCaller(address common.Address, caller bind.ContractCaller) (*InputsCaller, error) { + contract, err := bindInputs(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &InputsCaller{contract: contract}, nil +} + +// NewInputsTransactor creates a new write-only instance of Inputs, bound to a specific deployed contract. +func NewInputsTransactor(address common.Address, transactor bind.ContractTransactor) (*InputsTransactor, error) { + contract, err := bindInputs(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &InputsTransactor{contract: contract}, nil +} + +// NewInputsFilterer creates a new log filterer instance of Inputs, bound to a specific deployed contract. +func NewInputsFilterer(address common.Address, filterer bind.ContractFilterer) (*InputsFilterer, error) { + contract, err := bindInputs(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &InputsFilterer{contract: contract}, nil +} + +// bindInputs binds a generic wrapper to an already deployed contract. +func bindInputs(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := InputsMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Inputs *InputsRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Inputs.Contract.InputsCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Inputs *InputsRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Inputs.Contract.InputsTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Inputs *InputsRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Inputs.Contract.InputsTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Inputs *InputsCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Inputs.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Inputs *InputsTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Inputs.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Inputs *InputsTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Inputs.Contract.contract.Transact(opts, method, params...) +} + +// EvmAdvance is a paid mutator transaction binding the contract method 0x415bf363. +// +// Solidity: function EvmAdvance(uint256 chainId, address appContract, address msgSender, uint256 blockNumber, uint256 blockTimestamp, uint256 prevRandao, uint256 index, bytes payload) returns() +func (_Inputs *InputsTransactor) EvmAdvance(opts *bind.TransactOpts, chainId *big.Int, appContract common.Address, msgSender common.Address, blockNumber *big.Int, blockTimestamp *big.Int, prevRandao *big.Int, index *big.Int, payload []byte) (*types.Transaction, error) { + return _Inputs.contract.Transact(opts, "EvmAdvance", chainId, appContract, msgSender, blockNumber, blockTimestamp, prevRandao, index, payload) +} + +// EvmAdvance is a paid mutator transaction binding the contract method 0x415bf363. +// +// Solidity: function EvmAdvance(uint256 chainId, address appContract, address msgSender, uint256 blockNumber, uint256 blockTimestamp, uint256 prevRandao, uint256 index, bytes payload) returns() +func (_Inputs *InputsSession) EvmAdvance(chainId *big.Int, appContract common.Address, msgSender common.Address, blockNumber *big.Int, blockTimestamp *big.Int, prevRandao *big.Int, index *big.Int, payload []byte) (*types.Transaction, error) { + return _Inputs.Contract.EvmAdvance(&_Inputs.TransactOpts, chainId, appContract, msgSender, blockNumber, blockTimestamp, prevRandao, index, payload) +} + +// EvmAdvance is a paid mutator transaction binding the contract method 0x415bf363. +// +// Solidity: function EvmAdvance(uint256 chainId, address appContract, address msgSender, uint256 blockNumber, uint256 blockTimestamp, uint256 prevRandao, uint256 index, bytes payload) returns() +func (_Inputs *InputsTransactorSession) EvmAdvance(chainId *big.Int, appContract common.Address, msgSender common.Address, blockNumber *big.Int, blockTimestamp *big.Int, prevRandao *big.Int, index *big.Int, payload []byte) (*types.Transaction, error) { + return _Inputs.Contract.EvmAdvance(&_Inputs.TransactOpts, chainId, appContract, msgSender, blockNumber, blockTimestamp, prevRandao, index, payload) +} diff --git a/pkg/contracts/iquorum/iquorum.go b/pkg/contracts/iquorum/iquorum.go new file mode 100644 index 000000000..31ffd2da9 --- /dev/null +++ b/pkg/contracts/iquorum/iquorum.go @@ -0,0 +1,1296 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iquorum + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IConsensusClaim is an auto generated low-level Go binding around an user-defined struct. +type IConsensusClaim struct { + Status uint8 + StagingBlockNumber *big.Int + StagedOutputsMerkleRoot [32]byte +} + +// LeafProof is an auto generated low-level Go binding around an user-defined struct. +type LeafProof struct { + DataBlock [32]byte + Siblings [][32]byte +} + +// MachineValidityProof is an auto generated low-level Go binding around an user-defined struct. +type MachineValidityProof struct { + IflagsYProof LeafProof + HtifTohostProof LeafProof + TxBufferProof LeafProof +} + +// IQuorumMetaData contains all meta data concerning the IQuorum contract. +var IQuorumMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"acceptClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"claim\",\"type\":\"tuple\",\"internalType\":\"structIConsensus.Claim\",\"components\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"},{\"name\":\"stagingBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stagedOutputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getClaimStagingPeriod\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEpochLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLastFinalizedMachineMerkleRoot\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfAcceptedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfStagedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNumberOfSubmittedClaims\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isOutputsMerkleRootValid\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isValidatorInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidators\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOf\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"numOfValidatorsInFavorOfAnyClaimInEpoch\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"submitClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"proof\",\"type\":\"tuple\",\"internalType\":\"structMachineValidityProof\",\"components\":[{\"name\":\"iflagsYProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"htifTohostProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"txBufferProof\",\"type\":\"tuple\",\"internalType\":\"structLeafProof\",\"components\":[{\"name\":\"dataBlock\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"siblings\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorById\",\"inputs\":[{\"name\":\"id\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"validatorId\",\"inputs\":[{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"wasInputFinalized\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputIndex\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"blockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"ClaimAccepted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ClaimSubmitted\",\"inputs\":[{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"appContract\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"outputsMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ApplicationForeclosed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationNotDeployed\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ApplicationReverted\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"error\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"CallerIsNotValidator\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ClaimNotStaged\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"claimStatus\",\"type\":\"uint8\",\"internalType\":\"enumIConsensus.ClaimStatus\"}]},{\"type\":\"error\",\"name\":\"ClaimStagingPeriodNotOverYet\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"machineMerkleRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"numberOfBlocksAfterStaging\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"IllformedApplicationReturnData\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"type\":\"error\",\"name\":\"InputBoxNotDeployed\",\"inputs\":[{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"InvalidMachineMerkleProof\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineHtifTohostRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPostEpochMachineIflagsYRegister\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidSiblingsArrayLength\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotEpochFinalBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotFirstClaim\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NotPastBlock\",\"inputs\":[{\"name\":\"lastProcessedBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentBlockNumber\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", +} + +// IQuorumABI is the input ABI used to generate the binding from. +// Deprecated: Use IQuorumMetaData.ABI instead. +var IQuorumABI = IQuorumMetaData.ABI + +// IQuorum is an auto generated Go binding around an Ethereum contract. +type IQuorum struct { + IQuorumCaller // Read-only binding to the contract + IQuorumTransactor // Write-only binding to the contract + IQuorumFilterer // Log filterer for contract events +} + +// IQuorumCaller is an auto generated read-only Go binding around an Ethereum contract. +type IQuorumCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IQuorumTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IQuorumFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IQuorumSession struct { + Contract *IQuorum // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IQuorumCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IQuorumCallerSession struct { + Contract *IQuorumCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IQuorumTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IQuorumTransactorSession struct { + Contract *IQuorumTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IQuorumRaw is an auto generated low-level Go binding around an Ethereum contract. +type IQuorumRaw struct { + Contract *IQuorum // Generic contract binding to access the raw methods on +} + +// IQuorumCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IQuorumCallerRaw struct { + Contract *IQuorumCaller // Generic read-only contract binding to access the raw methods on +} + +// IQuorumTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IQuorumTransactorRaw struct { + Contract *IQuorumTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIQuorum creates a new instance of IQuorum, bound to a specific deployed contract. +func NewIQuorum(address common.Address, backend bind.ContractBackend) (*IQuorum, error) { + contract, err := bindIQuorum(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IQuorum{IQuorumCaller: IQuorumCaller{contract: contract}, IQuorumTransactor: IQuorumTransactor{contract: contract}, IQuorumFilterer: IQuorumFilterer{contract: contract}}, nil +} + +// NewIQuorumCaller creates a new read-only instance of IQuorum, bound to a specific deployed contract. +func NewIQuorumCaller(address common.Address, caller bind.ContractCaller) (*IQuorumCaller, error) { + contract, err := bindIQuorum(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IQuorumCaller{contract: contract}, nil +} + +// NewIQuorumTransactor creates a new write-only instance of IQuorum, bound to a specific deployed contract. +func NewIQuorumTransactor(address common.Address, transactor bind.ContractTransactor) (*IQuorumTransactor, error) { + contract, err := bindIQuorum(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IQuorumTransactor{contract: contract}, nil +} + +// NewIQuorumFilterer creates a new log filterer instance of IQuorum, bound to a specific deployed contract. +func NewIQuorumFilterer(address common.Address, filterer bind.ContractFilterer) (*IQuorumFilterer, error) { + contract, err := bindIQuorum(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IQuorumFilterer{contract: contract}, nil +} + +// bindIQuorum binds a generic wrapper to an already deployed contract. +func bindIQuorum(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IQuorumMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IQuorum *IQuorumRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IQuorum.Contract.IQuorumCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IQuorum *IQuorumRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IQuorum.Contract.IQuorumTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IQuorum *IQuorumRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IQuorum.Contract.IQuorumTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IQuorum *IQuorumCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IQuorum.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IQuorum *IQuorumTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IQuorum.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IQuorum *IQuorumTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IQuorum.Contract.contract.Transact(opts, method, params...) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IQuorum *IQuorumCaller) GetClaim(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) + + if err != nil { + return *new(IConsensusClaim), err + } + + out0 := *abi.ConvertType(out[0], new(IConsensusClaim)).(*IConsensusClaim) + + return out0, err + +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IQuorum *IQuorumSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IQuorum.Contract.GetClaim(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaim is a free data retrieval call binding the contract method 0xa1abc0ae. +// +// Solidity: function getClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns((uint8,uint256,bytes32) claim) +func (_IQuorum *IQuorumCallerSession) GetClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (IConsensusClaim, error) { + return _IQuorum.Contract.GetClaim(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IQuorum *IQuorumCaller) GetClaimStagingPeriod(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getClaimStagingPeriod") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IQuorum *IQuorumSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IQuorum.Contract.GetClaimStagingPeriod(&_IQuorum.CallOpts) +} + +// GetClaimStagingPeriod is a free data retrieval call binding the contract method 0xa04c6564. +// +// Solidity: function getClaimStagingPeriod() view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetClaimStagingPeriod() (*big.Int, error) { + return _IQuorum.Contract.GetClaimStagingPeriod(&_IQuorum.CallOpts) +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IQuorum *IQuorumCaller) GetEpochLength(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getEpochLength") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IQuorum *IQuorumSession) GetEpochLength() (*big.Int, error) { + return _IQuorum.Contract.GetEpochLength(&_IQuorum.CallOpts) +} + +// GetEpochLength is a free data retrieval call binding the contract method 0xcfe8a73b. +// +// Solidity: function getEpochLength() view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetEpochLength() (*big.Int, error) { + return _IQuorum.Contract.GetEpochLength(&_IQuorum.CallOpts) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IQuorum *IQuorumCaller) GetLastFinalizedMachineMerkleRoot(opts *bind.CallOpts, appContract common.Address) ([32]byte, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getLastFinalizedMachineMerkleRoot", appContract) + + if err != nil { + return *new([32]byte), err + } + + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + + return out0, err + +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IQuorum *IQuorumSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IQuorum.Contract.GetLastFinalizedMachineMerkleRoot(&_IQuorum.CallOpts, appContract) +} + +// GetLastFinalizedMachineMerkleRoot is a free data retrieval call binding the contract method 0x5ac9cfbf. +// +// Solidity: function getLastFinalizedMachineMerkleRoot(address appContract) view returns(bytes32) +func (_IQuorum *IQuorumCallerSession) GetLastFinalizedMachineMerkleRoot(appContract common.Address) ([32]byte, error) { + return _IQuorum.Contract.GetLastFinalizedMachineMerkleRoot(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCaller) GetNumberOfAcceptedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getNumberOfAcceptedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfAcceptedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfAcceptedClaims is a free data retrieval call binding the contract method 0x80a80953. +// +// Solidity: function getNumberOfAcceptedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetNumberOfAcceptedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfAcceptedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCaller) GetNumberOfStagedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getNumberOfStagedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfStagedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfStagedClaims is a free data retrieval call binding the contract method 0x02c657a4. +// +// Solidity: function getNumberOfStagedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetNumberOfStagedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfStagedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCaller) GetNumberOfSubmittedClaims(opts *bind.CallOpts, appContract common.Address) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "getNumberOfSubmittedClaims", appContract) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfSubmittedClaims(&_IQuorum.CallOpts, appContract) +} + +// GetNumberOfSubmittedClaims is a free data retrieval call binding the contract method 0x43aacc77. +// +// Solidity: function getNumberOfSubmittedClaims(address appContract) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) GetNumberOfSubmittedClaims(appContract common.Address) (*big.Int, error) { + return _IQuorum.Contract.GetNumberOfSubmittedClaims(&_IQuorum.CallOpts, appContract) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IQuorum *IQuorumCaller) IsOutputsMerkleRootValid(opts *bind.CallOpts, appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "isOutputsMerkleRootValid", appContract, outputsMerkleRoot) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IQuorum *IQuorumSession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IQuorum.Contract.IsOutputsMerkleRootValid(&_IQuorum.CallOpts, appContract, outputsMerkleRoot) +} + +// IsOutputsMerkleRootValid is a free data retrieval call binding the contract method 0xe5cc8664. +// +// Solidity: function isOutputsMerkleRootValid(address appContract, bytes32 outputsMerkleRoot) view returns(bool) +func (_IQuorum *IQuorumCallerSession) IsOutputsMerkleRootValid(appContract common.Address, outputsMerkleRoot [32]byte) (bool, error) { + return _IQuorum.Contract.IsOutputsMerkleRootValid(&_IQuorum.CallOpts, appContract, outputsMerkleRoot) +} + +// IsValidatorInFavorOf is a free data retrieval call binding the contract method 0x4b84231c. +// +// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, uint256 id) view returns(bool) +func (_IQuorum *IQuorumCaller) IsValidatorInFavorOf(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, id *big.Int) (bool, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "isValidatorInFavorOf", appContract, lastProcessedBlockNumber, machineMerkleRoot, id) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsValidatorInFavorOf is a free data retrieval call binding the contract method 0x4b84231c. +// +// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, uint256 id) view returns(bool) +func (_IQuorum *IQuorumSession) IsValidatorInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, id *big.Int) (bool, error) { + return _IQuorum.Contract.IsValidatorInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, id) +} + +// IsValidatorInFavorOf is a free data retrieval call binding the contract method 0x4b84231c. +// +// Solidity: function isValidatorInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, uint256 id) view returns(bool) +func (_IQuorum *IQuorumCallerSession) IsValidatorInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, id *big.Int) (bool, error) { + return _IQuorum.Contract.IsValidatorInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, id) +} + +// IsValidatorInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x4b53459c. +// +// Solidity: function isValidatorInFavorOfAnyClaimInEpoch(address appContract, uint256 lastProcessedBlockNumber, uint256 id) view returns(bool) +func (_IQuorum *IQuorumCaller) IsValidatorInFavorOfAnyClaimInEpoch(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, id *big.Int) (bool, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "isValidatorInFavorOfAnyClaimInEpoch", appContract, lastProcessedBlockNumber, id) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// IsValidatorInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x4b53459c. +// +// Solidity: function isValidatorInFavorOfAnyClaimInEpoch(address appContract, uint256 lastProcessedBlockNumber, uint256 id) view returns(bool) +func (_IQuorum *IQuorumSession) IsValidatorInFavorOfAnyClaimInEpoch(appContract common.Address, lastProcessedBlockNumber *big.Int, id *big.Int) (bool, error) { + return _IQuorum.Contract.IsValidatorInFavorOfAnyClaimInEpoch(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, id) +} + +// IsValidatorInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x4b53459c. +// +// Solidity: function isValidatorInFavorOfAnyClaimInEpoch(address appContract, uint256 lastProcessedBlockNumber, uint256 id) view returns(bool) +func (_IQuorum *IQuorumCallerSession) IsValidatorInFavorOfAnyClaimInEpoch(appContract common.Address, lastProcessedBlockNumber *big.Int, id *big.Int) (bool, error) { + return _IQuorum.Contract.IsValidatorInFavorOfAnyClaimInEpoch(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, id) +} + +// NumOfValidators is a free data retrieval call binding the contract method 0x1e526e45. +// +// Solidity: function numOfValidators() view returns(uint256) +func (_IQuorum *IQuorumCaller) NumOfValidators(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "numOfValidators") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// NumOfValidators is a free data retrieval call binding the contract method 0x1e526e45. +// +// Solidity: function numOfValidators() view returns(uint256) +func (_IQuorum *IQuorumSession) NumOfValidators() (*big.Int, error) { + return _IQuorum.Contract.NumOfValidators(&_IQuorum.CallOpts) +} + +// NumOfValidators is a free data retrieval call binding the contract method 0x1e526e45. +// +// Solidity: function numOfValidators() view returns(uint256) +func (_IQuorum *IQuorumCallerSession) NumOfValidators() (*big.Int, error) { + return _IQuorum.Contract.NumOfValidators(&_IQuorum.CallOpts) +} + +// NumOfValidatorsInFavorOf is a free data retrieval call binding the contract method 0x7051bfd5. +// +// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns(uint256) +func (_IQuorum *IQuorumCaller) NumOfValidatorsInFavorOf(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "numOfValidatorsInFavorOf", appContract, lastProcessedBlockNumber, machineMerkleRoot) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// NumOfValidatorsInFavorOf is a free data retrieval call binding the contract method 0x7051bfd5. +// +// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns(uint256) +func (_IQuorum *IQuorumSession) NumOfValidatorsInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*big.Int, error) { + return _IQuorum.Contract.NumOfValidatorsInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// NumOfValidatorsInFavorOf is a free data retrieval call binding the contract method 0x7051bfd5. +// +// Solidity: function numOfValidatorsInFavorOf(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) NumOfValidatorsInFavorOf(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*big.Int, error) { + return _IQuorum.Contract.NumOfValidatorsInFavorOf(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// NumOfValidatorsInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x446ccbf0. +// +// Solidity: function numOfValidatorsInFavorOfAnyClaimInEpoch(address appContract, uint256 lastProcessedBlockNumber) view returns(uint256) +func (_IQuorum *IQuorumCaller) NumOfValidatorsInFavorOfAnyClaimInEpoch(opts *bind.CallOpts, appContract common.Address, lastProcessedBlockNumber *big.Int) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "numOfValidatorsInFavorOfAnyClaimInEpoch", appContract, lastProcessedBlockNumber) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// NumOfValidatorsInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x446ccbf0. +// +// Solidity: function numOfValidatorsInFavorOfAnyClaimInEpoch(address appContract, uint256 lastProcessedBlockNumber) view returns(uint256) +func (_IQuorum *IQuorumSession) NumOfValidatorsInFavorOfAnyClaimInEpoch(appContract common.Address, lastProcessedBlockNumber *big.Int) (*big.Int, error) { + return _IQuorum.Contract.NumOfValidatorsInFavorOfAnyClaimInEpoch(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber) +} + +// NumOfValidatorsInFavorOfAnyClaimInEpoch is a free data retrieval call binding the contract method 0x446ccbf0. +// +// Solidity: function numOfValidatorsInFavorOfAnyClaimInEpoch(address appContract, uint256 lastProcessedBlockNumber) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) NumOfValidatorsInFavorOfAnyClaimInEpoch(appContract common.Address, lastProcessedBlockNumber *big.Int) (*big.Int, error) { + return _IQuorum.Contract.NumOfValidatorsInFavorOfAnyClaimInEpoch(&_IQuorum.CallOpts, appContract, lastProcessedBlockNumber) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IQuorum *IQuorumCaller) SupportsInterface(opts *bind.CallOpts, interfaceId [4]byte) (bool, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "supportsInterface", interfaceId) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IQuorum *IQuorumSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IQuorum.Contract.SupportsInterface(&_IQuorum.CallOpts, interfaceId) +} + +// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. +// +// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) +func (_IQuorum *IQuorumCallerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { + return _IQuorum.Contract.SupportsInterface(&_IQuorum.CallOpts, interfaceId) +} + +// ValidatorById is a free data retrieval call binding the contract method 0x1c45396a. +// +// Solidity: function validatorById(uint256 id) view returns(address) +func (_IQuorum *IQuorumCaller) ValidatorById(opts *bind.CallOpts, id *big.Int) (common.Address, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "validatorById", id) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// ValidatorById is a free data retrieval call binding the contract method 0x1c45396a. +// +// Solidity: function validatorById(uint256 id) view returns(address) +func (_IQuorum *IQuorumSession) ValidatorById(id *big.Int) (common.Address, error) { + return _IQuorum.Contract.ValidatorById(&_IQuorum.CallOpts, id) +} + +// ValidatorById is a free data retrieval call binding the contract method 0x1c45396a. +// +// Solidity: function validatorById(uint256 id) view returns(address) +func (_IQuorum *IQuorumCallerSession) ValidatorById(id *big.Int) (common.Address, error) { + return _IQuorum.Contract.ValidatorById(&_IQuorum.CallOpts, id) +} + +// ValidatorId is a free data retrieval call binding the contract method 0x0a6f1fe8. +// +// Solidity: function validatorId(address validator) view returns(uint256) +func (_IQuorum *IQuorumCaller) ValidatorId(opts *bind.CallOpts, validator common.Address) (*big.Int, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "validatorId", validator) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ValidatorId is a free data retrieval call binding the contract method 0x0a6f1fe8. +// +// Solidity: function validatorId(address validator) view returns(uint256) +func (_IQuorum *IQuorumSession) ValidatorId(validator common.Address) (*big.Int, error) { + return _IQuorum.Contract.ValidatorId(&_IQuorum.CallOpts, validator) +} + +// ValidatorId is a free data retrieval call binding the contract method 0x0a6f1fe8. +// +// Solidity: function validatorId(address validator) view returns(uint256) +func (_IQuorum *IQuorumCallerSession) ValidatorId(validator common.Address) (*big.Int, error) { + return _IQuorum.Contract.ValidatorId(&_IQuorum.CallOpts, validator) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorum *IQuorumCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorum *IQuorumSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorum.Contract.Version(&_IQuorum.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorum *IQuorumCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorum.Contract.Version(&_IQuorum.CallOpts) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumCaller) WasInputFinalized(opts *bind.CallOpts, appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + var out []interface{} + err := _IQuorum.contract.Call(opts, &out, "wasInputFinalized", appContract, inputIndex, blockNumber) + + if err != nil { + return *new(bool), err + } + + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + + return out0, err + +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IQuorum.Contract.WasInputFinalized(&_IQuorum.CallOpts, appContract, inputIndex, blockNumber) +} + +// WasInputFinalized is a free data retrieval call binding the contract method 0x2b73ad31. +// +// Solidity: function wasInputFinalized(address appContract, uint256 inputIndex, uint256 blockNumber) view returns(bool) +func (_IQuorum *IQuorumCallerSession) WasInputFinalized(appContract common.Address, inputIndex *big.Int, blockNumber *big.Int) (bool, error) { + return _IQuorum.Contract.WasInputFinalized(&_IQuorum.CallOpts, appContract, inputIndex, blockNumber) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IQuorum *IQuorumTransactor) AcceptClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IQuorum.contract.Transact(opts, "acceptClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IQuorum *IQuorumSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IQuorum.Contract.AcceptClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// AcceptClaim is a paid mutator transaction binding the contract method 0x8e2c381c. +// +// Solidity: function acceptClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot) returns() +func (_IQuorum *IQuorumTransactorSession) AcceptClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte) (*types.Transaction, error) { + return _IQuorum.Contract.AcceptClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumTransactor) SubmitClaim(opts *bind.TransactOpts, appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.contract.Transact(opts, "submitClaim", appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// SubmitClaim is a paid mutator transaction binding the contract method 0x92ebd98c. +// +// Solidity: function submitClaim(address appContract, uint256 lastProcessedBlockNumber, bytes32 machineMerkleRoot, ((bytes32,bytes32[]),(bytes32,bytes32[]),(bytes32,bytes32[])) proof) returns() +func (_IQuorum *IQuorumTransactorSession) SubmitClaim(appContract common.Address, lastProcessedBlockNumber *big.Int, machineMerkleRoot [32]byte, proof MachineValidityProof) (*types.Transaction, error) { + return _IQuorum.Contract.SubmitClaim(&_IQuorum.TransactOpts, appContract, lastProcessedBlockNumber, machineMerkleRoot, proof) +} + +// IQuorumClaimAcceptedIterator is returned from FilterClaimAccepted and is used to iterate over the raw logs and unpacked data for ClaimAccepted events raised by the IQuorum contract. +type IQuorumClaimAcceptedIterator struct { + Event *IQuorumClaimAccepted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IQuorumClaimAcceptedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IQuorumClaimAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IQuorumClaimAccepted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IQuorumClaimAcceptedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IQuorumClaimAcceptedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IQuorumClaimAccepted represents a ClaimAccepted event raised by the IQuorum contract. +type IQuorumClaimAccepted struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimAccepted is a free log retrieval operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) FilterClaimAccepted(opts *bind.FilterOpts, appContract []common.Address) (*IQuorumClaimAcceptedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.FilterLogs(opts, "ClaimAccepted", appContractRule) + if err != nil { + return nil, err + } + return &IQuorumClaimAcceptedIterator{contract: _IQuorum.contract, event: "ClaimAccepted", logs: logs, sub: sub}, nil +} + +// WatchClaimAccepted is a free log subscription operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) WatchClaimAccepted(opts *bind.WatchOpts, sink chan<- *IQuorumClaimAccepted, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.WatchLogs(opts, "ClaimAccepted", appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IQuorumClaimAccepted) + if err := _IQuorum.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimAccepted is a log parse operation binding the contract event 0x8d40f6fff97997587f3d67c44cf2201ae7df0ef9a14ac7399b9a6f0fcaa3c46f. +// +// Solidity: event ClaimAccepted(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) ParseClaimAccepted(log types.Log) (*IQuorumClaimAccepted, error) { + event := new(IQuorumClaimAccepted) + if err := _IQuorum.contract.UnpackLog(event, "ClaimAccepted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IQuorumClaimStagedIterator is returned from FilterClaimStaged and is used to iterate over the raw logs and unpacked data for ClaimStaged events raised by the IQuorum contract. +type IQuorumClaimStagedIterator struct { + Event *IQuorumClaimStaged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IQuorumClaimStagedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IQuorumClaimStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IQuorumClaimStaged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IQuorumClaimStagedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IQuorumClaimStagedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IQuorumClaimStaged represents a ClaimStaged event raised by the IQuorum contract. +type IQuorumClaimStaged struct { + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimStaged is a free log retrieval operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) FilterClaimStaged(opts *bind.FilterOpts, appContract []common.Address) (*IQuorumClaimStagedIterator, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.FilterLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return &IQuorumClaimStagedIterator{contract: _IQuorum.contract, event: "ClaimStaged", logs: logs, sub: sub}, nil +} + +// WatchClaimStaged is a free log subscription operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) WatchClaimStaged(opts *bind.WatchOpts, sink chan<- *IQuorumClaimStaged, appContract []common.Address) (event.Subscription, error) { + + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.WatchLogs(opts, "ClaimStaged", appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IQuorumClaimStaged) + if err := _IQuorum.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimStaged is a log parse operation binding the contract event 0x5bd3547877c38b4ee6fc63a44b7d7846debe64218c29e930faacba7fcfac1db9. +// +// Solidity: event ClaimStaged(address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) ParseClaimStaged(log types.Log) (*IQuorumClaimStaged, error) { + event := new(IQuorumClaimStaged) + if err := _IQuorum.contract.UnpackLog(event, "ClaimStaged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// IQuorumClaimSubmittedIterator is returned from FilterClaimSubmitted and is used to iterate over the raw logs and unpacked data for ClaimSubmitted events raised by the IQuorum contract. +type IQuorumClaimSubmittedIterator struct { + Event *IQuorumClaimSubmitted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IQuorumClaimSubmittedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IQuorumClaimSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IQuorumClaimSubmitted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IQuorumClaimSubmittedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IQuorumClaimSubmittedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IQuorumClaimSubmitted represents a ClaimSubmitted event raised by the IQuorum contract. +type IQuorumClaimSubmitted struct { + Submitter common.Address + AppContract common.Address + LastProcessedBlockNumber *big.Int + OutputsMerkleRoot [32]byte + MachineMerkleRoot [32]byte + Raw types.Log // Blockchain specific contextual infos +} + +// FilterClaimSubmitted is a free log retrieval operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) FilterClaimSubmitted(opts *bind.FilterOpts, submitter []common.Address, appContract []common.Address) (*IQuorumClaimSubmittedIterator, error) { + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.FilterLogs(opts, "ClaimSubmitted", submitterRule, appContractRule) + if err != nil { + return nil, err + } + return &IQuorumClaimSubmittedIterator{contract: _IQuorum.contract, event: "ClaimSubmitted", logs: logs, sub: sub}, nil +} + +// WatchClaimSubmitted is a free log subscription operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) WatchClaimSubmitted(opts *bind.WatchOpts, sink chan<- *IQuorumClaimSubmitted, submitter []common.Address, appContract []common.Address) (event.Subscription, error) { + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + var appContractRule []interface{} + for _, appContractItem := range appContract { + appContractRule = append(appContractRule, appContractItem) + } + + logs, sub, err := _IQuorum.contract.WatchLogs(opts, "ClaimSubmitted", submitterRule, appContractRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IQuorumClaimSubmitted) + if err := _IQuorum.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseClaimSubmitted is a log parse operation binding the contract event 0x9d98f38c9329d29c2204350787eae2783da0002dbe80097dab5a71057c6573bb. +// +// Solidity: event ClaimSubmitted(address indexed submitter, address indexed appContract, uint256 lastProcessedBlockNumber, bytes32 outputsMerkleRoot, bytes32 machineMerkleRoot) +func (_IQuorum *IQuorumFilterer) ParseClaimSubmitted(log types.Log) (*IQuorumClaimSubmitted, error) { + event := new(IQuorumClaimSubmitted) + if err := _IQuorum.contract.UnpackLog(event, "ClaimSubmitted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iquorumfactory/iquorumfactory.go b/pkg/contracts/iquorumfactory/iquorumfactory.go new file mode 100644 index 000000000..690b9686f --- /dev/null +++ b/pkg/contracts/iquorumfactory/iquorumfactory.go @@ -0,0 +1,456 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iquorumfactory + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IQuorumFactoryMetaData contains all meta data concerning the IQuorumFactory contract. +var IQuorumFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"calculateQuorumAddress\",\"inputs\":[{\"name\":\"validators\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"newQuorum\",\"inputs\":[{\"name\":\"validators\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIQuorum\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"newQuorum\",\"inputs\":[{\"name\":\"validators\",\"type\":\"address[]\",\"internalType\":\"address[]\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIQuorum\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"QuorumCreated\",\"inputs\":[{\"name\":\"quorum\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"contractIQuorum\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"EmptyQuorum\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ZeroAddressValidator\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", +} + +// IQuorumFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use IQuorumFactoryMetaData.ABI instead. +var IQuorumFactoryABI = IQuorumFactoryMetaData.ABI + +// IQuorumFactory is an auto generated Go binding around an Ethereum contract. +type IQuorumFactory struct { + IQuorumFactoryCaller // Read-only binding to the contract + IQuorumFactoryTransactor // Write-only binding to the contract + IQuorumFactoryFilterer // Log filterer for contract events +} + +// IQuorumFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type IQuorumFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IQuorumFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IQuorumFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IQuorumFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IQuorumFactorySession struct { + Contract *IQuorumFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IQuorumFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IQuorumFactoryCallerSession struct { + Contract *IQuorumFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IQuorumFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IQuorumFactoryTransactorSession struct { + Contract *IQuorumFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IQuorumFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type IQuorumFactoryRaw struct { + Contract *IQuorumFactory // Generic contract binding to access the raw methods on +} + +// IQuorumFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IQuorumFactoryCallerRaw struct { + Contract *IQuorumFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// IQuorumFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IQuorumFactoryTransactorRaw struct { + Contract *IQuorumFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIQuorumFactory creates a new instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactory(address common.Address, backend bind.ContractBackend) (*IQuorumFactory, error) { + contract, err := bindIQuorumFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IQuorumFactory{IQuorumFactoryCaller: IQuorumFactoryCaller{contract: contract}, IQuorumFactoryTransactor: IQuorumFactoryTransactor{contract: contract}, IQuorumFactoryFilterer: IQuorumFactoryFilterer{contract: contract}}, nil +} + +// NewIQuorumFactoryCaller creates a new read-only instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactoryCaller(address common.Address, caller bind.ContractCaller) (*IQuorumFactoryCaller, error) { + contract, err := bindIQuorumFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IQuorumFactoryCaller{contract: contract}, nil +} + +// NewIQuorumFactoryTransactor creates a new write-only instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*IQuorumFactoryTransactor, error) { + contract, err := bindIQuorumFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IQuorumFactoryTransactor{contract: contract}, nil +} + +// NewIQuorumFactoryFilterer creates a new log filterer instance of IQuorumFactory, bound to a specific deployed contract. +func NewIQuorumFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*IQuorumFactoryFilterer, error) { + contract, err := bindIQuorumFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IQuorumFactoryFilterer{contract: contract}, nil +} + +// bindIQuorumFactory binds a generic wrapper to an already deployed contract. +func bindIQuorumFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IQuorumFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IQuorumFactory *IQuorumFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IQuorumFactory.Contract.IQuorumFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IQuorumFactory *IQuorumFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IQuorumFactory.Contract.IQuorumFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IQuorumFactory *IQuorumFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IQuorumFactory.Contract.IQuorumFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IQuorumFactory *IQuorumFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IQuorumFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IQuorumFactory *IQuorumFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IQuorumFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IQuorumFactory *IQuorumFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IQuorumFactory.Contract.contract.Transact(opts, method, params...) +} + +// CalculateQuorumAddress is a free data retrieval call binding the contract method 0xdbf30807. +// +// Solidity: function calculateQuorumAddress(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IQuorumFactory *IQuorumFactoryCaller) CalculateQuorumAddress(opts *bind.CallOpts, validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + var out []interface{} + err := _IQuorumFactory.contract.Call(opts, &out, "calculateQuorumAddress", validators, epochLength, claimStagingPeriod, salt) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// CalculateQuorumAddress is a free data retrieval call binding the contract method 0xdbf30807. +// +// Solidity: function calculateQuorumAddress(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IQuorumFactory *IQuorumFactorySession) CalculateQuorumAddress(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IQuorumFactory.Contract.CalculateQuorumAddress(&_IQuorumFactory.CallOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// CalculateQuorumAddress is a free data retrieval call binding the contract method 0xdbf30807. +// +// Solidity: function calculateQuorumAddress(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) view returns(address) +func (_IQuorumFactory *IQuorumFactoryCallerSession) CalculateQuorumAddress(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (common.Address, error) { + return _IQuorumFactory.Contract.CalculateQuorumAddress(&_IQuorumFactory.CallOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorumFactory *IQuorumFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IQuorumFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorumFactory *IQuorumFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorumFactory.Contract.Version(&_IQuorumFactory.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IQuorumFactory *IQuorumFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IQuorumFactory.Contract.Version(&_IQuorumFactory.CallOpts) +} + +// NewQuorum is a paid mutator transaction binding the contract method 0x0f726dd4. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactor) NewQuorum(opts *bind.TransactOpts, validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IQuorumFactory.contract.Transact(opts, "newQuorum", validators, epochLength, claimStagingPeriod, salt) +} + +// NewQuorum is a paid mutator transaction binding the contract method 0x0f726dd4. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IQuorumFactory *IQuorumFactorySession) NewQuorum(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// NewQuorum is a paid mutator transaction binding the contract method 0x0f726dd4. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod, bytes32 salt) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactorSession) NewQuorum(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, salt [32]byte) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod, salt) +} + +// NewQuorum0 is a paid mutator transaction binding the contract method 0xae123219. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactor) NewQuorum0(opts *bind.TransactOpts, validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IQuorumFactory.contract.Transact(opts, "newQuorum0", validators, epochLength, claimStagingPeriod) +} + +// NewQuorum0 is a paid mutator transaction binding the contract method 0xae123219. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IQuorumFactory *IQuorumFactorySession) NewQuorum0(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum0(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod) +} + +// NewQuorum0 is a paid mutator transaction binding the contract method 0xae123219. +// +// Solidity: function newQuorum(address[] validators, uint256 epochLength, uint256 claimStagingPeriod) returns(address) +func (_IQuorumFactory *IQuorumFactoryTransactorSession) NewQuorum0(validators []common.Address, epochLength *big.Int, claimStagingPeriod *big.Int) (*types.Transaction, error) { + return _IQuorumFactory.Contract.NewQuorum0(&_IQuorumFactory.TransactOpts, validators, epochLength, claimStagingPeriod) +} + +// IQuorumFactoryQuorumCreatedIterator is returned from FilterQuorumCreated and is used to iterate over the raw logs and unpacked data for QuorumCreated events raised by the IQuorumFactory contract. +type IQuorumFactoryQuorumCreatedIterator struct { + Event *IQuorumFactoryQuorumCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *IQuorumFactoryQuorumCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(IQuorumFactoryQuorumCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(IQuorumFactoryQuorumCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *IQuorumFactoryQuorumCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *IQuorumFactoryQuorumCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// IQuorumFactoryQuorumCreated represents a QuorumCreated event raised by the IQuorumFactory contract. +type IQuorumFactoryQuorumCreated struct { + Quorum common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterQuorumCreated is a free log retrieval operation binding the contract event 0x446698b70271bce331e53210572bd37ac8c590b6cdca2e6763e6448243cba802. +// +// Solidity: event QuorumCreated(address quorum) +func (_IQuorumFactory *IQuorumFactoryFilterer) FilterQuorumCreated(opts *bind.FilterOpts) (*IQuorumFactoryQuorumCreatedIterator, error) { + + logs, sub, err := _IQuorumFactory.contract.FilterLogs(opts, "QuorumCreated") + if err != nil { + return nil, err + } + return &IQuorumFactoryQuorumCreatedIterator{contract: _IQuorumFactory.contract, event: "QuorumCreated", logs: logs, sub: sub}, nil +} + +// WatchQuorumCreated is a free log subscription operation binding the contract event 0x446698b70271bce331e53210572bd37ac8c590b6cdca2e6763e6448243cba802. +// +// Solidity: event QuorumCreated(address quorum) +func (_IQuorumFactory *IQuorumFactoryFilterer) WatchQuorumCreated(opts *bind.WatchOpts, sink chan<- *IQuorumFactoryQuorumCreated) (event.Subscription, error) { + + logs, sub, err := _IQuorumFactory.contract.WatchLogs(opts, "QuorumCreated") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(IQuorumFactoryQuorumCreated) + if err := _IQuorumFactory.contract.UnpackLog(event, "QuorumCreated", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseQuorumCreated is a log parse operation binding the contract event 0x446698b70271bce331e53210572bd37ac8c590b6cdca2e6763e6448243cba802. +// +// Solidity: event QuorumCreated(address quorum) +func (_IQuorumFactory *IQuorumFactoryFilterer) ParseQuorumCreated(log types.Log) (*IQuorumFactoryQuorumCreated, error) { + event := new(IQuorumFactoryQuorumCreated) + if err := _IQuorumFactory.contract.UnpackLog(event, "QuorumCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go new file mode 100644 index 000000000..f0f9a112c --- /dev/null +++ b/pkg/contracts/iselfhostedapplicationfactory/iselfhostedapplicationfactory.go @@ -0,0 +1,369 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iselfhostedapplicationfactory + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// WithdrawalConfig is an auto generated low-level Go binding around an user-defined struct. +type WithdrawalConfig struct { + Guardian common.Address + Log2LeavesPerAccount uint8 + Log2MaxNumOfAccounts uint8 + AccountsDriveStartIndex uint64 + WithdrawalOutputBuilder common.Address +} + +// ISelfHostedApplicationFactoryMetaData contains all meta data concerning the ISelfHostedApplicationFactory contract. +var ISelfHostedApplicationFactoryMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"calculateAddresses\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"deployContracts\",\"inputs\":[{\"name\":\"authorityOwner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"epochLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"claimStagingPeriod\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"templateHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"inputBox\",\"type\":\"address\",\"internalType\":\"contractIInputBox\"},{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplication\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthority\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getApplicationFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIApplicationFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getAuthorityFactory\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIAuthorityFactory\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidWithdrawalConfig\",\"inputs\":[{\"name\":\"withdrawalConfig\",\"type\":\"tuple\",\"internalType\":\"structWithdrawalConfig\",\"components\":[{\"name\":\"guardian\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"log2LeavesPerAccount\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"log2MaxNumOfAccounts\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"accountsDriveStartIndex\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"withdrawalOutputBuilder\",\"type\":\"address\",\"internalType\":\"contractIWithdrawalOutputBuilder\"}]}]},{\"type\":\"error\",\"name\":\"ZeroEpochLength\",\"inputs\":[]}]", +} + +// ISelfHostedApplicationFactoryABI is the input ABI used to generate the binding from. +// Deprecated: Use ISelfHostedApplicationFactoryMetaData.ABI instead. +var ISelfHostedApplicationFactoryABI = ISelfHostedApplicationFactoryMetaData.ABI + +// ISelfHostedApplicationFactory is an auto generated Go binding around an Ethereum contract. +type ISelfHostedApplicationFactory struct { + ISelfHostedApplicationFactoryCaller // Read-only binding to the contract + ISelfHostedApplicationFactoryTransactor // Write-only binding to the contract + ISelfHostedApplicationFactoryFilterer // Log filterer for contract events +} + +// ISelfHostedApplicationFactoryCaller is an auto generated read-only Go binding around an Ethereum contract. +type ISelfHostedApplicationFactoryCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ISelfHostedApplicationFactoryTransactor is an auto generated write-only Go binding around an Ethereum contract. +type ISelfHostedApplicationFactoryTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ISelfHostedApplicationFactoryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type ISelfHostedApplicationFactoryFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ISelfHostedApplicationFactorySession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type ISelfHostedApplicationFactorySession struct { + Contract *ISelfHostedApplicationFactory // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ISelfHostedApplicationFactoryCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type ISelfHostedApplicationFactoryCallerSession struct { + Contract *ISelfHostedApplicationFactoryCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// ISelfHostedApplicationFactoryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type ISelfHostedApplicationFactoryTransactorSession struct { + Contract *ISelfHostedApplicationFactoryTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ISelfHostedApplicationFactoryRaw is an auto generated low-level Go binding around an Ethereum contract. +type ISelfHostedApplicationFactoryRaw struct { + Contract *ISelfHostedApplicationFactory // Generic contract binding to access the raw methods on +} + +// ISelfHostedApplicationFactoryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type ISelfHostedApplicationFactoryCallerRaw struct { + Contract *ISelfHostedApplicationFactoryCaller // Generic read-only contract binding to access the raw methods on +} + +// ISelfHostedApplicationFactoryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type ISelfHostedApplicationFactoryTransactorRaw struct { + Contract *ISelfHostedApplicationFactoryTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewISelfHostedApplicationFactory creates a new instance of ISelfHostedApplicationFactory, bound to a specific deployed contract. +func NewISelfHostedApplicationFactory(address common.Address, backend bind.ContractBackend) (*ISelfHostedApplicationFactory, error) { + contract, err := bindISelfHostedApplicationFactory(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &ISelfHostedApplicationFactory{ISelfHostedApplicationFactoryCaller: ISelfHostedApplicationFactoryCaller{contract: contract}, ISelfHostedApplicationFactoryTransactor: ISelfHostedApplicationFactoryTransactor{contract: contract}, ISelfHostedApplicationFactoryFilterer: ISelfHostedApplicationFactoryFilterer{contract: contract}}, nil +} + +// NewISelfHostedApplicationFactoryCaller creates a new read-only instance of ISelfHostedApplicationFactory, bound to a specific deployed contract. +func NewISelfHostedApplicationFactoryCaller(address common.Address, caller bind.ContractCaller) (*ISelfHostedApplicationFactoryCaller, error) { + contract, err := bindISelfHostedApplicationFactory(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &ISelfHostedApplicationFactoryCaller{contract: contract}, nil +} + +// NewISelfHostedApplicationFactoryTransactor creates a new write-only instance of ISelfHostedApplicationFactory, bound to a specific deployed contract. +func NewISelfHostedApplicationFactoryTransactor(address common.Address, transactor bind.ContractTransactor) (*ISelfHostedApplicationFactoryTransactor, error) { + contract, err := bindISelfHostedApplicationFactory(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &ISelfHostedApplicationFactoryTransactor{contract: contract}, nil +} + +// NewISelfHostedApplicationFactoryFilterer creates a new log filterer instance of ISelfHostedApplicationFactory, bound to a specific deployed contract. +func NewISelfHostedApplicationFactoryFilterer(address common.Address, filterer bind.ContractFilterer) (*ISelfHostedApplicationFactoryFilterer, error) { + contract, err := bindISelfHostedApplicationFactory(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &ISelfHostedApplicationFactoryFilterer{contract: contract}, nil +} + +// bindISelfHostedApplicationFactory binds a generic wrapper to an already deployed contract. +func bindISelfHostedApplicationFactory(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := ISelfHostedApplicationFactoryMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ISelfHostedApplicationFactory.Contract.ISelfHostedApplicationFactoryCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.ISelfHostedApplicationFactoryTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.ISelfHostedApplicationFactoryTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ISelfHostedApplicationFactory.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.contract.Transact(opts, method, params...) +} + +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. +// +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) CalculateAddresses(opts *bind.CallOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { + var out []interface{} + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "calculateAddresses", authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) + + if err != nil { + return *new(common.Address), *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + out1 := *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + + return out0, out1, err + +} + +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. +// +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) +} + +// CalculateAddresses is a free data retrieval call binding the contract method 0x888d6843. +// +// Solidity: function calculateAddresses(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) view returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) CalculateAddresses(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (common.Address, common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.CalculateAddresses(&_ISelfHostedApplicationFactory.CallOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) +} + +// GetApplicationFactory is a free data retrieval call binding the contract method 0xe63d50ff. +// +// Solidity: function getApplicationFactory() view returns(address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) GetApplicationFactory(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "getApplicationFactory") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetApplicationFactory is a free data retrieval call binding the contract method 0xe63d50ff. +// +// Solidity: function getApplicationFactory() view returns(address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) GetApplicationFactory() (common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.GetApplicationFactory(&_ISelfHostedApplicationFactory.CallOpts) +} + +// GetApplicationFactory is a free data retrieval call binding the contract method 0xe63d50ff. +// +// Solidity: function getApplicationFactory() view returns(address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) GetApplicationFactory() (common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.GetApplicationFactory(&_ISelfHostedApplicationFactory.CallOpts) +} + +// GetAuthorityFactory is a free data retrieval call binding the contract method 0x75689f83. +// +// Solidity: function getAuthorityFactory() view returns(address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) GetAuthorityFactory(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "getAuthorityFactory") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// GetAuthorityFactory is a free data retrieval call binding the contract method 0x75689f83. +// +// Solidity: function getAuthorityFactory() view returns(address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) GetAuthorityFactory() (common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.GetAuthorityFactory(&_ISelfHostedApplicationFactory.CallOpts) +} + +// GetAuthorityFactory is a free data retrieval call binding the contract method 0x75689f83. +// +// Solidity: function getAuthorityFactory() view returns(address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) GetAuthorityFactory() (common.Address, error) { + return _ISelfHostedApplicationFactory.Contract.GetAuthorityFactory(&_ISelfHostedApplicationFactory.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _ISelfHostedApplicationFactory.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _ISelfHostedApplicationFactory.Contract.Version(&_ISelfHostedApplicationFactory.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _ISelfHostedApplicationFactory.Contract.Version(&_ISelfHostedApplicationFactory.CallOpts) +} + +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. +// +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactor) DeployContracts(opts *bind.TransactOpts, authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.contract.Transact(opts, "deployContracts", authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) +} + +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. +// +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactorySession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) +} + +// DeployContracts is a paid mutator transaction binding the contract method 0xa71fe0b1. +// +// Solidity: function deployContracts(address authorityOwner, uint256 epochLength, uint256 claimStagingPeriod, bytes32 templateHash, address inputBox, (address,uint8,uint8,uint64,address) withdrawalConfig, bytes32 salt) returns(address, address) +func (_ISelfHostedApplicationFactory *ISelfHostedApplicationFactoryTransactorSession) DeployContracts(authorityOwner common.Address, epochLength *big.Int, claimStagingPeriod *big.Int, templateHash [32]byte, inputBox common.Address, withdrawalConfig WithdrawalConfig, salt [32]byte) (*types.Transaction, error) { + return _ISelfHostedApplicationFactory.Contract.DeployContracts(&_ISelfHostedApplicationFactory.TransactOpts, authorityOwner, epochLength, claimStagingPeriod, templateHash, inputBox, withdrawalConfig, salt) +} diff --git a/pkg/contracts/itournament/itournament.go b/pkg/contracts/itournament/itournament.go new file mode 100644 index 000000000..cdbfc71c3 --- /dev/null +++ b/pkg/contracts/itournament/itournament.go @@ -0,0 +1,2482 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package itournament + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// ITournamentBisectingMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentBisectingMatchView struct { + RevealingParent [32]byte + WaitingLeft [32]byte + WaitingRight [32]byte + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + CurrentHeight uint64 + Responder uint8 +} + +// ITournamentCommitmentStandingView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentCommitmentStandingView struct { + Joined bool + FinalState [32]byte + Claimer common.Address + ClockRunning bool + ClockDeadline uint64 + ClockAllowance uint64 +} + +// ITournamentInnerResultView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentInnerResultView struct { + Disposition uint8 + ParentCommitment [32]byte + PausedAllowance uint64 +} + +// ITournamentReadyToSealMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentReadyToSealMatchView struct { + RevealingParent [32]byte + WaitingLeft [32]byte + WaitingRight [32]byte + SegmentStartPosition *big.Int + SegmentStartCycle *big.Int + Responder uint8 +} + +// ITournamentSealedMatchView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentSealedMatchView struct { + AgreeState [32]byte + DivergencePosition *big.Int + DivergenceCycle *big.Int + FinalStateOne [32]byte + FinalStateTwo [32]byte +} + +// ITournamentTournamentDescriptor is an auto generated low-level Go binding around an user-defined struct. +type ITournamentTournamentDescriptor struct { + InitialHash [32]byte + BaseCycle *big.Int + Log2Stride uint64 + Height uint64 + Level uint64 + Kind uint8 + StartInstant uint64 + Allowance uint64 +} + +// ITournamentTournamentStandingView is an auto generated low-level Go binding around an user-defined struct. +type ITournamentTournamentStandingView struct { + Standing uint8 + AcceptsJoins bool + HasCandidate bool + Candidate [32]byte + FinalState [32]byte + ParentCommitment [32]byte + FinishedAt uint64 + WinnerExpiresAt uint64 +} + +// MatchId is an auto generated low-level Go binding around an user-defined struct. +type MatchId struct { + CommitmentOne [32]byte + CommitmentTwo [32]byte +} + +// ITournamentMetaData contains all meta data concerning the ITournament contract. +var ITournamentMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"advanceMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newLeftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"newRightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"bisectingMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.BisectingMatchView\",\"components\":[{\"name\":\"revealingParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingLeft\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingRight\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"segmentStartCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"currentHeight\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"responder\",\"type\":\"uint8\",\"internalType\":\"enumITournament.CommitmentSide\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondRecovery\",\"inputs\":[],\"outputs\":[{\"name\":\"disposition\",\"type\":\"uint8\",\"internalType\":\"enumITournament.BondDisposition\"},{\"name\":\"claimer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payment\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"bondValue\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"classifyMatchTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"outcome\",\"type\":\"uint8\",\"internalType\":\"enumITournament.MatchTimeoutOutcome\"},{\"name\":\"deferredCharge\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"commitmentStanding\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.CommitmentStandingView\",\"components\":[{\"name\":\"joined\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"claimer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"clockRunning\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"clockDeadline\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"clockAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"eliminateInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"eliminateMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getCommitmentJoinedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getLeafMatchSealedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchAdvancedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchCreatedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMatchDeletedCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNewInnerTournamentCount\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"innerResult\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.InnerResultView\",\"components\":[{\"name\":\"disposition\",\"type\":\"uint8\",\"internalType\":\"enumITournament.InnerTournamentDisposition\"},{\"name\":\"parentCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"pausedAllowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"joinTournament\",\"inputs\":[{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"readyToSealMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.ReadyToSealMatchView\",\"components\":[{\"name\":\"revealingParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingLeft\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"waitingRight\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"segmentStartCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"responder\",\"type\":\"uint8\",\"internalType\":\"enumITournament.CommitmentSide\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"sealInnerMatchAndCreateInnerTournament\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightLeaf\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"agreeHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeHashProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"sealedMatch\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"internalType\":\"Match.IdHash\"}],\"outputs\":[{\"name\":\"actualPhase\",\"type\":\"uint8\",\"internalType\":\"enumMatch.Phase\"},{\"name\":\"value\",\"type\":\"tuple\",\"internalType\":\"structITournament.SealedMatchView\",\"components\":[{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"divergencePosition\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"divergenceCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"finalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentDescriptor\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentDescriptor\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"baseCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2Stride\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentKind\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tournamentStanding\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structITournament.TournamentStandingView\",\"components\":[{\"name\":\"standing\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentStanding\"},{\"name\":\"acceptsJoins\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"hasCandidate\",\"type\":\"bool\",\"internalType\":\"bool\"},{\"name\":\"candidate\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"parentCommitment\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"finishedAt\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"winnerExpiresAt\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"tryRecoveringBond\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winInnerTournament\",\"inputs\":[{\"name\":\"childTournament\",\"type\":\"address\",\"internalType\":\"contractITournament\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winLeafMatch\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"proofs\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"winMatchByTimeout\",\"inputs\":[{\"name\":\"matchId\",\"type\":\"tuple\",\"internalType\":\"structMatch.Id\",\"components\":[{\"name\":\"commitmentOne\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"commitmentTwo\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightNode\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"BondRecovered\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"claimer\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"payment\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"burned\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"CommitmentJoined\",\"inputs\":[{\"name\":\"commitment\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"finalStateHash\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"submitter\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"LeafMatchSealed\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"agreeState\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"divergencePosition\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"finalStateOne\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"finalStateTwo\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"deadlineOne\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"},{\"name\":\"deadlineTwo\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchAdvanced\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"otherParent\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"leftNode\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"segmentStartPosition\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"currentHeight\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"responderDeadline\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchCreated\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"leftOfTwo\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"responderDeadline\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"},{\"name\":\"eliminableAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MatchDeleted\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Tree.Node\"},{\"name\":\"reason\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.MatchDeletionReason\"},{\"name\":\"winnerCommitment\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumITournament.WinnerCommitment\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"NewInnerTournament\",\"inputs\":[{\"name\":\"matchIdHash\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Match.IdHash\"},{\"name\":\"childTournament\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractITournament\"},{\"name\":\"one\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"two\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Machine.Hash\"},{\"name\":\"descriptor\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structITournament.TournamentDescriptor\",\"components\":[{\"name\":\"initialHash\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"baseCycle\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"log2Stride\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"height\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"level\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumITournament.TournamentKind\"},{\"name\":\"startInstant\",\"type\":\"uint64\",\"internalType\":\"Time.Instant\"},{\"name\":\"allowance\",\"type\":\"uint64\",\"internalType\":\"Time.Duration\"}]},{\"name\":\"bondValue\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PartialBondRefund\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":true,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"StandingChanged\",\"inputs\":[{\"name\":\"matchCount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"dangling\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"parentCommitment\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"Tree.Node\"},{\"name\":\"resultAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"},{\"name\":\"winnerExpiresAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"Time.Instant\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"CannotAdvanceTimedOutClock\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentCannotBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentMustBeEliminated\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ChildTournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ClockAlreadyInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"CommitmentProofWrongSize\",\"inputs\":[{\"name\":\"treeHeight\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"siblingsLength\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"CommitmentStateMismatch\",\"inputs\":[{\"name\":\"expected\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"computed\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"IncorrectAgreeState\",\"inputs\":[{\"name\":\"initialState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"agreeState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"InsufficientBond\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidChildrenNodes\",\"inputs\":[{\"name\":\"expectedParent\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"leftChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"rightChild\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"InvalidContestedFinalState\",\"inputs\":[{\"name\":\"contestedFinalStateOne\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"contestedFinalStateTwo\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"finalState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"MatchCannotBeAdvanced\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeEliminatedByTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchCannotBeWonByTimeout\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchDoesNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"MatchIsNotSealed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyDetected\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonLeafTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RequireNonRootTournament\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentFailedNoWinner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsClosed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentIsFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TournamentNotFinished\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongChildren\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"left\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"right\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]},{\"type\":\"error\",\"name\":\"WrongFinalState\",\"inputs\":[{\"name\":\"whichCommitment\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"computedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"},{\"name\":\"committedPostState\",\"type\":\"bytes32\",\"internalType\":\"Machine.Hash\"}]},{\"type\":\"error\",\"name\":\"WrongNodesForStep\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongTournamentWinner\",\"inputs\":[{\"name\":\"commitmentRoot\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"},{\"name\":\"winner\",\"type\":\"bytes32\",\"internalType\":\"Tree.Node\"}]}]", +} + +// ITournamentABI is the input ABI used to generate the binding from. +// Deprecated: Use ITournamentMetaData.ABI instead. +var ITournamentABI = ITournamentMetaData.ABI + +// ITournament is an auto generated Go binding around an Ethereum contract. +type ITournament struct { + ITournamentCaller // Read-only binding to the contract + ITournamentTransactor // Write-only binding to the contract + ITournamentFilterer // Log filterer for contract events +} + +// ITournamentCaller is an auto generated read-only Go binding around an Ethereum contract. +type ITournamentCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ITournamentTransactor is an auto generated write-only Go binding around an Ethereum contract. +type ITournamentTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ITournamentFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type ITournamentFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ITournamentSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type ITournamentSession struct { + Contract *ITournament // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ITournamentCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type ITournamentCallerSession struct { + Contract *ITournamentCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// ITournamentTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type ITournamentTransactorSession struct { + Contract *ITournamentTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ITournamentRaw is an auto generated low-level Go binding around an Ethereum contract. +type ITournamentRaw struct { + Contract *ITournament // Generic contract binding to access the raw methods on +} + +// ITournamentCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type ITournamentCallerRaw struct { + Contract *ITournamentCaller // Generic read-only contract binding to access the raw methods on +} + +// ITournamentTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type ITournamentTransactorRaw struct { + Contract *ITournamentTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewITournament creates a new instance of ITournament, bound to a specific deployed contract. +func NewITournament(address common.Address, backend bind.ContractBackend) (*ITournament, error) { + contract, err := bindITournament(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &ITournament{ITournamentCaller: ITournamentCaller{contract: contract}, ITournamentTransactor: ITournamentTransactor{contract: contract}, ITournamentFilterer: ITournamentFilterer{contract: contract}}, nil +} + +// NewITournamentCaller creates a new read-only instance of ITournament, bound to a specific deployed contract. +func NewITournamentCaller(address common.Address, caller bind.ContractCaller) (*ITournamentCaller, error) { + contract, err := bindITournament(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &ITournamentCaller{contract: contract}, nil +} + +// NewITournamentTransactor creates a new write-only instance of ITournament, bound to a specific deployed contract. +func NewITournamentTransactor(address common.Address, transactor bind.ContractTransactor) (*ITournamentTransactor, error) { + contract, err := bindITournament(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &ITournamentTransactor{contract: contract}, nil +} + +// NewITournamentFilterer creates a new log filterer instance of ITournament, bound to a specific deployed contract. +func NewITournamentFilterer(address common.Address, filterer bind.ContractFilterer) (*ITournamentFilterer, error) { + contract, err := bindITournament(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &ITournamentFilterer{contract: contract}, nil +} + +// bindITournament binds a generic wrapper to an already deployed contract. +func bindITournament(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := ITournamentMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ITournament *ITournamentRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ITournament.Contract.ITournamentCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ITournament *ITournamentRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ITournament.Contract.ITournamentTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ITournament *ITournamentRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ITournament.Contract.ITournamentTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ITournament *ITournamentCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ITournament.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ITournament *ITournamentTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ITournament.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ITournament *ITournamentTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ITournament.Contract.contract.Transact(opts, method, params...) +} + +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. +// +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentCaller) BisectingMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView +}, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "bisectingMatch", matchIdHash) + + outstruct := new(struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView + }) + if err != nil { + return *outstruct, err + } + + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentBisectingMatchView)).(*ITournamentBisectingMatchView) + + return *outstruct, err + +} + +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. +// +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentSession) BisectingMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView +}, error) { + return _ITournament.Contract.BisectingMatch(&_ITournament.CallOpts, matchIdHash) +} + +// BisectingMatch is a free data retrieval call binding the contract method 0xb8bc3e5d. +// +// Solidity: function bisectingMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint64,uint8) value) +func (_ITournament *ITournamentCallerSession) BisectingMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentBisectingMatchView +}, error) { + return _ITournament.Contract.BisectingMatch(&_ITournament.CallOpts, matchIdHash) +} + +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. +// +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentCaller) BondRecovery(opts *bind.CallOpts) (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "bondRecovery") + + outstruct := new(struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int + }) + if err != nil { + return *outstruct, err + } + + outstruct.Disposition = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Claimer = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.Payment = *abi.ConvertType(out[2], new(*big.Int)).(**big.Int) + + return *outstruct, err + +} + +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. +// +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentSession) BondRecovery() (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { + return _ITournament.Contract.BondRecovery(&_ITournament.CallOpts) +} + +// BondRecovery is a free data retrieval call binding the contract method 0x2cf66e56. +// +// Solidity: function bondRecovery() view returns(uint8 disposition, address claimer, uint256 payment) +func (_ITournament *ITournamentCallerSession) BondRecovery() (struct { + Disposition uint8 + Claimer common.Address + Payment *big.Int +}, error) { + return _ITournament.Contract.BondRecovery(&_ITournament.CallOpts) +} + +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentCaller) BondValue(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "bondValue") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentSession) BondValue() (*big.Int, error) { + return _ITournament.Contract.BondValue(&_ITournament.CallOpts) +} + +// BondValue is a free data retrieval call binding the contract method 0xd2d5862c. +// +// Solidity: function bondValue() view returns(uint256) +func (_ITournament *ITournamentCallerSession) BondValue() (*big.Int, error) { + return _ITournament.Contract.BondValue(&_ITournament.CallOpts) +} + +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. +// +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentCaller) ClassifyMatchTimeout(opts *bind.CallOpts, matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "classifyMatchTimeout", matchId) + + outstruct := new(struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 + }) + if err != nil { + return *outstruct, err + } + + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Outcome = *abi.ConvertType(out[1], new(uint8)).(*uint8) + outstruct.DeferredCharge = *abi.ConvertType(out[2], new(uint64)).(*uint64) + + return *outstruct, err + +} + +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. +// +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentSession) ClassifyMatchTimeout(matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { + return _ITournament.Contract.ClassifyMatchTimeout(&_ITournament.CallOpts, matchId) +} + +// ClassifyMatchTimeout is a free data retrieval call binding the contract method 0xdcead735. +// +// Solidity: function classifyMatchTimeout((bytes32,bytes32) matchId) view returns(uint8 actualPhase, uint8 outcome, uint64 deferredCharge) +func (_ITournament *ITournamentCallerSession) ClassifyMatchTimeout(matchId MatchId) (struct { + ActualPhase uint8 + Outcome uint8 + DeferredCharge uint64 +}, error) { + return _ITournament.Contract.ClassifyMatchTimeout(&_ITournament.CallOpts, matchId) +} + +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. +// +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentCaller) CommitmentStanding(opts *bind.CallOpts, commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "commitmentStanding", commitmentRoot) + + if err != nil { + return *new(ITournamentCommitmentStandingView), err + } + + out0 := *abi.ConvertType(out[0], new(ITournamentCommitmentStandingView)).(*ITournamentCommitmentStandingView) + + return out0, err + +} + +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. +// +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentSession) CommitmentStanding(commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { + return _ITournament.Contract.CommitmentStanding(&_ITournament.CallOpts, commitmentRoot) +} + +// CommitmentStanding is a free data retrieval call binding the contract method 0xf3cb94fc. +// +// Solidity: function commitmentStanding(bytes32 commitmentRoot) view returns((bool,bytes32,address,bool,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) CommitmentStanding(commitmentRoot [32]byte) (ITournamentCommitmentStandingView, error) { + return _ITournament.Contract.CommitmentStanding(&_ITournament.CallOpts, commitmentRoot) +} + +// GetCommitmentJoinedCount is a free data retrieval call binding the contract method 0x2c243a1e. +// +// Solidity: function getCommitmentJoinedCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetCommitmentJoinedCount(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "getCommitmentJoinedCount") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetCommitmentJoinedCount is a free data retrieval call binding the contract method 0x2c243a1e. +// +// Solidity: function getCommitmentJoinedCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetCommitmentJoinedCount() (*big.Int, error) { + return _ITournament.Contract.GetCommitmentJoinedCount(&_ITournament.CallOpts) +} + +// GetCommitmentJoinedCount is a free data retrieval call binding the contract method 0x2c243a1e. +// +// Solidity: function getCommitmentJoinedCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetCommitmentJoinedCount() (*big.Int, error) { + return _ITournament.Contract.GetCommitmentJoinedCount(&_ITournament.CallOpts) +} + +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. +// +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetLeafMatchSealedCount(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "getLeafMatchSealedCount") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. +// +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetLeafMatchSealedCount() (*big.Int, error) { + return _ITournament.Contract.GetLeafMatchSealedCount(&_ITournament.CallOpts) +} + +// GetLeafMatchSealedCount is a free data retrieval call binding the contract method 0x87373745. +// +// Solidity: function getLeafMatchSealedCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetLeafMatchSealedCount() (*big.Int, error) { + return _ITournament.Contract.GetLeafMatchSealedCount(&_ITournament.CallOpts) +} + +// GetMatchAdvancedCount is a free data retrieval call binding the contract method 0xf8cb3bd0. +// +// Solidity: function getMatchAdvancedCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetMatchAdvancedCount(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "getMatchAdvancedCount") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetMatchAdvancedCount is a free data retrieval call binding the contract method 0xf8cb3bd0. +// +// Solidity: function getMatchAdvancedCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetMatchAdvancedCount() (*big.Int, error) { + return _ITournament.Contract.GetMatchAdvancedCount(&_ITournament.CallOpts) +} + +// GetMatchAdvancedCount is a free data retrieval call binding the contract method 0xf8cb3bd0. +// +// Solidity: function getMatchAdvancedCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetMatchAdvancedCount() (*big.Int, error) { + return _ITournament.Contract.GetMatchAdvancedCount(&_ITournament.CallOpts) +} + +// GetMatchCreatedCount is a free data retrieval call binding the contract method 0x8989ced4. +// +// Solidity: function getMatchCreatedCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetMatchCreatedCount(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "getMatchCreatedCount") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetMatchCreatedCount is a free data retrieval call binding the contract method 0x8989ced4. +// +// Solidity: function getMatchCreatedCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetMatchCreatedCount() (*big.Int, error) { + return _ITournament.Contract.GetMatchCreatedCount(&_ITournament.CallOpts) +} + +// GetMatchCreatedCount is a free data retrieval call binding the contract method 0x8989ced4. +// +// Solidity: function getMatchCreatedCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetMatchCreatedCount() (*big.Int, error) { + return _ITournament.Contract.GetMatchCreatedCount(&_ITournament.CallOpts) +} + +// GetMatchDeletedCount is a free data retrieval call binding the contract method 0xd3976945. +// +// Solidity: function getMatchDeletedCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetMatchDeletedCount(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "getMatchDeletedCount") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetMatchDeletedCount is a free data retrieval call binding the contract method 0xd3976945. +// +// Solidity: function getMatchDeletedCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetMatchDeletedCount() (*big.Int, error) { + return _ITournament.Contract.GetMatchDeletedCount(&_ITournament.CallOpts) +} + +// GetMatchDeletedCount is a free data retrieval call binding the contract method 0xd3976945. +// +// Solidity: function getMatchDeletedCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetMatchDeletedCount() (*big.Int, error) { + return _ITournament.Contract.GetMatchDeletedCount(&_ITournament.CallOpts) +} + +// GetNewInnerTournamentCount is a free data retrieval call binding the contract method 0x30beca25. +// +// Solidity: function getNewInnerTournamentCount() view returns(uint256) +func (_ITournament *ITournamentCaller) GetNewInnerTournamentCount(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "getNewInnerTournamentCount") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// GetNewInnerTournamentCount is a free data retrieval call binding the contract method 0x30beca25. +// +// Solidity: function getNewInnerTournamentCount() view returns(uint256) +func (_ITournament *ITournamentSession) GetNewInnerTournamentCount() (*big.Int, error) { + return _ITournament.Contract.GetNewInnerTournamentCount(&_ITournament.CallOpts) +} + +// GetNewInnerTournamentCount is a free data retrieval call binding the contract method 0x30beca25. +// +// Solidity: function getNewInnerTournamentCount() view returns(uint256) +func (_ITournament *ITournamentCallerSession) GetNewInnerTournamentCount() (*big.Int, error) { + return _ITournament.Contract.GetNewInnerTournamentCount(&_ITournament.CallOpts) +} + +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. +// +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentCaller) InnerResult(opts *bind.CallOpts) (ITournamentInnerResultView, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "innerResult") + + if err != nil { + return *new(ITournamentInnerResultView), err + } + + out0 := *abi.ConvertType(out[0], new(ITournamentInnerResultView)).(*ITournamentInnerResultView) + + return out0, err + +} + +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. +// +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentSession) InnerResult() (ITournamentInnerResultView, error) { + return _ITournament.Contract.InnerResult(&_ITournament.CallOpts) +} + +// InnerResult is a free data retrieval call binding the contract method 0x08f0f877. +// +// Solidity: function innerResult() view returns((uint8,bytes32,uint64)) +func (_ITournament *ITournamentCallerSession) InnerResult() (ITournamentInnerResultView, error) { + return _ITournament.Contract.InnerResult(&_ITournament.CallOpts) +} + +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. +// +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentCaller) ReadyToSealMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "readyToSealMatch", matchIdHash) + + outstruct := new(struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView + }) + if err != nil { + return *outstruct, err + } + + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentReadyToSealMatchView)).(*ITournamentReadyToSealMatchView) + + return *outstruct, err + +} + +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. +// +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentSession) ReadyToSealMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { + return _ITournament.Contract.ReadyToSealMatch(&_ITournament.CallOpts, matchIdHash) +} + +// ReadyToSealMatch is a free data retrieval call binding the contract method 0x62d0d040. +// +// Solidity: function readyToSealMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,bytes32,bytes32,uint256,uint256,uint8) value) +func (_ITournament *ITournamentCallerSession) ReadyToSealMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentReadyToSealMatchView +}, error) { + return _ITournament.Contract.ReadyToSealMatch(&_ITournament.CallOpts, matchIdHash) +} + +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. +// +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentCaller) SealedMatch(opts *bind.CallOpts, matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "sealedMatch", matchIdHash) + + outstruct := new(struct { + ActualPhase uint8 + Value ITournamentSealedMatchView + }) + if err != nil { + return *outstruct, err + } + + outstruct.ActualPhase = *abi.ConvertType(out[0], new(uint8)).(*uint8) + outstruct.Value = *abi.ConvertType(out[1], new(ITournamentSealedMatchView)).(*ITournamentSealedMatchView) + + return *outstruct, err + +} + +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. +// +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentSession) SealedMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { + return _ITournament.Contract.SealedMatch(&_ITournament.CallOpts, matchIdHash) +} + +// SealedMatch is a free data retrieval call binding the contract method 0x6e42fc59. +// +// Solidity: function sealedMatch(bytes32 matchIdHash) view returns(uint8 actualPhase, (bytes32,uint256,uint256,bytes32,bytes32) value) +func (_ITournament *ITournamentCallerSession) SealedMatch(matchIdHash [32]byte) (struct { + ActualPhase uint8 + Value ITournamentSealedMatchView +}, error) { + return _ITournament.Contract.SealedMatch(&_ITournament.CallOpts, matchIdHash) +} + +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. +// +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentCaller) TournamentDescriptor(opts *bind.CallOpts) (ITournamentTournamentDescriptor, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "tournamentDescriptor") + + if err != nil { + return *new(ITournamentTournamentDescriptor), err + } + + out0 := *abi.ConvertType(out[0], new(ITournamentTournamentDescriptor)).(*ITournamentTournamentDescriptor) + + return out0, err + +} + +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. +// +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentSession) TournamentDescriptor() (ITournamentTournamentDescriptor, error) { + return _ITournament.Contract.TournamentDescriptor(&_ITournament.CallOpts) +} + +// TournamentDescriptor is a free data retrieval call binding the contract method 0x21719b1e. +// +// Solidity: function tournamentDescriptor() view returns((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) TournamentDescriptor() (ITournamentTournamentDescriptor, error) { + return _ITournament.Contract.TournamentDescriptor(&_ITournament.CallOpts) +} + +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. +// +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentCaller) TournamentStanding(opts *bind.CallOpts) (ITournamentTournamentStandingView, error) { + var out []interface{} + err := _ITournament.contract.Call(opts, &out, "tournamentStanding") + + if err != nil { + return *new(ITournamentTournamentStandingView), err + } + + out0 := *abi.ConvertType(out[0], new(ITournamentTournamentStandingView)).(*ITournamentTournamentStandingView) + + return out0, err + +} + +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. +// +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentSession) TournamentStanding() (ITournamentTournamentStandingView, error) { + return _ITournament.Contract.TournamentStanding(&_ITournament.CallOpts) +} + +// TournamentStanding is a free data retrieval call binding the contract method 0x0589deec. +// +// Solidity: function tournamentStanding() view returns((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64)) +func (_ITournament *ITournamentCallerSession) TournamentStanding() (ITournamentTournamentStandingView, error) { + return _ITournament.Contract.TournamentStanding(&_ITournament.CallOpts) +} + +// AdvanceMatch is a paid mutator transaction binding the contract method 0xfcc85391. +// +// Solidity: function advanceMatch((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode, bytes32 newLeftNode, bytes32 newRightNode) returns() +func (_ITournament *ITournamentTransactor) AdvanceMatch(opts *bind.TransactOpts, matchId MatchId, leftNode [32]byte, rightNode [32]byte, newLeftNode [32]byte, newRightNode [32]byte) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "advanceMatch", matchId, leftNode, rightNode, newLeftNode, newRightNode) +} + +// AdvanceMatch is a paid mutator transaction binding the contract method 0xfcc85391. +// +// Solidity: function advanceMatch((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode, bytes32 newLeftNode, bytes32 newRightNode) returns() +func (_ITournament *ITournamentSession) AdvanceMatch(matchId MatchId, leftNode [32]byte, rightNode [32]byte, newLeftNode [32]byte, newRightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.AdvanceMatch(&_ITournament.TransactOpts, matchId, leftNode, rightNode, newLeftNode, newRightNode) +} + +// AdvanceMatch is a paid mutator transaction binding the contract method 0xfcc85391. +// +// Solidity: function advanceMatch((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode, bytes32 newLeftNode, bytes32 newRightNode) returns() +func (_ITournament *ITournamentTransactorSession) AdvanceMatch(matchId MatchId, leftNode [32]byte, rightNode [32]byte, newLeftNode [32]byte, newRightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.AdvanceMatch(&_ITournament.TransactOpts, matchId, leftNode, rightNode, newLeftNode, newRightNode) +} + +// EliminateInnerTournament is a paid mutator transaction binding the contract method 0x26860b49. +// +// Solidity: function eliminateInnerTournament(address childTournament) returns() +func (_ITournament *ITournamentTransactor) EliminateInnerTournament(opts *bind.TransactOpts, childTournament common.Address) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "eliminateInnerTournament", childTournament) +} + +// EliminateInnerTournament is a paid mutator transaction binding the contract method 0x26860b49. +// +// Solidity: function eliminateInnerTournament(address childTournament) returns() +func (_ITournament *ITournamentSession) EliminateInnerTournament(childTournament common.Address) (*types.Transaction, error) { + return _ITournament.Contract.EliminateInnerTournament(&_ITournament.TransactOpts, childTournament) +} + +// EliminateInnerTournament is a paid mutator transaction binding the contract method 0x26860b49. +// +// Solidity: function eliminateInnerTournament(address childTournament) returns() +func (_ITournament *ITournamentTransactorSession) EliminateInnerTournament(childTournament common.Address) (*types.Transaction, error) { + return _ITournament.Contract.EliminateInnerTournament(&_ITournament.TransactOpts, childTournament) +} + +// EliminateMatchByTimeout is a paid mutator transaction binding the contract method 0x9a9b4b2b. +// +// Solidity: function eliminateMatchByTimeout((bytes32,bytes32) matchId) returns() +func (_ITournament *ITournamentTransactor) EliminateMatchByTimeout(opts *bind.TransactOpts, matchId MatchId) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "eliminateMatchByTimeout", matchId) +} + +// EliminateMatchByTimeout is a paid mutator transaction binding the contract method 0x9a9b4b2b. +// +// Solidity: function eliminateMatchByTimeout((bytes32,bytes32) matchId) returns() +func (_ITournament *ITournamentSession) EliminateMatchByTimeout(matchId MatchId) (*types.Transaction, error) { + return _ITournament.Contract.EliminateMatchByTimeout(&_ITournament.TransactOpts, matchId) +} + +// EliminateMatchByTimeout is a paid mutator transaction binding the contract method 0x9a9b4b2b. +// +// Solidity: function eliminateMatchByTimeout((bytes32,bytes32) matchId) returns() +func (_ITournament *ITournamentTransactorSession) EliminateMatchByTimeout(matchId MatchId) (*types.Transaction, error) { + return _ITournament.Contract.EliminateMatchByTimeout(&_ITournament.TransactOpts, matchId) +} + +// JoinTournament is a paid mutator transaction binding the contract method 0x1d5bf796. +// +// Solidity: function joinTournament(bytes32 finalState, bytes32[] proof, bytes32 leftNode, bytes32 rightNode) payable returns() +func (_ITournament *ITournamentTransactor) JoinTournament(opts *bind.TransactOpts, finalState [32]byte, proof [][32]byte, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "joinTournament", finalState, proof, leftNode, rightNode) +} + +// JoinTournament is a paid mutator transaction binding the contract method 0x1d5bf796. +// +// Solidity: function joinTournament(bytes32 finalState, bytes32[] proof, bytes32 leftNode, bytes32 rightNode) payable returns() +func (_ITournament *ITournamentSession) JoinTournament(finalState [32]byte, proof [][32]byte, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.JoinTournament(&_ITournament.TransactOpts, finalState, proof, leftNode, rightNode) +} + +// JoinTournament is a paid mutator transaction binding the contract method 0x1d5bf796. +// +// Solidity: function joinTournament(bytes32 finalState, bytes32[] proof, bytes32 leftNode, bytes32 rightNode) payable returns() +func (_ITournament *ITournamentTransactorSession) JoinTournament(finalState [32]byte, proof [][32]byte, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.JoinTournament(&_ITournament.TransactOpts, finalState, proof, leftNode, rightNode) +} + +// SealInnerMatchAndCreateInnerTournament is a paid mutator transaction binding the contract method 0x3f36e2fe. +// +// Solidity: function sealInnerMatchAndCreateInnerTournament((bytes32,bytes32) matchId, bytes32 leftLeaf, bytes32 rightLeaf, bytes32 agreeHash, bytes32[] agreeHashProof) returns() +func (_ITournament *ITournamentTransactor) SealInnerMatchAndCreateInnerTournament(opts *bind.TransactOpts, matchId MatchId, leftLeaf [32]byte, rightLeaf [32]byte, agreeHash [32]byte, agreeHashProof [][32]byte) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "sealInnerMatchAndCreateInnerTournament", matchId, leftLeaf, rightLeaf, agreeHash, agreeHashProof) +} + +// SealInnerMatchAndCreateInnerTournament is a paid mutator transaction binding the contract method 0x3f36e2fe. +// +// Solidity: function sealInnerMatchAndCreateInnerTournament((bytes32,bytes32) matchId, bytes32 leftLeaf, bytes32 rightLeaf, bytes32 agreeHash, bytes32[] agreeHashProof) returns() +func (_ITournament *ITournamentSession) SealInnerMatchAndCreateInnerTournament(matchId MatchId, leftLeaf [32]byte, rightLeaf [32]byte, agreeHash [32]byte, agreeHashProof [][32]byte) (*types.Transaction, error) { + return _ITournament.Contract.SealInnerMatchAndCreateInnerTournament(&_ITournament.TransactOpts, matchId, leftLeaf, rightLeaf, agreeHash, agreeHashProof) +} + +// SealInnerMatchAndCreateInnerTournament is a paid mutator transaction binding the contract method 0x3f36e2fe. +// +// Solidity: function sealInnerMatchAndCreateInnerTournament((bytes32,bytes32) matchId, bytes32 leftLeaf, bytes32 rightLeaf, bytes32 agreeHash, bytes32[] agreeHashProof) returns() +func (_ITournament *ITournamentTransactorSession) SealInnerMatchAndCreateInnerTournament(matchId MatchId, leftLeaf [32]byte, rightLeaf [32]byte, agreeHash [32]byte, agreeHashProof [][32]byte) (*types.Transaction, error) { + return _ITournament.Contract.SealInnerMatchAndCreateInnerTournament(&_ITournament.TransactOpts, matchId, leftLeaf, rightLeaf, agreeHash, agreeHashProof) +} + +// SealLeafMatch is a paid mutator transaction binding the contract method 0x5017746a. +// +// Solidity: function sealLeafMatch((bytes32,bytes32) matchId, bytes32 leftLeaf, bytes32 rightLeaf, bytes32 agreeHash, bytes32[] agreeHashProof) returns() +func (_ITournament *ITournamentTransactor) SealLeafMatch(opts *bind.TransactOpts, matchId MatchId, leftLeaf [32]byte, rightLeaf [32]byte, agreeHash [32]byte, agreeHashProof [][32]byte) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "sealLeafMatch", matchId, leftLeaf, rightLeaf, agreeHash, agreeHashProof) +} + +// SealLeafMatch is a paid mutator transaction binding the contract method 0x5017746a. +// +// Solidity: function sealLeafMatch((bytes32,bytes32) matchId, bytes32 leftLeaf, bytes32 rightLeaf, bytes32 agreeHash, bytes32[] agreeHashProof) returns() +func (_ITournament *ITournamentSession) SealLeafMatch(matchId MatchId, leftLeaf [32]byte, rightLeaf [32]byte, agreeHash [32]byte, agreeHashProof [][32]byte) (*types.Transaction, error) { + return _ITournament.Contract.SealLeafMatch(&_ITournament.TransactOpts, matchId, leftLeaf, rightLeaf, agreeHash, agreeHashProof) +} + +// SealLeafMatch is a paid mutator transaction binding the contract method 0x5017746a. +// +// Solidity: function sealLeafMatch((bytes32,bytes32) matchId, bytes32 leftLeaf, bytes32 rightLeaf, bytes32 agreeHash, bytes32[] agreeHashProof) returns() +func (_ITournament *ITournamentTransactorSession) SealLeafMatch(matchId MatchId, leftLeaf [32]byte, rightLeaf [32]byte, agreeHash [32]byte, agreeHashProof [][32]byte) (*types.Transaction, error) { + return _ITournament.Contract.SealLeafMatch(&_ITournament.TransactOpts, matchId, leftLeaf, rightLeaf, agreeHash, agreeHashProof) +} + +// TryRecoveringBond is a paid mutator transaction binding the contract method 0x33807cbc. +// +// Solidity: function tryRecoveringBond() returns(bool) +func (_ITournament *ITournamentTransactor) TryRecoveringBond(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "tryRecoveringBond") +} + +// TryRecoveringBond is a paid mutator transaction binding the contract method 0x33807cbc. +// +// Solidity: function tryRecoveringBond() returns(bool) +func (_ITournament *ITournamentSession) TryRecoveringBond() (*types.Transaction, error) { + return _ITournament.Contract.TryRecoveringBond(&_ITournament.TransactOpts) +} + +// TryRecoveringBond is a paid mutator transaction binding the contract method 0x33807cbc. +// +// Solidity: function tryRecoveringBond() returns(bool) +func (_ITournament *ITournamentTransactorSession) TryRecoveringBond() (*types.Transaction, error) { + return _ITournament.Contract.TryRecoveringBond(&_ITournament.TransactOpts) +} + +// WinInnerTournament is a paid mutator transaction binding the contract method 0x4a95153e. +// +// Solidity: function winInnerTournament(address childTournament, bytes32 leftNode, bytes32 rightNode) returns() +func (_ITournament *ITournamentTransactor) WinInnerTournament(opts *bind.TransactOpts, childTournament common.Address, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "winInnerTournament", childTournament, leftNode, rightNode) +} + +// WinInnerTournament is a paid mutator transaction binding the contract method 0x4a95153e. +// +// Solidity: function winInnerTournament(address childTournament, bytes32 leftNode, bytes32 rightNode) returns() +func (_ITournament *ITournamentSession) WinInnerTournament(childTournament common.Address, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.WinInnerTournament(&_ITournament.TransactOpts, childTournament, leftNode, rightNode) +} + +// WinInnerTournament is a paid mutator transaction binding the contract method 0x4a95153e. +// +// Solidity: function winInnerTournament(address childTournament, bytes32 leftNode, bytes32 rightNode) returns() +func (_ITournament *ITournamentTransactorSession) WinInnerTournament(childTournament common.Address, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.WinInnerTournament(&_ITournament.TransactOpts, childTournament, leftNode, rightNode) +} + +// WinLeafMatch is a paid mutator transaction binding the contract method 0x6041ddd5. +// +// Solidity: function winLeafMatch((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode, bytes proofs) returns() +func (_ITournament *ITournamentTransactor) WinLeafMatch(opts *bind.TransactOpts, matchId MatchId, leftNode [32]byte, rightNode [32]byte, proofs []byte) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "winLeafMatch", matchId, leftNode, rightNode, proofs) +} + +// WinLeafMatch is a paid mutator transaction binding the contract method 0x6041ddd5. +// +// Solidity: function winLeafMatch((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode, bytes proofs) returns() +func (_ITournament *ITournamentSession) WinLeafMatch(matchId MatchId, leftNode [32]byte, rightNode [32]byte, proofs []byte) (*types.Transaction, error) { + return _ITournament.Contract.WinLeafMatch(&_ITournament.TransactOpts, matchId, leftNode, rightNode, proofs) +} + +// WinLeafMatch is a paid mutator transaction binding the contract method 0x6041ddd5. +// +// Solidity: function winLeafMatch((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode, bytes proofs) returns() +func (_ITournament *ITournamentTransactorSession) WinLeafMatch(matchId MatchId, leftNode [32]byte, rightNode [32]byte, proofs []byte) (*types.Transaction, error) { + return _ITournament.Contract.WinLeafMatch(&_ITournament.TransactOpts, matchId, leftNode, rightNode, proofs) +} + +// WinMatchByTimeout is a paid mutator transaction binding the contract method 0xff78e0ee. +// +// Solidity: function winMatchByTimeout((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode) returns() +func (_ITournament *ITournamentTransactor) WinMatchByTimeout(opts *bind.TransactOpts, matchId MatchId, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.contract.Transact(opts, "winMatchByTimeout", matchId, leftNode, rightNode) +} + +// WinMatchByTimeout is a paid mutator transaction binding the contract method 0xff78e0ee. +// +// Solidity: function winMatchByTimeout((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode) returns() +func (_ITournament *ITournamentSession) WinMatchByTimeout(matchId MatchId, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.WinMatchByTimeout(&_ITournament.TransactOpts, matchId, leftNode, rightNode) +} + +// WinMatchByTimeout is a paid mutator transaction binding the contract method 0xff78e0ee. +// +// Solidity: function winMatchByTimeout((bytes32,bytes32) matchId, bytes32 leftNode, bytes32 rightNode) returns() +func (_ITournament *ITournamentTransactorSession) WinMatchByTimeout(matchId MatchId, leftNode [32]byte, rightNode [32]byte) (*types.Transaction, error) { + return _ITournament.Contract.WinMatchByTimeout(&_ITournament.TransactOpts, matchId, leftNode, rightNode) +} + +// ITournamentBondRecoveredIterator is returned from FilterBondRecovered and is used to iterate over the raw logs and unpacked data for BondRecovered events raised by the ITournament contract. +type ITournamentBondRecoveredIterator struct { + Event *ITournamentBondRecovered // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentBondRecoveredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentBondRecovered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentBondRecovered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentBondRecoveredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentBondRecoveredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentBondRecovered represents a BondRecovered event raised by the ITournament contract. +type ITournamentBondRecovered struct { + Commitment [32]byte + Claimer common.Address + Payment *big.Int + Burned *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterBondRecovered is a free log retrieval operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) FilterBondRecovered(opts *bind.FilterOpts, commitment [][32]byte, claimer []common.Address) (*ITournamentBondRecoveredIterator, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + var claimerRule []interface{} + for _, claimerItem := range claimer { + claimerRule = append(claimerRule, claimerItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "BondRecovered", commitmentRule, claimerRule) + if err != nil { + return nil, err + } + return &ITournamentBondRecoveredIterator{contract: _ITournament.contract, event: "BondRecovered", logs: logs, sub: sub}, nil +} + +// WatchBondRecovered is a free log subscription operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) WatchBondRecovered(opts *bind.WatchOpts, sink chan<- *ITournamentBondRecovered, commitment [][32]byte, claimer []common.Address) (event.Subscription, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + var claimerRule []interface{} + for _, claimerItem := range claimer { + claimerRule = append(claimerRule, claimerItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "BondRecovered", commitmentRule, claimerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentBondRecovered) + if err := _ITournament.contract.UnpackLog(event, "BondRecovered", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseBondRecovered is a log parse operation binding the contract event 0x11bda32901e3e37ab2859f6a8b7b525ecd2b67c579f173700ad8642346cabf4e. +// +// Solidity: event BondRecovered(bytes32 indexed commitment, address indexed claimer, uint256 payment, uint256 burned) +func (_ITournament *ITournamentFilterer) ParseBondRecovered(log types.Log) (*ITournamentBondRecovered, error) { + event := new(ITournamentBondRecovered) + if err := _ITournament.contract.UnpackLog(event, "BondRecovered", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentCommitmentJoinedIterator is returned from FilterCommitmentJoined and is used to iterate over the raw logs and unpacked data for CommitmentJoined events raised by the ITournament contract. +type ITournamentCommitmentJoinedIterator struct { + Event *ITournamentCommitmentJoined // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentCommitmentJoinedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentCommitmentJoined) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentCommitmentJoined) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentCommitmentJoinedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentCommitmentJoinedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentCommitmentJoined represents a CommitmentJoined event raised by the ITournament contract. +type ITournamentCommitmentJoined struct { + Commitment [32]byte + FinalStateHash [32]byte + Submitter common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterCommitmentJoined is a free log retrieval operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. +// +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) +func (_ITournament *ITournamentFilterer) FilterCommitmentJoined(opts *bind.FilterOpts, commitment [][32]byte, submitter []common.Address) (*ITournamentCommitmentJoinedIterator, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "CommitmentJoined", commitmentRule, submitterRule) + if err != nil { + return nil, err + } + return &ITournamentCommitmentJoinedIterator{contract: _ITournament.contract, event: "CommitmentJoined", logs: logs, sub: sub}, nil +} + +// WatchCommitmentJoined is a free log subscription operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. +// +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) +func (_ITournament *ITournamentFilterer) WatchCommitmentJoined(opts *bind.WatchOpts, sink chan<- *ITournamentCommitmentJoined, commitment [][32]byte, submitter []common.Address) (event.Subscription, error) { + + var commitmentRule []interface{} + for _, commitmentItem := range commitment { + commitmentRule = append(commitmentRule, commitmentItem) + } + + var submitterRule []interface{} + for _, submitterItem := range submitter { + submitterRule = append(submitterRule, submitterItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "CommitmentJoined", commitmentRule, submitterRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentCommitmentJoined) + if err := _ITournament.contract.UnpackLog(event, "CommitmentJoined", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseCommitmentJoined is a log parse operation binding the contract event 0xf8e98e9201e0caf973fa5520838a058bd8a819e0a8f5dd1fa08c3e550d4b9872. +// +// Solidity: event CommitmentJoined(bytes32 indexed commitment, bytes32 finalStateHash, address indexed submitter) +func (_ITournament *ITournamentFilterer) ParseCommitmentJoined(log types.Log) (*ITournamentCommitmentJoined, error) { + event := new(ITournamentCommitmentJoined) + if err := _ITournament.contract.UnpackLog(event, "CommitmentJoined", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentLeafMatchSealedIterator is returned from FilterLeafMatchSealed and is used to iterate over the raw logs and unpacked data for LeafMatchSealed events raised by the ITournament contract. +type ITournamentLeafMatchSealedIterator struct { + Event *ITournamentLeafMatchSealed // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentLeafMatchSealedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentLeafMatchSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentLeafMatchSealed) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentLeafMatchSealedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentLeafMatchSealedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentLeafMatchSealed represents a LeafMatchSealed event raised by the ITournament contract. +type ITournamentLeafMatchSealed struct { + MatchIdHash [32]byte + One [32]byte + Two [32]byte + AgreeState [32]byte + DivergencePosition *big.Int + FinalStateOne [32]byte + FinalStateTwo [32]byte + DeadlineOne uint64 + DeadlineTwo uint64 + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterLeafMatchSealed is a free log retrieval operation binding the contract event 0x71fadc30cf69447f432880db37c552ec27366ea80468707d257a8e86c0d10efb. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, bytes32 one, bytes32 two, bytes32 agreeState, uint256 divergencePosition, bytes32 finalStateOne, bytes32 finalStateTwo, uint64 deadlineOne, uint64 deadlineTwo, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) FilterLeafMatchSealed(opts *bind.FilterOpts, matchIdHash [][32]byte) (*ITournamentLeafMatchSealedIterator, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "LeafMatchSealed", matchIdHashRule) + if err != nil { + return nil, err + } + return &ITournamentLeafMatchSealedIterator{contract: _ITournament.contract, event: "LeafMatchSealed", logs: logs, sub: sub}, nil +} + +// WatchLeafMatchSealed is a free log subscription operation binding the contract event 0x71fadc30cf69447f432880db37c552ec27366ea80468707d257a8e86c0d10efb. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, bytes32 one, bytes32 two, bytes32 agreeState, uint256 divergencePosition, bytes32 finalStateOne, bytes32 finalStateTwo, uint64 deadlineOne, uint64 deadlineTwo, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) WatchLeafMatchSealed(opts *bind.WatchOpts, sink chan<- *ITournamentLeafMatchSealed, matchIdHash [][32]byte) (event.Subscription, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "LeafMatchSealed", matchIdHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentLeafMatchSealed) + if err := _ITournament.contract.UnpackLog(event, "LeafMatchSealed", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseLeafMatchSealed is a log parse operation binding the contract event 0x71fadc30cf69447f432880db37c552ec27366ea80468707d257a8e86c0d10efb. +// +// Solidity: event LeafMatchSealed(bytes32 indexed matchIdHash, bytes32 one, bytes32 two, bytes32 agreeState, uint256 divergencePosition, bytes32 finalStateOne, bytes32 finalStateTwo, uint64 deadlineOne, uint64 deadlineTwo, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) ParseLeafMatchSealed(log types.Log) (*ITournamentLeafMatchSealed, error) { + event := new(ITournamentLeafMatchSealed) + if err := _ITournament.contract.UnpackLog(event, "LeafMatchSealed", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentMatchAdvancedIterator is returned from FilterMatchAdvanced and is used to iterate over the raw logs and unpacked data for MatchAdvanced events raised by the ITournament contract. +type ITournamentMatchAdvancedIterator struct { + Event *ITournamentMatchAdvanced // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentMatchAdvancedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentMatchAdvanced) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentMatchAdvanced) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentMatchAdvancedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentMatchAdvancedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentMatchAdvanced represents a MatchAdvanced event raised by the ITournament contract. +type ITournamentMatchAdvanced struct { + MatchIdHash [32]byte + One [32]byte + Two [32]byte + OtherParent [32]byte + LeftNode [32]byte + SegmentStartPosition *big.Int + CurrentHeight uint64 + ResponderDeadline uint64 + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterMatchAdvanced is a free log retrieval operation binding the contract event 0x7f64b1b1c9315d0c13cf8aab2cec06ca48d1e7bccd4b409bb8cd0774647bc67e. +// +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 one, bytes32 two, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 currentHeight, uint64 responderDeadline, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) FilterMatchAdvanced(opts *bind.FilterOpts, matchIdHash [][32]byte) (*ITournamentMatchAdvancedIterator, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "MatchAdvanced", matchIdHashRule) + if err != nil { + return nil, err + } + return &ITournamentMatchAdvancedIterator{contract: _ITournament.contract, event: "MatchAdvanced", logs: logs, sub: sub}, nil +} + +// WatchMatchAdvanced is a free log subscription operation binding the contract event 0x7f64b1b1c9315d0c13cf8aab2cec06ca48d1e7bccd4b409bb8cd0774647bc67e. +// +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 one, bytes32 two, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 currentHeight, uint64 responderDeadline, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) WatchMatchAdvanced(opts *bind.WatchOpts, sink chan<- *ITournamentMatchAdvanced, matchIdHash [][32]byte) (event.Subscription, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "MatchAdvanced", matchIdHashRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentMatchAdvanced) + if err := _ITournament.contract.UnpackLog(event, "MatchAdvanced", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseMatchAdvanced is a log parse operation binding the contract event 0x7f64b1b1c9315d0c13cf8aab2cec06ca48d1e7bccd4b409bb8cd0774647bc67e. +// +// Solidity: event MatchAdvanced(bytes32 indexed matchIdHash, bytes32 one, bytes32 two, bytes32 otherParent, bytes32 leftNode, uint256 segmentStartPosition, uint64 currentHeight, uint64 responderDeadline, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) ParseMatchAdvanced(log types.Log) (*ITournamentMatchAdvanced, error) { + event := new(ITournamentMatchAdvanced) + if err := _ITournament.contract.UnpackLog(event, "MatchAdvanced", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentMatchCreatedIterator is returned from FilterMatchCreated and is used to iterate over the raw logs and unpacked data for MatchCreated events raised by the ITournament contract. +type ITournamentMatchCreatedIterator struct { + Event *ITournamentMatchCreated // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentMatchCreatedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentMatchCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentMatchCreated) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentMatchCreatedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentMatchCreatedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentMatchCreated represents a MatchCreated event raised by the ITournament contract. +type ITournamentMatchCreated struct { + MatchIdHash [32]byte + One [32]byte + Two [32]byte + LeftOfTwo [32]byte + ResponderDeadline uint64 + EliminableAt uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterMatchCreated is a free log retrieval operation binding the contract event 0xb98467806859c2b452dbfe60905ffe650a4ac0bada72cbe887f71bdb014f8d7f. +// +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 responderDeadline, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) FilterMatchCreated(opts *bind.FilterOpts, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (*ITournamentMatchCreatedIterator, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + var oneRule []interface{} + for _, oneItem := range one { + oneRule = append(oneRule, oneItem) + } + var twoRule []interface{} + for _, twoItem := range two { + twoRule = append(twoRule, twoItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "MatchCreated", matchIdHashRule, oneRule, twoRule) + if err != nil { + return nil, err + } + return &ITournamentMatchCreatedIterator{contract: _ITournament.contract, event: "MatchCreated", logs: logs, sub: sub}, nil +} + +// WatchMatchCreated is a free log subscription operation binding the contract event 0xb98467806859c2b452dbfe60905ffe650a4ac0bada72cbe887f71bdb014f8d7f. +// +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 responderDeadline, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) WatchMatchCreated(opts *bind.WatchOpts, sink chan<- *ITournamentMatchCreated, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (event.Subscription, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + var oneRule []interface{} + for _, oneItem := range one { + oneRule = append(oneRule, oneItem) + } + var twoRule []interface{} + for _, twoItem := range two { + twoRule = append(twoRule, twoItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "MatchCreated", matchIdHashRule, oneRule, twoRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentMatchCreated) + if err := _ITournament.contract.UnpackLog(event, "MatchCreated", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseMatchCreated is a log parse operation binding the contract event 0xb98467806859c2b452dbfe60905ffe650a4ac0bada72cbe887f71bdb014f8d7f. +// +// Solidity: event MatchCreated(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, bytes32 leftOfTwo, uint64 responderDeadline, uint64 eliminableAt) +func (_ITournament *ITournamentFilterer) ParseMatchCreated(log types.Log) (*ITournamentMatchCreated, error) { + event := new(ITournamentMatchCreated) + if err := _ITournament.contract.UnpackLog(event, "MatchCreated", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentMatchDeletedIterator is returned from FilterMatchDeleted and is used to iterate over the raw logs and unpacked data for MatchDeleted events raised by the ITournament contract. +type ITournamentMatchDeletedIterator struct { + Event *ITournamentMatchDeleted // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentMatchDeletedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentMatchDeleted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentMatchDeleted) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentMatchDeletedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentMatchDeletedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentMatchDeleted represents a MatchDeleted event raised by the ITournament contract. +type ITournamentMatchDeleted struct { + MatchIdHash [32]byte + One [32]byte + Two [32]byte + Reason uint8 + WinnerCommitment uint8 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterMatchDeleted is a free log retrieval operation binding the contract event 0x1d3ae066e466a0203dcc05b06daeba9922750bcf99aa7837a92123fca1287406. +// +// Solidity: event MatchDeleted(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, uint8 reason, uint8 winnerCommitment) +func (_ITournament *ITournamentFilterer) FilterMatchDeleted(opts *bind.FilterOpts, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (*ITournamentMatchDeletedIterator, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + var oneRule []interface{} + for _, oneItem := range one { + oneRule = append(oneRule, oneItem) + } + var twoRule []interface{} + for _, twoItem := range two { + twoRule = append(twoRule, twoItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "MatchDeleted", matchIdHashRule, oneRule, twoRule) + if err != nil { + return nil, err + } + return &ITournamentMatchDeletedIterator{contract: _ITournament.contract, event: "MatchDeleted", logs: logs, sub: sub}, nil +} + +// WatchMatchDeleted is a free log subscription operation binding the contract event 0x1d3ae066e466a0203dcc05b06daeba9922750bcf99aa7837a92123fca1287406. +// +// Solidity: event MatchDeleted(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, uint8 reason, uint8 winnerCommitment) +func (_ITournament *ITournamentFilterer) WatchMatchDeleted(opts *bind.WatchOpts, sink chan<- *ITournamentMatchDeleted, matchIdHash [][32]byte, one [][32]byte, two [][32]byte) (event.Subscription, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + var oneRule []interface{} + for _, oneItem := range one { + oneRule = append(oneRule, oneItem) + } + var twoRule []interface{} + for _, twoItem := range two { + twoRule = append(twoRule, twoItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "MatchDeleted", matchIdHashRule, oneRule, twoRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentMatchDeleted) + if err := _ITournament.contract.UnpackLog(event, "MatchDeleted", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseMatchDeleted is a log parse operation binding the contract event 0x1d3ae066e466a0203dcc05b06daeba9922750bcf99aa7837a92123fca1287406. +// +// Solidity: event MatchDeleted(bytes32 indexed matchIdHash, bytes32 indexed one, bytes32 indexed two, uint8 reason, uint8 winnerCommitment) +func (_ITournament *ITournamentFilterer) ParseMatchDeleted(log types.Log) (*ITournamentMatchDeleted, error) { + event := new(ITournamentMatchDeleted) + if err := _ITournament.contract.UnpackLog(event, "MatchDeleted", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentNewInnerTournamentIterator is returned from FilterNewInnerTournament and is used to iterate over the raw logs and unpacked data for NewInnerTournament events raised by the ITournament contract. +type ITournamentNewInnerTournamentIterator struct { + Event *ITournamentNewInnerTournament // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentNewInnerTournamentIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentNewInnerTournament) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentNewInnerTournament) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentNewInnerTournamentIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentNewInnerTournamentIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentNewInnerTournament represents a NewInnerTournament event raised by the ITournament contract. +type ITournamentNewInnerTournament struct { + MatchIdHash [32]byte + ChildTournament common.Address + One [32]byte + Two [32]byte + ContestedFinalStateOne [32]byte + ContestedFinalStateTwo [32]byte + Descriptor ITournamentTournamentDescriptor + BondValue *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterNewInnerTournament is a free log retrieval operation binding the contract event 0x8ac9fa8a2f66ec2f1841fa997537ff3b5b13f0e6b4a276c5ce7c8c28764e2466. +// +// Solidity: event NewInnerTournament(bytes32 indexed matchIdHash, address indexed childTournament, bytes32 one, bytes32 two, bytes32 contestedFinalStateOne, bytes32 contestedFinalStateTwo, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64) descriptor, uint256 bondValue) +func (_ITournament *ITournamentFilterer) FilterNewInnerTournament(opts *bind.FilterOpts, matchIdHash [][32]byte, childTournament []common.Address) (*ITournamentNewInnerTournamentIterator, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + var childTournamentRule []interface{} + for _, childTournamentItem := range childTournament { + childTournamentRule = append(childTournamentRule, childTournamentItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "NewInnerTournament", matchIdHashRule, childTournamentRule) + if err != nil { + return nil, err + } + return &ITournamentNewInnerTournamentIterator{contract: _ITournament.contract, event: "NewInnerTournament", logs: logs, sub: sub}, nil +} + +// WatchNewInnerTournament is a free log subscription operation binding the contract event 0x8ac9fa8a2f66ec2f1841fa997537ff3b5b13f0e6b4a276c5ce7c8c28764e2466. +// +// Solidity: event NewInnerTournament(bytes32 indexed matchIdHash, address indexed childTournament, bytes32 one, bytes32 two, bytes32 contestedFinalStateOne, bytes32 contestedFinalStateTwo, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64) descriptor, uint256 bondValue) +func (_ITournament *ITournamentFilterer) WatchNewInnerTournament(opts *bind.WatchOpts, sink chan<- *ITournamentNewInnerTournament, matchIdHash [][32]byte, childTournament []common.Address) (event.Subscription, error) { + + var matchIdHashRule []interface{} + for _, matchIdHashItem := range matchIdHash { + matchIdHashRule = append(matchIdHashRule, matchIdHashItem) + } + var childTournamentRule []interface{} + for _, childTournamentItem := range childTournament { + childTournamentRule = append(childTournamentRule, childTournamentItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "NewInnerTournament", matchIdHashRule, childTournamentRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentNewInnerTournament) + if err := _ITournament.contract.UnpackLog(event, "NewInnerTournament", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseNewInnerTournament is a log parse operation binding the contract event 0x8ac9fa8a2f66ec2f1841fa997537ff3b5b13f0e6b4a276c5ce7c8c28764e2466. +// +// Solidity: event NewInnerTournament(bytes32 indexed matchIdHash, address indexed childTournament, bytes32 one, bytes32 two, bytes32 contestedFinalStateOne, bytes32 contestedFinalStateTwo, (bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64) descriptor, uint256 bondValue) +func (_ITournament *ITournamentFilterer) ParseNewInnerTournament(log types.Log) (*ITournamentNewInnerTournament, error) { + event := new(ITournamentNewInnerTournament) + if err := _ITournament.contract.UnpackLog(event, "NewInnerTournament", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentPartialBondRefundIterator is returned from FilterPartialBondRefund and is used to iterate over the raw logs and unpacked data for PartialBondRefund events raised by the ITournament contract. +type ITournamentPartialBondRefundIterator struct { + Event *ITournamentPartialBondRefund // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentPartialBondRefundIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentPartialBondRefund) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentPartialBondRefund) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentPartialBondRefundIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentPartialBondRefundIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentPartialBondRefund represents a PartialBondRefund event raised by the ITournament contract. +type ITournamentPartialBondRefund struct { + Recipient common.Address + Value *big.Int + Success bool + Raw types.Log // Blockchain specific contextual infos +} + +// FilterPartialBondRefund is a free log retrieval operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. +// +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) +func (_ITournament *ITournamentFilterer) FilterPartialBondRefund(opts *bind.FilterOpts, recipient []common.Address, success []bool) (*ITournamentPartialBondRefundIterator, error) { + + var recipientRule []interface{} + for _, recipientItem := range recipient { + recipientRule = append(recipientRule, recipientItem) + } + + var successRule []interface{} + for _, successItem := range success { + successRule = append(successRule, successItem) + } + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "PartialBondRefund", recipientRule, successRule) + if err != nil { + return nil, err + } + return &ITournamentPartialBondRefundIterator{contract: _ITournament.contract, event: "PartialBondRefund", logs: logs, sub: sub}, nil +} + +// WatchPartialBondRefund is a free log subscription operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. +// +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) +func (_ITournament *ITournamentFilterer) WatchPartialBondRefund(opts *bind.WatchOpts, sink chan<- *ITournamentPartialBondRefund, recipient []common.Address, success []bool) (event.Subscription, error) { + + var recipientRule []interface{} + for _, recipientItem := range recipient { + recipientRule = append(recipientRule, recipientItem) + } + + var successRule []interface{} + for _, successItem := range success { + successRule = append(successRule, successItem) + } + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "PartialBondRefund", recipientRule, successRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentPartialBondRefund) + if err := _ITournament.contract.UnpackLog(event, "PartialBondRefund", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParsePartialBondRefund is a log parse operation binding the contract event 0xeff3c62ee3695d2aab88b472c281a81d4f20bd5e8b13b775210e77a228d46926. +// +// Solidity: event PartialBondRefund(address indexed recipient, uint256 value, bool indexed success) +func (_ITournament *ITournamentFilterer) ParsePartialBondRefund(log types.Log) (*ITournamentPartialBondRefund, error) { + event := new(ITournamentPartialBondRefund) + if err := _ITournament.contract.UnpackLog(event, "PartialBondRefund", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// ITournamentStandingChangedIterator is returned from FilterStandingChanged and is used to iterate over the raw logs and unpacked data for StandingChanged events raised by the ITournament contract. +type ITournamentStandingChangedIterator struct { + Event *ITournamentStandingChanged // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *ITournamentStandingChangedIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(ITournamentStandingChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(ITournamentStandingChanged) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *ITournamentStandingChangedIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *ITournamentStandingChangedIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// ITournamentStandingChanged represents a StandingChanged event raised by the ITournament contract. +type ITournamentStandingChanged struct { + MatchCount *big.Int + Dangling [32]byte + ParentCommitment [32]byte + ResultAt uint64 + WinnerExpiresAt uint64 + Raw types.Log // Blockchain specific contextual infos +} + +// FilterStandingChanged is a free log retrieval operation binding the contract event 0x2fbc19cf9980c334e64332c6587cddbdddc46fcbf678fe3f062427570b7fdefd. +// +// Solidity: event StandingChanged(uint256 matchCount, bytes32 dangling, bytes32 parentCommitment, uint64 resultAt, uint64 winnerExpiresAt) +func (_ITournament *ITournamentFilterer) FilterStandingChanged(opts *bind.FilterOpts) (*ITournamentStandingChangedIterator, error) { + + logs, sub, err := _ITournament.contract.FilterLogs(opts, "StandingChanged") + if err != nil { + return nil, err + } + return &ITournamentStandingChangedIterator{contract: _ITournament.contract, event: "StandingChanged", logs: logs, sub: sub}, nil +} + +// WatchStandingChanged is a free log subscription operation binding the contract event 0x2fbc19cf9980c334e64332c6587cddbdddc46fcbf678fe3f062427570b7fdefd. +// +// Solidity: event StandingChanged(uint256 matchCount, bytes32 dangling, bytes32 parentCommitment, uint64 resultAt, uint64 winnerExpiresAt) +func (_ITournament *ITournamentFilterer) WatchStandingChanged(opts *bind.WatchOpts, sink chan<- *ITournamentStandingChanged) (event.Subscription, error) { + + logs, sub, err := _ITournament.contract.WatchLogs(opts, "StandingChanged") + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(ITournamentStandingChanged) + if err := _ITournament.contract.UnpackLog(event, "StandingChanged", log); err != nil { + // If the signature doesn't match, skip this log. + if errors.Is(err, bind.ErrEventSignatureMismatch) { + continue + } + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseStandingChanged is a log parse operation binding the contract event 0x2fbc19cf9980c334e64332c6587cddbdddc46fcbf678fe3f062427570b7fdefd. +// +// Solidity: event StandingChanged(uint256 matchCount, bytes32 dangling, bytes32 parentCommitment, uint64 resultAt, uint64 winnerExpiresAt) +func (_ITournament *ITournamentFilterer) ParseStandingChanged(log types.Log) (*ITournamentStandingChanged, error) { + event := new(ITournamentStandingChanged) + if err := _ITournament.contract.UnpackLog(event, "StandingChanged", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go new file mode 100644 index 000000000..037f31930 --- /dev/null +++ b/pkg/contracts/iusdwithdrawaloutputbuilder/iusdwithdrawaloutputbuilder.go @@ -0,0 +1,307 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package iusdwithdrawaloutputbuilder + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// IUsdWithdrawalOutputBuilderMetaData contains all meta data concerning the IUsdWithdrawalOutputBuilder contract. +var IUsdWithdrawalOutputBuilderMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"buildWithdrawalOutput\",\"inputs\":[{\"name\":\"appContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"account\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"output\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"token\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractIERC20\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"major\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"minor\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"patch\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"preRelease\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"buildMetadata\",\"type\":\"string\",\"internalType\":\"string\"}],\"stateMutability\":\"view\"},{\"type\":\"error\",\"name\":\"InvalidAccountSize\",\"inputs\":[{\"name\":\"attemptedAccountSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"accountSize\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]}]", +} + +// IUsdWithdrawalOutputBuilderABI is the input ABI used to generate the binding from. +// Deprecated: Use IUsdWithdrawalOutputBuilderMetaData.ABI instead. +var IUsdWithdrawalOutputBuilderABI = IUsdWithdrawalOutputBuilderMetaData.ABI + +// IUsdWithdrawalOutputBuilder is an auto generated Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilder struct { + IUsdWithdrawalOutputBuilderCaller // Read-only binding to the contract + IUsdWithdrawalOutputBuilderTransactor // Write-only binding to the contract + IUsdWithdrawalOutputBuilderFilterer // Log filterer for contract events +} + +// IUsdWithdrawalOutputBuilderCaller is an auto generated read-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IUsdWithdrawalOutputBuilderTransactor is an auto generated write-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IUsdWithdrawalOutputBuilderFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type IUsdWithdrawalOutputBuilderFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// IUsdWithdrawalOutputBuilderSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type IUsdWithdrawalOutputBuilderSession struct { + Contract *IUsdWithdrawalOutputBuilder // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IUsdWithdrawalOutputBuilderCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type IUsdWithdrawalOutputBuilderCallerSession struct { + Contract *IUsdWithdrawalOutputBuilderCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// IUsdWithdrawalOutputBuilderTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type IUsdWithdrawalOutputBuilderTransactorSession struct { + Contract *IUsdWithdrawalOutputBuilderTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// IUsdWithdrawalOutputBuilderRaw is an auto generated low-level Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderRaw struct { + Contract *IUsdWithdrawalOutputBuilder // Generic contract binding to access the raw methods on +} + +// IUsdWithdrawalOutputBuilderCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderCallerRaw struct { + Contract *IUsdWithdrawalOutputBuilderCaller // Generic read-only contract binding to access the raw methods on +} + +// IUsdWithdrawalOutputBuilderTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type IUsdWithdrawalOutputBuilderTransactorRaw struct { + Contract *IUsdWithdrawalOutputBuilderTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewIUsdWithdrawalOutputBuilder creates a new instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilder(address common.Address, backend bind.ContractBackend) (*IUsdWithdrawalOutputBuilder, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilder{IUsdWithdrawalOutputBuilderCaller: IUsdWithdrawalOutputBuilderCaller{contract: contract}, IUsdWithdrawalOutputBuilderTransactor: IUsdWithdrawalOutputBuilderTransactor{contract: contract}, IUsdWithdrawalOutputBuilderFilterer: IUsdWithdrawalOutputBuilderFilterer{contract: contract}}, nil +} + +// NewIUsdWithdrawalOutputBuilderCaller creates a new read-only instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilderCaller(address common.Address, caller bind.ContractCaller) (*IUsdWithdrawalOutputBuilderCaller, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilderCaller{contract: contract}, nil +} + +// NewIUsdWithdrawalOutputBuilderTransactor creates a new write-only instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilderTransactor(address common.Address, transactor bind.ContractTransactor) (*IUsdWithdrawalOutputBuilderTransactor, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilderTransactor{contract: contract}, nil +} + +// NewIUsdWithdrawalOutputBuilderFilterer creates a new log filterer instance of IUsdWithdrawalOutputBuilder, bound to a specific deployed contract. +func NewIUsdWithdrawalOutputBuilderFilterer(address common.Address, filterer bind.ContractFilterer) (*IUsdWithdrawalOutputBuilderFilterer, error) { + contract, err := bindIUsdWithdrawalOutputBuilder(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &IUsdWithdrawalOutputBuilderFilterer{contract: contract}, nil +} + +// bindIUsdWithdrawalOutputBuilder binds a generic wrapper to an already deployed contract. +func bindIUsdWithdrawalOutputBuilder(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := IUsdWithdrawalOutputBuilderMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IUsdWithdrawalOutputBuilder.Contract.IUsdWithdrawalOutputBuilderCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.IUsdWithdrawalOutputBuilderTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.IUsdWithdrawalOutputBuilderTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _IUsdWithdrawalOutputBuilder.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _IUsdWithdrawalOutputBuilder.Contract.contract.Transact(opts, method, params...) +} + +// BuildWithdrawalOutput is a free data retrieval call binding the contract method 0x1d2675a3. +// +// Solidity: function buildWithdrawalOutput(address appContract, bytes account) view returns(bytes output) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCaller) BuildWithdrawalOutput(opts *bind.CallOpts, appContract common.Address, account []byte) ([]byte, error) { + var out []interface{} + err := _IUsdWithdrawalOutputBuilder.contract.Call(opts, &out, "buildWithdrawalOutput", appContract, account) + + if err != nil { + return *new([]byte), err + } + + out0 := *abi.ConvertType(out[0], new([]byte)).(*[]byte) + + return out0, err + +} + +// BuildWithdrawalOutput is a free data retrieval call binding the contract method 0x1d2675a3. +// +// Solidity: function buildWithdrawalOutput(address appContract, bytes account) view returns(bytes output) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderSession) BuildWithdrawalOutput(appContract common.Address, account []byte) ([]byte, error) { + return _IUsdWithdrawalOutputBuilder.Contract.BuildWithdrawalOutput(&_IUsdWithdrawalOutputBuilder.CallOpts, appContract, account) +} + +// BuildWithdrawalOutput is a free data retrieval call binding the contract method 0x1d2675a3. +// +// Solidity: function buildWithdrawalOutput(address appContract, bytes account) view returns(bytes output) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerSession) BuildWithdrawalOutput(appContract common.Address, account []byte) ([]byte, error) { + return _IUsdWithdrawalOutputBuilder.Contract.BuildWithdrawalOutput(&_IUsdWithdrawalOutputBuilder.CallOpts, appContract, account) +} + +// Token is a free data retrieval call binding the contract method 0xfc0c546a. +// +// Solidity: function token() view returns(address) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCaller) Token(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _IUsdWithdrawalOutputBuilder.contract.Call(opts, &out, "token") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// Token is a free data retrieval call binding the contract method 0xfc0c546a. +// +// Solidity: function token() view returns(address) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderSession) Token() (common.Address, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Token(&_IUsdWithdrawalOutputBuilder.CallOpts) +} + +// Token is a free data retrieval call binding the contract method 0xfc0c546a. +// +// Solidity: function token() view returns(address) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerSession) Token() (common.Address, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Token(&_IUsdWithdrawalOutputBuilder.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCaller) Version(opts *bind.CallOpts) (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + var out []interface{} + err := _IUsdWithdrawalOutputBuilder.contract.Call(opts, &out, "version") + + outstruct := new(struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string + }) + if err != nil { + return *outstruct, err + } + + outstruct.Major = *abi.ConvertType(out[0], new(uint64)).(*uint64) + outstruct.Minor = *abi.ConvertType(out[1], new(uint64)).(*uint64) + outstruct.Patch = *abi.ConvertType(out[2], new(uint64)).(*uint64) + outstruct.PreRelease = *abi.ConvertType(out[3], new(string)).(*string) + outstruct.BuildMetadata = *abi.ConvertType(out[4], new(string)).(*string) + + return *outstruct, err + +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Version(&_IUsdWithdrawalOutputBuilder.CallOpts) +} + +// Version is a free data retrieval call binding the contract method 0x54fd4d50. +// +// Solidity: function version() view returns(uint64 major, uint64 minor, uint64 patch, string preRelease, string buildMetadata) +func (_IUsdWithdrawalOutputBuilder *IUsdWithdrawalOutputBuilderCallerSession) Version() (struct { + Major uint64 + Minor uint64 + Patch uint64 + PreRelease string + BuildMetadata string +}, error) { + return _IUsdWithdrawalOutputBuilder.Contract.Version(&_IUsdWithdrawalOutputBuilder.CallOpts) +} diff --git a/pkg/contracts/outputs/outputs.go b/pkg/contracts/outputs/outputs.go new file mode 100644 index 000000000..cc0814151 --- /dev/null +++ b/pkg/contracts/outputs/outputs.go @@ -0,0 +1,248 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package outputs + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType + _ = time.Tick + _ = context.Background +) + +// OutputsMetaData contains all meta data concerning the Outputs contract. +var OutputsMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"DelegateCallVoucher\",\"inputs\":[{\"name\":\"destination\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"Notice\",\"inputs\":[{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"Voucher\",\"inputs\":[{\"name\":\"destination\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"payload\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"}]", +} + +// OutputsABI is the input ABI used to generate the binding from. +// Deprecated: Use OutputsMetaData.ABI instead. +var OutputsABI = OutputsMetaData.ABI + +// Outputs is an auto generated Go binding around an Ethereum contract. +type Outputs struct { + OutputsCaller // Read-only binding to the contract + OutputsTransactor // Write-only binding to the contract + OutputsFilterer // Log filterer for contract events +} + +// OutputsCaller is an auto generated read-only Go binding around an Ethereum contract. +type OutputsCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// OutputsTransactor is an auto generated write-only Go binding around an Ethereum contract. +type OutputsTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// OutputsFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type OutputsFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// OutputsSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type OutputsSession struct { + Contract *Outputs // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// OutputsCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type OutputsCallerSession struct { + Contract *OutputsCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// OutputsTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type OutputsTransactorSession struct { + Contract *OutputsTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// OutputsRaw is an auto generated low-level Go binding around an Ethereum contract. +type OutputsRaw struct { + Contract *Outputs // Generic contract binding to access the raw methods on +} + +// OutputsCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type OutputsCallerRaw struct { + Contract *OutputsCaller // Generic read-only contract binding to access the raw methods on +} + +// OutputsTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type OutputsTransactorRaw struct { + Contract *OutputsTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewOutputs creates a new instance of Outputs, bound to a specific deployed contract. +func NewOutputs(address common.Address, backend bind.ContractBackend) (*Outputs, error) { + contract, err := bindOutputs(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &Outputs{OutputsCaller: OutputsCaller{contract: contract}, OutputsTransactor: OutputsTransactor{contract: contract}, OutputsFilterer: OutputsFilterer{contract: contract}}, nil +} + +// NewOutputsCaller creates a new read-only instance of Outputs, bound to a specific deployed contract. +func NewOutputsCaller(address common.Address, caller bind.ContractCaller) (*OutputsCaller, error) { + contract, err := bindOutputs(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &OutputsCaller{contract: contract}, nil +} + +// NewOutputsTransactor creates a new write-only instance of Outputs, bound to a specific deployed contract. +func NewOutputsTransactor(address common.Address, transactor bind.ContractTransactor) (*OutputsTransactor, error) { + contract, err := bindOutputs(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &OutputsTransactor{contract: contract}, nil +} + +// NewOutputsFilterer creates a new log filterer instance of Outputs, bound to a specific deployed contract. +func NewOutputsFilterer(address common.Address, filterer bind.ContractFilterer) (*OutputsFilterer, error) { + contract, err := bindOutputs(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &OutputsFilterer{contract: contract}, nil +} + +// bindOutputs binds a generic wrapper to an already deployed contract. +func bindOutputs(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := OutputsMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Outputs *OutputsRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Outputs.Contract.OutputsCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Outputs *OutputsRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Outputs.Contract.OutputsTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Outputs *OutputsRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Outputs.Contract.OutputsTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Outputs *OutputsCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Outputs.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Outputs *OutputsTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Outputs.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Outputs *OutputsTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Outputs.Contract.contract.Transact(opts, method, params...) +} + +// DelegateCallVoucher is a paid mutator transaction binding the contract method 0x10321e8b. +// +// Solidity: function DelegateCallVoucher(address destination, bytes payload) returns() +func (_Outputs *OutputsTransactor) DelegateCallVoucher(opts *bind.TransactOpts, destination common.Address, payload []byte) (*types.Transaction, error) { + return _Outputs.contract.Transact(opts, "DelegateCallVoucher", destination, payload) +} + +// DelegateCallVoucher is a paid mutator transaction binding the contract method 0x10321e8b. +// +// Solidity: function DelegateCallVoucher(address destination, bytes payload) returns() +func (_Outputs *OutputsSession) DelegateCallVoucher(destination common.Address, payload []byte) (*types.Transaction, error) { + return _Outputs.Contract.DelegateCallVoucher(&_Outputs.TransactOpts, destination, payload) +} + +// DelegateCallVoucher is a paid mutator transaction binding the contract method 0x10321e8b. +// +// Solidity: function DelegateCallVoucher(address destination, bytes payload) returns() +func (_Outputs *OutputsTransactorSession) DelegateCallVoucher(destination common.Address, payload []byte) (*types.Transaction, error) { + return _Outputs.Contract.DelegateCallVoucher(&_Outputs.TransactOpts, destination, payload) +} + +// Notice is a paid mutator transaction binding the contract method 0xc258d6e5. +// +// Solidity: function Notice(bytes payload) returns() +func (_Outputs *OutputsTransactor) Notice(opts *bind.TransactOpts, payload []byte) (*types.Transaction, error) { + return _Outputs.contract.Transact(opts, "Notice", payload) +} + +// Notice is a paid mutator transaction binding the contract method 0xc258d6e5. +// +// Solidity: function Notice(bytes payload) returns() +func (_Outputs *OutputsSession) Notice(payload []byte) (*types.Transaction, error) { + return _Outputs.Contract.Notice(&_Outputs.TransactOpts, payload) +} + +// Notice is a paid mutator transaction binding the contract method 0xc258d6e5. +// +// Solidity: function Notice(bytes payload) returns() +func (_Outputs *OutputsTransactorSession) Notice(payload []byte) (*types.Transaction, error) { + return _Outputs.Contract.Notice(&_Outputs.TransactOpts, payload) +} + +// Voucher is a paid mutator transaction binding the contract method 0x237a816f. +// +// Solidity: function Voucher(address destination, uint256 value, bytes payload) returns() +func (_Outputs *OutputsTransactor) Voucher(opts *bind.TransactOpts, destination common.Address, value *big.Int, payload []byte) (*types.Transaction, error) { + return _Outputs.contract.Transact(opts, "Voucher", destination, value, payload) +} + +// Voucher is a paid mutator transaction binding the contract method 0x237a816f. +// +// Solidity: function Voucher(address destination, uint256 value, bytes payload) returns() +func (_Outputs *OutputsSession) Voucher(destination common.Address, value *big.Int, payload []byte) (*types.Transaction, error) { + return _Outputs.Contract.Voucher(&_Outputs.TransactOpts, destination, value, payload) +} + +// Voucher is a paid mutator transaction binding the contract method 0x237a816f. +// +// Solidity: function Voucher(address destination, uint256 value, bytes payload) returns() +func (_Outputs *OutputsTransactorSession) Voucher(destination common.Address, value *big.Int, payload []byte) (*types.Transaction, error) { + return _Outputs.Contract.Voucher(&_Outputs.TransactOpts, destination, value, payload) +} diff --git a/pkg/emulator/emulator.go b/pkg/emulator/emulator.go new file mode 100644 index 000000000..a57b3bb79 --- /dev/null +++ b/pkg/emulator/emulator.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// This package is a binding to the emulator's C API. +// Refer to the machine-c files in the emulator's repository for documentation +// (mainly machine-c-api.h and jsonrpc-machine-c-api.h). +// +//nolint:gocritic // CGo output-parameter wrappers trigger dupSubExpr false positives. +package emulator + +// #cgo LDFLAGS: -lcartesi -lcartesi_jsonrpc +// #include +// #include "cartesi-machine/cm-jsonrpc.h" +import "C" + +import ( + "encoding/json" + "runtime" + "time" + "unsafe" +) + +// GetVersion returns the linked library's encoded runtime semantic version. +func GetVersion() uint64 { + return uint64(C.cm_get_version()) +} + +// DefaultMarchID reads the architecture from the linked library's default configuration. +func DefaultMarchID() (uint64, error) { + var local Machine + config, err := local.GetDefaultConfig() + if err != nil { + return 0, err + } + var decoded struct { + Processor struct { + Registers struct { + MarchID uint64 `json:"marchid"` + } `json:"registers"` + } `json:"processor"` + } + if err := json.Unmarshal([]byte(config), &decoded); err != nil { + return 0, err + } + return decoded.Processor.Registers.MarchID, nil +} + +// GetUarchPristineHash reads the pristine uarch hash from the linked library. +func GetUarchPristineHash() (Hash, error) { + var local Machine + return local.GetUarchPristineHash() +} + +func ConnectServer(address string, timeout time.Duration) (*RemoteMachine, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cAddr := C.CString(address) + defer C.free(unsafe.Pointer(cAddr)) + + var cm *C.cm_machine + if err := newError(C.cm_jsonrpc_connect_server(cAddr, C.int64_t(timeout.Milliseconds()), &cm)); err != nil { + return nil, err + } + return &RemoteMachine{Machine: Machine{ptr: cm}}, nil +} + +func SpawnServer(address string, timeout time.Duration) (*RemoteMachine, string, uint32, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cAddr := C.CString(address) + defer C.free(unsafe.Pointer(cAddr)) + + var cm *C.cm_machine + var boundAddr *C.char + var pid C.uint32_t + code := C.cm_jsonrpc_spawn_server(cAddr, C.int64_t(timeout.Milliseconds()), &cm, &boundAddr, &pid) + if err := newError(code); err != nil { + return nil, "", 0, err + } + return &RemoteMachine{Machine: Machine{ptr: cm}}, C.GoString(boundAddr), uint32(pid), nil +} + +func CreateMachine(config, runtimeConfig, dir string) (*Machine, error) { + machine := &Machine{} + err := machine.Create(config, runtimeConfig, dir) + return machine, err +} diff --git a/pkg/emulator/identity_test.go b/pkg/emulator/identity_test.go new file mode 100644 index 000000000..55be4287b --- /dev/null +++ b/pkg/emulator/identity_test.go @@ -0,0 +1,62 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package emulator + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultMarchID(t *testing.T) { + marchID, err := DefaultMarchID() + require.NoError(t, err) + require.Equal(t, MarchID, marchID) +} + +func TestUarchIdentityHash(t *testing.T) { + pristine, err := GetUarchPristineHash() + require.NoError(t, err) + machine := createLoopMachine(t) + localPristine, err := machine.GetUarchPristineHash() + require.NoError(t, err) + require.Equal(t, pristine, localPristine) + node, err := machine.GetNodeHash(UarchStateStartAddress, UarchStateLog2Size) + require.NoError(t, err) + require.Equal(t, pristine, node) + + require.NoError(t, machine.WriteReg(REG_UARCH_X1, 1)) + node, err = machine.GetNodeHash(UarchStateStartAddress, UarchStateLog2Size) + require.NoError(t, err) + require.NotEqual(t, pristine, node) + localPristine, err = machine.GetUarchPristineHash() + require.NoError(t, err) + require.Equal(t, pristine, localPristine) +} + +func TestUarchIdentityHashFromServer(t *testing.T) { + const timeout = 5 * time.Second + remote, _, _, err := SpawnServer("127.0.0.1:0", timeout) + require.NoError(t, err) + stopped := false + t.Cleanup(func() { + if !stopped { + assert.NoError(t, remote.ShutdownServer()) + } + remote.Delete() + }) + require.NoError(t, remote.SetTimeout(timeout.Milliseconds())) + expected, err := GetUarchPristineHash() + require.NoError(t, err) + actual, err := remote.GetUarchPristineHash() + require.NoError(t, err) + require.Equal(t, expected, actual) + require.NoError(t, remote.ShutdownServer()) + stopped = true + actual, err = remote.GetUarchPristineHash() + require.Error(t, err) + require.Zero(t, actual) +} diff --git a/pkg/emulator/machine.go b/pkg/emulator/machine.go new file mode 100644 index 000000000..5ab2013f7 --- /dev/null +++ b/pkg/emulator/machine.go @@ -0,0 +1,537 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// This package is a binding to the emulator's C API. +// Refer to the machine-c files in the emulator's repository for documentation +// (mainly machine-c-api.h and jsonrpc-machine-c-api.h). +// +//nolint:gocritic // CGo output-parameter wrappers trigger dupSubExpr false positives. +package emulator + +// #include +// #include +// #include "cartesi-machine/cm.h" +// +// static inline cm_error go_cm_send_cmio_response( +// cm_machine *m, +// uint16_t reason, +// const uint8_t *data, +// uint32_t length, +// const uint8_t *revert_root_hash) +// { +// return cm_send_cmio_response( +// m, reason, data, length, (const cm_hash *) revert_root_hash); +// } +import "C" + +import ( + "encoding/json" + "math" + "runtime" + "sync" + "unsafe" +) + +const HashSize = C.sizeof_cm_hash + +// Common type aliases +type Hash = [HashSize]byte + +// RevertUarchTail is the emulator-owned JSON array of base64-encoded state +// hashes for the reset-delimited uarch period corresponding to a revert root. +// It remains opaque so collector output can be passed back without decoding and +// re-encoding every hash. +type RevertUarchTail json.RawMessage + +// ----------------------------------------------------------------------------- +// Machine Methods +// ----------------------------------------------------------------------------- + +type Machine struct { + ptr *C.cm_machine + mu sync.Mutex +} + +func (m *Machine) callCAPI(f func()) { + m.mu.Lock() + defer m.mu.Unlock() + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + f() +} + +// destroy +func (m *Machine) Destroy() error { + var err error + m.callCAPI(func() { + err = newError(C.cm_destroy(m.ptr)) + }) + return err +} + +// delete +func (m *Machine) Delete() { + m.callCAPI(func() { + C.cm_delete(m.ptr) + }) +} + +// create +func (m *Machine) Create(config, runtimeConfig, dir string) error { + var err error + m.callCAPI(func() { + var cConfig *C.char + var cRuntime *C.char + var cDir *C.char + if config != "" { + cConfig = C.CString(config) + defer C.free(unsafe.Pointer(cConfig)) + } + if runtimeConfig != "" { + cRuntime = C.CString(runtimeConfig) + defer C.free(unsafe.Pointer(cRuntime)) + } + if dir != "" { + cDir = C.CString(dir) + defer C.free(unsafe.Pointer(cDir)) + } + err = newError(C.cm_create_new(cConfig, cRuntime, cDir, &m.ptr)) + }) + return err +} + +// get_default_config +func (m *Machine) GetDefaultConfig() (string, error) { + var cfg *C.char + var err error + var res string + + m.callCAPI(func() { + // passing 'm->ptr' so it can fetch remote defaults if the API supports that + err = newError(C.cm_get_default_config(m.ptr, &cfg)) + if err != nil || cfg == nil { + return + } + res = C.GoString(cfg) + // no need to free 'cfg' here, as it is a static string + }) + + if err != nil { + return "", err + } + return res, nil +} + +// get_initial_config +func (m *Machine) GetInitialConfig() (string, error) { + var cfg *C.char + var err error + var res string + + m.callCAPI(func() { + err = newError(C.cm_get_initial_config(m.ptr, &cfg)) + if err != nil || cfg == nil { + return + } + res = C.GoString(cfg) + // no need to free 'cfg' here, as it is a static string + }) + + if err != nil { + return "", err + } + return res, nil +} + +// get_proof +func (m *Machine) GetProof(address uint64, log2TargetSize, log2RootSize int32) (string, error) { + var proof *C.char + var err error + var res string + + m.callCAPI(func() { + err = newError(C.cm_get_proof( + m.ptr, + C.uint64_t(address), + C.int32_t(log2TargetSize), + C.int32_t(log2RootSize), + &proof, + )) + if err != nil || proof == nil { + return + } + res = C.GoString(proof) + // no need to free 'proof' here, as it is a static string + }) + + if err != nil { + return "", err + } + return res, nil +} + +// get_reg_address +func (m *Machine) GetRegAddress(reg RegID) (uint64, error) { + var val C.uint64_t + var err error + + m.callCAPI(func() { + err = newError(C.cm_get_reg_address(m.ptr, C.cm_reg(reg), &val)) + }) + + if err != nil { + return 0, err + } + return uint64(val), nil +} + +// get_root_hash +func (m *Machine) GetRootHash() (Hash, error) { + var cHash C.cm_hash + var err error + + m.callCAPI(func() { + err = newError(C.cm_get_root_hash(m.ptr, &cHash)) + }) + if err != nil { + return Hash{}, err + } + + // Zero-copy: reinterpret C array as Go array + return *(*Hash)(unsafe.Pointer(&cHash)), nil +} + +// GetUarchPristineHash reads the pristine uarch hash from this machine's library or server. +func (m *Machine) GetUarchPristineHash() (Hash, error) { + var cHash C.cm_hash + var err error + m.callCAPI(func() { + err = newError(C.cm_get_uarch_pristine_hash(m.ptr, &cHash)) + }) + if err != nil { + return Hash{}, err + } + return *(*Hash)(unsafe.Pointer(&cHash)), nil +} + +// GetNodeHash reads the hash of an aligned machine-memory span. +func (m *Machine) GetNodeHash(address uint64, log2Size int32) (Hash, error) { + var cHash C.cm_hash + var err error + m.callCAPI(func() { + err = newError(C.cm_get_node_hash(m.ptr, C.uint64_t(address), C.int32_t(log2Size), &cHash)) + }) + if err != nil { + return Hash{}, err + } + return *(*Hash)(unsafe.Pointer(&cHash)), nil +} + +// get_runtime_config +func (m *Machine) GetRuntimeConfig() (string, error) { + var rcfg *C.char + var err error + var str string + + m.callCAPI(func() { + err = newError(C.cm_get_runtime_config(m.ptr, &rcfg)) + if err != nil || rcfg == nil { + return + } + str = C.GoString(rcfg) + // no need to free 'rcfg' here, as it is a static string + }) + + if err != nil { + return "", err + } + return str, nil +} + +// is_empty +func (m *Machine) IsEmpty() (bool, error) { + var yes C.bool + var err error + + m.callCAPI(func() { + err = newError(C.cm_is_empty(m.ptr, &yes)) + }) + + if err != nil { + return false, err + } + return bool(yes), nil +} + +// load +func (m *Machine) Load(dir string, runtimeConfig string) error { + var err error + + m.callCAPI(func() { + cDir := C.CString(dir) + defer C.free(unsafe.Pointer(cDir)) + + var cRuntime *C.char + if runtimeConfig != "" { + cRuntime = C.CString(runtimeConfig) + defer C.free(unsafe.Pointer(cRuntime)) + } + err = newError(C.cm_load(m.ptr, cDir, cRuntime, SharingNone)) + }) + + return err +} + +// read_memory +func (m *Machine) ReadMemory(address, length uint64) ([]byte, error) { + if length == 0 { + return []byte{}, nil + } + + buf := make([]byte, length) + var err error + + m.callCAPI(func() { + err = newError( + C.cm_read_memory(m.ptr, C.uint64_t(address), + (*C.uchar)(unsafe.Pointer(&buf[0])), + C.uint64_t(length)), + ) + }) + + if err != nil { + return nil, err + } + return buf, nil +} + +// write_memory +func (m *Machine) WriteMemory(address uint64, data []byte) error { + if len(data) == 0 { + return nil + } + + var err error + + m.callCAPI(func() { + code := C.cm_write_memory(m.ptr, + C.uint64_t(address), + (*C.uchar)(unsafe.Pointer(&data[0])), + C.uint64_t(len(data))) + err = newError(code) + }) + + return err +} + +// read_reg +func (m *Machine) ReadReg(r RegID) (uint64, error) { + var val C.uint64_t + var err error + + m.callCAPI(func() { + err = newError(C.cm_read_reg(m.ptr, C.cm_reg(r), &val)) + }) + + if err != nil { + return 0, err + } + return uint64(val), nil +} + +// write_reg +func (m *Machine) WriteReg(r RegID, value uint64) error { + var err error + + m.callCAPI(func() { + err = newError(C.cm_write_reg(m.ptr, C.cm_reg(r), C.uint64_t(value))) + }) + + return err +} + +// receive_cmio_request +func (m *Machine) ReceiveCmioRequest() (uint8, uint16, []byte, error) { + var cCmd C.uint8_t + var cReason C.uint16_t + var cLen C.uint64_t + var data []byte + var err error + + m.callCAPI(func() { + // This is how the emulator own source code does it, + // first call to get the length then allocate and read the data + err = newError(C.cm_receive_cmio_request( + m.ptr, + &cCmd, + &cReason, + (*C.uint8_t)(unsafe.Pointer(nil)), + &cLen, + )) + if err != nil { + return + } + data = make([]byte, cLen) + if cLen != 0 { + err = newError(C.cm_receive_cmio_request( + m.ptr, + (*C.uint8_t)(unsafe.Pointer(nil)), + (*C.uint16_t)(unsafe.Pointer(nil)), + (*C.uint8_t)(unsafe.Pointer(&data[0])), + &cLen, + )) + } + }) + + if err != nil { + return 0, 0, nil, err + } + return uint8(cCmd), uint16(cReason), data, nil +} + +// run +func (m *Machine) Run(mcycleEnd uint64) (BreakReason, error) { + var br C.cm_break_reason + var err error + + m.callCAPI(func() { + err = newError(C.cm_run(m.ptr, C.uint64_t(mcycleEnd), &br)) + }) + + if err != nil { + return BreakReasonFailed, err + } + return BreakReason(br), nil +} + +// collect_mcycle_root_hashes +func (m *Machine) CollectMCycleRootHashes( + mcycleEnd, + log2McyclePeriod, + mcyclePhase uint64, + log2BundleMcycleCount int32, + previousPartialBundle json.RawMessage, +) ([]byte, error) { + var err error + var result []byte + + m.callCAPI(func() { + var cResult *C.char + var previousPartialBundleC *C.char + if len(previousPartialBundle) > 0 { + previousPartialBundleC = C.CString(string(previousPartialBundle)) + defer C.free(unsafe.Pointer(previousPartialBundleC)) + } + err = newError(C.cm_collect_mcycle_root_hashes( + m.ptr, + C.uint64_t(mcycleEnd), + C.uint64_t(log2McyclePeriod), + C.uint64_t(mcyclePhase), + C.int32_t(log2BundleMcycleCount), + previousPartialBundleC, + &cResult)) + if err == nil { + result = []byte(C.GoString(cResult)) + } + }) + if err != nil { + return nil, err + } + + return result, nil +} + +// collect_uarch_cycle_root_hashes +func (m *Machine) CollectUarchCycleRootHashes( + mcycleEnd uint64, + log2BundleUarchCycleCount int32, + revertUarchTail RevertUarchTail, +) ([]byte, error) { + var err error + var result []byte + + m.callCAPI(func() { + var cResult *C.char + var revertUarchTailC *C.char + if len(revertUarchTail) > 0 { + revertUarchTailC = C.CString(string(revertUarchTail)) + defer C.free(unsafe.Pointer(revertUarchTailC)) + } + err = newError(C.cm_collect_uarch_cycle_root_hashes( + m.ptr, + C.uint64_t(mcycleEnd), + C.int32_t(log2BundleUarchCycleCount), + revertUarchTailC, + &cResult)) + if err == nil { + result = []byte(C.GoString(cResult)) + } + }) + + if err != nil { + return nil, err + } + return result, nil +} + +// send_cmio_response +func (m *Machine) SendCmioResponse(reason uint16, data []byte, revertRootHash *Hash) error { + var err error + + // we can't let cm handle this because, e.g. 4GB+2MB would clamp to 2MB instead of an error. + if len(data) > math.MaxUint32 { + return Error{ErrCodeInvalidArgument, "data field of SendCmioResponse is larger than 4GB"} + } + + m.callCAPI(func() { + var ptrData *C.uint8_t + sizeData := C.uint32_t(len(data)) + if sizeData > 0 { + ptrData = (*C.uint8_t)(unsafe.Pointer(&data[0])) + } + var ptrHash *C.uint8_t + if revertRootHash != nil { + ptrHash = (*C.uint8_t)(unsafe.Pointer(revertRootHash)) + } + err = newError(C.go_cm_send_cmio_response( + m.ptr, + C.uint16_t(reason), + ptrData, + sizeData, + ptrHash, + )) + }) + + return err +} + +// set_runtime_config +func (m *Machine) SetRuntimeConfig(runtimeJSON string) error { + var err error + + m.callCAPI(func() { + var rc *C.char + if runtimeJSON != "" { + rc = C.CString(runtimeJSON) + defer C.free(unsafe.Pointer(rc)) + } + err = newError(C.cm_set_runtime_config(m.ptr, rc)) + }) + + return err +} + +// store +func (m *Machine) Store(directory string) error { + var err error + + m.callCAPI(func() { + cDir := C.CString(directory) + defer C.free(unsafe.Pointer(cDir)) + err = newError(C.cm_store(m.ptr, cDir, SharingAll)) + }) + + return err +} diff --git a/pkg/emulator/machine_test.go b/pkg/emulator/machine_test.go new file mode 100644 index 000000000..178265d11 --- /dev/null +++ b/pkg/emulator/machine_test.go @@ -0,0 +1,177 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package emulator + +import ( + "encoding/base64" + "encoding/binary" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mcycleRootHashesResult struct { + Hashes []string `json:"hashes"` + MCyclePhase uint64 `json:"mcycle_phase"` + BreakReason string `json:"break_reason"` + PartialBundle json.RawMessage `json:"partial_bundle,omitempty"` +} + +func createLoopMachine(t *testing.T) *Machine { + t.Helper() + + const ramLength = 4096 + image := make([]byte, ramLength) + // jal x0, 0 loops forever while allowing mcycle to advance predictably. + binary.LittleEndian.PutUint32(image, 0x0000006f) + imagePath := filepath.Join(t.TempDir(), "loop.bin") + require.NoError(t, os.WriteFile(imagePath, image, 0o600)) + + config, err := json.Marshal(map[string]any{ + "ram": map[string]any{ + "length": ramLength, + "backing_store": map[string]any{ + "data_filename": imagePath, + }, + }, + }) + require.NoError(t, err) + + machine, err := CreateMachine(string(config), "", "") + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, machine.Destroy()) + machine.Delete() + }) + return machine +} + +func collectMCycleRootHashes( + t *testing.T, + machine *Machine, + target, + log2Period, + phase uint64, + log2Bundle int32, + partialBundle json.RawMessage, +) mcycleRootHashesResult { + t.Helper() + + rawResult, err := machine.CollectMCycleRootHashes(target, log2Period, phase, log2Bundle, partialBundle) + require.NoError(t, err) + var result mcycleRootHashesResult + require.NoError(t, json.Unmarshal(rawResult, &result)) + return result +} + +func TestCollectMCycleRootHashes_PartitionedBundlingMatchesOneShot(t *testing.T) { + const ( + mcycleStart = uint64(1) + mcycleEnd = uint64(65) + log2Period = uint64(2) + log2Bundle = int32(2) + ) + targets := []uint64{2, 7, 19, 34, mcycleEnd} + + oneShotMachine := createLoopMachine(t) + partitionedMachine := createLoopMachine(t) + for _, machine := range []*Machine{oneShotMachine, partitionedMachine} { + breakReason, err := machine.Run(mcycleStart) + require.NoError(t, err) + require.Equal(t, BreakReasonReachedTargetMcycle, breakReason) + } + + oneShot := collectMCycleRootHashes( + t, + oneShotMachine, + mcycleEnd, + log2Period, + mcycleStart%(uint64(1)< 0 + } + + require.True(t, sawPartialBundle, "test partitions must exercise partial-bundle continuation") + require.Equal(t, oneShot, partitioned) + + oneShotRoot, err := oneShotMachine.GetRootHash() + require.NoError(t, err) + partitionedRoot, err := partitionedMachine.GetRootHash() + require.NoError(t, err) + require.Equal(t, oneShotRoot, partitionedRoot) +} + +func TestCollectUarchCycleRootHashes_AcceptsOpaqueRevertTail(t *testing.T) { + machine := createLoopMachine(t) + zeroHash := Hash{} + encodedZeroHash := base64.StdEncoding.EncodeToString(zeroHash[:]) + tail, err := json.Marshal([]string{encodedZeroHash, encodedZeroHash}) + require.NoError(t, err) + + rawResult, err := machine.CollectUarchCycleRootHashes(1, 0, RevertUarchTail(tail)) + require.NoError(t, err) + + var result struct { + Hashes []string `json:"hashes"` + BreakReason string `json:"break_reason"` + } + require.NoError(t, json.Unmarshal(rawResult, &result)) + require.NotEmpty(t, result.Hashes) + require.Equal(t, "reached_target_mcycle", result.BreakReason) +} + +func TestCollectMCycleBundlesMatchDenseSamples(t *testing.T) { + for _, height := range []int32{2, 6} { + plain := collectMCycleRootHashes(t, createLoopMachine(t), 256, 2, 0, 0, nil) + bundled := collectMCycleRootHashes(t, createLoopMachine(t), 256, 2, 0, height, nil) + width := 1 << height + require.Len(t, plain.Hashes, 64) + require.Len(t, bundled.Hashes, len(plain.Hashes)/width) + for entry, encoded := range bundled.Hashes { + level := make([]Hash, width) + for index := range level { + data, err := base64.StdEncoding.DecodeString(plain.Hashes[entry*width+index]) + require.NoError(t, err) + require.Len(t, data, 32) + copy(level[index][:], data) + } + for len(level) > 1 { + next := make([]Hash, len(level)/2) + for index := range next { + next[index] = crypto.Keccak256Hash(level[2*index][:], level[2*index+1][:]) + } + level = next + } + data, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err) + require.Equal(t, level[0][:], data) + } + } +} diff --git a/pkg/emulator/remote.go b/pkg/emulator/remote.go new file mode 100644 index 000000000..4911a6bed --- /dev/null +++ b/pkg/emulator/remote.go @@ -0,0 +1,181 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// This package is a binding to the emulator's C API. +// Refer to the machine-c files in the emulator's repository for documentation +// (mainly machine-c-api.h and jsonrpc-machine-c-api.h). +// +//nolint:gocritic // CGo output-parameter wrappers trigger dupSubExpr false positives. +package emulator + +// #include +// #include "cartesi-machine/cm-jsonrpc.h" +import "C" + +import ( + "unsafe" +) + +// ----------------------------------------------------------------------------- +// RemoteMachine: the remote machine object +// ----------------------------------------------------------------------------- + +type RemoteMachine struct { + Machine +} + +// set_timeout +func (m *RemoteMachine) SetTimeout(milliseconds int64) error { + var err error + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_set_timeout(m.ptr, C.int64_t(milliseconds))) + }) + return err +} + +// get_timeout +func (m *RemoteMachine) GetTimeout() (int64, error) { + var ms C.int64_t + var err error + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_get_timeout(m.ptr, &ms)) + }) + + if err != nil { + return 0, err + } + return int64(ms), nil +} + +// set_cleanup_call +func (m *RemoteMachine) SetCleanupCall(call int32) error { + var err error + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_set_cleanup_call(m.ptr, C.cm_jsonrpc_cleanup_call(call))) + }) + + return err +} + +// get_cleanup_call +func (m *RemoteMachine) GetCleanupCall() (int32, error) { + var call C.cm_jsonrpc_cleanup_call + var err error + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_get_cleanup_call(m.ptr, &call)) + }) + + if err != nil { + return 0, err + } + return int32(call), nil +} + +// get_server_address +func (m *RemoteMachine) GetServerAddress() (string, error) { + var cAddr *C.char + var err error + var addr string + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_get_server_address(m.ptr, &cAddr)) + addr = C.GoString(cAddr) + }) + + if err != nil { + return "", err + } + return addr, nil +} + +// get_server_version +func (m *RemoteMachine) GetServerVersion() (string, error) { + var cVer *C.char + var err error + var ver string + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_get_server_version(m.ptr, &cVer)) + ver = C.GoString(cVer) + }) + + if err != nil { + return "", err + } + return ver, nil +} + +// fork_server +func (m *RemoteMachine) ForkServer() (*RemoteMachine, string, uint32, error) { + var forked *C.cm_machine + var cAddr *C.char + var addr string + var pid C.uint32_t + var err error + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_fork_server(m.ptr, &forked, &cAddr, &pid)) + addr = C.GoString(cAddr) + }) + + if err != nil { + return nil, "", 0, err + } + return &RemoteMachine{Machine: Machine{ptr: forked}}, addr, uint32(pid), nil +} + +// rebind_server +func (m *RemoteMachine) RebindServer(newAddr string) (string, error) { + var cBoundAddr *C.char + var boundAddr string + var err error + + m.callCAPI(func() { + cAddr := C.CString(newAddr) + defer C.free(unsafe.Pointer(cAddr)) + + err = newError(C.cm_jsonrpc_rebind_server(m.ptr, cAddr, &cBoundAddr)) + boundAddr = C.GoString(cBoundAddr) + }) + + if err != nil { + return "", err + } + return boundAddr, nil +} + +// shutdown_server +func (m *RemoteMachine) ShutdownServer() error { + var err error + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_shutdown_server(m.ptr)) + }) + + return err +} + +// emancipate_server +func (m *RemoteMachine) EmancipateServer() error { + var err error + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_emancipate_server(m.ptr)) + }) + + return err +} + +// delay_next_request +func (m *RemoteMachine) DelayNextRequest(milliseconds uint64) error { + var err error + + m.callCAPI(func() { + err = newError(C.cm_jsonrpc_delay_next_request(m.ptr, C.uint64_t(milliseconds))) + }) + + return err +} diff --git a/pkg/emulator/types.go b/pkg/emulator/types.go new file mode 100644 index 000000000..6350edbb2 --- /dev/null +++ b/pkg/emulator/types.go @@ -0,0 +1,373 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// This package is a binding to the emulator's C API. +// Refer to the machine-c files in the emulator's repository for documentation +// (mainly machine-c-api.h and jsonrpc-machine-c-api.h). +package emulator + +// #include +// #include "cartesi-machine/cm-jsonrpc.h" +import "C" +import "fmt" + +// ----------------------------------------------------------------------------- +// Error + Helpers +// ----------------------------------------------------------------------------- + +type ErrorCode int32 + +const ( + ErrCodeOk ErrorCode = C.CM_ERROR_OK + ErrCodeInvalidArgument ErrorCode = C.CM_ERROR_INVALID_ARGUMENT + ErrCodeDomainError ErrorCode = C.CM_ERROR_DOMAIN_ERROR + ErrCodeLengthError ErrorCode = C.CM_ERROR_LENGTH_ERROR + ErrCodeOutOfRange ErrorCode = C.CM_ERROR_OUT_OF_RANGE + ErrCodeLogicError ErrorCode = C.CM_ERROR_LOGIC_ERROR + ErrCodeRuntimeError ErrorCode = C.CM_ERROR_RUNTIME_ERROR + ErrCodeRangeError ErrorCode = C.CM_ERROR_RANGE_ERROR + ErrCodeOverflowError ErrorCode = C.CM_ERROR_OVERFLOW_ERROR + ErrCodeUnderflowError ErrorCode = C.CM_ERROR_UNDERFLOW_ERROR + // ErrCodeRegexError ErrorCode = C.CM_ERROR_REGEX_ERROR + // ErrCodeSystemError ErrorCode = C.CM_ERROR_SYSTEM_ERROR + ErrCodeBadTypeid ErrorCode = C.CM_ERROR_BAD_TYPEID + ErrCodeBadCast ErrorCode = C.CM_ERROR_BAD_CAST + ErrCodeBadAnyCast ErrorCode = C.CM_ERROR_BAD_ANY_CAST + ErrCodeBadOptionalAccess ErrorCode = C.CM_ERROR_BAD_OPTIONAL_ACCESS + ErrCodeBadWeakPtr ErrorCode = C.CM_ERROR_BAD_WEAK_PTR + ErrCodeBadFunctionCall ErrorCode = C.CM_ERROR_BAD_FUNCTION_CALL + ErrCodeBadAlloc ErrorCode = C.CM_ERROR_BAD_ALLOC + ErrCodeBadArrayNewLength ErrorCode = C.CM_ERROR_BAD_ARRAY_NEW_LENGTH + ErrCodeBadException ErrorCode = C.CM_ERROR_BAD_EXCEPTION + ErrCodeBadVariantAccess ErrorCode = C.CM_ERROR_BAD_VARIANT_ACCESS + ErrCodeException ErrorCode = C.CM_ERROR_EXCEPTION + ErrCodeUnknown ErrorCode = C.CM_ERROR_UNKNOWN +) + +type Error struct { + Code ErrorCode + Msg string +} + +func (e Error) Error() string { + return fmt.Sprintf("Cartesi JSONRPC error %d: %s", e.Code, e.Msg) +} + +func newError(code C.cm_error) error { + if code == C.CM_ERROR_OK { + return nil + } + msg := C.GoString(C.cm_get_last_error_message()) + return Error{Code: ErrorCode(code), Msg: msg} +} + +// ----------------------------------------------------------------------------- +// Enums + Constants from machine-c-api.h & jsonrpc-machine-c-api.h +// ----------------------------------------------------------------------------- + +// Cleanup calls +// +//nolint:revive // Keep the CM_JSONRPC_* names used by the emulator C API. +const ( + CM_JSONRPC_NOTHING = C.CM_JSONRPC_NOTHING + CM_JSONRPC_DESTROY = C.CM_JSONRPC_DESTROY + CM_JSONRPC_SHUTDOWN = C.CM_JSONRPC_SHUTDOWN +) + +const ( + // MarchID identifies the architecture supported by the compiled header. + MarchID uint64 = C.CM_MARCHID + // UarchStateStartAddress is the start of the complete uarch state. + UarchStateStartAddress uint64 = C.CM_UARCH_STATE_START_ADDRESS + // UarchStateLog2Size is the uarch state's size exponent in bytes. + UarchStateLog2Size int32 = C.CM_UARCH_STATE_LOG2_SIZE +) + +// BreakReason +type BreakReason int32 + +const ( + BreakReasonFailed BreakReason = C.CM_BREAK_REASON_FAILED + BreakReasonHalted BreakReason = C.CM_BREAK_REASON_HALTED + BreakReasonYieldedManually BreakReason = C.CM_BREAK_REASON_YIELDED_MANUALLY + BreakReasonYieldedAutomatically BreakReason = C.CM_BREAK_REASON_YIELDED_AUTOMATICALLY + BreakReasonYieldedSoftly BreakReason = C.CM_BREAK_REASON_YIELDED_SOFTLY + BreakReasonReachedTargetMcycle BreakReason = C.CM_BREAK_REASON_REACHED_TARGET_MCYCLE + BreakReasonConsoleOutput BreakReason = C.CM_BREAK_REASON_CONSOLE_OUTPUT + BreakReasonConsoleInput BreakReason = C.CM_BREAK_REASON_CONSOLE_INPUT + BreakReasonMcycleOverflow BreakReason = C.CM_BREAK_REASON_MCYCLE_OVERFLOW +) + +func (reason BreakReason) String() (s string) { + switch reason { + case BreakReasonFailed: + s = "failed" + case BreakReasonHalted: + s = "halted" + case BreakReasonYieldedManually: + s = "yielded manually" + case BreakReasonYieldedAutomatically: + s = "yielded automatically" + case BreakReasonYieldedSoftly: + s = "yielded softly" + case BreakReasonReachedTargetMcycle: + s = "reached target mcycle" + case BreakReasonConsoleOutput: + s = "console output" + case BreakReasonConsoleInput: + s = "console input" + case BreakReasonMcycleOverflow: + s = "mcycle overflow" + default: + return "invalid break reason" + } + return "break reason: " + s + +} + +// SharingMode +type SharingMode = C.cm_sharing_mode + +const ( + SharingNone SharingMode = C.CM_SHARING_NONE + SharingConfig SharingMode = C.CM_SHARING_CONFIG + SharingAll SharingMode = C.CM_SHARING_ALL +) + +type ( + CmioYieldCommand uint8 + CmioYieldReason uint16 +) + +const ( + HtifDeviceShift uint64 = C.CM_HTIF_DEV_SHIFT + HtifCommandShift uint64 = C.CM_HTIF_CMD_SHIFT + HtifReasonShift uint64 = C.CM_HTIF_REASON_SHIFT + HtifDeviceYield uint64 = C.CM_HTIF_DEV_YIELD + + // type + YieldAutomatic CmioYieldCommand = C.CM_HTIF_YIELD_CMD_AUTOMATIC + YieldManual CmioYieldCommand = C.CM_HTIF_YIELD_CMD_MANUAL + + // NOTE: these values do not form an enum (e.g., automatic-progress == manual-accepted). + + // reason - request + AutomaticYieldReasonProgress CmioYieldReason = C.CM_HTIF_YIELD_AUTOMATIC_REASON_PROGRESS + AutomaticYieldReasonOutput CmioYieldReason = C.CM_HTIF_YIELD_AUTOMATIC_REASON_TX_OUTPUT + AutomaticYieldReasonReport CmioYieldReason = C.CM_HTIF_YIELD_AUTOMATIC_REASON_TX_REPORT + + ManualYieldReasonAccepted CmioYieldReason = C.CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED + ManualYieldReasonRejected CmioYieldReason = C.CM_HTIF_YIELD_MANUAL_REASON_RX_REJECTED + ManualYieldReasonException CmioYieldReason = C.CM_HTIF_YIELD_MANUAL_REASON_TX_EXCEPTION + + // reason - reply + YieldReasonAdvanceState CmioYieldReason = C.CM_HTIF_YIELD_REASON_ADVANCE_STATE + YieldReasonInspectState CmioYieldReason = C.CM_HTIF_YIELD_REASON_INSPECT_STATE +) + +// cm_reg addresses/CSRs +type RegID = int32 // from cm_reg enum + +//nolint:revive // Keep the REG_* names used by the emulator C API. +const ( + // Processor x registers + REG_X0 RegID = C.CM_REG_X0 + REG_X1 RegID = C.CM_REG_X1 + REG_X2 RegID = C.CM_REG_X2 + REG_X3 RegID = C.CM_REG_X3 + REG_X4 RegID = C.CM_REG_X4 + REG_X5 RegID = C.CM_REG_X5 + REG_X6 RegID = C.CM_REG_X6 + REG_X7 RegID = C.CM_REG_X7 + REG_X8 RegID = C.CM_REG_X8 + REG_X9 RegID = C.CM_REG_X9 + REG_X10 RegID = C.CM_REG_X10 + REG_X11 RegID = C.CM_REG_X11 + REG_X12 RegID = C.CM_REG_X12 + REG_X13 RegID = C.CM_REG_X13 + REG_X14 RegID = C.CM_REG_X14 + REG_X15 RegID = C.CM_REG_X15 + REG_X16 RegID = C.CM_REG_X16 + REG_X17 RegID = C.CM_REG_X17 + REG_X18 RegID = C.CM_REG_X18 + REG_X19 RegID = C.CM_REG_X19 + REG_X20 RegID = C.CM_REG_X20 + REG_X21 RegID = C.CM_REG_X21 + REG_X22 RegID = C.CM_REG_X22 + REG_X23 RegID = C.CM_REG_X23 + REG_X24 RegID = C.CM_REG_X24 + REG_X25 RegID = C.CM_REG_X25 + REG_X26 RegID = C.CM_REG_X26 + REG_X27 RegID = C.CM_REG_X27 + REG_X28 RegID = C.CM_REG_X28 + REG_X29 RegID = C.CM_REG_X29 + REG_X30 RegID = C.CM_REG_X30 + REG_X31 RegID = C.CM_REG_X31 + // Processor f registers + REG_F0 RegID = C.CM_REG_F0 + REG_F1 RegID = C.CM_REG_F1 + REG_F2 RegID = C.CM_REG_F2 + REG_F3 RegID = C.CM_REG_F3 + REG_F4 RegID = C.CM_REG_F4 + REG_F5 RegID = C.CM_REG_F5 + REG_F6 RegID = C.CM_REG_F6 + REG_F7 RegID = C.CM_REG_F7 + REG_F8 RegID = C.CM_REG_F8 + REG_F9 RegID = C.CM_REG_F9 + REG_F10 RegID = C.CM_REG_F10 + REG_F11 RegID = C.CM_REG_F11 + REG_F12 RegID = C.CM_REG_F12 + REG_F13 RegID = C.CM_REG_F13 + REG_F14 RegID = C.CM_REG_F14 + REG_F15 RegID = C.CM_REG_F15 + REG_F16 RegID = C.CM_REG_F16 + REG_F17 RegID = C.CM_REG_F17 + REG_F18 RegID = C.CM_REG_F18 + REG_F19 RegID = C.CM_REG_F19 + REG_F20 RegID = C.CM_REG_F20 + REG_F21 RegID = C.CM_REG_F21 + REG_F22 RegID = C.CM_REG_F22 + REG_F23 RegID = C.CM_REG_F23 + REG_F24 RegID = C.CM_REG_F24 + REG_F25 RegID = C.CM_REG_F25 + REG_F26 RegID = C.CM_REG_F26 + REG_F27 RegID = C.CM_REG_F27 + REG_F28 RegID = C.CM_REG_F28 + REG_F29 RegID = C.CM_REG_F29 + REG_F30 RegID = C.CM_REG_F30 + REG_F31 RegID = C.CM_REG_F31 + // Processor CSRs + REG_PC RegID = C.CM_REG_PC + REG_FCSR RegID = C.CM_REG_FCSR + REG_MVENDORID RegID = C.CM_REG_MVENDORID + REG_MARCHID RegID = C.CM_REG_MARCHID + REG_MIMPID RegID = C.CM_REG_MIMPID + REG_MCYCLE RegID = C.CM_REG_MCYCLE + REG_IMCYCLEMAX RegID = C.CM_REG_IMCYCLEMAX + REG_ICYCLEINSTRET RegID = C.CM_REG_ICYCLEINSTRET + REG_MSTATUS RegID = C.CM_REG_MSTATUS + REG_MTVEC RegID = C.CM_REG_MTVEC + REG_MSCRATCH RegID = C.CM_REG_MSCRATCH + REG_MEPC RegID = C.CM_REG_MEPC + REG_MCAUSE RegID = C.CM_REG_MCAUSE + REG_MTVAL RegID = C.CM_REG_MTVAL + REG_MISA RegID = C.CM_REG_MISA + REG_MIE RegID = C.CM_REG_MIE + REG_MIP RegID = C.CM_REG_MIP + REG_MEDELEG RegID = C.CM_REG_MEDELEG + REG_MIDELEG RegID = C.CM_REG_MIDELEG + REG_MCOUNTEREN RegID = C.CM_REG_MCOUNTEREN + REG_MENVCFG RegID = C.CM_REG_MENVCFG + REG_STVEC RegID = C.CM_REG_STVEC + REG_SSCRATCH RegID = C.CM_REG_SSCRATCH + REG_SEPC RegID = C.CM_REG_SEPC + REG_SCAUSE RegID = C.CM_REG_SCAUSE + REG_STVAL RegID = C.CM_REG_STVAL + REG_SATP RegID = C.CM_REG_SATP + REG_SCOUNTEREN RegID = C.CM_REG_SCOUNTEREN + REG_SENVCFG RegID = C.CM_REG_SENVCFG + REG_ILRSC RegID = C.CM_REG_ILRSC + REG_IPRV RegID = C.CM_REG_IPRV + REG_IFLAGS_X RegID = C.CM_REG_IFLAGS_X + REG_IFLAGS_Y RegID = C.CM_REG_IFLAGS_Y + REG_IFLAGS_H RegID = C.CM_REG_IFLAGS_H + REG_IUNREP RegID = C.CM_REG_IUNREP + // Device registers + REG_CLINT_MTIMECMP RegID = C.CM_REG_CLINT_MTIMECMP + REG_PLIC_GIRQPEND RegID = C.CM_REG_PLIC_GIRQPEND + REG_PLIC_GIRQSRVD RegID = C.CM_REG_PLIC_GIRQSRVD + REG_HTIF_TOHOST RegID = C.CM_REG_HTIF_TOHOST + REG_HTIF_FROMHOST RegID = C.CM_REG_HTIF_FROMHOST + REG_HTIF_IHALT RegID = C.CM_REG_HTIF_IHALT + REG_HTIF_ICONSOLE RegID = C.CM_REG_HTIF_ICONSOLE + REG_HTIF_IYIELD RegID = C.CM_REG_HTIF_IYIELD + // Microarchitecture registers + REG_UARCH_X0 RegID = C.CM_REG_UARCH_X0 + REG_UARCH_X1 RegID = C.CM_REG_UARCH_X1 + REG_UARCH_X2 RegID = C.CM_REG_UARCH_X2 + REG_UARCH_X3 RegID = C.CM_REG_UARCH_X3 + REG_UARCH_X4 RegID = C.CM_REG_UARCH_X4 + REG_UARCH_X5 RegID = C.CM_REG_UARCH_X5 + REG_UARCH_X6 RegID = C.CM_REG_UARCH_X6 + REG_UARCH_X7 RegID = C.CM_REG_UARCH_X7 + REG_UARCH_X8 RegID = C.CM_REG_UARCH_X8 + REG_UARCH_X9 RegID = C.CM_REG_UARCH_X9 + REG_UARCH_X10 RegID = C.CM_REG_UARCH_X10 + REG_UARCH_X11 RegID = C.CM_REG_UARCH_X11 + REG_UARCH_X12 RegID = C.CM_REG_UARCH_X12 + REG_UARCH_X13 RegID = C.CM_REG_UARCH_X13 + REG_UARCH_X14 RegID = C.CM_REG_UARCH_X14 + REG_UARCH_X15 RegID = C.CM_REG_UARCH_X15 + REG_UARCH_X16 RegID = C.CM_REG_UARCH_X16 + REG_UARCH_X17 RegID = C.CM_REG_UARCH_X17 + REG_UARCH_X18 RegID = C.CM_REG_UARCH_X18 + REG_UARCH_X19 RegID = C.CM_REG_UARCH_X19 + REG_UARCH_X20 RegID = C.CM_REG_UARCH_X20 + REG_UARCH_X21 RegID = C.CM_REG_UARCH_X21 + REG_UARCH_X22 RegID = C.CM_REG_UARCH_X22 + REG_UARCH_X23 RegID = C.CM_REG_UARCH_X23 + REG_UARCH_X24 RegID = C.CM_REG_UARCH_X24 + REG_UARCH_X25 RegID = C.CM_REG_UARCH_X25 + REG_UARCH_X26 RegID = C.CM_REG_UARCH_X26 + REG_UARCH_X27 RegID = C.CM_REG_UARCH_X27 + REG_UARCH_X28 RegID = C.CM_REG_UARCH_X28 + REG_UARCH_X29 RegID = C.CM_REG_UARCH_X29 + REG_UARCH_X30 RegID = C.CM_REG_UARCH_X30 + REG_UARCH_X31 RegID = C.CM_REG_UARCH_X31 + REG_UARCH_PC RegID = C.CM_REG_UARCH_PC + REG_UARCH_CYCLE RegID = C.CM_REG_UARCH_CYCLE + REG_UARCH_HALT RegID = C.CM_REG_UARCH_HALT + // Views of registers + REG_HTIF_TOHOST_DEV RegID = C.CM_REG_HTIF_TOHOST_DEV + REG_HTIF_TOHOST_CMD RegID = C.CM_REG_HTIF_TOHOST_CMD + REG_HTIF_TOHOST_REASON RegID = C.CM_REG_HTIF_TOHOST_REASON + REG_HTIF_TOHOST_DATA RegID = C.CM_REG_HTIF_TOHOST_DATA + REG_HTIF_FROMHOST_DEV RegID = C.CM_REG_HTIF_FROMHOST_DEV + REG_HTIF_FROMHOST_CMD RegID = C.CM_REG_HTIF_FROMHOST_CMD + REG_HTIF_FROMHOST_REASON RegID = C.CM_REG_HTIF_FROMHOST_REASON + REG_HTIF_FROMHOST_DATA RegID = C.CM_REG_HTIF_FROMHOST_DATA + // Enumeration helpers + REG_UNKNOWN RegID = C.CM_REG_UNKNOWN_ + REG_FIRST RegID = C.CM_REG_FIRST_ + REG_LAST RegID = C.CM_REG_LAST_ +) + +const ( + CmioRxBufferStart uint64 = C.CM_AR_CMIO_RX_BUFFER_START + CmioRxBufferLog2Size uint64 = C.CM_AR_CMIO_RX_BUFFER_LOG2_SIZE + + CmioTxBufferStart uint64 = C.CM_AR_CMIO_TX_BUFFER_START + CmioTxBufferLog2Size uint64 = C.CM_AR_CMIO_TX_BUFFER_LOG2_SIZE + + // Log2MaxUarchCyclesPerMCycle omits the C API's ROLLUP_ prefix. + Log2MaxUarchCyclesPerMCycle uint64 = C.CM_ROLLUP_LOG2_MAX_UARCH_CYCLES_PER_MCYCLE + // Log2MaxMCyclesPerAdvanceState omits the C API's ROLLUP_ prefix. + Log2MaxMCyclesPerAdvanceState uint64 = C.CM_ROLLUP_LOG2_MAX_MCYCLES_PER_ADVANCE_STATE + // Log2MaxAdvanceStatesPerEpoch omits the C API's ROLLUP_ prefix. + Log2MaxAdvanceStatesPerEpoch uint64 = C.CM_ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH +) + +type MachineRuntimeConfig struct { + Concurrency *ConcurrencyRuntimeConfig `json:"concurrency,omitempty"` + SkipVersionCheck bool `json:"skip_version_check"` + SoftYield bool `json:"soft_yield"` +} + +type ConcurrencyRuntimeConfig struct { + UpdateHashTree uint64 `json:"update_hash_tree"` +} + +func NewMachineRuntimeConfig() *MachineRuntimeConfig { + return &MachineRuntimeConfig{ + Concurrency: &ConcurrencyRuntimeConfig{ + UpdateHashTree: 0, + }, + SkipVersionCheck: false, + SoftYield: false, + } +} + +const ( + HashTreeLog2WordSize uint32 = C.CM_HASH_TREE_LOG2_WORD_SIZE + HashTreeLog2RootSize uint32 = C.CM_HASH_TREE_LOG2_ROOT_SIZE +) diff --git a/pkg/ethutil/anvil.go b/pkg/ethutil/anvil.go new file mode 100644 index 000000000..c8e2a7e81 --- /dev/null +++ b/pkg/ethutil/anvil.go @@ -0,0 +1,124 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + "log" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" +) + +const anvilTestEpochLength uint64 = 10 + +func CreateAnvilSnapshotAndDeployApp( + ctx context.Context, + client *ethclient.Client, + factoryAddr common.Address, + templateHash common.Hash, + inputBoxAddress common.Address, + salt string, +) (common.Address, func(), error) { + zero := common.Address{} + if client == nil { + return zero, nil, fmt.Errorf("ethclient Client is nil") + } + + // Create a snapshot of the current state + snapshotID, err := CreateAnvilSnapshot(client.Client()) + if err != nil { + return zero, nil, fmt.Errorf("failed to create snapshot: %w", err) + } + + chainId, err := client.ChainID(ctx) + if err != nil { + _ = RevertToAnvilSnapshot(client.Client(), snapshotID) + return zero, nil, fmt.Errorf("failed to retrieve chainID from Anvil: %w", err) + } + + privateKey, err := MnemonicToPrivateKey(FoundryMnemonic, 0) + if err != nil { + _ = RevertToAnvilSnapshot(client.Client(), snapshotID) + return zero, nil, fmt.Errorf("failed to create privateKey: %w", err) + } + + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainId) + if err != nil { + _ = RevertToAnvilSnapshot(client.Client(), snapshotID) + return zero, nil, fmt.Errorf("failed to create TransactOpts: %w", err) + } + + parsedSalt, err := ParseSalt(salt) + if err != nil { + _ = RevertToAnvilSnapshot(client.Client(), snapshotID) + return zero, nil, fmt.Errorf("failed to parse salt: %w", err) + } + + deployment := &SelfhostedApplicationDeployment{ + FactoryAddress: factoryAddr, + AuthorityOwnerAddress: txOpts.From, + TemplateHash: templateHash, + InputBoxAddress: inputBoxAddress, + EpochLength: anvilTestEpochLength, + Salt: parsedSalt, + } + applicationAddress, _, err := deployment.Deploy(ctx, client, NewStaticTransactOptsFactory(txOpts)) + if err != nil { + _ = RevertToAnvilSnapshot(client.Client(), snapshotID) + return zero, nil, fmt.Errorf("failed to deploy self-hosted application: %w", err) + } + + // Define a cleanup function to revert to the snapshot + cleanup := func() { + err := RevertToAnvilSnapshot(client.Client(), snapshotID) + if err != nil { + log.Printf("failed to revert to snapshot: %v", err) + } + } + + return applicationAddress, cleanup, nil +} + +func CreateAnvilSnapshot(rpcClient *rpc.Client) (string, error) { + var snapshotID string + // Using the JSON-RPC method "evm_snapshot" to create a snapshot + err := rpcClient.Call(&snapshotID, "evm_snapshot") + if err != nil { + return "", fmt.Errorf("failed to create snapshot: %w", err) + } + return snapshotID, nil +} + +func RevertToAnvilSnapshot(rpcClient *rpc.Client, snapshotID string) error { + var success bool + // Using the JSON-RPC method "evm_revert" to revert to the snapshot + err := rpcClient.Call(&success, "evm_revert", snapshotID) + if err != nil { + return fmt.Errorf("failed to revert to snapshot: %w", err) + } + if !success { + return fmt.Errorf("failed to revert to snapshot with ID: %s", snapshotID) + } + return nil +} + +// Mines a new block +func MineNewBlock( + ctx context.Context, + client *ethclient.Client, +) (uint64, error) { + if client == nil { + return 0, fmt.Errorf("MineNewBlock: client is nil") + } + rpcClient := client.Client() + err := rpcClient.CallContext(ctx, nil, "evm_mine") + if err != nil { + return 0, err + } + return client.BlockNumber(ctx) +} diff --git a/pkg/ethutil/anvil_test.go b/pkg/ethutil/anvil_test.go new file mode 100644 index 000000000..e78566806 --- /dev/null +++ b/pkg/ethutil/anvil_test.go @@ -0,0 +1,59 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "sync" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +func TestMineNewBlock(t *testing.T) { + // A devnet can mine between independent head reads. Control the RPC state + // to verify that this call mines once and reads the resulting head. + state := &mineBlockTestState{number: 41} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("evm", &mineBlockTestEVM{state: state})) + require.NoError(t, server.RegisterName("eth", &mineBlockTestEth{state: state})) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + + number, err := MineNewBlock(t.Context(), client) + + require.NoError(t, err) + require.Equal(t, uint64(42), number) + state.mu.Lock() + defer state.mu.Unlock() + require.Equal(t, []string{"evm_mine", "eth_blockNumber"}, state.calls) +} + +type mineBlockTestState struct { + mu sync.Mutex + number hexutil.Uint64 + calls []string +} + +type mineBlockTestEVM struct{ state *mineBlockTestState } + +func (b *mineBlockTestEVM) Mine() (string, error) { + b.state.mu.Lock() + defer b.state.mu.Unlock() + b.state.calls = append(b.state.calls, "evm_mine") + b.state.number++ + return "0x0", nil +} + +type mineBlockTestEth struct{ state *mineBlockTestState } + +func (b *mineBlockTestEth) BlockNumber() (hexutil.Uint64, error) { + b.state.mu.Lock() + defer b.state.mu.Unlock() + b.state.calls = append(b.state.calls, "eth_blockNumber") + return b.state.number, nil +} diff --git a/pkg/ethutil/application.go b/pkg/ethutil/application.go new file mode 100644 index 000000000..19f13a13f --- /dev/null +++ b/pkg/ethutil/application.go @@ -0,0 +1,170 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type IApplicationDeployment interface { + Deploy(context.Context, *ethclient.Client, TransactOptsFactory) (common.Address, IApplicationDeploymentResult, error) + DeployWithTransaction( + context.Context, *ethclient.Client, TransactOptsFactory, TransactionRunner, + ) (common.Address, IApplicationDeploymentResult, error) + GetFactoryAddress() common.Address +} +type IApplicationDeploymentResult interface{} + +type ApplicationDeployment struct { + FactoryAddress common.Address `json:"factory"` + Consensus common.Address `json:"consensus"` + OwnerAddress common.Address `json:"owner"` + TemplateHash common.Hash `json:"template_hash"` + WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` + Salt SaltBytes `json:"salt"` + + // needed by model.Application + InputBoxAddress common.Address `json:"inputbox_address"` + IInputBoxBlock uint64 `json:"inputbox_block"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + ConsensusType string `json:"consensus_type,omitempty"` + + Verbose bool +} + +type ApplicationDeploymentResult struct { + Deployment *ApplicationDeployment `json:"deployment"` + + ApplicationAddress common.Address `json:"address"` +} + +func (me *ApplicationDeployment) String() string { + result := "" + result += "application deployment:\n" + result += fmt.Sprintf("\tapplication owner: %v\n", me.OwnerAddress) + result += fmt.Sprintf("\tconsensus address: %v\n", me.Consensus) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) + if me.Verbose { + result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) + result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) + result += fmt.Sprintf("\tsalt: %v\n", me.Salt) + result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) + if me.ConsensusType != "" { + result += fmt.Sprintf("\tconsensus type: %v\n", me.ConsensusType) + } + } + return result +} + +func (me *ApplicationDeploymentResult) String() string { + result := "" + result += fmt.Sprintf("\tapplication address: %v\n", me.ApplicationAddress) + return result +} + +func (me *ApplicationDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction uses runner to submit the deployment. A broadcast-only +// runner returns the predicted address and no confirmed deployment result. +// The predicted address does not prove that the deployment transaction succeeded. +func (me *ApplicationDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + runner TransactionRunner, +) (common.Address, IApplicationDeploymentResult, error) { + zero := common.Address{} + result := &ApplicationDeploymentResult{} + result.Deployment = me + factory, err := iapplicationfactory.NewIApplicationFactory(me.FactoryAddress, client) + if err != nil { + return zero, nil, fmt.Errorf("failed to instantiate contract: %w", err) + } + + if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { + return zero, nil, err + } + if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + + // check if addresses are available (have no code) + applicationAddress, err := factory.CalculateApplicationAddress( + &bind.CallOpts{Context: ctx}, + me.Consensus, + me.OwnerAddress, + me.TemplateHash, + me.InputBoxAddress, + me.WithdrawalConfig, + me.Salt, + ) + if err != nil { + return zero, nil, err + } + + applicationCode, err := client.CodeAt(ctx, applicationAddress, nil) + if err != nil { + return zero, nil, err + } + if len(applicationCode) != 0 { + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", applicationAddress) + } + + // deploy the contracts + txOpts, err := txOptsFactory.NewTransactOpts(ctx) + if err != nil { + return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) + } + + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewApplication0( + opts, + me.Consensus, + me.OwnerAddress, + me.TemplateHash, + me.InputBoxAddress, + me.WithdrawalConfig, + me.Salt, + ) + }, runner) + if err != nil { + return zero, nil, fmt.Errorf("transaction failed: %w", err) + } + + if receipt == nil { + return applicationAddress, nil, nil + } + + // Look for the specific event in the receipt logs + for _, vLog := range receipt.Logs { + // Parse log for ApplicationCreated event + event, err := factory.ParseApplicationCreated(*vLog) + if err != nil { + continue // Skip logs that don't match + } + result.ApplicationAddress = event.AppContract + if err := VerifyDeployedWithdrawalConfig(ctx, client, result.ApplicationAddress, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + return result.ApplicationAddress, result, nil + } + return zero, nil, fmt.Errorf("failed to find ApplicationCreated event in receipt logs") +} + +func (me *ApplicationDeployment) GetFactoryAddress() common.Address { + return me.FactoryAddress +} diff --git a/pkg/ethutil/authority.go b/pkg/ethutil/authority.go new file mode 100644 index 000000000..b5139418f --- /dev/null +++ b/pkg/ethutil/authority.go @@ -0,0 +1,101 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type AuthorityDeployment struct { + Address common.Address `json:"address"` + FactoryAddress common.Address `json:"factory"` + OwnerAddress common.Address `json:"owner"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + Salt SaltBytes `json:"salt"` + Verbose bool `json:"-"` +} + +func (me *AuthorityDeployment) String() string { + result := "" + result += "authority deployment:\n" + result += fmt.Sprintf("\tauthority owner: %v\n", me.OwnerAddress) + if me.Verbose { + result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) + result += fmt.Sprintf("\tsalt: %v\n", me.Salt) + result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) + result += fmt.Sprintf("\tclaim staging period: %v\n", me.ClaimStagingPeriod) + } + return result +} + +func (me *AuthorityDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, +) (common.Address, error) { + return me.DeployWithTransaction(ctx, client, txOpts, nil) +} + +// DeployWithTransaction returns a predicted address when runner does not wait for +// a receipt. The prediction does not prove that the deployment transaction succeeded. +func (me *AuthorityDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, + runner TransactionRunner, +) (common.Address, error) { + zero := common.Address{} + factory, err := iauthorityfactory.NewIAuthorityFactory(me.FactoryAddress, client) + if err != nil { + return common.Address{}, fmt.Errorf("failed to instantiate contract: %w", err) + } + + // check if addresses are available (have no code) + epochLength := new(big.Int).SetUint64(me.EpochLength) + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) + authorityAddress, err := factory.CalculateAuthorityAddress( + &bind.CallOpts{Context: ctx}, me.OwnerAddress, epochLength, claimStagingPeriod, me.Salt, + ) + if err != nil { + return zero, err + } + + authorityCode, err := client.CodeAt(ctx, authorityAddress, nil) + if err != nil { + return zero, err + } + if len(authorityCode) != 0 { + return zero, fmt.Errorf("authority with address %v already exists; use a different salt", authorityAddress) + } + + // deploy the contracts + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewAuthority0(opts, me.OwnerAddress, epochLength, claimStagingPeriod, me.Salt) + }, runner) + if err != nil { + return common.Address{}, fmt.Errorf("failed to create new authority: %w", err) + } + + if receipt == nil { + return authorityAddress, nil + } + + // search for the matching event + for _, vLog := range receipt.Logs { + event, err := factory.ParseAuthorityCreated(*vLog) + if err != nil { + continue // Skip logs that don't match + } + return event.Authority, nil + } + return common.Address{}, fmt.Errorf("failed to find event in receipt logs") +} diff --git a/pkg/ethutil/bip32.go b/pkg/ethutil/bip32.go new file mode 100644 index 000000000..fbea7514a --- /dev/null +++ b/pkg/ethutil/bip32.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "crypto/hmac" + "crypto/sha512" + "encoding/binary" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/crypto" +) + +// Minimal BIP-32 private key derivation over go-ethereum's secp256k1 curve. +// See https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki + +// hardenedKeyStart is the first child index that requires the parent private key. +const hardenedKeyStart uint32 = 0x80000000 + +// scalarSize is the length in bytes of a serialized secp256k1 scalar. +const scalarSize = 32 + +// masterKeySalt is the HMAC key BIP-32 mandates for master key generation. +var masterKeySalt = []byte("Bitcoin seed") + +// extendedKey is a BIP-32 extended private key: a secp256k1 scalar paired with +// the chain code that seeds the derivation of its children. +type extendedKey struct { + key []byte // scalarSize bytes, big endian + chainCode []byte +} + +// newMasterKey derives the master extended private key from a BIP-39 seed. +func newMasterKey(seed []byte) (*extendedKey, error) { + mac := hmac.New(sha512.New, masterKeySalt) + mac.Write(seed) + sum := mac.Sum(nil) + + key := new(big.Int).SetBytes(sum[:scalarSize]) + if key.Sign() == 0 || key.Cmp(crypto.S256().Params().N) >= 0 { + return nil, fmt.Errorf("seed produces an invalid master key") + } + return &extendedKey{key: sum[:scalarSize], chainCode: sum[scalarSize:]}, nil +} + +// childKey derives the child of k at index, following BIP-32 CKDpriv. +// Indexes at or above hardenedKeyStart derive hardened children. +func (k *extendedKey) childKey(index uint32) (*extendedKey, error) { + mac := hmac.New(sha512.New, k.chainCode) + if index >= hardenedKeyStart { + mac.Write([]byte{0}) + mac.Write(k.key) + } else { + parent, err := crypto.ToECDSA(k.key) + if err != nil { + return nil, fmt.Errorf("invalid parent key: %w", err) + } + mac.Write(crypto.CompressPubkey(&parent.PublicKey)) + } + mac.Write(binary.BigEndian.AppendUint32(nil, index)) + sum := mac.Sum(nil) + + // The child key is (parse256(IL) + parent) mod n. BIP-32 requires the + // caller to move on to the next index in the astronomically unlikely case + // that IL is out of range or the sum is zero; report it instead, so that a + // wrong result can never be mistaken for a valid key. + order := crypto.S256().Params().N + child := new(big.Int).SetBytes(sum[:scalarSize]) + if child.Cmp(order) >= 0 { + return nil, fmt.Errorf("child %v is out of the curve order", index) + } + child.Add(child, new(big.Int).SetBytes(k.key)) + child.Mod(child, order) + if child.Sign() == 0 { + return nil, fmt.Errorf("child %v derives the zero key", index) + } + + return &extendedKey{ + key: child.FillBytes(make([]byte, scalarSize)), + chainCode: sum[scalarSize:], + }, nil +} diff --git a/pkg/ethutil/bip32_test.go b/pkg/ethutil/bip32_test.go new file mode 100644 index 000000000..0d55113ee --- /dev/null +++ b/pkg/ethutil/bip32_test.go @@ -0,0 +1,190 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +// The published BIP-32 test vectors. They show that this derivation agrees with +// the standard. See https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki +const ( + vector1Seed = "000102030405060708090a0b0c0d0e0f" + vector2Seed = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" + + "9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542" + vector3Seed = "4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4ac" + + "ba45d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be" + vector4Seed = "3ddd5602285899a946114506157c7997e5444528f3003f6134712147db19b678" +) + +type bip32Vector struct { + name string + seed string + path []uint32 + key string + chainCode string +} + +// hardened returns the child index that derives a hardened child at index. +func hardened(index uint32) uint32 { return hardenedKeyStart + index } + +var bip32Vectors = []bip32Vector{{ + name: "vector 1, m", + seed: vector1Seed, + key: "e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35", + chainCode: "873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508", +}, { + name: "vector 1, m/0'", + seed: vector1Seed, + path: []uint32{hardenedKeyStart}, + key: "edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea", + chainCode: "47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141", +}, { + name: "vector 1, m/0'/1", + seed: vector1Seed, + path: []uint32{hardenedKeyStart, 1}, + key: "3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368", + chainCode: "2a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19", +}, { + name: "vector 1, m/0'/1/2'", + seed: vector1Seed, + path: []uint32{hardenedKeyStart, 1, hardened(2)}, + key: "cbce0d719ecf7431d88e6a89fa1483e02e35092af60c042b1df2ff59fa424dca", + chainCode: "04466b9cc8e161e966409ca52986c584f07e9dc81f735db683c3ff6ec7b1503f", +}, { + name: "vector 1, m/0'/1/2'/2", + seed: vector1Seed, + path: []uint32{hardenedKeyStart, 1, hardened(2), 2}, + key: "0f479245fb19a38a1954c5c7c0ebab2f9bdfd96a17563ef28a6a4b1a2a764ef4", + chainCode: "cfb71883f01676f587d023cc53a35bc7f88f724b1f8c2892ac1275ac822a3edd", +}, { + name: "vector 1, m/0'/1/2'/2/1000000000", + seed: vector1Seed, + path: []uint32{hardenedKeyStart, 1, hardened(2), 2, 1000000000}, + key: "471b76e389e528d6de6d816857e012c5455051cad6660850e58372a6c3e6e7c8", + chainCode: "c783e67b921d2beb8f6b389cc646d7263b4145701dadd2161548a8b078e65e9e", +}, { + // Vector 2 uses a 512-bit seed and the largest child indexes. + name: "vector 2, m", + seed: vector2Seed, + key: "4b03d6fc340455b363f51020ad3ecca4f0850280cf436c70c727923f6db46c3e", + chainCode: "60499f801b896d83179a4374aeb7822aaeaceaa0db1f85ee3e904c4defbd9689", +}, { + name: "vector 2, m/0", + seed: vector2Seed, + path: []uint32{0}, + key: "abe74a98f6c7eabee0428f53798f0ab8aa1bd37873999041703c742f15ac7e1e", + chainCode: "f0909affaa7ee7abe5dd4e100598d4dc53cd709d5a5c2cac40e7412f232f7c9c", +}, { + // The child index here is 0xffffffff, the largest possible value. + name: "vector 2, m/0/2147483647'", + seed: vector2Seed, + path: []uint32{0, hardened(2147483647)}, + key: "877c779ad9687164e9c2f4f0f4ff0340814392330693ce95a58fe18fd52e6e93", + chainCode: "be17a268474a6bb9c61e1d720cf6215e2a88c5406c4aee7b38547f585c9a37d9", +}, { + name: "vector 2, m/0/2147483647'/1", + seed: vector2Seed, + path: []uint32{0, hardened(2147483647), 1}, + key: "704addf544a06e5ee4bea37098463c23613da32020d604506da8c0518e1da4b7", + chainCode: "f366f48f1ea9f2d1d3fe958c95ca84ea18e4c4ddb9366c336c927eb246fb38cb", +}, { + name: "vector 2, m/0/2147483647'/1/2147483646'", + seed: vector2Seed, + path: []uint32{0, hardened(2147483647), 1, hardened(2147483646)}, + key: "f1c7c871a54a804afe328b4c83a1c33b8e5ff48f5087273f04efa83b247d6a2d", + chainCode: "637807030d55d01f9a0cb3a7839515d796bd07706386a6eddf06cc29a65a0e29", +}, { + name: "vector 2, m/0/2147483647'/1/2147483646'/2", + seed: vector2Seed, + path: []uint32{0, hardened(2147483647), 1, hardened(2147483646), 2}, + key: "bb7d39bdb83ecf58f2fd82b6d918341cbef428661ef01ab97c28a4842125ac23", + chainCode: "9452b549be8cea3ecb7a84bec10dcfd94afe4d129ebfd3b3cb58eedf394ed271", +}, { + // Vector 3 shows that the master key keeps its leading zero. + name: "vector 3, m", + seed: vector3Seed, + key: "00ddb80b067e0d4993197fe10f2657a844a384589847602d56f0c629c81aae32", + chainCode: "01d28a3e53cffa419ec122c968b3259e16b65076495494d97cae10bbfec3c36f", +}, { + name: "vector 3, m/0'", + seed: vector3Seed, + path: []uint32{hardenedKeyStart}, + key: "491f7a2eebc7b57028e0d3faa0acda02e75c33b03c48fb288c41e2ea44e1daef", + chainCode: "e5fea12a97b927fc9dc3d2cb0d1ea1cf50aa5a1fdc1f933e8906bb38df3377bd", +}, { + // Vector 4 shows that a derived key also keeps its leading zero. + name: "vector 4, m", + seed: vector4Seed, + key: "12c0d59c7aa3a10973dbd3f478b65f2516627e3fe61e00c345be9a477ad2e215", + chainCode: "d0c8a1f6edf2500798c3e0b54f1b56e45f6d03e6076abd36e5e2f54101e44ce6", +}, { + name: "vector 4, m/0'", + seed: vector4Seed, + path: []uint32{hardenedKeyStart}, + key: "00d948e9261e41362a688b916f297121ba6bfb2274a3575ac0e456551dfd7f7e", + chainCode: "cdc0f06456a14876c898790e0b3b1a41c531170aec69da44ff7b7265bfe7743b", +}, { + name: "vector 4, m/0'/1'", + seed: vector4Seed, + path: []uint32{hardenedKeyStart, hardened(1)}, + key: "3a2086edd7d9df86c3487a5905a1712a9aa664bce8cc268141e07549eaa8661d", + chainCode: "a48ee6674c5264a237703fd383bccd9fad4d9378ac98ab05e6e7029b06360c0d", +}} + +// derive walks path from the master key of seed. +func derive(t *testing.T, seed string, path []uint32) *extendedKey { + t.Helper() + raw, err := hex.DecodeString(seed) + require.Nil(t, err) + + key, err := newMasterKey(raw) + require.Nil(t, err) + for _, index := range path { + key, err = key.childKey(index) + require.Nil(t, err) + } + return key +} + +func TestBIP32Vectors(t *testing.T) { + for _, v := range bip32Vectors { + t.Run(v.name, func(t *testing.T) { + key := derive(t, v.seed, v.path) + require.Equal(t, v.key, hex.EncodeToString(key.key)) + require.Equal(t, v.chainCode, hex.EncodeToString(key.chainCode)) + // A short key would still print the same hex, so check the length. + require.Len(t, key.key, scalarSize) + require.Len(t, key.chainCode, scalarSize) + }) + } +} + +// TestHardenedChildDiffers shows that the two branches of childKey do not +// derive the same child. Index n and index n' must give different keys. +func TestHardenedChildDiffers(t *testing.T) { + master := derive(t, vector1Seed, nil) + + normal, err := master.childKey(1) + require.Nil(t, err) + hard, err := master.childKey(hardened(1)) + require.Nil(t, err) + + require.NotEqual(t, normal.key, hard.key) + require.NotEqual(t, normal.chainCode, hard.chainCode) +} + +// TestChildKeyRejectsInvalidParent shows that childKey does not derive a child +// from a parent key that is not on the curve. +func TestChildKeyRejectsInvalidParent(t *testing.T) { + invalid := &extendedKey{ + key: make([]byte, scalarSize), + chainCode: make([]byte, scalarSize), + } + _, err := invalid.childKey(0) + require.Error(t, err) +} diff --git a/pkg/ethutil/client.go b/pkg/ethutil/client.go new file mode 100644 index 000000000..c10488b57 --- /dev/null +++ b/pkg/ethutil/client.go @@ -0,0 +1,217 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + "log/slog" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/httpclient" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/hashicorp/go-retryablehttp" +) + +// parseRetryAfterHeader parses the Retry-After header and returns the +// delay duration according to the spec: https://httpwg.org/specs/rfc7231.html#header.retry-after +// The bool returned will be true if the header was successfully parsed. +// Otherwise, the header was either not present, or was not parseable according to the spec. +func parseRetryAfterHeader(headers []string) (time.Duration, bool) { + if len(headers) == 0 || headers[0] == "" { + return 0, false + } + header := headers[0] + // 'Retry-After' is provided in seconds. + if sleep, err := strconv.ParseInt(header, 10, 64); err == nil { + if sleep < 0 { // a negative sleep doesn't make sense + return 0, false + } + if sleep > int64(time.Duration(math.MaxInt64)/time.Second) { + return time.Duration(math.MaxInt64), true + } + return time.Second * time.Duration(sleep), true + } + + // 'Retry-After' is provided as a date. + retryTime, err := time.Parse(time.RFC1123, header) + if err != nil { + return 0, false + } + if duration := time.Until(retryTime); duration > 0 { + return duration, true + } + return 0, true // past date +} + +// RetryConfig holds configuration for the retryable HTTP client. +type RetryConfig struct { + MaxRetries uint64 + RetryMinWait time.Duration + RetryMaxWait time.Duration + RequestTimeout time.Duration +} + +// NewEthClient creates an Ethereum JSON-RPC client with retryable HTTP transport. +// Only HTTP(S) endpoints with a hostname are supported. Endpoint userinfo, +// paths, and queries remain below retry and RPC error handling. The constructor +// owns its HTTP client: rpc.WithHTTPClient options are superseded by the protected +// retry client. Other RPC options, including headers and authentication, apply. +func NewEthClient( + ctx context.Context, + endpoint string, + logger *slog.Logger, + retryConfig RetryConfig, + rpcOptions ...rpc.ClientOption, +) (*ethclient.Client, error) { + rclient := retryablehttp.NewClient() + safeBase, inner, err := httpclient.NewClient(endpoint, rclient.HTTPClient, httpclient.Options{PublicationReplies: true}) + if err != nil { + return nil, err + } + rclient.HTTPClient = inner + rclient.Logger = &retryLeveledLogger{logger: logger} + rclient.RetryMax = int(min(retryConfig.MaxRetries, uint64(math.MaxInt))) + rclient.RetryWaitMin = retryConfig.RetryMinWait + rclient.RetryWaitMax = retryConfig.RetryMaxWait + rclient.HTTPClient.Timeout = retryConfig.RequestTimeout + rclient.Backoff = retryBackoff + rclient.CheckRetry = protectedRetryPolicy + + httpClient := rclient.StandardClient() + httpClient.CheckRedirect = inner.CheckRedirect + httpClient.Transport = nullErrorTransport{next: httpClient.Transport} + opts := make([]rpc.ClientOption, 0, len(rpcOptions)+1) + for _, opt := range rpcOptions { + if opt != nil { + opts = append(opts, opt) + } + } + // Always keep endpoint restoration and diagnostic protection. ClientOption is + // deliberately opaque; do not inspect it to try to extract an injected client. + opts = append(opts, rpc.WithHTTPClient(httpClient)) + + rpcClient, err := rpc.DialOptions(ctx, safeBase, opts...) //nolint:forbidigo // Protected Ethereum connection. + if err != nil { + return nil, httpclient.Diagnostic(ctx, err, "dial eth client", safeBase) + } + + return ethclient.NewClient(rpcClient), nil +} + +// DialEthClient connects to an Ethereum JSON-RPC endpoint like +// ethclient.DialContext, and accepts a null error member in the responses like +// NewEthClient. It accepts only HTTP(S) endpoints with a hostname and does not +// follow redirects. +func DialEthClient(ctx context.Context, endpoint string) (*ethclient.Client, error) { + safeBase, client, err := httpclient.NewClient(endpoint, nil, httpclient.Options{PublicationReplies: true}) + if err != nil { + return nil, err + } + client.Transport = nullErrorTransport{next: client.Transport} + rpcClient, err := rpc.DialOptions(ctx, safeBase, rpc.WithHTTPClient(client)) //nolint:forbidigo // Protected Ethereum connection. + if err != nil { + return nil, httpclient.Diagnostic(ctx, err, "dial eth client", safeBase) + } + return ethclient.NewClient(rpcClient), nil +} + +// Retry decisions use the original cause, because retryablehttp inspects both +// concrete certificate types and invalid-header text. Policy errors are discarded +// locally; the returned error can only be the request context's safe sentinel. +func protectedRetryPolicy(ctx context.Context, resp *http.Response, err error) (bool, error) { + retry, _ := retryablehttp.DefaultRetryPolicy(ctx, resp, httpclient.ClassificationError(err)) + // The policy uses the original cause only for its decision. Its error + // output is restricted locally instead of trusting dependency behavior. + return retry, ctx.Err() +} + +// retryBackoff caps server-directed Retry-After delays while preserving the +// library's default exponential backoff for every other case. +func retryBackoff(minDuration, maxDuration time.Duration, attemptNum int, resp *http.Response) time.Duration { + if resp != nil { + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable { + if sleep, ok := parseRetryAfterHeader(resp.Header["Retry-After"]); ok { + return min(maxDuration, sleep) + } + } + } + + return retryablehttp.DefaultBackoff(minDuration, maxDuration, attemptNum, resp) +} + +// The transport supplies safe diagnostics before retryablehttp logs them. +// This adapter only lowers pure cancellation failures to Debug. +var _ retryablehttp.LeveledLogger = (*retryLeveledLogger)(nil) + +type retryLeveledLogger struct{ logger *slog.Logger } + +func (l *retryLeveledLogger) Error(msg string, values ...any) { + for i := 0; i+1 < len(values); i += 2 { + if values[i] == "error" { + if err, ok := values[i+1].(error); ok && errutil.IsOnlyCancellation(err) { + l.logger.Debug(msg, values...) + return + } + } + } + l.logger.Error(msg, values...) +} + +func (l *retryLeveledLogger) Info(msg string, values ...any) { l.logger.Info(msg, values...) } +func (l *retryLeveledLogger) Debug(msg string, values ...any) { l.logger.Debug(msg, values...) } +func (l *retryLeveledLogger) Warn(msg string, values ...any) { l.logger.Warn(msg, values...) } + +// RedactEndpointFromError returns a new error with all occurrences of the +// endpoint URL replaced by its redacted (scheme://host) form. Returns nil +// for nil errors. This legacy API replaces only exact URL representations; +// masked or differently escaped URLs can escape replacement. HTTP callers +// should use NewEthClient or DialEthClient, which protect the error producer. +// +// When redaction occurs, the returned error intentionally does not wrap the +// original; errors.Is and errors.As will not match the original error chain. +// This prevents callers from recovering the sensitive URL via error unwrapping. +// +// Deprecated: use NewEthClient or DialEthClient to protect diagnostics at the +// HTTP transport boundary instead of matching endpoint strings after failure. +func RedactEndpointFromError(err error, endpoint string) error { + if err == nil { + return nil + } + redacted := redactURLString(endpoint) + if endpoint == redacted { + return err + } + original := err.Error() + msg := strings.ReplaceAll(original, endpoint, redacted) + // Also replace the normalized/canonical form which may differ from the + // raw endpoint due to percent-encoding or other URL canonicalization. + if u, parseErr := url.Parse(endpoint); parseErr == nil { + if normalized := u.String(); normalized != endpoint { + msg = strings.ReplaceAll(msg, normalized, redacted) + } + } + if msg == original { + return err + } + return fmt.Errorf("%s", msg) +} + +// redactURLString strips the path, query, and fragment from a URL string, +// returning only scheme://host. Returns "[REDACTED]" if the URL cannot be +// parsed or has no scheme/host. +func redactURLString(raw string) string { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "[REDACTED]" + } + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) +} diff --git a/pkg/ethutil/client_cancellation_test.go b/pkg/ethutil/client_cancellation_test.go new file mode 100644 index 000000000..ac5d7185b --- /dev/null +++ b/pkg/ethutil/client_cancellation_test.go @@ -0,0 +1,79 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestHTTPFailureLogLevel(t *testing.T) { + transportErr := errors.New("connection reset") + for _, test := range []struct { + name string + err error + level slog.Level + }{ + {name: "cancellation", err: context.Canceled, level: slog.LevelDebug}, + {name: "wrapped cancellation", err: fmt.Errorf("request: %w", context.Canceled), level: slog.LevelDebug}, + {name: "joined cancellations", err: errors.Join(context.Canceled, context.Canceled), level: slog.LevelDebug}, + {name: "deadline", err: context.DeadlineExceeded, level: slog.LevelError}, + {name: "transport failure", err: transportErr, level: slog.LevelError}, + {name: "cancellation text is not a cause", err: errors.New("context canceled"), level: slog.LevelError}, + {name: "mixed failure", err: errors.Join(context.Canceled, transportErr), level: slog.LevelError}, + {name: "mixed deadline", err: errors.Join(context.Canceled, context.DeadlineExceeded), level: slog.LevelError}, + } { + t.Run(test.name, func(t *testing.T) { + const endpoint = "https://rpc.example.test" + var logs bytes.Buffer + logger := &retryLeveledLogger{logger: slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))} + logger.Error("request failed", "url", endpoint, "error", &url.Error{Op: "Post", URL: endpoint, Err: test.err}) + require.Contains(t, logs.String(), `"level":"`+test.level.String()+`"`) + require.Equal(t, 1, strings.Count(logs.String(), `"msg":"request failed"`)) + require.Contains(t, logs.String(), "https://rpc.example.test") + require.NotContains(t, logs.String(), "secret-key") + if errors.Is(test.err, transportErr) { + require.Contains(t, logs.String(), transportErr.Error()) + } + if errors.Is(test.err, context.DeadlineExceeded) { + require.Contains(t, logs.String(), context.DeadlineExceeded.Error()) + } + }) + } +} + +func TestNewEthClientCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + cancel() + <-r.Context().Done() + })) + defer server.Close() + + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + // The request context, not the context used to construct the client, is canceled. + client, err := NewEthClient(t.Context(), server.URL, logger, RetryConfig{RequestTimeout: time.Second}) + require.NoError(t, err) + defer client.Close() + _, err = client.ChainID(ctx) + require.ErrorIs(t, err, context.Canceled, "cancellation must still reach the caller") + require.NotContains(t, logs.String(), `"level":"ERROR"`) + require.Contains(t, logs.String(), `"msg":"request failed"`) + require.Contains(t, logs.String(), context.Canceled.Error()) +} diff --git a/pkg/ethutil/client_secrets_test.go b/pkg/ethutil/client_secrets_test.go new file mode 100644 index 000000000..b8f6ea1c3 --- /dev/null +++ b/pkg/ethutil/client_secrets_test.go @@ -0,0 +1,503 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/httpclient" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/hashicorp/go-retryablehttp" + "github.com/stretchr/testify/require" +) + +func secretTestClient(t *testing.T, endpoint string, retry bool, logs *bytes.Buffer, options ...rpc.ClientOption) *ethclient.Client { + t.Helper() + var client *ethclient.Client + var err error + if retry { + logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + client, err = NewEthClient(t.Context(), endpoint, logger, RetryConfig{RequestTimeout: time.Second}, options...) + } else { + client, err = DialEthClient(t.Context(), endpoint) + } + require.NoError(t, err) + t.Cleanup(client.Close) + return client +} + +func TestEthClientAcceptsFirstResponseFrame(t *testing.T) { + for _, retry := range []bool{false, true} { + for _, trailing := range []string{` {"another":true}`, ` PROVIDER_TRAILING_DATA`} { + t.Run(fmt.Sprintf("retry=%v/%s", retry, trailing), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":"0x1"}`+trailing) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL, retry, &logs) + chainID, err := client.ChainID(t.Context()) + require.NoError(t, err) + require.Equal(t, big.NewInt(1), chainID) + }) + } + } +} + +func TestEthClientStillRejectsMalformedOuterFieldTypes(t *testing.T) { + const marker = "123456789012345678901234567890" + for _, field := range []string{"jsonrpc", "method"} { + for _, retry := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/retry=%v", field, retry), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"%s":%s,"id":1,"result":"0x1"}`, field, marker) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/"+marker, retry, &logs) + _, err := client.ChainID(t.Context()) + require.Error(t, err) + require.Contains(t, err.Error(), "decode RPC response") + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), marker) + }) + } + } +} + +func TestEthClientNonSuccessNonceClassification(t *testing.T) { + const marker = "NONCE_PROVIDER_CREDENTIAL" + for _, retry := range []bool{false, true} { + for _, status := range []int{http.StatusBadRequest, http.StatusInternalServerError} { + for _, body := range []string{ + `{"error":{"code":-32000,"message":"[Nonce Too Low] ` + marker + `"}}`, + "broadcast rejected: NONCE TOO LOW " + marker, + } { + t.Run(fmt.Sprintf("retry=%v/status=%d/%s", retry, status, body[:1]), func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/"+marker, retry, &logs) + var hash common.Hash + err := client.Client().CallContext(t.Context(), &hash, "eth_sendRawTransaction", "0x01") + require.Error(t, err) + wantNonce := body[0] == '{' && (!retry || status == http.StatusBadRequest) + require.Equal(t, wantNonce, IsNonceTooLowError(err)) + require.Equal(t, int32(1), requests.Load()) + require.NotContains(t, err.Error(), marker) + }) + } + } + } +} + +func TestNewEthClientActuallyRetriesWithProtectedRequests(t *testing.T) { + const marker = "RETRY_PROVIDER_CREDENTIAL" + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/"+marker+"?key="+marker, r.RequestURI) + user, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, marker, password) + if attempts.Add(1) == 1 { + http.Error(w, r.RequestURI+" "+r.Header.Get("Authorization"), http.StatusServiceUnavailable) + return + } + var request struct{ ID json.RawMessage } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":"0x1"}`, request.ID) + })) + defer server.Close() + endpoint, err := url.Parse(server.URL + "/" + marker + "?key=" + marker) + require.NoError(t, err) + endpoint.User = url.UserPassword("operator", marker) + var logs bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + client, err := NewEthClient(t.Context(), endpoint.String(), logger, RetryConfig{ + MaxRetries: 2, RetryMinWait: time.Millisecond, RetryMaxWait: time.Millisecond, RequestTimeout: time.Second, + }) + require.NoError(t, err) + defer client.Close() + chainID, err := client.ChainID(t.Context()) + require.NoError(t, err) + require.Equal(t, big.NewInt(1), chainID) + require.Equal(t, int32(2), attempts.Load()) + require.Contains(t, logs.String(), "retrying request") + require.Contains(t, logs.String(), server.URL) + require.NotContains(t, logs.String(), marker) +} + +func TestNewEthClientDoesNotRetryInvalidHeadersOrCertificates(t *testing.T) { + const marker = "NONRETRYABLE_CREDENTIAL" + for _, certificate := range []bool{false, true} { + t.Run(fmt.Sprint("certificate=", certificate), func(t *testing.T) { + var requests atomic.Int32 + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) }) + server := httptest.NewServer(handler) + if certificate { + server.Close() + server = httptest.NewTLSServer(handler) + } + defer server.Close() + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + var options []rpc.ClientOption + if !certificate { + options = append(options, rpc.WithHeader("X-"+marker, "invalid\nvalue")) + } + client, err := NewEthClient(t.Context(), server.URL+"/"+marker, logger, RetryConfig{ + MaxRetries: 2, RetryMinWait: time.Millisecond, RetryMaxWait: time.Millisecond, RequestTimeout: time.Second, + }, options...) + require.NoError(t, err) + defer client.Close() + _, err = client.ChainID(t.Context()) + require.Error(t, err) + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), marker) + require.NotContains(t, logs.String(), marker) + require.Equal(t, 1, strings.Count(logs.String(), `"msg":"request failed"`)) + require.Zero(t, requests.Load()) + }) + } +} + +func TestEthClientSecretsOnRealFailures(t *testing.T) { + for _, retry := range []bool{false, true} { + t.Run(fmt.Sprint("retry=", retry), func(t *testing.T) { + var previous string + for _, marker := range []string{"secret_Ab09_one", "secret_Zx17_two"} { + var logs bytes.Buffer + endpoint := "http://user_" + marker + ":pass_" + marker + "@127.0.0.1:1/" + marker + "?key=" + marker + client := secretTestClient(t, endpoint, retry, &logs) + _, err := client.ChainID(t.Context()) + require.Error(t, err) + require.Contains(t, err.Error(), "http://127.0.0.1:1") + require.NotContains(t, err.Error(), marker) + require.NotContains(t, logs.String(), marker) + if retry { + require.Contains(t, logs.String(), "request failed") + require.Contains(t, logs.String(), "http://127.0.0.1:1") + } + if previous != "" { + require.Equal(t, previous, err.Error(), "different credentials changed diagnostics") + } + previous = err.Error() + } + }) + } +} + +func TestEthClientProtocolAndEnvelopeDiagnostics(t *testing.T) { + const marker = "SECRET_Ab09_reflection" + const payload = `{"jsonrpc":"2.0","id":1,"result":"0x1"}` + const trailerFormat = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + "%x\r\n%s\r\n0\r\n%s\r\n\r\n" + for _, retry := range []bool{false, true} { + for _, tc := range []struct{ name, reply string }{ + {"status", "HTTP/1.1 " + marker + " Nope\r\nContent-Length: 0\r\n\r\n"}, + {"header", "HTTP/1.1 200 OK\r\n" + marker + "\r\nContent-Length: 0\r\n\r\n"}, + {"trailer", fmt.Sprintf(trailerFormat, len(payload), payload, marker)}, + } { + t.Run(fmt.Sprintf("%s/retry=%v", tc.name, retry), func(t *testing.T) { + var received atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received.Store(strings.Contains(r.RequestURI, marker)) + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer conn.Close() + _, _ = io.WriteString(conn, tc.reply) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/"+marker+"?key="+marker, retry, &logs) + _, err := client.ChainID(t.Context()) + require.Error(t, err) + require.True(t, received.Load()) + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), marker) + require.NotContains(t, logs.String(), marker) + }) + } + // An invalid JSON character is reflected by go-ethereum's decoder. + // Even a one-character credential must stay out of its diagnostic. + const syntaxMarker = "Z" + t.Run(fmt.Sprint("envelope syntax/retry=", retry), func(t *testing.T) { + var received atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received.Store(strings.Contains(r.RequestURI, syntaxMarker)) + _, _ = io.WriteString(w, syntaxMarker) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/"+syntaxMarker, retry, &logs) + _, err := client.ChainID(t.Context()) + require.Error(t, err) + require.True(t, received.Load()) + require.NotContains(t, err.Error(), syntaxMarker) + require.Contains(t, err.Error(), "decode RPC response") + require.Contains(t, err.Error(), server.URL) + }) + } +} + +func TestValidLargeRPCDataRetainsDependencyClassification(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"range too large","data":1e999}}`) + })) + defer server.Close() + // go-ethereum stores error as RawMessage and ignores decodeError's conversion + // error. Preserve that behavior rather than introducing a stricter data type. + baseline, err := ethclient.DialContext(t.Context(), server.URL) + require.NoError(t, err) + defer baseline.Close() + _, baselineErr := baseline.ChainID(t.Context()) + var baselineData rpc.DataError + require.ErrorAs(t, baselineErr, &baselineData) + for _, retry := range []bool{false, true} { + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/SECRET", retry, &logs) + _, err := client.ChainID(t.Context()) + var rpcError rpc.Error + var data rpc.DataError + require.ErrorAs(t, err, &rpcError) + require.ErrorAs(t, err, &data) + require.Equal(t, -32005, rpcError.ErrorCode()) + require.Equal(t, baselineData.ErrorData(), data.ErrorData()) + require.Equal(t, baselineErr.Error(), err.Error()) + } +} + +func TestEthClientNonSuccessKeepsRPCErrorCode(t *testing.T) { + for _, code := range []int{-32047, -32600, -32005, 400} { + for _, retry := range []bool{false, true} { + t.Run(fmt.Sprintf("%d/retry=%v", code, retry), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(400) + _, _ = fmt.Fprintf(w, + `{"jsonrpc":"2.0","id":1,"error":{"code":%d,"message":"SECRET password=3","data":"SECRET"}}`, code) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/SECRET?password=3", retry, &logs) + _, err := client.ChainID(t.Context()) + require.Error(t, err) + var httpError rpc.HTTPError + require.ErrorAs(t, err, &httpError) + var body struct { + Error struct { + Code int `json:"code"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(httpError.Body, &body)) + require.Equal(t, code, body.Error.Code) + require.NotContains(t, err.Error(), "SECRET") + require.NotContains(t, string(httpError.Body), "password=3") + }) + } + } +} + +func TestConfinedClientActuallyChunksHTTPErrorRanges(t *testing.T) { + for _, retry := range []bool{false, true} { + t.Run(fmt.Sprint(retry), func(t *testing.T) { + var ranges []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []struct { + FromBlock string `json:"fromBlock"` + ToBlock string `json:"toBlock"` + } `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, "eth_getLogs", request.Method) + require.Len(t, request.Params, 1) + from, err := strconv.ParseUint(strings.TrimPrefix(request.Params[0].FromBlock, "0x"), 16, 64) + require.NoError(t, err) + to, err := strconv.ParseUint(strings.TrimPrefix(request.Params[0].ToBlock, "0x"), 16, 64) + require.NoError(t, err) + ranges = append(ranges, fmt.Sprintf("%d-%d", from, to)) + if to-from > 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"error":{"code":-32005,"message":"SECRET password=3"}}`, request.ID) + return + } + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":[]}`, request.ID) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/SECRET?password=3", retry, &logs) + filter := Filter{ + MaxChunkSize: big.NewInt(4), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + query := ethereum.FilterQuery{FromBlock: big.NewInt(0), ToBlock: big.NewInt(3)} + results, err := filter.ChunkedFilterLogs(t.Context(), client, query) + require.NoError(t, err) + for _, err := range results { + require.NoError(t, err) + } + require.Equal(t, []string{"0-3", "0-1", "2-3"}, ranges) + }) + } +} + +func TestEthClientRejectsRedirectsAndKeepsRevertTypes(t *testing.T) { + for _, retry := range []bool{false, true} { + t.Run(fmt.Sprint(retry), func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if strings.Contains(r.URL.Path, "/redirect") { + w.Header().Set("Location", "http://%SECRET") + w.WriteHeader(http.StatusTemporaryRedirect) + return + } + _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"execution reverted","data":"0xdeadbeef"}}`) + })) + defer server.Close() + var logs bytes.Buffer + client := secretTestClient(t, server.URL+"/redirect/SECRET", retry, &logs) + _, err := client.ChainID(t.Context()) + require.Error(t, err) + var status rpc.HTTPError + require.ErrorAs(t, err, &status) + require.Equal(t, http.StatusTemporaryRedirect, status.StatusCode) + require.Equal(t, int32(1), requests.Load()) + require.NotContains(t, err.Error(), "SECRET") + client = secretTestClient(t, server.URL+"/revert/SECRET", retry, &logs) + _, err = client.ChainID(t.Context()) + var rpcError rpc.Error + var dataError rpc.DataError + require.ErrorAs(t, err, &rpcError) + require.ErrorAs(t, err, &dataError) + require.Equal(t, 3, rpcError.ErrorCode()) + require.Equal(t, "0xdeadbeef", dataError.ErrorData()) + data, ok := ethclient.RevertErrorData(err) + require.True(t, ok) + require.Equal(t, []byte{0xde, 0xad, 0xbe, 0xef}, data) + }) + } +} + +func TestEthClientWireAndOwnedHTTPClient(t *testing.T) { + for _, auth := range []string{"", "Bearer explicit"} { + var received atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received.Store(true) + require.Equal(t, "/base%2Fkey//./rpc?key=a%2Fb&key=second+", r.RequestURI) + if auth != "" { + require.Equal(t, auth, r.Header.Get("Authorization")) + } else { + user, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "user", user) + require.Equal(t, "3", password) + } + var payload struct { + ID json.RawMessage `json:"id"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":"0x1","error":null}`, payload.ID) + })) + endpoint, _ := url.Parse(server.URL + "/base%2Fkey//./rpc?key=a%2Fb&key=second+") + endpoint.User = url.UserPassword("user", "3") + var logs bytes.Buffer + var override atomic.Bool + injected := &http.Client{Transport: secretRoundTripFunc(func(*http.Request) (*http.Response, error) { + override.Store(true) + return nil, errors.New("override must not run") + })} + options := []rpc.ClientOption{rpc.WithHTTPClient(injected)} + if auth != "" { + options = append(options, rpc.WithHeader("Authorization", auth)) + } + client := secretTestClient(t, endpoint.String(), true, &logs, options...) + chain, err := client.ChainID(t.Context()) + require.NoError(t, err) + require.Equal(t, big.NewInt(1), chain) + require.True(t, received.Load()) + require.False(t, override.Load()) + server.Close() + } +} + +type secretRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f secretRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestProtectedRetryPolicyMatchesOriginalPolicy(t *testing.T) { + for _, cause := range []error{ + errors.New("net/http: invalid header field value for SECRET"), + &tls.CertificateVerificationError{Err: errors.New("SECRET certificate")}, + errors.New("ordinary SECRET network failure"), + context.Canceled, context.DeadlineExceeded, + } { + original := &url.Error{Op: http.MethodPost, URL: "http://host/SECRET", Err: cause} + protected := &url.Error{ + Op: http.MethodPost, URL: "http://host", + Err: httpclient.Diagnostic(t.Context(), cause, "HTTP request", "http://host"), + } + want, wantErr := retryablehttp.DefaultRetryPolicy(context.Background(), nil, original) + got, gotErr := protectedRetryPolicy(context.Background(), nil, protected) + require.Equal(t, want, got) + require.Equal(t, wantErr, gotErr) + require.NotContains(t, protected.Error(), "SECRET") + } + for _, cause := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("SECRET failure"))} { + var logs bytes.Buffer + original := &http.Client{Transport: secretRoundTripFunc(func(*http.Request) (*http.Response, error) { return nil, cause })} + _, inner, err := httpclient.NewClient("http://host/SECRET", original, httpclient.Options{}) + require.NoError(t, err) + rclient := retryablehttp.NewClient() + rclient.RetryMax = 0 + rclient.HTTPClient = inner + logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + rclient.Logger = &retryLeveledLogger{logger: logger} + rclient.CheckRetry = protectedRetryPolicy + _, err = rclient.StandardClient().Get("http://host") + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, errutil.IsOnlyCancellation(cause), errutil.IsOnlyCancellation(err)) + require.NotContains(t, err.Error(), "SECRET") + require.NotContains(t, logs.String(), "SECRET") + } +} + +func TestEthConstructorsRejectNonHTTP(t *testing.T) { + for _, endpoint := range []string{"SECRET.sock", "wss://host/SECRET", "https:///SECRET", "http://host/%SECRET"} { + _, err := DialEthClient(t.Context(), endpoint) + require.Error(t, err) + require.NotContains(t, err.Error(), "SECRET") + _, err = NewEthClient(t.Context(), endpoint, slog.Default(), RetryConfig{}) + require.Error(t, err) + require.NotContains(t, err.Error(), "SECRET") + } +} diff --git a/pkg/ethutil/client_test.go b/pkg/ethutil/client_test.go new file mode 100644 index 000000000..982aecb89 --- /dev/null +++ b/pkg/ethutil/client_test.go @@ -0,0 +1,304 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRedactURLString(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "strips path with API key", + input: "https://eth-mainnet.g.alchemy.com/v2/abcdef123456", + expected: "https://eth-mainnet.g.alchemy.com", + }, + { + name: "strips query params", + input: "https://mainnet.infura.io/v3/key123?project=secret", + expected: "https://mainnet.infura.io", + }, + { + name: "preserves scheme and host with port", + input: "http://localhost:8545/rpc", + expected: "http://localhost:8545", + }, + { + name: "handles plain host URL", + input: "http://localhost:8545", + expected: "http://localhost:8545", + }, + { + name: "redacts bare path without scheme", + input: "/v2/secret-key", + expected: "[REDACTED]", + }, + { + name: "redacts empty string", + input: "", + expected: "[REDACTED]", + }, + } + + // Verify URL roundtrip fidelity: redacting url.Parse(s).String() must + // produce the same result as redacting s directly, otherwise string-based + // redaction could silently miss normalized URLs. + roundtripEndpoints := []string{ + "https://eth-mainnet.g.alchemy.com/v2/abcdef123456", + "https://mainnet.infura.io/v3/key123?project=secret", + "http://localhost:8545/rpc", + "https://rpc.example.com/v2/key%2Bwith%2Bencoding", + "wss://ws.alchemy.com/v2/secret-key-123", + } + for _, ep := range roundtripEndpoints { + parsed, err := url.Parse(ep) + if err != nil { + continue + } + tests = append(tests, struct { + name string + input string + expected string + }{ + name: "roundtrip fidelity: " + parsed.Host, + input: parsed.String(), + expected: redactURLString(ep), + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := redactURLString(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestRetryLeveledLoggerPreservesSafeValues(t *testing.T) { + var logs bytes.Buffer + logger := &retryLeveledLogger{logger: slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))} + for _, log := range []func(string, ...any){logger.Error, logger.Warn, logger.Info, logger.Debug} { + log("request", "url", "https://provider.example", "attempt", 2) + } + require.Equal(t, 4, strings.Count(logs.String(), `"msg":"request"`)) + require.Equal(t, 4, strings.Count(logs.String(), `"url":"https://provider.example"`)) + require.Contains(t, logs.String(), `"attempt":2`) +} + +func TestRedactEndpointFromError(t *testing.T) { + t.Run("returns nil for nil error", func(t *testing.T) { + require.NoError(t, RedactEndpointFromError(nil, "https://host/v2/key")) + }) + + t.Run("redacts endpoint from error message", func(t *testing.T) { + endpoint := "https://alchemy.com/v2/secret-key" + err := fmt.Errorf("Get %q: connection refused", endpoint) + redacted := RedactEndpointFromError(err, endpoint) + + require.NotContains(t, redacted.Error(), "secret-key") + require.Contains(t, redacted.Error(), "https://alchemy.com") + }) + + t.Run("returns original when endpoint not in error", func(t *testing.T) { + original := errors.New("some other error") + result := RedactEndpointFromError(original, "https://host/v2/key") + require.Equal(t, original, result) + }) + + t.Run("returns original when nothing to redact", func(t *testing.T) { + original := errors.New("connection refused") + result := RedactEndpointFromError(original, "http://localhost:8545") + require.Equal(t, original, result) + }) + + t.Run("redacts url.Error", func(t *testing.T) { + endpoint := "https://alchemy.com/v2/secret-key" + urlErr := &url.Error{Op: "Get", URL: endpoint, Err: errors.New("timeout")} + redacted := RedactEndpointFromError(urlErr, endpoint) + + require.NotContains(t, redacted.Error(), "secret-key") + require.Contains(t, redacted.Error(), "https://alchemy.com") + }) +} + +func TestParseRetryAfterHeader(t *testing.T) { + maxDuration := time.Duration(math.MaxInt64) + overflowSeconds := strconv.FormatInt(int64(maxDuration/time.Second)+1, 10) + pastDate := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC1123) + + tests := []struct { + name string + headers []string + want time.Duration + wantOK bool + }{ + { + name: "missing header", + wantOK: false, + }, + { + name: "empty header", + headers: []string{""}, + wantOK: false, + }, + { + name: "invalid header", + headers: []string{"not-a-date-or-number"}, + wantOK: false, + }, + { + name: "negative seconds", + headers: []string{"-1"}, + wantOK: false, + }, + { + name: "positive seconds", + headers: []string{"3"}, + want: 3 * time.Second, + wantOK: true, + }, + { + name: "seconds overflow saturates", + headers: []string{overflowSeconds}, + want: maxDuration, + wantOK: true, + }, + { + name: "past date", + headers: []string{pastDate}, + want: 0, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseRetryAfterHeader(tt.headers) + require.Equal(t, tt.wantOK, ok) + require.Equal(t, tt.want, got) + }) + } +} + +func TestRetryBackoff(t *testing.T) { + response := func(status int, retryAfter string) *http.Response { + resp := &http.Response{ + StatusCode: status, + Header: http.Header{}, + } + if retryAfter != "" { + resp.Header.Set("Retry-After", retryAfter) + } + return resp + } + + maxDuration := time.Duration(math.MaxInt64) + overflowSeconds := strconv.FormatInt(int64(maxDuration/time.Second)+1, 10) + + tests := []struct { + name string + minDuration time.Duration + maxDuration time.Duration + attemptNum int + resp *http.Response + want time.Duration + }{ + { + name: "429 retry after is capped by max duration", + minDuration: 100 * time.Millisecond, + maxDuration: 3 * time.Second, + attemptNum: 1, + resp: response(http.StatusTooManyRequests, "10"), + want: 3 * time.Second, + }, + { + name: "503 retry after below max is honored", + minDuration: 100 * time.Millisecond, + maxDuration: 3 * time.Second, + attemptNum: 1, + resp: response(http.StatusServiceUnavailable, "2"), + want: 2 * time.Second, + }, + { + name: "overflowing retry after is capped by max duration", + minDuration: 100 * time.Millisecond, + maxDuration: 3 * time.Second, + attemptNum: 1, + resp: response(http.StatusTooManyRequests, overflowSeconds), + want: 3 * time.Second, + }, + { + name: "invalid retry after falls back to exponential backoff", + minDuration: 100 * time.Millisecond, + maxDuration: 3 * time.Second, + attemptNum: 1, + resp: response(http.StatusTooManyRequests, "-1"), + want: 200 * time.Millisecond, + }, + { + name: "retry after is ignored for non retry-after status", + minDuration: 100 * time.Millisecond, + maxDuration: 3 * time.Second, + attemptNum: 1, + resp: response(http.StatusInternalServerError, "2"), + want: 200 * time.Millisecond, + }, + { + name: "exponential overflow is capped by max duration", + minDuration: time.Second, + maxDuration: 3 * time.Second, + attemptNum: 100, + want: 3 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := retryBackoff(tt.minDuration, tt.maxDuration, tt.attemptNum, tt.resp) + require.Equal(t, tt.want, got) + }) + } +} + +func TestNewEthClientRequestTimeout(t *testing.T) { + const requestTimeout = 25 * time.Millisecond + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(10 * requestTimeout) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + })) + defer server.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + client, err := NewEthClient(context.Background(), server.URL, logger, RetryConfig{ + RequestTimeout: requestTimeout, + }) + require.NoError(t, err) + + start := time.Now() + _, err = client.ChainID(context.Background()) + elapsed := time.Since(start) + + require.Error(t, err) + require.Less(t, elapsed, 5*requestTimeout) +} diff --git a/pkg/ethutil/deployment_transaction.go b/pkg/ethutil/deployment_transaction.go new file mode 100644 index 000000000..08a5403d2 --- /dev/null +++ b/pkg/ethutil/deployment_transaction.go @@ -0,0 +1,57 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +// TransactionRunner controls transaction submission and receipt waiting. A nil +// receipt with no error means broadcast-only: the deployment is not yet confirmed. +type TransactionRunner func( + context.Context, + *bind.TransactOpts, + func(*bind.TransactOpts) (*types.Transaction, error), +) (*types.Receipt, error) + +func runDeploymentTransaction( + ctx context.Context, + client *ethclient.Client, + opts *bind.TransactOpts, + build func(*bind.TransactOpts) (*types.Transaction, error), + runner TransactionRunner, +) (*types.Receipt, error) { + var receipt *types.Receipt + var err error + if runner != nil { + receipt, err = runner(ctx, opts, build) + } else { + prepared := *opts + prepared.Context = ctx + prepared.NoSend = true + tx, buildErr := build(&prepared) + if buildErr != nil { + return nil, fmt.Errorf("failed to prepare deployment transaction: %w", buildErr) + } + if err := client.SendTransaction(ctx, tx); err != nil { + return nil, fmt.Errorf("failed to broadcast deployment transaction %s: %w", tx.Hash(), err) + } + receipt, err = bind.WaitMined(ctx, client, tx) + if err != nil { + return nil, fmt.Errorf("failed to wait for deployment transaction %s; outcome is unknown: %w", tx.Hash(), err) + } + } + if err != nil { + return nil, err + } + if receipt != nil && receipt.Status != types.ReceiptStatusSuccessful { + return nil, fmt.Errorf("deployment transaction %s failed", receipt.TxHash) + } + return receipt, nil +} diff --git a/pkg/ethutil/deployment_transaction_test.go b/pkg/ethutil/deployment_transaction_test.go new file mode 100644 index 000000000..abda763c2 --- /dev/null +++ b/pkg/ethutil/deployment_transaction_test.go @@ -0,0 +1,284 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" +) + +func TestDeploymentsHandleBroadcastOnlyAndFailedReceipts(t *testing.T) { + appAddress := common.HexToAddress("0x01") + consensusAddress := common.HexToAddress("0x02") + factoryAddress := common.HexToAddress("0x03") + app := &ApplicationDeployment{FactoryAddress: factoryAddress} + selfhosted := &SelfhostedApplicationDeployment{FactoryAddress: factoryAddress} + prt := &PRTApplicationDeployment{ApplicationDeployment: ApplicationDeployment{FactoryAddress: factoryAddress}} + authority := &AuthorityDeployment{FactoryAddress: factoryAddress} + quorum := &QuorumDeployment{FactoryAddress: factoryAddress} + + for _, variant := range []struct { + name string + metadata *bind.MetaData + method string + outputs []any + deploy func(context.Context, *ethclient.Client, *bind.TransactOpts, TransactionRunner) (common.Address, error) + }{ + { + name: "application", metadata: iapplicationfactory.IApplicationFactoryMetaData, + method: "calculateApplicationAddress", outputs: []any{appAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := app.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "selfhosted", metadata: iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData, + method: "calculateAddresses", outputs: []any{appAddress, consensusAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := selfhosted.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "prt", metadata: idaveappfactory.IDaveAppFactoryMetaData, + method: "calculateDaveAppAddress", outputs: []any{appAddress, consensusAddress}, + deploy: func( + ctx context.Context, client *ethclient.Client, opts *bind.TransactOpts, runner TransactionRunner, + ) (common.Address, error) { + address, result, err := prt.DeployWithTransaction(ctx, client, NewStaticTransactOptsFactory(opts), runner) + if result != nil { + return address, fmt.Errorf("unexpected confirmed deployment result") + } + return address, err + }, + }, + { + name: "authority", metadata: iauthorityfactory.IAuthorityFactoryMetaData, + method: "calculateAuthorityAddress", outputs: []any{consensusAddress}, deploy: authority.DeployWithTransaction, + }, + { + name: "quorum", metadata: iquorumfactory.IQuorumFactoryMetaData, + method: "calculateQuorumAddress", outputs: []any{consensusAddress}, deploy: quorum.DeployWithTransaction, + }, + } { + t.Run(variant.name, func(t *testing.T) { + for _, noWait := range []bool{true, false} { + t.Run(fmt.Sprintf("no_wait_%t", noWait), func(t *testing.T) { + parsed, err := variant.metadata.GetAbi() + require.NoError(t, err) + method := parsed.Methods[variant.method] + data, err := method.Outputs.Pack(variant.outputs...) + require.NoError(t, err) + backend := &deploymentCallBackend{responses: map[string]hexutil.Bytes{string(method.ID): data}} + if variant.name == "selfhosted" { + for _, name := range []string{"getApplicationFactory", "getAuthorityFactory"} { + getter := parsed.Methods[name] + getterData, err := getter.Outputs.Pack(factoryAddress) + require.NoError(t, err) + backend.responses[string(getter.ID)] = getterData + } + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + opts := &bind.TransactOpts{ + From: common.HexToAddress("0x04"), Nonce: big.NewInt(0), + GasPrice: big.NewInt(1), GasLimit: 1_000_000, NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + called := false + var hash common.Hash + address, err := variant.deploy(t.Context(), client, opts, + func( + ctx context.Context, txOpts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + called = true + require.Equal(t, t.Context(), ctx) + tx, err := build(txOpts) + require.NoError(t, err) + hash = tx.Hash() + if noWait { + return nil, nil + } + return &types.Receipt{Status: types.ReceiptStatusFailed, TxHash: hash}, nil + }) + require.True(t, called) + if noWait { + require.NoError(t, err) + require.Equal(t, variant.outputs[0], address, "return the primary address computed before broadcast") + } else { + require.Zero(t, address) + require.ErrorContains(t, err, hash.Hex()) + require.ErrorContains(t, err, "failed") + } + }) + } + }) + } +} + +type deploymentCallBackend struct { + responses map[string]hexutil.Bytes +} + +func (b *deploymentCallBackend) Call(_ context.Context, call prtFactoryCall, _ string) (hexutil.Bytes, error) { + if len(call.Input) >= 4 { + if response, found := b.responses[string(call.Input[:4])]; found { + return response, nil + } + } + return nil, fmt.Errorf("unexpected contract call: %x", call.Input) +} + +func (*deploymentCallBackend) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{}, nil +} + +func TestDefaultDeploymentRunnerKeepsHashAndChecksReceipt(t *testing.T) { + tx := types.NewTx(&types.LegacyTx{Nonce: 7, Gas: 12345, GasPrice: big.NewInt(1)}) + for _, test := range []struct { + name string + sendError error + receiptError error + status uint64 + wantError string + }{ + {name: "mined", status: types.ReceiptStatusSuccessful}, + {name: "reverted", status: types.ReceiptStatusFailed, wantError: "failed"}, + {name: "broadcast error", sendError: errors.New("connection closed"), wantError: "failed to broadcast"}, + {name: "wait timeout", receiptError: errors.New("provider unavailable"), wantError: "outcome is unknown"}, + } { + t.Run(test.name, func(t *testing.T) { + backend := &deploymentReceiptBackend{ + sendError: test.sendError, receiptError: test.receiptError, + receipt: &types.Receipt{ + TxHash: tx.Hash(), Status: test.status, BlockNumber: big.NewInt(1), Logs: []*types.Log{}, + }, + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + opts := &bind.TransactOpts{GasLimit: 12345} + receipt, err := runDeploymentTransaction(ctx, client, opts, func(prepared *bind.TransactOpts) (*types.Transaction, error) { + require.True(t, prepared.NoSend) + require.Equal(t, opts.GasLimit, prepared.GasLimit) + require.Equal(t, ctx, prepared.Context) + return tx, nil + }, nil) + require.False(t, opts.NoSend, "the caller's options must remain unchanged") + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.ErrorContains(t, err, tx.Hash().Hex()) + require.Nil(t, receipt) + } else { + require.NoError(t, err) + require.Equal(t, tx.Hash(), receipt.TxHash) + } + }) + } +} + +type deploymentReceiptBackend struct { + sendError error + receiptError error + receipt *types.Receipt +} + +func (b *deploymentReceiptBackend) SendRawTransaction(context.Context, hexutil.Bytes) (common.Hash, error) { + return b.receipt.TxHash, b.sendError +} + +func (b *deploymentReceiptBackend) GetTransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) { + return b.receipt, b.receiptError +} + +func TestSelfhostedDefaultPreservesTransactionPreparation(t *testing.T) { + parsed, err := iselfhostedapplicationfactory.ISelfHostedApplicationFactoryMetaData.GetAbi() + require.NoError(t, err) + backend := &selfhostedTransactionBackend{deploymentCallBackend: &deploymentCallBackend{responses: map[string]hexutil.Bytes{}}} + for _, name := range []string{"calculateAddresses", "getApplicationFactory", "getAuthorityFactory"} { + method := parsed.Methods[name] + outputs := []any{common.HexToAddress("0x01")} + if name == "calculateAddresses" { + outputs = append(outputs, common.HexToAddress("0x02")) + } + data, err := method.Outputs.Pack(outputs...) + require.NoError(t, err) + backend.responses[string(method.ID)] = data + } + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + opts := &bind.TransactOpts{ + From: common.HexToAddress("0x03"), Nonce: big.NewInt(99), GasPrice: big.NewInt(88), Value: big.NewInt(77), GasLimit: 12345, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + deployment := &SelfhostedApplicationDeployment{FactoryAddress: common.HexToAddress("0x04")} + _, _, err = deployment.Deploy(t.Context(), client, NewStaticTransactOptsFactory(opts)) + require.ErrorContains(t, err, "stop after transaction preparation") + require.NotNil(t, backend.tx) + require.Equal(t, uint64(7), backend.tx.Nonce()) + require.Equal(t, big.NewInt(42), backend.tx.GasPrice()) + require.Zero(t, backend.tx.Value().Sign()) + require.Equal(t, opts.GasLimit, backend.tx.Gas()) + require.Equal(t, big.NewInt(99), opts.Nonce, "the original caller options are not changed") +} + +type selfhostedTransactionBackend struct { + *deploymentCallBackend + tx *types.Transaction +} + +func (*selfhostedTransactionBackend) GetTransactionCount(context.Context, common.Address, string) hexutil.Uint64 { + return 7 +} + +func (*selfhostedTransactionBackend) GasPrice(context.Context) hexutil.Uint64 { + return 42 +} + +func (b *selfhostedTransactionBackend) SendRawTransaction(_ context.Context, data hexutil.Bytes) (common.Hash, error) { + b.tx = new(types.Transaction) + if err := b.tx.UnmarshalBinary(data); err != nil { + return common.Hash{}, err + } + return b.tx.Hash(), fmt.Errorf("stop after transaction preparation") +} diff --git a/pkg/ethutil/ethutil.go b/pkg/ethutil/ethutil.go index 13a009215..d53d818c1 100644 --- a/pkg/ethutil/ethutil.go +++ b/pkg/ethutil/ethutil.go @@ -9,31 +9,25 @@ import ( "context" "fmt" "math/big" + "strings" - "github.com/cartesi/rollups-node/pkg/addresses" - "github.com/cartesi/rollups-node/pkg/contracts" + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/rpc" ) -// Gas limit when sending transactions. -const GasLimit = 30_000_000 - // Dev mnemonic used by Foundry/Anvil. const FoundryMnemonic = "test test test test test test test test test test test junk" -// Interface that sign blockchain transactions. -type Signer interface { - - // Create the base transaction used in the contract bindings. - MakeTransactor() (*bind.TransactOpts, error) - - // Get the account address of the signer. - Account() common.Address +func TrimHex(s string) string { + s = strings.TrimPrefix(s, "0x") + s = strings.TrimPrefix(s, "0X") + s = strings.TrimSpace(s) + return s } // Add input to the input box for the given DApp address. @@ -41,73 +35,95 @@ type Signer interface { func AddInput( ctx context.Context, client *ethclient.Client, - book *addresses.Book, - signer Signer, + txOptsFactory TransactOptsFactory, + inputBoxAddress common.Address, + application common.Address, input []byte, -) (int, error) { - inputBox, err := contracts.NewInputBox(book.InputBox, client) +) (uint64, uint64, common.Hash, error) { + if client == nil { + return 0, 0, common.Hash{}, fmt.Errorf("AddInput: client is nil") + } + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) if err != nil { - return 0, fmt.Errorf("failed to connect to InputBox contract: %v", err) + return 0, 0, common.Hash{}, fmt.Errorf("failed to connect to InputBox contract: %w", err) } receipt, err := sendTransaction( - ctx, client, signer, big.NewInt(0), GasLimit, + ctx, client, txOptsFactory, big.NewInt(0), func(txOpts *bind.TransactOpts) (*types.Transaction, error) { - return inputBox.AddInput(txOpts, book.CartesiDApp, input) + return inputBox.AddInput(txOpts, application, input) }, ) if err != nil { - return 0, err + return 0, 0, common.Hash{}, err + } + index, err := getInputIndex(inputBoxAddress, inputBox, receipt) + if err != nil { + return 0, 0, common.Hash{}, fmt.Errorf("failed to get input index: %w", err) } - return getInputIndex(ctx, client, book, inputBox, receipt) + return index, receipt.BlockNumber.Uint64(), receipt.TxHash, nil } -// Convenience function to add an input using Foundry Mnemonic -// This function waits until the transaction is added to a block and return the input index. -func AddInputUsingFoundryMnemonic( +// AddInputAsync sends an addInput transaction without waiting for the receipt. +// Returns the transaction hash immediately. Useful for load testing where +// throughput matters more than per-input confirmation. +// +// NOT safe for concurrent use with a shared transactionOpts: callers must +// serialize calls so that nonce assignment does not race. +func AddInputAsync( ctx context.Context, - blockchainHttpEnpoint string, - payload string, -) (int, error) { - - // Send Input - client, err := ethclient.DialContext(ctx, blockchainHttpEnpoint) + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + inputBoxAddress common.Address, + application common.Address, + input []byte, +) (common.Hash, error) { + if client == nil { + return common.Hash{}, fmt.Errorf("AddInputAsync: client is nil") + } + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) if err != nil { - return 0, err + return common.Hash{}, fmt.Errorf("failed to connect to InputBox contract: %w", err) } - defer client.Close() - - signer, err := NewMnemonicSigner(ctx, client, FoundryMnemonic, 0) + txOpts, err := _prepareTransaction(ctx, client, txOptsFactory, big.NewInt(0)) if err != nil { - return 0, err + return common.Hash{}, fmt.Errorf("failed to prepare transaction: %w", err) } - book := addresses.GetTestBook() + txOptsCopy := *txOpts + txOptsCopy.Context = ctx + tx, err := inputBox.AddInput(&txOptsCopy, application, input) + if err != nil { + return common.Hash{}, fmt.Errorf("failed to send transaction: %w", err) + } + return tx.Hash(), nil +} - payloadBytes, err := hexutil.Decode(payload) +func CheckAddressForCode(ctx context.Context, client *ethclient.Client, address common.Address) error { + code, err := client.CodeAt(ctx, address, nil) if err != nil { - panic(err) + return fmt.Errorf("failed to check address for code: %w", err) } - return AddInput(ctx, client, book, signer, payloadBytes) + if len(code) == 0 { + return fmt.Errorf("No code at address: %v", address) + } + return nil } // Get input index in the transaction by looking at the event logs. func getInputIndex( - ctx context.Context, - client *ethclient.Client, - book *addresses.Book, - inputBox *contracts.InputBox, + inputBoxAddress common.Address, + inputBox *iinputbox.IInputBox, receipt *types.Receipt, -) (int, error) { +) (uint64, error) { for _, log := range receipt.Logs { - if log.Address != book.InputBox { + if log.Address != inputBoxAddress { continue } inputAdded, err := inputBox.ParseInputAdded(*log) if err != nil { - return 0, fmt.Errorf("failed to parse input added event: %v", err) + return 0, fmt.Errorf("failed to parse input added event: %w", err) } - // We assume that int will fit all dapp inputs - inputIndex := int(inputAdded.InputIndex.Int64()) - return inputIndex, nil + // We assume that uint64 will fit all dapp inputs for now + return inputAdded.Index.Uint64(), nil } return 0, fmt.Errorf("input index not found") } @@ -116,20 +132,24 @@ func getInputIndex( // Return the event with the input sender and payload. func GetInputFromInputBox( client *ethclient.Client, - book *addresses.Book, - inputIndex int, -) (*contracts.InputBoxInputAdded, error) { - inputBox, err := contracts.NewInputBox(book.InputBox, client) + inputBoxAddress common.Address, + application common.Address, + inputIndex uint64, +) (*iinputbox.IInputBoxInputAdded, error) { + if client == nil { + return nil, fmt.Errorf("GetInputFromInputBox: client is nil") + } + inputBox, err := iinputbox.NewIInputBox(inputBoxAddress, client) if err != nil { - return nil, fmt.Errorf("failed to connect to InputBox contract: %v", err) + return nil, fmt.Errorf("failed to connect to InputBox contract: %w", err) } it, err := inputBox.FilterInputAdded( nil, - []common.Address{book.CartesiDApp}, - []*big.Int{big.NewInt(int64(inputIndex))}, + []common.Address{application}, + []*big.Int{new(big.Int).SetUint64(inputIndex)}, ) if err != nil { - return nil, fmt.Errorf("failed to filter input added: %v", err) + return nil, fmt.Errorf("failed to filter input added: %w", err) } defer it.Close() if !it.Next() { @@ -140,47 +160,64 @@ func GetInputFromInputBox( // ValidateNotice validates the given notice for the specified Dapp. // It returns nil if the notice is valid and an execution-reverted error otherwise. -func ValidateNotice( +func ValidateOutput( ctx context.Context, client *ethclient.Client, - book *addresses.Book, - notice []byte, - proof *contracts.Proof, + appAddr common.Address, + index uint64, + output []byte, + outputHashesSiblings []common.Hash, ) error { + if client == nil { + return fmt.Errorf("ValidateOutput: client is nil") + } + proof := iapplication.OutputValidityProof{ + OutputIndex: index, + OutputHashesSiblings: make([][32]byte, len(outputHashesSiblings)), + } - dapp, err := contracts.NewCartesiDApp(book.CartesiDApp, client) - if err != nil { - return fmt.Errorf("failed to connect to CartesiDapp contract: %v", err) + for i, hash := range outputHashesSiblings { + copy(proof.OutputHashesSiblings[i][:], hash[:]) } - response, err := dapp.ValidateNotice(&bind.CallOpts{Context: ctx}, notice, *proof) - _ = response + app, err := iapplication.NewIApplication(appAddr, client) if err != nil { - return err + return fmt.Errorf("failed to connect to CartesiDapp contract: %w", err) } - - return nil + return app.ValidateOutput(&bind.CallOpts{Context: ctx}, output, proof) } // Executes a voucher given its payload, destination and proof. // This function waits until the transaction is added to a block and returns the transaction hash. -func ExecuteVoucher( +func ExecuteOutput( ctx context.Context, client *ethclient.Client, - book *addresses.Book, - signer Signer, - voucher []byte, - destination *common.Address, - proof *contracts.Proof, + txOptsFactory TransactOptsFactory, + appAddr common.Address, + index uint64, + output []byte, + outputHashesSiblings []common.Hash, ) (*common.Hash, error) { - dapp, err := contracts.NewCartesiDApp(book.CartesiDApp, client) + if client == nil { + return nil, fmt.Errorf("ExecuteOutput: client is nil") + } + proof := iapplication.OutputValidityProof{ + OutputIndex: index, + OutputHashesSiblings: make([][32]byte, len(outputHashesSiblings)), + } + + for i, hash := range outputHashesSiblings { + copy(proof.OutputHashesSiblings[i][:], hash[:]) + } + + app, err := iapplication.NewIApplication(appAddr, client) if err != nil { - return nil, fmt.Errorf("failed to connect to CartesiDapp contract: %v", err) + return nil, fmt.Errorf("failed to connect to CartesiDapp contract: %w", err) } receipt, err := sendTransaction( - ctx, client, signer, big.NewInt(0), GasLimit, + ctx, client, txOptsFactory, big.NewInt(0), func(txOpts *bind.TransactOpts) (*types.Transaction, error) { - return dapp.ExecuteVoucher(txOpts, *destination, voucher, *proof) + return app.ExecuteOutput(txOpts, output, proof) }, ) if err != nil { @@ -190,59 +227,134 @@ func ExecuteVoucher( return &receipt.TxHash, nil } -// Advances the Devnet timestamp -func AdvanceDevnetTime(ctx context.Context, - blockchainHttpEnpoint string, - timeInSeconds int, -) error { - client, err := rpc.DialContext(ctx, blockchainHttpEnpoint) +// Retrieves the template hash from the application contract. +func GetTemplateHash( + ctx context.Context, + client *ethclient.Client, + applicationAddress common.Address, +) (*common.Hash, error) { + if client == nil { + return nil, fmt.Errorf("get template hash: client is nil") + } + cartesiApplication, err := iapplication.NewIApplicationCaller( + applicationAddress, + client, + ) if err != nil { - return err + return nil, fmt.Errorf("get template hash failed to instantiate binding: %w", err) } - defer client.Close() - return client.CallContext(ctx, nil, "evm_increaseTime", timeInSeconds) + var hash common.Hash + hash, err = cartesiApplication.GetTemplateHash(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, fmt.Errorf("get template hash failed to call contract method: %w", err) + } + return &hash, nil +} +func GetConsensus( + ctx context.Context, + client *ethclient.Client, + appAddress common.Address, +) (common.Address, error) { + return GetConsensusAt(ctx, client, appAddress, nil) } -// Sets the timestamp for the next block at Devnet -func SetNextDevnetBlockTimestamp( +func GetConsensusAt( ctx context.Context, - blockchainHttpEndpoint string, - timestamp int64, -) error { + client *ethclient.Client, + appAddress common.Address, + blockNumber *big.Int, +) (common.Address, error) { + if client == nil { + return common.Address{}, fmt.Errorf("get consensus: client is nil") + } + app, err := iapplication.NewIApplication(appAddress, client) + if err != nil { + return common.Address{}, fmt.Errorf("Failed to instantiate contract: %w", err) + } + opts := &bind.CallOpts{Context: ctx, BlockNumber: blockNumber} + consensus, err := app.GetOutputsMerkleRootValidator(opts) + if err != nil { + return common.Address{}, fmt.Errorf("error retrieving application epoch length: %w", err) + } + return consensus, nil +} + +func GetInputBox( + ctx context.Context, + client *ethclient.Client, + appAddress common.Address, +) (common.Address, error) { + if client == nil { + return common.Address{}, fmt.Errorf("get input box: client is nil") + } + app, err := iapplication.NewIApplication(appAddress, client) + if err != nil { + return common.Address{}, fmt.Errorf("failed to instantiate contract: %w", err) + } + inputBox, err := app.GetInputBox(&bind.CallOpts{Context: ctx}) + if err != nil { + return common.Address{}, fmt.Errorf("error retrieving application input box: %w", err) + } + return inputBox, nil +} - client, err := rpc.DialContext(ctx, blockchainHttpEndpoint) +func GetEpochLength( + ctx context.Context, + client *ethclient.Client, + consensusAddr common.Address, +) (uint64, error) { + if client == nil { + return 0, fmt.Errorf("get epoch length: client is nil") + } + consensus, err := iconsensus.NewIConsensus(consensusAddr, client) + if err != nil { + return 0, fmt.Errorf("Failed to instantiate contract: %w", err) + } + epochLengthRaw, err := consensus.GetEpochLength(&bind.CallOpts{Context: ctx}) if err != nil { - return err + return 0, fmt.Errorf("error retrieving application epoch length: %w", err) } - defer client.Close() - return client.CallContext(ctx, nil, "evm_setNextBlockTimestamp", timestamp) + return epochLengthRaw.Uint64(), nil } -// Mine blocks. -// Assumes the HTTP provider is anvil as the `rpc_modules` method cannot be -// be relied upon to discover what modules are supported and decide what mine -// method to use -func MineBlocks( +// GetClaimStagingPeriod returns the consensus contract's immutable +// claimStagingPeriod, in blocks. Solidity guarantees this value cannot change +// for the lifetime of the contract, so it is safe to cache locally. +func GetClaimStagingPeriod( ctx context.Context, - blockchainHttpEndpoint string, - numBlocks uint64, - blockInterval uint64, + client *ethclient.Client, + consensusAddr common.Address, ) (uint64, error) { - client, err := rpc.DialContext(ctx, blockchainHttpEndpoint) + if client == nil { + return 0, fmt.Errorf("get claim staging period: client is nil") + } + consensus, err := iconsensus.NewIConsensus(consensusAddr, client) + if err != nil { + return 0, fmt.Errorf("failed to instantiate contract: %w", err) + } + raw, err := consensus.GetClaimStagingPeriod(&bind.CallOpts{Context: ctx}) if err != nil { - return 0, err + return 0, fmt.Errorf("error retrieving claim staging period: %w", err) } - defer client.Close() + return raw.Uint64(), nil +} - err = client.CallContext(ctx, nil, "anvil_mine", numBlocks, blockInterval) +func GetInputBoxDeploymentBlock( + ctx context.Context, + client *ethclient.Client, + inputBoxAddress common.Address, +) (*big.Int, error) { + if client == nil { + return nil, fmt.Errorf("get epoch length: client is nil") + } + inputbox, err := iinputbox.NewIInputBox(inputBoxAddress, client) if err != nil { - return 0, err + return nil, fmt.Errorf("failed to create input box instance: %w", err) } - ethClient, err := ethclient.DialContext(ctx, blockchainHttpEndpoint) + block, err := inputbox.GetDeploymentBlockNumber(&bind.CallOpts{Context: ctx}) if err != nil { - return 0, err + return nil, fmt.Errorf("error retrieving inputbox deployment block: %w", err) } - defer ethClient.Close() - return ethClient.BlockNumber(ctx) + return block, nil } diff --git a/pkg/ethutil/ethutil_test.go b/pkg/ethutil/ethutil_test.go index 5e8509fca..832adda86 100644 --- a/pkg/ethutil/ethutil_test.go +++ b/pkg/ethutil/ethutil_test.go @@ -5,15 +5,24 @@ package ethutil import ( "context" - "io" + "crypto/rand" + "errors" + "math/big" + "os" + "sync" + "sync/atomic" "testing" "time" - "github.com/cartesi/rollups-node/internal/deps" - "github.com/cartesi/rollups-node/pkg/addresses" - "github.com/cartesi/rollups-node/pkg/testutil" + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/pkg/contracts/inputs" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -23,105 +32,186 @@ const testTimeout = 300 * time.Second // and connects to it using go-ethereum's client. type EthUtilSuite struct { suite.Suite - ctx context.Context - cancel context.CancelFunc - deps *deps.DepsContainers - client *ethclient.Client - endpoint string - signer Signer - book *addresses.Book + ctx context.Context + cancel context.CancelFunc + client *ethclient.Client + endpoint config.URL + txOptsFactory TransactOptsFactory + inputBoxAddr common.Address + selfHostedAppFactory common.Address + appAddr common.Address + machineDir string + cleanup func() } func (s *EthUtilSuite) SetupTest() { s.ctx, s.cancel = context.WithTimeout(context.Background(), testTimeout) var err error - s.deps, err = newDevNetContainer(context.Background()) + s.endpoint, err = config.GetBlockchainHttpEndpoint() s.Require().Nil(err) - s.endpoint, err = s.deps.DevnetEndpoint(s.ctx, "ws") + s.client, err = ethclient.DialContext(s.ctx, s.endpoint.Raw()) s.Require().Nil(err) - s.client, err = ethclient.DialContext(s.ctx, s.endpoint) + chainId, err := s.client.ChainID(s.ctx) s.Require().Nil(err) - s.signer, err = NewMnemonicSigner(s.ctx, s.client, FoundryMnemonic, 0) + privateKey, err := MnemonicToPrivateKey(FoundryMnemonic, 0) s.Require().Nil(err) - s.book = addresses.GetTestBook() + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, chainId) + s.Require().Nil(err) + s.txOptsFactory = NewStaticTransactOptsFactory(txOpts) + + s.selfHostedAppFactory, err = config.GetContractsSelfHostedApplicationFactoryAddress() + s.Require().Nil(err) + + var templateHash common.Hash + _, err = rand.Read(templateHash[:]) + s.Require().Nil(err) + + s.inputBoxAddr, err = config.GetContractsInputBoxAddress() + s.Require().Nil(err) + + salt := "0000000000000000000000000000000000000000000000000000000000000000" + s.appAddr, s.cleanup, err = CreateAnvilSnapshotAndDeployApp( + s.ctx, + s.client, + s.selfHostedAppFactory, + templateHash, + s.inputBoxAddr, + salt, + ) + s.Require().Nil(err) } func (s *EthUtilSuite) TearDownTest() { - err := deps.Terminate(context.Background(), s.deps) - s.Nil(err) + os.RemoveAll(s.machineDir) + if s.cleanup != nil { + s.cleanup() + } s.cancel() } func (s *EthUtilSuite) TestAddInput() { - sender := common.HexToAddress("f39fd6e51aad88f6f4ce6ab8827279cfffb92266") - payload := common.Hex2Bytes("deadbeef") - - inputIndex, err := AddInput(s.ctx, s.client, s.book, s.signer, payload) - if !s.Nil(err) { - s.logDevnetOutput() - s.T().FailNow() - } - s.Require().Equal(0, inputIndex) + sender := s.txOptsFactory.From() + payload := common.Hex2Bytes("deadbeef") - event, err := GetInputFromInputBox(s.client, s.book, inputIndex) + indexChan := make(chan uint64) + errChan := make(chan error) + + waitGroup := sync.WaitGroup{} + waitGroup.Add(1) + + go func() { + waitGroup.Done() + inputIndex, _, _, err := AddInput(s.ctx, s.client, s.txOptsFactory, s.inputBoxAddr, s.appAddr, payload) + if err != nil { + errChan <- err + return + } + indexChan <- inputIndex + }() + + waitGroup.Wait() + time.Sleep(1 * time.Second) + _, err := MineNewBlock(s.ctx, s.client) s.Require().Nil(err) - s.Require().Equal(sender, event.Sender) - s.Require().Equal(payload, event.Input) -} -func (s *EthUtilSuite) TestMineOneBlock() { - s.mineBlocks(1) + select { + case err := <-errChan: + s.Require().FailNow("Unexpected Error", err) + case inputIndex := <-indexChan: + s.Require().Equal(uint64(0), inputIndex) + + event, err := GetInputFromInputBox(s.client, s.inputBoxAddr, s.appAddr, inputIndex) + s.Require().Nil(err) + + inputsABI, err := inputs.InputsMetaData.GetAbi() + s.Require().Nil(err) + advanceInputABI := inputsABI.Methods["EvmAdvance"] + inputArgs := map[string]interface{}{} + err = advanceInputABI.Inputs.UnpackIntoMap(inputArgs, event.Input[4:]) + s.Require().Nil(err) + + s.T().Log(inputArgs) + s.Require().Equal(sender, inputArgs["msgSender"]) + s.Require().Equal(payload, inputArgs["payload"]) + } } -func (s *EthUtilSuite) TestMineAHundredBlocks() { - s.mineBlocks(100) +func TestEthUtilSuite(t *testing.T) { + suite.Run(t, new(EthUtilSuite)) } -func (s *EthUtilSuite) mineBlocks(numBlocks uint64) { - lastBlockNumber, err := s.client.BlockNumber(s.ctx) - s.Require().Nil(err) - expectedBlockNumber := lastBlockNumber + numBlocks +func TestAddInputAsyncUsesContextForBindingTransaction(t *testing.T) { + timeout := 20 * time.Millisecond - blockNumber, err := MineBlocks(s.ctx, s.endpoint, numBlocks, 1) - s.Require().Nil(err) - s.Require().Equal(expectedBlockNumber, blockNumber) + srv := rpc.NewServer() + backend := &addInputAsyncContextBackend{estimateGasTimeout: 10 * timeout} + require.NoError(t, srv.RegisterName("eth", backend)) + defer srv.Stop() + + client := ethclient.NewClient(rpc.DialInProc(srv)) + defer client.Close() + + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + txOpts, err := bind.NewKeyedTransactorWithChainID(privateKey, big.NewInt(1)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + _, err = AddInputAsync( + ctx, + client, + NewStaticTransactOptsFactory(txOpts), + common.HexToAddress("0x1000000000000000000000000000000000000001"), + common.HexToAddress("0x2000000000000000000000000000000000000002"), + []byte("payload"), + ) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.True(t, backend.estimateGasCalled.Load(), "expected AddInputAsync to reach the binding gas-estimation boundary") } -// Log the output of the given container -func (s *EthUtilSuite) logDevnetOutput() { - reader, err := s.deps.DevnetLogs(s.ctx) - s.Require().Nil(err) - defer reader.Close() +type addInputAsyncContextBackend struct { + estimateGasCalled atomic.Bool + estimateGasTimeout time.Duration +} - bytes, err := io.ReadAll(reader) - s.Require().Nil(err) - s.T().Log(string(bytes)) +func (b *addInputAsyncContextBackend) GetTransactionCount( + context.Context, + common.Address, + string, +) (hexutil.Uint64, error) { + return 0, nil } -func TestEthUtilSuite(t *testing.T) { - suite.Run(t, new(EthUtilSuite)) +func (b *addInputAsyncContextBackend) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(1)), nil +} + +func (b *addInputAsyncContextBackend) GetCode( + context.Context, + common.Address, + string, +) (hexutil.Bytes, error) { + return hexutil.Bytes{0x01}, nil } -// We use the node devnet docker image to test the client. -// This image starts an anvil node with the Rollups contracts already deployed. -func newDevNetContainer(ctx context.Context) (*deps.DepsContainers, error) { - - container, err := deps.Run(ctx, deps.DepsConfig{ - Devnet: &deps.DevnetConfig{ - DockerImage: deps.DefaultDevnetDockerImage, - BlockTime: deps.DefaultDevnetBlockTime, - BlockToWaitForOnStartup: deps.DefaultDevnetBlockToWaitForOnStartup, - Port: testutil.GetCartesiTestDepsPortRange(), - }, - }) - if err != nil { - return nil, err +func (b *addInputAsyncContextBackend) EstimateGas( + ctx context.Context, + _ map[string]interface{}, +) (hexutil.Uint64, error) { + b.estimateGasCalled.Store(true) + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-time.After(b.estimateGasTimeout): + return 0, errors.New("eth_estimateGas was not called with the AddInputAsync context") } - return container, nil } diff --git a/pkg/ethutil/filter.go b/pkg/ethutil/filter.go new file mode 100644 index 000000000..34e2d849c --- /dev/null +++ b/pkg/ethutil/filter.go @@ -0,0 +1,99 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "iter" + "log/slog" + "math" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type Filter struct { + MaxChunkSize *big.Int + Logger *slog.Logger +} + +// ChunkedFilterLogs emits matching logs in order over inclusive block ranges. +// It narrows failed queries until a single block succeeds or returns an error. +func (f *Filter) ChunkedFilterLogs( + ctx context.Context, + client *ethclient.Client, + q ethereum.FilterQuery, +) ( + iter.Seq2[*types.Log, error], + error, +) { + if q.FromBlock == nil { + q.FromBlock = big.NewInt(0) + } + if q.ToBlock == nil { + end, err := client.BlockNumber(ctx) + if err != nil { + return nil, err + } + q.ToBlock = big.NewInt(0).SetUint64(end) + } + maximum := f.MaxChunkSize + if maximum == nil || maximum.Uint64() == 0 { + maximum = new(big.Int).SetUint64(math.MaxUint64) + } + + return func(yield func(log *types.Log, err error) bool) { + one := big.NewInt(1) + endBlock := new(big.Int).Set(q.ToBlock) + + delta := new(big.Int).Sub(q.ToBlock, q.FromBlock) + + // split the query + if delta.Cmp(maximum) >= 0 { + q.ToBlock.Add(q.FromBlock, maximum) + + // range is inclusive, so remove 1 + // e.g. a chunk size of 500 is: from: 0, to: 499 + q.ToBlock.Sub(q.ToBlock, one) + } + + for q.FromBlock.Cmp(endBlock) <= 0 { + f.Logger.Debug("ChunkedFilterLogs for range", "from", q.FromBlock, "to", q.ToBlock, "max", maximum) + logs, err := client.FilterLogs(ctx, q) + delta := new(big.Int).Sub(q.ToBlock, q.FromBlock) + + if err != nil { + if delta.Sign() == 0 || ctx.Err() != nil { + yield(nil, err) + return + } + f.Logger.Debug("ChunkedFilterLogs narrowing failed range", "from", q.FromBlock, "to", q.ToBlock) + q.ToBlock.Add(q.FromBlock, new(big.Int).Rsh(delta, 1)) + continue + } + + for _, log := range logs { + if !yield(&log, nil) { + return + } + } + + // ------------------------ + // [ delta | delta ] + // ^ ^ + // From To + // ------------------------ + // [ delta | delta ] + // ^ ^ + // From To + // ------------------------ + q.FromBlock.Add(q.ToBlock, one) + q.ToBlock.Add(q.FromBlock, delta) + if q.ToBlock.Cmp(endBlock) > 0 { + q.ToBlock.Set(endBlock) + } + } + }, nil +} diff --git a/pkg/ethutil/filter_progress_test.go b/pkg/ethutil/filter_progress_test.go new file mode 100644 index 000000000..0ec2399d9 --- /dev/null +++ b/pkg/ethutil/filter_progress_test.go @@ -0,0 +1,112 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +const twoBlockRange = "0-1" + +type progressRangeQuery struct { + FromBlock *hexutil.Big `json:"fromBlock"` + ToBlock *hexutil.Big `json:"toBlock"` +} + +const progressRangeLimitMessage = "provider range limit" + +type progressRangeLimit struct{} + +func (progressRangeLimit) Error() string { return progressRangeLimitMessage } +func (progressRangeLimit) ErrorCode() int { return -32005 } + +type progressFilterRPC struct { + ranges []string + fail func(uint64, uint64) error +} + +func (s *progressFilterRPC) GetLogs(_ context.Context, q progressRangeQuery) ([]types.Log, error) { + from, to := q.FromBlock.ToInt().Uint64(), q.ToBlock.ToInt().Uint64() + s.ranges = append(s.ranges, fmt.Sprintf("%d-%d", from, to)) + // Bound a regression in the test itself: an unchanged query must fail the + // semantic assertions rather than hang the entire test process. + if len(s.ranges) > 8 { + return nil, errors.New("test RPC request budget exhausted") + } + if err := s.fail(from, to); err != nil { + return nil, err + } + return []types.Log{{BlockNumber: from, Topics: []common.Hash{{}}, Data: []byte{}}}, nil +} + +func TestChunkedFilterLogsMakesProgressToSingleBlocks(t *testing.T) { + const singleZeroBlockRange = "0-0" + for _, scenario := range []struct { + name, wantError string + end int64 + fail func(uint64, uint64) error + wantRanges []string + wantBlocks []uint64 + }{ + {name: "single blocks succeed", end: 1, + fail: func(from, to uint64) error { + if from != to { + return progressRangeLimit{} + } + return nil + }, wantRanges: []string{twoBlockRange, singleZeroBlockRange, "1-1"}, wantBlocks: []uint64{0, 1}}, + {name: "single block also rejected", end: 1, + fail: func(uint64, uint64) error { return progressRangeLimit{} }, + wantRanges: []string{twoBlockRange, singleZeroBlockRange}, wantError: progressRangeLimitMessage}, + {name: "single block cannot shrink", end: 0, + fail: func(uint64, uint64) error { return progressRangeLimit{} }, + wantRanges: []string{singleZeroBlockRange}, wantError: progressRangeLimitMessage}, + {name: "timeout splits to one block", end: 1, + fail: func(uint64, uint64) error { return context.DeadlineExceeded }, + wantRanges: []string{twoBlockRange, singleZeroBlockRange}, wantError: context.DeadlineExceeded.Error()}, + } { + t.Run(scenario.name, func(t *testing.T) { + api := &progressFilterRPC{fail: scenario.fail} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", api)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + filter := Filter{MaxChunkSize: big.NewInt(4), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + logs, err := filter.ChunkedFilterLogs(t.Context(), client, + ethereum.FilterQuery{FromBlock: new(big.Int), ToBlock: big.NewInt(scenario.end)}) + require.NoError(t, err) + var blocks []uint64 + for log, readErr := range logs { + if readErr != nil { + err = readErr + break + } + blocks = append(blocks, log.BlockNumber) + } + if scenario.wantError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, scenario.wantError) + } + require.Equal(t, scenario.wantRanges, api.ranges) + require.Equal(t, scenario.wantBlocks, blocks) + }) + } +} diff --git a/pkg/ethutil/filter_rpc_test.go b/pkg/ethutil/filter_rpc_test.go new file mode 100644 index 000000000..5ac1b3080 --- /dev/null +++ b/pkg/ethutil/filter_rpc_test.go @@ -0,0 +1,247 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "io" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +type rangeRPCError int + +func (e rangeRPCError) Error() string { return "query refused" } +func (e rangeRPCError) ErrorCode() int { return int(e) } + +type logRangeRPC struct { + mu sync.Mutex + queries [][2]uint64 + fail func(context.Context, uint64, uint64) error + hasLogs func(uint64) bool + stop context.CancelFunc +} + +func (s *logRangeRPC) GetLogs(ctx context.Context, query progressRangeQuery) ([]types.Log, error) { + from, to := query.FromBlock.ToInt().Uint64(), query.ToBlock.ToInt().Uint64() + s.mu.Lock() + s.queries = append(s.queries, [2]uint64{from, to}) + overBudget := len(s.queries) > 64 + s.mu.Unlock() + if overBudget { + s.stop() + return nil, errors.New("test RPC request budget exhausted") + } + if s.fail != nil { + if err := s.fail(ctx, from, to); err != nil { + return nil, err + } + } + var logs []types.Log + for block := from; ; block++ { + for index := range uint(2) { + if s.hasLogs != nil && !s.hasLogs(block) { + continue + } + logs = append(logs, types.Log{BlockNumber: block, Index: index, + Topics: []common.Hash{{}}, Data: []byte{byte(index)}}) + } + if block == to { + return logs, nil + } + } +} + +func (s *logRangeRPC) requestedRanges() [][2]uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return append([][2]uint64(nil), s.queries...) +} + +func runLogRangeRPC( + t *testing.T, api *logRangeRPC, from, to uint64, maximum *big.Int, timeout time.Duration, +) ([]types.Log, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + api.stop = cancel + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", api)) + defer server.Stop() + httpServer := httptest.NewServer(server) + defer httpServer.Close() + rpcClient, err := rpc.DialOptions(ctx, httpServer.URL, rpc.WithHTTPClient(&http.Client{Timeout: timeout})) + require.NoError(t, err) + client := ethclient.NewClient(rpcClient) + defer client.Close() + filter := Filter{MaxChunkSize: maximum, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + stream, err := filter.ChunkedFilterLogs(ctx, client, + ethereum.FilterQuery{FromBlock: new(big.Int).SetUint64(from), ToBlock: new(big.Int).SetUint64(to)}) + require.NoError(t, err) + var logs []types.Log + for log, err := range stream { + if err != nil { + return logs, err + } + logs = append(logs, *log) + } + return logs, nil +} + +func requireLogRangeCoverage(t *testing.T, logs []types.Log, first, last uint64) { + t.Helper() + require.Equal(t, 2*(last-first+1), uint64(len(logs))) + for index, log := range logs { + require.Equal(t, first+uint64(index/2), log.BlockNumber) + require.Equal(t, uint(index%2), log.Index) + require.Equal(t, []byte{byte(index % 2)}, log.Data) + } +} + +func TestChunkedFilterLogsSplitsEveryRPCError(t *testing.T) { + for _, code := range []rangeRPCError{-32047, -32600, -32005, 400, -32602, -32616, -32099} { + t.Run(big.NewInt(int64(code)).String(), func(t *testing.T) { + api := &logRangeRPC{fail: func(_ context.Context, from, to uint64) error { + if from != to { + return code + } + return nil + }} + logs, err := runLogRangeRPC(t, api, 0, 4, nil, 0) + require.NoError(t, err) + require.Equal(t, [][2]uint64{{0, 4}, {0, 2}, {0, 1}, {0, 0}, {1, 1}, {2, 2}, {3, 3}, {4, 4}}, + api.requestedRanges()) + requireLogRangeCoverage(t, logs, 0, 4) + }) + } +} + +func TestChunkedFilterLogsStopsAtRefusedSingleBlock(t *testing.T) { + api := &logRangeRPC{fail: func(context.Context, uint64, uint64) error { return rangeRPCError(-32616) }} + logs, err := runLogRangeRPC(t, api, 0, 39, nil, 0) + require.ErrorContains(t, err, "query refused") + require.Empty(t, logs) + require.Equal(t, [][2]uint64{{0, 39}, {0, 19}, {0, 9}, {0, 4}, {0, 2}, {0, 1}, {0, 0}}, api.requestedRanges()) +} + +func TestChunkedFilterLogsPreservesPrefixBeforeSingleBlockError(t *testing.T) { + api := &logRangeRPC{fail: func(_ context.Context, from, to uint64) error { + if from != to || from == 2 { + return rangeRPCError(-32602) + } + return nil + }} + logs, err := runLogRangeRPC(t, api, 0, 3, nil, 0) + require.ErrorContains(t, err, "query refused") + requireLogRangeCoverage(t, logs, 0, 1) + require.Equal(t, [][2]uint64{{0, 3}, {0, 1}, {0, 0}, {1, 1}, {2, 2}}, api.requestedRanges()) +} + +func TestChunkedFilterLogsSplitsTransportTimeout(t *testing.T) { + api := &logRangeRPC{fail: func(ctx context.Context, from, to uint64) error { + if from == to { + return nil + } + <-ctx.Done() + return ctx.Err() + }} + logs, err := runLogRangeRPC(t, api, 0, 1, nil, 30*time.Millisecond) + require.NoError(t, err) + require.Equal(t, [][2]uint64{{0, 1}, {0, 0}, {1, 1}}, api.requestedRanges()) + requireLogRangeCoverage(t, logs, 0, 1) +} + +func TestChunkedFilterLogsHonorsInclusiveMaximum(t *testing.T) { + for _, test := range []struct { + name string + from, to uint64 + maximum *big.Int + want [][2]uint64 + }{ + {name: "one block", to: 0, maximum: big.NewInt(4), want: [][2]uint64{{0, 0}}}, + {name: "within cap", to: 2, maximum: big.NewInt(4), want: [][2]uint64{{0, 2}}}, + {name: "exact cap", to: 3, maximum: big.NewInt(4), want: [][2]uint64{{0, 3}}}, + {name: "one above cap", to: 4, maximum: big.NewInt(4), want: [][2]uint64{{0, 3}, {4, 4}}}, + {name: "even count", to: 5, maximum: big.NewInt(4), want: [][2]uint64{{0, 3}, {4, 5}}}, + {name: "single block cap", to: 2, maximum: big.NewInt(1), want: [][2]uint64{{0, 0}, {1, 1}, {2, 2}}}, + {name: "no cap", to: 4, want: [][2]uint64{{0, 4}}}, + {name: "zero cap", to: 4, maximum: new(big.Int), want: [][2]uint64{{0, 4}}}, + {name: "uint64 end", from: ^uint64(0) - 4, to: ^uint64(0), maximum: big.NewInt(4), + want: [][2]uint64{{^uint64(0) - 4, ^uint64(0) - 1}, {^uint64(0), ^uint64(0)}}}, + } { + t.Run(test.name, func(t *testing.T) { + api := &logRangeRPC{} + logs, err := runLogRangeRPC(t, api, test.from, test.to, test.maximum, 0) + require.NoError(t, err) + require.Equal(t, test.want, api.requestedRanges()) + requireLogRangeCoverage(t, logs, test.from, test.to) + }) + } +} + +func TestChunkedFilterLogsReturnsCancellation(t *testing.T) { + api := &logRangeRPC{} + api.fail = func(ctx context.Context, _, _ uint64) error { + api.stop() + // Wait for client cancellation to reach the server before replying. + <-ctx.Done() + return ctx.Err() + } + logs, err := runLogRangeRPC(t, api, 0, 4, nil, 0) + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, logs) + require.Equal(t, [][2]uint64{{0, 4}}, api.requestedRanges()) +} + +func TestChunkedFilterLogsPreservesRemoteCancellationError(t *testing.T) { + api := &logRangeRPC{fail: func(context.Context, uint64, uint64) error { return context.Canceled }} + logs, err := runLogRangeRPC(t, api, 0, 4, nil, 0) + + require.Error(t, err) + require.NotErrorIs(t, err, context.Canceled) + var rpcErr rpc.Error + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, context.Canceled.Error(), rpcErr.Error()) + require.Empty(t, logs) + require.Equal(t, [][2]uint64{{0, 4}, {0, 2}, {0, 1}, {0, 0}}, api.requestedRanges()) +} + +func TestChunkedFilterLogsPreservesSparseAndEmptyResults(t *testing.T) { + for _, empty := range []bool{false, true} { + t.Run(map[bool]string{false: "sparse", true: "empty"}[empty], func(t *testing.T) { + api := &logRangeRPC{hasLogs: func(block uint64) bool { return !empty && block%2 == 1 }} + api.fail = func(_ context.Context, from, to uint64) error { + if to-from+1 > 2 { + return rangeRPCError(-32616) + } + return nil + } + logs, err := runLogRangeRPC(t, api, 0, 4, nil, 0) + require.NoError(t, err) + require.Equal(t, [][2]uint64{{0, 4}, {0, 2}, {0, 1}, {2, 3}, {4, 4}}, api.requestedRanges()) + if empty { + require.Empty(t, logs) + return + } + require.Len(t, logs, 4) + for index, log := range logs { + require.Equal(t, uint64(1+2*(index/2)), log.BlockNumber) + require.Equal(t, uint(index%2), log.Index) + } + }) + } +} diff --git a/pkg/ethutil/http_response_test.go b/pkg/ethutil/http_response_test.go new file mode 100644 index 000000000..11944d1f1 --- /dev/null +++ b/pkg/ethutil/http_response_test.go @@ -0,0 +1,251 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +func responseTestClient(t *testing.T, endpoint string, retry bool, logs *bytes.Buffer) *ethclient.Client { + t.Helper() + if !retry { + return secretTestClient(t, endpoint, false, logs) + } + client, err := NewEthClient(t.Context(), endpoint, slog.New(slog.NewTextHandler(logs, nil)), RetryConfig{ + MaxRetries: 2, RetryMinWait: time.Millisecond, RetryMaxWait: time.Millisecond, RequestTimeout: 5 * time.Second, + }) + require.NoError(t, err) + t.Cleanup(client.Close) + return client +} + +func TestTruncatedHTTPErrorBodyPreservesRetryDecision(t *testing.T) { + const marker = "TRUNCATED_RESPONSE_CREDENTIAL" + for _, retry := range []bool{false, true} { + for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} { + t.Run(fmt.Sprintf("retry=%v/status=%d", retry, status), func(t *testing.T) { + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + require.Equal(t, "/"+marker+"?key="+marker, r.RequestURI) + user, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, marker, password) + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer conn.Close() + body := "nonce too low " + r.RequestURI + " " + r.Header.Get("Authorization") + _, _ = fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s", + status, http.StatusText(status), len(body)+10, body) + })) + defer server.Close() + endpoint, err := url.Parse(server.URL + "/" + marker + "?key=" + marker) + require.NoError(t, err) + endpoint.User = url.UserPassword("operator", marker) + var logs bytes.Buffer + client := responseTestClient(t, endpoint.String(), retry, &logs) + _, err = client.ChainID(t.Context()) + require.Error(t, err) + require.NotContains(t, err.Error(), marker) + require.NotContains(t, logs.String(), marker) + require.False(t, IsNonceTooLowError(err), "partial diagnostic bodies must be discarded") + wantAttempts := int32(1) + if retry && status == http.StatusServiceUnavailable { + wantAttempts = 3 + require.Contains(t, err.Error(), server.URL) + } else { + var httpError rpc.HTTPError + require.ErrorAs(t, err, &httpError) + require.Equal(t, status, httpError.StatusCode) + require.Equal(t, "HTTP request failed", string(httpError.Body)) + } + require.Equal(t, wantAttempts, attempts.Load()) + }) + } + } +} + +func TestHTTPErrorBodyReadPreservesCanceledContext(t *testing.T) { + for _, retry := range []bool{false, true} { + for _, deadline := range []bool{false, true} { + t.Run(fmt.Sprintf("retry=%v/deadline=%v", retry, deadline), func(t *testing.T) { + ready := make(chan struct{}, 1) + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.Header().Set("Content-Length", "1000") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "nonce too low CREDENTIAL") + w.(http.Flusher).Flush() + ready <- struct{}{} + <-r.Context().Done() + })) + defer server.Close() + var logs bytes.Buffer + client := responseTestClient(t, server.URL+"/CREDENTIAL", retry, &logs) + ctx, cancel := context.WithCancel(t.Context()) + want := context.Canceled + if deadline { + cancel() + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + want = context.DeadlineExceeded + } + defer cancel() + result := make(chan error, 1) + go func() { + _, err := client.ChainID(ctx) + result <- err + }() + select { + case <-ready: + case <-time.After(5 * time.Second): + t.Fatal("response headers were not received") + } + if !deadline { + cancel() + } + err := <-result + require.ErrorIs(t, err, want) + require.NotContains(t, err.Error(), "CREDENTIAL") + require.NotContains(t, logs.String(), "CREDENTIAL") + require.Equal(t, int32(1), attempts.Load()) + }) + } + } +} + +func TestClientTimeoutOnErrorBodyPreservesRetryDecisionAndCause(t *testing.T) { + const ( + marker = "CLIENT_TIMEOUT_CREDENTIAL" + maxRetries = 2 + requestTimeout = 100 * time.Millisecond + ) + for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + require.Equal(t, "/"+marker+"?key="+marker, r.RequestURI) + user, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, marker, password) + w.Header().Set("Content-Length", "1000000") + w.WriteHeader(status) + _, _ = io.WriteString(w, "nonce too low "+marker) + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer server.Close() + endpoint, err := url.Parse(server.URL + "/" + marker + "?key=" + marker) + require.NoError(t, err) + endpoint.User = url.UserPassword("operator", marker) + var logs bytes.Buffer + client, err := NewEthClient(t.Context(), endpoint.String(), slog.New(slog.NewTextHandler(&logs, nil)), RetryConfig{ + MaxRetries: maxRetries, RetryMinWait: time.Millisecond, RetryMaxWait: time.Millisecond, RequestTimeout: requestTimeout, + }) + require.NoError(t, err) + defer client.Close() + _, err = client.ChainID(t.Context()) + require.Error(t, err) + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), marker) + require.NotContains(t, logs.String(), marker) + require.False(t, IsNonceTooLowError(err), "partial diagnostic text must be discarded") + if status == http.StatusBadRequest { + require.Equal(t, int32(1), attempts.Load()) + require.ErrorIs(t, err, context.DeadlineExceeded) + var timeout net.Error + require.ErrorAs(t, err, &timeout) + require.True(t, timeout.Timeout()) + require.Contains(t, err.Error(), "read RPC response") + require.Contains(t, err.Error(), ": timeout") + require.NotContains(t, err.Error(), "context deadline exceeded", "the caller context is still active") + } else { + require.Equal(t, int32(maxRetries+1), attempts.Load()) + require.NotErrorIs(t, err, context.DeadlineExceeded, "exhausted 5xx status retries discard the body cause on baseline") + } + }) + } +} + +func TestEthClientDoesNotRetainOtherServiceErrorMessages(t *testing.T) { + const message = "Machine not ready" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, message, http.StatusServiceUnavailable) + })) + defer server.Close() + var logs bytes.Buffer + client := responseTestClient(t, server.URL+"/CREDENTIAL", false, &logs) + _, err := client.ChainID(t.Context()) + var httpError rpc.HTTPError + require.ErrorAs(t, err, &httpError) + require.Equal(t, http.StatusServiceUnavailable, httpError.StatusCode) + require.Equal(t, "HTTP request failed", string(httpError.Body)) +} + +func TestEthClientEmptyResponseHasDecodeLabel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + for _, retry := range []bool{false, true} { + var logs bytes.Buffer + client := responseTestClient(t, server.URL+"/CREDENTIAL", retry, &logs) + _, err := client.ChainID(t.Context()) + require.ErrorIs(t, err, io.EOF) + require.Contains(t, err.Error(), "empty response") + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), "connection closed") + require.NotContains(t, err.Error(), "CREDENTIAL") + } +} + +func TestEthClientZoneScopedEndpointConstructs(t *testing.T) { + for _, retry := range []bool{false, true} { + var logs bytes.Buffer + _ = responseTestClient(t, "http://user:password@[::1%25lo0]:8545/CREDENTIAL?key=CREDENTIAL", retry, &logs) + } +} + +func TestEthClientBatchAcceptsFirstResponseFrame(t *testing.T) { + for _, retry := range []bool{false, true} { + for _, trailing := range []string{` {"another":true}`, ` PROVIDER_TRAILING_DATA`} { + t.Run(fmt.Sprintf("retry=%v/%s", retry, trailing), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []struct{ ID json.RawMessage } + require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) + require.Len(t, requests, 1) + _, _ = fmt.Fprintf(w, `[{"jsonrpc":"2.0","id":%s,"result":"0x1"}]%s`, requests[0].ID, trailing) + })) + defer server.Close() + var logs bytes.Buffer + client := responseTestClient(t, server.URL+"/CREDENTIAL", retry, &logs) + var result string + batch := []rpc.BatchElem{{Method: "eth_chainId", Result: &result}} + require.NoError(t, client.Client().BatchCallContext(t.Context(), batch)) + require.NoError(t, batch[0].Error) + require.Equal(t, "0x1", result) + }) + } + } +} diff --git a/pkg/ethutil/legacy_fees.go b/pkg/ethutil/legacy_fees.go new file mode 100644 index 000000000..912d7fca6 --- /dev/null +++ b/pkg/ethutil/legacy_fees.go @@ -0,0 +1,45 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +type legacyFeeFactory struct { + TransactOptsFactory + client ethereum.GasPricer +} + +// WithLegacyFees forces legacy transactions with a fresh suggested gas price +// for each transaction. Without this wrapper, the binding selects the fee +// format from the caller's options and the network's latest block. +// Gas estimation, signing, nonce selection, value, and NoSend are unchanged. +func WithLegacyFees(factory TransactOptsFactory, client ethereum.GasPricer) TransactOptsFactory { + return &legacyFeeFactory{TransactOptsFactory: factory, client: client} +} + +func (f *legacyFeeFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts, err := f.TransactOptsFactory.NewTransactOpts(ctx) + if err != nil { + return nil, err + } + price, err := f.client.SuggestGasPrice(ctx) + if err != nil { + return nil, fmt.Errorf("suggest legacy gas price: %w", err) + } + // Zero is valid for a zero-fee network; a missing or negative price is not. + if price == nil || price.Sign() < 0 { + return nil, fmt.Errorf("suggest legacy gas price: invalid price %v", price) + } + prepared := *opts + prepared.GasPrice = price + prepared.GasFeeCap = nil + prepared.GasTipCap = nil + return &prepared, nil +} diff --git a/pkg/ethutil/legacy_fees_test.go b/pkg/ethutil/legacy_fees_test.go new file mode 100644 index 000000000..8f388f469 --- /dev/null +++ b/pkg/ethutil/legacy_fees_test.go @@ -0,0 +1,220 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "math/big" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iinputbox" +) + +func TestLegacyFeeFactorySignedTransactions(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + force bool + baseFee *big.Int + gasLimit uint64 + wantType uint8 + wantPrices int32 + }{ + {name: "automatic dynamic", baseFee: big.NewInt(10), wantType: types.DynamicFeeTxType}, + {name: "automatic legacy", wantType: types.LegacyTxType, wantPrices: 2}, + {name: "forced legacy estimated", force: true, baseFee: big.NewInt(10), wantType: types.LegacyTxType, wantPrices: 2}, + { + name: "forced legacy manual gas", force: true, baseFee: big.NewInt(10), gasLimit: 123456, + wantType: types.LegacyTxType, wantPrices: 2, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + backend := &legacyFeeRPC{baseFee: test.baseFee} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + t.Cleanup(server.Stop) + client := ethclient.NewClient(rpc.DialInProc(server)) + t.Cleanup(client.Close) + key, err := crypto.GenerateKey() + require.NoError(t, err) + chainID := big.NewInt(31337) + opts, err := bind.NewKeyedTransactorWithChainID(key, chainID) + require.NoError(t, err) + opts.Nonce = big.NewInt(7) + opts.Value = big.NewInt(19) + opts.GasLimit = test.gasLimit + opts.NoSend = true + factory := NewStaticTransactOptsFactory(opts) + if test.force { + // A forced format must not leave incompatible dynamic fee fields. + opts.GasFeeCap, opts.GasTipCap = big.NewInt(20), big.NewInt(2) + factory = WithLegacyFees(factory, client) + } + require.Equal(t, opts.From, factory.From()) + inputBox, err := iinputbox.NewIInputBox(common.HexToAddress("0x01"), client) + require.NoError(t, err) + for transaction := int64(1); transaction <= 2; transaction++ { + prepared, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.Equal(t, t.Context(), prepared.Context) + tx, err := inputBox.AddInput(prepared, common.HexToAddress("0x02"), []byte{3}) + require.NoError(t, err) + require.Equal(t, test.wantType, tx.Type()) + require.Equal(t, opts.Nonce.Uint64(), tx.Nonce()) + require.Equal(t, opts.Value, tx.Value()) + require.Equal(t, chainID, tx.ChainId()) + from, err := types.Sender(types.LatestSignerForChainID(chainID), tx) + require.NoError(t, err) + require.Equal(t, opts.From, from) + if test.wantType == types.LegacyTxType { + require.Equal(t, big.NewInt(100+transaction), tx.GasPrice(), "fetch a fresh price for each transaction") + } + if test.gasLimit == 0 { + require.EqualValues(t, 75000, tx.Gas()) + } else { + require.Equal(t, test.gasLimit, tx.Gas()) + } + } + require.Nil(t, opts.GasPrice, "do not mutate the source options") + if test.force { + require.Equal(t, big.NewInt(20), opts.GasFeeCap) + require.Equal(t, big.NewInt(2), opts.GasTipCap) + } + require.Equal(t, test.wantPrices, backend.prices.Load()) + if test.gasLimit == 0 { + require.EqualValues(t, 2, backend.estimates.Load()) + } else { + require.Zero(t, backend.estimates.Load()) + } + }) + } +} + +type legacyFeeRPC struct { + baseFee *big.Int + prices atomic.Int32 + estimates atomic.Int32 +} + +func (r *legacyFeeRPC) GasPrice(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(100 + int64(r.prices.Add(1)))), nil +} + +func (r *legacyFeeRPC) GetBlockByNumber(context.Context, string, bool) (*types.Header, error) { + return &types.Header{Number: big.NewInt(1), Difficulty: big.NewInt(0), BaseFee: r.baseFee}, nil +} + +func (*legacyFeeRPC) MaxPriorityFeePerGas(context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(2)), nil +} + +func (*legacyFeeRPC) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{1}, nil +} + +func (r *legacyFeeRPC) EstimateGas(context.Context, map[string]any) (hexutil.Uint64, error) { + r.estimates.Add(1) + return 75000, nil +} + +type legacyGasPricer func(context.Context) (*big.Int, error) + +func (p legacyGasPricer) SuggestGasPrice(ctx context.Context) (*big.Int, error) { return p(ctx) } + +func TestLegacyFeeFactoryErrors(t *testing.T) { + t.Parallel() + cause := errors.New("provider unavailable") + for _, test := range []struct { + name string + price *big.Int + err error + }{ + {name: "RPC error", err: cause}, + {name: "missing price"}, + {name: "negative price", price: big.NewInt(-1)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + opts := &bind.TransactOpts{GasPrice: big.NewInt(2), GasFeeCap: big.NewInt(3), GasTipCap: big.NewInt(1)} + factory := WithLegacyFees(NewStaticTransactOptsFactory(opts), legacyGasPricer(func(ctx context.Context) (*big.Int, error) { + require.Equal(t, t.Context(), ctx) + return test.price, test.err + })) + prepared, err := factory.NewTransactOpts(t.Context()) + require.Nil(t, prepared) + require.ErrorContains(t, err, "suggest legacy gas price") + if test.err != nil { + require.ErrorIs(t, err, test.err) + } + require.Equal(t, big.NewInt(2), opts.GasPrice) + require.Equal(t, big.NewInt(3), opts.GasFeeCap) + require.Equal(t, big.NewInt(1), opts.GasTipCap) + }) + } +} + +func TestLegacyFeeFactoryAcceptsZeroPrice(t *testing.T) { + t.Parallel() + opts := &bind.TransactOpts{GasPrice: big.NewInt(2), GasFeeCap: big.NewInt(3), GasTipCap: big.NewInt(1)} + factory := WithLegacyFees(NewStaticTransactOptsFactory(opts), legacyGasPricer(func(ctx context.Context) (*big.Int, error) { + require.Equal(t, t.Context(), ctx) + return big.NewInt(0), nil + })) + prepared, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + require.NotNil(t, prepared) + require.Equal(t, big.NewInt(0), prepared.GasPrice) + require.Nil(t, prepared.GasFeeCap) + require.Nil(t, prepared.GasTipCap) + require.Equal(t, big.NewInt(2), opts.GasPrice) + require.Equal(t, big.NewInt(3), opts.GasFeeCap) + require.Equal(t, big.NewInt(1), opts.GasTipCap) +} + +type failedLegacyOptsFactory struct { + TransactOptsFactory + err error +} + +func (f failedLegacyOptsFactory) NewTransactOpts(context.Context) (*bind.TransactOpts, error) { + return nil, f.err +} + +func TestLegacyFeeFactoryPreservesPreparationErrors(t *testing.T) { + t.Parallel() + cause := errors.New("signer unavailable") + factory := WithLegacyFees(failedLegacyOptsFactory{err: cause}, legacyGasPricer(func(context.Context) (*big.Int, error) { + t.Fatal("do not request a price when signer preparation fails") + return nil, nil + })) + opts, err := factory.NewTransactOpts(t.Context()) + require.Nil(t, opts) + require.ErrorIs(t, err, cause) +} + +func TestLegacyFeeFactoryPreservesCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + factory := WithLegacyFees(NewStaticTransactOptsFactory(&bind.TransactOpts{}), + legacyGasPricer(func(priceCtx context.Context) (*big.Int, error) { + require.Equal(t, ctx, priceCtx) + return nil, priceCtx.Err() + })) + opts, err := factory.NewTransactOpts(ctx) + require.Nil(t, opts) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/pkg/ethutil/mnemonic.go b/pkg/ethutil/mnemonic.go index fbb4cc29b..ba068f0af 100644 --- a/pkg/ethutil/mnemonic.go +++ b/pkg/ethutil/mnemonic.go @@ -4,72 +4,42 @@ package ethutil import ( - "context" "crypto/ecdsa" + "errors" "fmt" - "math/big" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/tyler-smith/go-bip32" "github.com/tyler-smith/go-bip39" ) -// Sign transactions using a mnemonic. -type MnemonicSigner struct { - privateKey *ecdsa.PrivateKey - chainId *big.Int - mnemonic string -} - -// Create a new mnemonic signer. -// Uses the client to get the chain ID. -func NewMnemonicSigner( - ctx context.Context, - client *ethclient.Client, - mnemonic string, - accountIndex uint32, -) (*MnemonicSigner, error) { - chainId, err := client.ChainID(ctx) - if err != nil { - return nil, fmt.Errorf("get chain id: %v", err) - } - privateKey, err := mnemonicToPrivateKey(mnemonic, accountIndex) - if err != nil { - return nil, err +func PrivateKeyToAddress(privateKey *ecdsa.PrivateKey) (common.Address, error) { + if privateKey == nil { + return common.Address{}, fmt.Errorf("private key is nil") } - signer := &MnemonicSigner{ - privateKey: privateKey, - chainId: chainId, - mnemonic: mnemonic, - } - return signer, nil -} - -func (s *MnemonicSigner) MakeTransactor() (*bind.TransactOpts, error) { - return bind.NewKeyedTransactorWithChainID(s.privateKey, s.chainId) -} - -func (s *MnemonicSigner) Account() common.Address { - publicKey := s.privateKey.Public() + publicKey := privateKey.Public() publicKeyECDSA := publicKey.(*ecdsa.PublicKey) - return crypto.PubkeyToAddress(*publicKeyECDSA) + return crypto.PubkeyToAddress(*publicKeyECDSA), nil } // Create the private key from mnemonic and account index based on the BIP44 standard. // For more info on BIP44, see https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki -func mnemonicToPrivateKey(mnemonic string, accountIndex uint32) (*ecdsa.PrivateKey, error) { - seed := bip39.NewSeed(mnemonic, "") +func MnemonicToPrivateKey(mnemonic string, accountIndex uint32) (*ecdsa.PrivateKey, error) { + // An index from 2^31 up selects a hardened child, which is a different path. + if accountIndex >= hardenedKeyStart { + return nil, errors.New("account index must be below 2^31") + } + seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "") + if err != nil { + return nil, err + } - masterKey, err := bip32.NewMasterKey(seed) + masterKey, err := newMasterKey(seed) if err != nil { - return nil, fmt.Errorf("failed to generate master key: %v", err) + return nil, fmt.Errorf("failed to generate master key: %w", err) } // get key at path m/44'/60'/0'/0/account - const hardenedKeyStart uint32 = 0x80000000 levels := []uint32{ hardenedKeyStart + 44, hardenedKeyStart + 60, @@ -79,11 +49,11 @@ func mnemonicToPrivateKey(mnemonic string, accountIndex uint32) (*ecdsa.PrivateK } key := masterKey for i, level := range levels { - key, err = key.NewChildKey(level) + key, err = key.childKey(level) if err != nil { - return nil, fmt.Errorf("failed to get child %v: %v", i, err) + return nil, fmt.Errorf("failed to get child %v: %w", i, err) } } - return crypto.ToECDSA(key.Key) + return crypto.ToECDSA(key.key) } diff --git a/pkg/ethutil/mnemonic_test.go b/pkg/ethutil/mnemonic_test.go index b42144e9d..1c74c4b21 100644 --- a/pkg/ethutil/mnemonic_test.go +++ b/pkg/ethutil/mnemonic_test.go @@ -4,23 +4,70 @@ package ethutil import ( + "math" + "strconv" "testing" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" ) -func testKey(t *testing.T, mnemonic string, account uint32, expected string) { - expectedKey, err := crypto.HexToECDSA(expected) - require.Nil(t, err) - - key, err := mnemonicToPrivateKey(mnemonic, account) - require.Nil(t, err) - require.Equal(t, expectedKey, key) +// The accounts that Anvil makes from FoundryMnemonic. They show that the full +// BIP-39 and BIP-44 path gives the keys that the devnet expects. +var foundryAccounts = []struct { + key string + address string +}{ + {"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}, + {"59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"}, + {"5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"}, + {"7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", "0x90F79bf6EB2c4f870365E785982E1f101E93b906"}, + {"47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65"}, + {"8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc"}, + {"92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", "0x976EA74026E726554dB657fA54763abd0C3a0aa9"}, + {"4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955"}, + {"dbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f"}, + {"2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"}, } func TestMnemonicToPrivateKey(t *testing.T) { - mnemonic := "test test test test test test test test test test test junk" - testKey(t, mnemonic, 0, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") - testKey(t, mnemonic, 1, "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + for index, account := range foundryAccounts { + expected, err := crypto.HexToECDSA(account.key) + require.Nil(t, err) + + key, err := MnemonicToPrivateKey(FoundryMnemonic, uint32(index)) + require.Nil(t, err) + require.Equal(t, expected, key) + + address, err := PrivateKeyToAddress(key) + require.Nil(t, err) + require.Equal(t, account.address, address.Hex()) + } +} + +func TestMnemonicToPrivateKeyRejectsBadMnemonic(t *testing.T) { + for _, mnemonic := range []string{ + "", + "test test test test test test test test test test test test", // bad checksum + "cartesi test test test test test test test test test test junk", + "test test test test test test test test test test junk", // 11 words + } { + _, err := MnemonicToPrivateKey(mnemonic, 0) + require.Error(t, err, "mnemonic %q", mnemonic) + } +} + +func TestPrivateKeyToAddressRejectsNil(t *testing.T) { + _, err := PrivateKeyToAddress(nil) + require.Error(t, err) +} + +func TestMnemonicToPrivateKeyRejectsHardenedIndex(t *testing.T) { + for _, index := range []uint32{hardenedKeyStart, hardenedKeyStart + 6, math.MaxUint32} { + _, err := MnemonicToPrivateKey(FoundryMnemonic, index) + require.Error(t, err, "index %v", index) + require.NotContains(t, err.Error(), strconv.FormatUint(uint64(index), 10)) + } + _, err := MnemonicToPrivateKey(FoundryMnemonic, hardenedKeyStart-1) + require.NoError(t, err) } diff --git a/pkg/ethutil/prt.go b/pkg/ethutil/prt.go new file mode 100644 index 000000000..76536ae4c --- /dev/null +++ b/pkg/ethutil/prt.go @@ -0,0 +1,214 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type PRTApplicationDeployment struct { + ApplicationDeployment + + // A zero manager disables rotation. Sentry IDs follow the supplied order. + SentryManager common.Address + Sentries []common.Address +} + +type PRTApplicationDeploymentResult struct { + Deployment *PRTApplicationDeployment + + ApplicationAddress common.Address `json:"application_address"` + DaveConsensusAddress common.Address `json:"dave_consensus_address"` + + InputBoxAddress common.Address `json:"inputbox_address"` + IInputBoxBlock uint64 `json:"inputbox_block"` +} + +func (me *PRTApplicationDeployment) String() string { + result := "" + result += "PRT application deployment:\n" + if me.Verbose { + result += fmt.Sprintf("\tPRT application factory address: %v\n", me.FactoryAddress) + result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) + result += fmt.Sprintf("\tsalt: %v\n", me.Salt) + result += fmt.Sprintf("\tsentry manager: %v\n", me.SentryManager) + result += fmt.Sprintf("\tsentries: %v\n", me.Sentries) + } + return result +} + +func (me *PRTApplicationDeploymentResult) String() string { + result := "" + result += fmt.Sprintf("\tapplication address: %v\n", me.ApplicationAddress) + result += fmt.Sprintf("\tconsensus address: %v\n", me.DaveConsensusAddress) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) + result += fmt.Sprintf("\tinput box block: %d\n", me.IInputBoxBlock) + return result +} + +func (me *PRTApplicationDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction returns the predicted application address and no confirmed +// deployment result when runner does not wait for a receipt. The prediction does +// not prove that the deployment transaction succeeded. +func (me *PRTApplicationDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + runner TransactionRunner, +) (common.Address, IApplicationDeploymentResult, error) { + zero := common.Address{} + result := &PRTApplicationDeploymentResult{Deployment: me} + if err := ValidateSentryAddresses(me.Sentries); err != nil { + return zero, nil, err + } + + factory, err := idaveappfactory.NewIDaveAppFactory(me.FactoryAddress, client) + if err != nil { + return zero, nil, fmt.Errorf("failed to instantiate contract binding: %w", err) + } + + if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { + return zero, nil, err + } + if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) + daveWC := idaveappfactory.WithdrawalConfig(me.WithdrawalConfig) + + // check if addresses are available (have no code) + addresses, err := factory.CalculateDaveAppAddress( + &bind.CallOpts{Context: ctx}, + me.TemplateHash, + claimStagingPeriod, + me.SentryManager, + me.Sentries, + daveWC, + me.Salt, + ) + if err != nil { + return zero, nil, err + } + applicationCode, err := client.CodeAt(ctx, addresses.AppContractAddress, nil) + if err != nil { + return zero, nil, err + } + if len(applicationCode) != 0 { + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", addresses.AppContractAddress) + } + + daveConsensusCode, err := client.CodeAt(ctx, addresses.DaveConsensusAddress, nil) + if err != nil { + return zero, nil, err + } + if len(daveConsensusCode) != 0 { + return zero, nil, fmt.Errorf("dave consensus with address %v already exists; use a different salt", addresses.DaveConsensusAddress) + } + + txOpts, err := txOptsFactory.NewTransactOpts(ctx) + if err != nil { + return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) + } + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewDaveApp( + opts, + me.TemplateHash, + claimStagingPeriod, + me.SentryManager, + me.Sentries, + daveWC, + me.Salt, + ) + }, runner) + if err != nil { + return zero, nil, fmt.Errorf("failed to deploy Dave application and consensus contracts: %w", err) + } + if receipt == nil { + return addresses.AppContractAddress, nil, nil + } + + // Look for the specific event in the receipt logs + for _, vLog := range receipt.Logs { + // Parse log for DaveConsensusCreated event + event, err := factory.ParseDaveAppCreated(*vLog) + if err != nil { + continue // Skip logs that don't match + } + result.ApplicationAddress = event.AppContract + result.DaveConsensusAddress = event.DaveConsensus + break + } + if result.ApplicationAddress == zero { + return zero, nil, fmt.Errorf("failed to find DaveAppCreated event in receipt logs") + } + appAddress := result.ApplicationAddress + application, err := iapplication.NewIApplication(appAddress, client) + if err != nil { + return zero, nil, fmt.Errorf("failed to instantiate application: %w", err) + } + + inputBoxAddress, err := application.GetInputBox(&bind.CallOpts{Context: ctx}) + if err != nil { + return zero, nil, fmt.Errorf("failed to retrieve input box: %w", err) + } + if me.InputBoxAddress != (common.Address{}) && inputBoxAddress != me.InputBoxAddress { + return zero, nil, fmt.Errorf( + "deployed application uses input box %v instead of %v", + inputBoxAddress, + me.InputBoxAddress, + ) + } + result.InputBoxAddress = inputBoxAddress + + inputBoxBlock, err := GetInputBoxDeploymentBlock(ctx, client, inputBoxAddress) + if err != nil { + return zero, nil, fmt.Errorf("failed to retrieve input box deployment block: %w", err) + } + if !inputBoxBlock.IsUint64() { + return zero, nil, fmt.Errorf("input box deployment block does not fit uint64: %v", inputBoxBlock) + } + result.IInputBoxBlock = inputBoxBlock.Uint64() + + if err := VerifyDeployedWithdrawalConfig(ctx, client, appAddress, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + + return appAddress, result, nil +} + +func (me *PRTApplicationDeployment) GetFactoryAddress() common.Address { + return me.FactoryAddress +} + +// ValidateSentryAddresses checks the constructor's address rules without changing +// slot order. An empty list is valid and requires the full claim staging period. +func ValidateSentryAddresses(sentries []common.Address) error { + seen := make(map[common.Address]struct{}, len(sentries)) + for i, address := range sentries { + if address == (common.Address{}) { + return fmt.Errorf("sentry %d must not be the zero address", i+1) + } + if _, duplicate := seen[address]; duplicate { + return fmt.Errorf("sentry %d duplicates address %s", i+1, address) + } + seen[address] = struct{}{} + } + return nil +} diff --git a/pkg/ethutil/prt_test.go b/pkg/ethutil/prt_test.go new file mode 100644 index 000000000..257c5d900 --- /dev/null +++ b/pkg/ethutil/prt_test.go @@ -0,0 +1,136 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/idaveappfactory" +) + +func TestPRTDeploymentSendsFactoryArguments(t *testing.T) { + withdrawalConfig := iapplicationfactory.WithdrawalConfig{ + Guardian: common.HexToAddress("0x1000000000000000000000000000000000000001"), + } + + for _, test := range []struct { + name string + manager common.Address + sentries []common.Address + }{ + {name: "no sentries"}, + {name: "manager without slots", manager: common.HexToAddress("0x10")}, + {name: "fixed slots", sentries: []common.Address{common.HexToAddress("0x30"), common.HexToAddress("0x20")}}, + {name: "rotatable slots", manager: common.HexToAddress("0x10"), + sentries: []common.Address{common.HexToAddress("0x30"), common.HexToAddress("0x20")}}, + } { + for _, claimStagingPeriod := range []uint64{0, 1234} { + t.Run(fmt.Sprintf("%s/period_%d", test.name, claimStagingPeriod), func(t *testing.T) { + parsed, err := idaveappfactory.IDaveAppFactoryMetaData.GetAbi() + require.NoError(t, err) + predicted := common.HexToAddress("0x400") + response, err := parsed.Methods["calculateDaveAppAddress"].Outputs.Pack(predicted, common.HexToAddress("0x500")) + require.NoError(t, err) + backend := &prtFactoryCallBackend{calls: make(chan prtFactoryCall, 1), response: response} + server := rpc.NewServer() + require.NoError(t, server.RegisterName("eth", backend)) + defer server.Stop() + client := ethclient.NewClient(rpc.DialInProc(server)) + defer client.Close() + deployment := &PRTApplicationDeployment{ + SentryManager: test.manager, Sentries: test.sentries, ApplicationDeployment: ApplicationDeployment{ + FactoryAddress: common.HexToAddress("0x200"), + TemplateHash: common.HexToHash("0x300"), + Salt: SaltBytes{31: 4}, + ClaimStagingPeriod: claimStagingPeriod, + WithdrawalConfig: withdrawalConfig, + }, + } + + opts := &bind.TransactOpts{ + Nonce: big.NewInt(0), GasPrice: big.NewInt(1), GasLimit: 1_000_000, NoSend: true, + Signer: func(_ common.Address, tx *types.Transaction) (*types.Transaction, error) { return tx, nil }, + } + var deployData []byte + address, result, err := deployment.DeployWithTransaction(t.Context(), client, NewStaticTransactOptsFactory(opts), + func( + _ context.Context, opts *bind.TransactOpts, build func(*bind.TransactOpts) (*types.Transaction, error), + ) (*types.Receipt, error) { + tx, err := build(opts) + require.NoError(t, err) + require.Equal(t, deployment.FactoryAddress, *tx.To()) + deployData = tx.Data() + return nil, nil + }) + require.NoError(t, err) + require.Equal(t, predicted, address) + require.Nil(t, result) + call := <-backend.calls + require.Equal(t, deployment.FactoryAddress, call.To) + for methodName, data := range map[string][]byte{"calculateDaveAppAddress": call.Input, "newDaveApp": deployData} { + method := parsed.Methods[methodName] + require.Equal(t, method.ID, data[:4]) + args, err := method.Inputs.Unpack(data[4:]) + require.NoError(t, err) + require.Equal(t, [32]byte(deployment.TemplateHash), args[0]) + require.Zero(t, new(big.Int).SetUint64(claimStagingPeriod).Cmp(args[1].(*big.Int))) + require.Equal(t, test.manager, args[2]) + require.Equal(t, append([]common.Address{}, test.sentries...), args[3]) + gotWithdrawal := abi.ConvertType(args[4], new(idaveappfactory.WithdrawalConfig)).(*idaveappfactory.WithdrawalConfig) + require.Equal(t, idaveappfactory.WithdrawalConfig(withdrawalConfig), *gotWithdrawal) + require.Equal(t, [32]byte(deployment.Salt), args[5]) + } + }) + } + } +} + +type prtFactoryCall struct { + To common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` +} + +type prtFactoryCallBackend struct { + calls chan prtFactoryCall + response hexutil.Bytes +} + +func (b *prtFactoryCallBackend) Call(_ context.Context, call prtFactoryCall, _ string) (hexutil.Bytes, error) { + b.calls <- call + return b.response, nil +} + +func (*prtFactoryCallBackend) GetCode(context.Context, common.Address, string) (hexutil.Bytes, error) { + return hexutil.Bytes{}, nil +} + +func TestPRTDeploymentRejectsInvalidSentriesBeforeRPC(t *testing.T) { + address := common.HexToAddress("0x01") + for _, test := range []struct { + name string + sentries []common.Address + wantError string + }{ + {name: "zero", sentries: []common.Address{{}}, wantError: "must not be the zero address"}, + {name: "duplicate", sentries: []common.Address{address, address}, wantError: "duplicates address"}, + } { + t.Run(test.name, func(t *testing.T) { + deployment := &PRTApplicationDeployment{Sentries: test.sentries} + _, _, err := deployment.Deploy(t.Context(), nil, nil) + require.ErrorContains(t, err, test.wantError) + }) + } +} diff --git a/pkg/ethutil/quorum.go b/pkg/ethutil/quorum.go new file mode 100644 index 000000000..716356f22 --- /dev/null +++ b/pkg/ethutil/quorum.go @@ -0,0 +1,103 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/pkg/contracts/iquorumfactory" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type QuorumDeployment struct { + Address common.Address `json:"address"` + FactoryAddress common.Address `json:"factory"` + Validators []common.Address `json:"validators"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + Salt SaltBytes `json:"salt"` + Verbose bool `json:"-"` +} + +func (me *QuorumDeployment) String() string { + result := "" + result += "quorum deployment:\n" + result += fmt.Sprintf("\tvalidators: %v\n", me.Validators) + if me.Verbose { + result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) + result += fmt.Sprintf("\tsalt: %v\n", me.Salt) + result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) + result += fmt.Sprintf("\tclaim staging period: %v\n", me.ClaimStagingPeriod) + } + return result +} + +func (me *QuorumDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, +) (common.Address, error) { + return me.DeployWithTransaction(ctx, client, txOpts, nil) +} + +// DeployWithTransaction returns a predicted address when runner does not wait for +// a receipt. The prediction does not prove that the deployment transaction succeeded. +func (me *QuorumDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOpts *bind.TransactOpts, + runner TransactionRunner, +) (common.Address, error) { + zero := common.Address{} + factory, err := iquorumfactory.NewIQuorumFactory(me.FactoryAddress, client) + if err != nil { + return zero, fmt.Errorf("failed to instantiate contract: %w", err) + } + + epochLength := new(big.Int).SetUint64(me.EpochLength) + claimStagingPeriod := new(big.Int).SetUint64(me.ClaimStagingPeriod) + quorumAddress, err := factory.CalculateQuorumAddress( + &bind.CallOpts{Context: ctx}, + me.Validators, + epochLength, + claimStagingPeriod, + me.Salt, + ) + if err != nil { + return zero, err + } + + quorumCode, err := client.CodeAt(ctx, quorumAddress, nil) + if err != nil { + return zero, err + } + if len(quorumCode) != 0 { + return zero, fmt.Errorf("quorum with address %v already exists; use a different salt", quorumAddress) + } + + receipt, err := runDeploymentTransaction(ctx, client, txOpts, func(opts *bind.TransactOpts) (*types.Transaction, error) { + return factory.NewQuorum(opts, me.Validators, epochLength, claimStagingPeriod, me.Salt) + }, runner) + if err != nil { + return zero, fmt.Errorf("failed to create new quorum: %w", err) + } + + if receipt == nil { + return quorumAddress, nil + } + + for _, vLog := range receipt.Logs { + event, err := factory.ParseQuorumCreated(*vLog) + if err != nil { + continue + } + return event.Quorum, nil + } + return zero, fmt.Errorf("failed to find event in receipt logs") +} diff --git a/pkg/ethutil/return_data_test.go b/pkg/ethutil/return_data_test.go new file mode 100644 index 000000000..6951e98f5 --- /dev/null +++ b/pkg/ethutil/return_data_test.go @@ -0,0 +1,69 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "errors" + "fmt" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/iconsensus" + "github.com/cartesi/rollups-node/pkg/contracts/idaveconsensus" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestApplicationReturnDataSuffix(t *testing.T) { + for _, contract := range []struct { + name string + metadata *bind.MetaData + }{ + {"IConsensus", iconsensus.IConsensusMetaData}, + {"IDaveConsensus", idaveconsensus.IDaveConsensusMetaData}, + } { + for _, name := range []string{"ApplicationReverted", "IllformedApplicationReturnData"} { + t.Run(contract.name+"/"+name, func(t *testing.T) { + parsed, err := contract.metadata.GetAbi() + require.NoError(t, err) + abiError, ok := parsed.Errors[name] + require.True(t, ok) + for _, data := range [][]byte{nil, {0, 0xff, '\n', '\r', 0x1b, '[', '2', 'J'}} { + packed, err := abiError.Inputs.Pack(common.HexToAddress("0x1234"), data) + require.NoError(t, err) + payload := append(append([]byte{}, abiError.ID[:revertSelectorLength]...), packed...) + rpcErr := &rpcDataError{code: 3, msg: executionRevertedMessage, data: payload} + for _, wrapped := range []error{rpcErr, fmt.Errorf("estimate: %w", rpcErr)} { + require.Equal(t, fmt.Sprintf(" Application return data: 0x%x.", data), + ApplicationReturnDataSuffix(wrapped, contract.metadata, name)) + } + } + truncated := &rpcDataError{data: abiError.ID[:revertSelectorLength]} + require.Empty(t, ApplicationReturnDataSuffix(truncated, contract.metadata, name)) + require.Empty(t, ApplicationReturnDataSuffix(nil, contract.metadata, name)) + require.Empty(t, ApplicationReturnDataSuffix(errors.New("unavailable"), contract.metadata, name)) + }) + } + } +} + +func TestApplicationReturnDataSuffixRejectsUnrelatedShapes(t *testing.T) { + // These errors do not have the (address, bytes) return-data shape. + for _, test := range []struct { + name string + args []any + }{ + {"AppReverted", []any{[]byte{1}}}, + {"InsufficientFunds", []any{common.Big1, common.Big2}}, + } { + t.Run(test.name, func(t *testing.T) { + err := describeTestRevert(t, test.name, test.args...) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, nil, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, &bind.MetaData{ABI: "invalid"}, test.name)) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, "Unknown")) + require.Empty(t, ApplicationReturnDataSuffix(err, describeTestMetaData, "Closed")) + }) + } +} diff --git a/pkg/ethutil/rpc_response.go b/pkg/ethutil/rpc_response.go new file mode 100644 index 000000000..10f2d90d8 --- /dev/null +++ b/pkg/ethutil/rpc_response.go @@ -0,0 +1,114 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/cartesi/rollups-node/internal/httpclient" +) + +// nullErrorTransport removes a null "error" member from JSON-RPC responses. +// JSON-RPC 2.0 forbids that member in a successful response, but some providers +// send it as null, and go-ethereum reports any error member as a failure since +// v1.17.5. +type nullErrorTransport struct { + next http.RoundTripper +} + +func (t nullErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.next.RoundTrip(req) + if err != nil { + return nil, err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + endpoint := (&url.URL{Scheme: req.URL.Scheme, Host: req.URL.Host}).String() + return nil, httpclient.Diagnostic(req.Context(), err, "read RPC response", endpoint) + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if err := validateRPCEnvelope(body); err != nil { + endpoint := (&url.URL{Scheme: req.URL.Scheme, Host: req.URL.Host}).String() + return nil, httpclient.Diagnostic(req.Context(), err, "decode RPC response", endpoint) + } + } + if fixed, changed := dropNullError(body); changed { + body = fixed + resp.ContentLength = int64(len(body)) + resp.Header.Del("Content-Length") + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil +} + +// Validate the first JSON value and the same outer field types as go-ethereum +// before its decoder can expose malformed provider input in a diagnostic. +// Error, Result, ID and Params have no envelope type constraints and are skipped: +// successful consensus input is neither decoded nor rewritten. +func validateRPCEnvelope(body []byte) error { + type envelope struct { + Version string `json:"jsonrpc"` + Method string `json:"method"` + } + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var batch []envelope + return json.NewDecoder(bytes.NewReader(body)).Decode(&batch) + } + var message envelope + return json.NewDecoder(bytes.NewReader(body)).Decode(&message) +} + +var errorMemberName = []byte(`"error"`) + +// dropNullError removes a null "error" member from a JSON-RPC response or from +// each response of a batch. It reports whether it changed the body, and it leaves +// a body that is not a JSON object or array of objects alone. +func dropNullError(body []byte) ([]byte, bool) { + if !bytes.Contains(body, errorMemberName) { + return body, false + } + trimmed := bytes.TrimLeft(body, " \t\r\n") + if len(trimmed) > 0 && trimmed[0] == '[' { + var batch []map[string]json.RawMessage + if json.Unmarshal(body, &batch) != nil { + return body, false + } + changed := false + for _, msg := range batch { + changed = dropNullErrorMember(msg) || changed + } + return marshalIfChanged(body, batch, changed) + } + var msg map[string]json.RawMessage + if json.Unmarshal(body, &msg) != nil { + return body, false + } + return marshalIfChanged(body, msg, dropNullErrorMember(msg)) +} + +func dropNullErrorMember(msg map[string]json.RawMessage) bool { + value, ok := msg["error"] + if !ok || !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return false + } + delete(msg, "error") + return true +} + +func marshalIfChanged(body []byte, value any, changed bool) ([]byte, bool) { + if !changed { + return body, false + } + fixed, err := json.Marshal(value) + if err != nil { + return body, false + } + return fixed, true +} diff --git a/pkg/ethutil/rpc_response_test.go b/pkg/ethutil/rpc_response_test.go new file mode 100644 index 000000000..90aee5cc6 --- /dev/null +++ b/pkg/ethutil/rpc_response_test.go @@ -0,0 +1,105 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDropNullError(t *testing.T) { + tests := []struct { + name string + body string + want string // empty when the body must come back unchanged + }{ + { + name: "single response", + body: `{"jsonrpc":"2.0","id":1,"result":"0x10","error":null}`, + want: `{"jsonrpc":"2.0","id":1,"result":"0x10"}`, + }, + { + name: "batch response", + body: `[{"jsonrpc":"2.0","id":1,"result":"0x1","error":null},` + + `{"jsonrpc":"2.0","id":2,"error":{"code":3,"message":"execution reverted"}}]`, + want: `[{"jsonrpc":"2.0","id":1,"result":"0x1"},` + + `{"jsonrpc":"2.0","id":2,"error":{"code":3,"message":"execution reverted"}}]`, + }, + { + name: "error object", + body: `{"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"execution reverted"}}`, + }, + { + name: "no error member", + body: `{"jsonrpc":"2.0","id":1,"result":"0x10"}`, + }, + { + name: "error string in the result", + body: `{"jsonrpc":"2.0","id":1,"result":["error"]}`, + }, + { + name: "not JSON", + body: `"error" page`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, changed := dropNullError([]byte(tc.body)) + if tc.want == "" { + require.False(t, changed) + require.Equal(t, tc.body, string(got)) + return + } + require.True(t, changed) + require.JSONEq(t, tc.want, string(got)) + }) + } +} + +// newNullErrorServer answers every JSON-RPC call with the result 0x10 and a null +// error member. +func newNullErrorServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID json.RawMessage `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":"0x10","error":null}`, request.ID) + })) + t.Cleanup(server.Close) + return server +} + +func TestNewEthClientAcceptsNullError(t *testing.T) { + server := newNullErrorServer(t) + logger := slog.New(slog.DiscardHandler) + client, err := NewEthClient(t.Context(), server.URL, logger, RetryConfig{RequestTimeout: time.Second}) + require.NoError(t, err) + defer client.Close() + number, err := client.BlockNumber(t.Context()) + require.NoError(t, err) + require.Equal(t, uint64(16), number) +} + +func TestDialEthClientAcceptsNullError(t *testing.T) { + server := newNullErrorServer(t) + client, err := DialEthClient(t.Context(), server.URL) + require.NoError(t, err) + defer client.Close() + number, err := client.BlockNumber(t.Context()) + require.NoError(t, err) + require.Equal(t, uint64(16), number) +} diff --git a/pkg/ethutil/rpcerror.go b/pkg/ethutil/rpcerror.go new file mode 100644 index 000000000..9b2bb305c --- /dev/null +++ b/pkg/ethutil/rpcerror.go @@ -0,0 +1,257 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" +) + +// JSONRPCInfo contains structured error information extracted from a JSON-RPC error response. +type JSONRPCInfo struct { + Code int + Message string + Data any + HasCode bool + HasData bool +} + +// ExtractJSONErrorInfo attempts to extract JSON-RPC error details from an error. +// It checks for rpc.Error (code + message) and rpc.DataError (error data). +// Returns the extracted info and true if the error contained JSON-RPC details. +func ExtractJSONErrorInfo(err error) (JSONRPCInfo, bool) { + var out JSONRPCInfo + if err == nil { + return out, false + } + + var e rpc.Error + if errors.As(err, &e) { + out.Code = e.ErrorCode() + out.Message = e.Error() + out.HasCode = true + } + + var de rpc.DataError + if errors.As(err, &de) { + out.Data = de.ErrorData() + out.HasData = true + if !out.HasCode { + out.Message = de.Error() + } + } + + return out, out.HasCode || out.HasData +} + +// MatchesSelector checks whether RPC error data starts with the given 4-byte selector. +// Handles varying representations across Ethereum clients: hex strings (with/without +// 0x prefix, any case) and raw []byte. +func MatchesSelector(data any, selector string) bool { + expected := common.FromHex(selector) + var got []byte + switch d := data.(type) { + case string: + got = common.FromHex(d) + case []byte: + got = d + default: + return false + } + return len(got) >= len(expected) && bytes.Equal(got[:len(expected)], expected) +} + +// IsCustomError checks whether an RPC error is a specific custom Solidity error +// defined in the given contract metadata. It extracts the revert data from the +// error and compares its 4-byte selector against the ABI-derived selector for +// errorName. This is case-insensitive and handles 0x prefix variations. +func IsCustomError(err error, metadata *bind.MetaData, errorName string) bool { + if metadata == nil { + return false + } + info, ok := ExtractJSONErrorInfo(err) + if !ok || !info.HasData { + return false + } + parsed, _ := metadata.GetAbi() + if parsed == nil { + return false + } + abiErr, ok := parsed.Errors[errorName] + if !ok { + return false + } + selector := fmt.Sprintf("0x%x", abiErr.ID[:4]) + return MatchesSelector(info.Data, selector) +} + +// revertSelectorLength is the length of a Solidity custom error selector. +const revertSelectorLength = 4 + +// revertDataBytes extracts the raw revert payload (selector + ABI-encoded +// arguments) carried by a JSON-RPC error. Returns (nil, false) when err +// carries no revert data or the payload is shorter than a selector. +func revertDataBytes(err error) ([]byte, bool) { + info, ok := ExtractJSONErrorInfo(err) + if !ok || !info.HasData { + return nil, false + } + var raw []byte + switch d := info.Data.(type) { + case string: + raw = common.FromHex(d) + case []byte: + raw = d + default: + return nil, false + } + if len(raw) < revertSelectorLength { + return nil, false + } + return raw, true +} + +// UnpackRevert checks that err carries revert data for the named custom error +// declared in metadata and ABI-decodes its arguments. It returns (values, +// true) when the selector matches and the data decodes cleanly, and +// (nil, false) otherwise. Using the generated ABI metadata is safer than +// reading fixed byte positions by hand, especially if the ABI changes later. +func UnpackRevert(err error, metadata *bind.MetaData, errorName string) ([]any, bool) { + raw, ok := revertDataBytes(err) + if !ok || metadata == nil { + return nil, false + } + parsed, _ := metadata.GetAbi() + if parsed == nil { + return nil, false + } + abiErr, ok := parsed.Errors[errorName] + if !ok { + return nil, false + } + if !bytes.Equal(raw[:revertSelectorLength], abiErr.ID[:revertSelectorLength]) { + return nil, false + } + values, unpackErr := abiErr.Inputs.Unpack(raw[revertSelectorLength:]) + if unpackErr != nil { + return nil, false + } + return values, true +} + +// ApplicationReturnDataSuffix formats the bytes carried by ApplicationReverted +// or IllformedApplicationReturnData(address, bytes). Contract-controlled bytes +// are hex-encoded so they stay inert in logs and stored reasons. Invalid revert +// data produces no suffix. The caller supplies its consensus ABI explicitly. +func ApplicationReturnDataSuffix(err error, metadata *bind.MetaData, name string) string { + values, ok := UnpackRevert(err, metadata, name) + if !ok || len(values) < 2 { + return "" + } + data, ok := values[1].([]byte) + if !ok { + return "" + } + return fmt.Sprintf(" Application return data: 0x%x.", data) +} + +// DescribeRevert decodes the custom-error revert payload carried by err +// against the given contract metadatas and renders it as +// "Name(param=value, ...)". It returns ("", false) when err carries no revert +// data or none of the metadatas declare an error matching the payload's +// selector. When the selector matches but the arguments do not decode, the +// bare error name is returned. Byte values are hex-encoded and string values +// are quoted — revert payloads can carry contract-controlled data, so they +// are kept inert. +func DescribeRevert(err error, metadatas ...*bind.MetaData) (string, bool) { + raw, ok := revertDataBytes(err) + if !ok { + return "", false + } + for _, metadata := range metadatas { + if metadata == nil { + continue + } + parsed, _ := metadata.GetAbi() + if parsed == nil { + continue + } + for name, abiErr := range parsed.Errors { + if !bytes.Equal(raw[:revertSelectorLength], abiErr.ID[:revertSelectorLength]) { + continue + } + values, unpackErr := abiErr.Inputs.Unpack(raw[revertSelectorLength:]) + if unpackErr != nil { + return name, true + } + return formatRevert(name, abiErr.Inputs, values), true + } + } + return "", false +} + +func formatRevert(name string, inputs abi.Arguments, values []any) string { + parts := make([]string, 0, len(values)) + for i, v := range values { + label := "" + if i < len(inputs) && inputs[i].Name != "" { + label = inputs[i].Name + "=" + } + parts = append(parts, label+formatRevertValue(v)) + } + return fmt.Sprintf("%s(%s)", name, strings.Join(parts, ", ")) +} + +func formatRevertValue(v any) string { + switch val := v.(type) { + case []byte: + return fmt.Sprintf("0x%x", val) + case [32]byte: + return fmt.Sprintf("0x%x", val) + case common.Address: + return val.Hex() + case common.Hash: + return val.Hex() + case string: + // Contract-controlled text: quote it so escape sequences cannot + // reach the terminal or logs unescaped. + return strconv.Quote(val) + default: + return fmt.Sprintf("%v", val) + } +} + +// IsNonceTooLowError reports whether an error returned by a contract-binding +// broadcast (e.g. SubmitClaim, AcceptClaim, Settle, JoinTournament) is the +// JSON-RPC "nonce too low" rejection. This is a transient broadcast-time +// condition: the chain has already mined a tx with this EOA's nonce N, so a +// new broadcast (also using N because bind.TransactOpts.Nonce is nil and the +// binding fetched it via PendingNonceAt) is rejected. The classic trigger is +// a node restart that straddles an in-flight tx: the pre-restart broadcast +// landed, but the post-restart Tick re-derives the same nonce from +// PendingNonceAt before the chain's pending view catches up. +// +// The check is a case-insensitive substring match because the JSON-RPC error +// from the node arrives as an opaque rpc.Error wrapper around the upstream +// string; go-ethereum's core.ErrNonceTooLow sentinel is not propagated +// through eth_sendRawTransaction. Both anvil and geth produce the literal +// "nonce too low" inside the wrapper. +// +// The recommended response is to treat this as a retry-later condition and +// rely on a pre-flight on-chain read (IsEpochSettled, IsCommitmentJoined, +// getClaim, etc.) at the next tick to reconcile against state. +func IsNonceTooLowError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "nonce too low") +} diff --git a/pkg/ethutil/rpcerror_test.go b/pkg/ethutil/rpcerror_test.go new file mode 100644 index 000000000..2adb0e88a --- /dev/null +++ b/pkg/ethutil/rpcerror_test.go @@ -0,0 +1,235 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "errors" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const executionRevertedMessage = "execution reverted" + +// rpcDataError simulates an RPC error with revert data, as returned by +// eth_estimateGas when a contract reverts. +type rpcDataError struct { + code int + msg string + data any +} + +func (e *rpcDataError) Error() string { return e.msg } +func (e *rpcDataError) ErrorCode() int { return e.code } +func (e *rpcDataError) ErrorData() any { return e.data } + +func TestExtractJSONErrorInfo(t *testing.T) { + t.Run("NilError", func(t *testing.T) { + info, ok := ExtractJSONErrorInfo(nil) + assert.False(t, ok) + assert.False(t, info.HasCode) + assert.False(t, info.HasData) + }) + + t.Run("PlainError", func(t *testing.T) { + _, ok := ExtractJSONErrorInfo(errors.New("plain")) + assert.False(t, ok) + }) + + t.Run("RPCDataError", func(t *testing.T) { + err := &rpcDataError{code: 3, msg: executionRevertedMessage, data: "0xdeadbeef"} + info, ok := ExtractJSONErrorInfo(err) + assert.True(t, ok) + assert.True(t, info.HasCode) + assert.True(t, info.HasData) + assert.Equal(t, 3, info.Code) + assert.Equal(t, "0xdeadbeef", info.Data) + }) +} + +func TestMatchesSelector(t *testing.T) { + t.Run("LowercaseHexWithPrefix", func(t *testing.T) { + assert.True(t, MatchesSelector("0xaabbccdd0000", "0xaabbccdd")) + }) + + t.Run("UppercaseHexWithPrefix", func(t *testing.T) { + assert.True(t, MatchesSelector("0xAABBCCDD0000", "0xaabbccdd")) + }) + + t.Run("MixedCaseHexWithPrefix", func(t *testing.T) { + assert.True(t, MatchesSelector("0xAaBbCcDd0000", "0xaabbccdd")) + }) + + t.Run("WithoutPrefix", func(t *testing.T) { + assert.True(t, MatchesSelector("aabbccdd0000", "0xaabbccdd")) + }) + + t.Run("ExactLength", func(t *testing.T) { + assert.True(t, MatchesSelector("0xaabbccdd", "0xaabbccdd")) + }) + + t.Run("DifferentSelector", func(t *testing.T) { + assert.False(t, MatchesSelector("0x11223344", "0xaabbccdd")) + }) + + t.Run("TooShort", func(t *testing.T) { + assert.False(t, MatchesSelector("0xaabb", "0xaabbccdd")) + }) + + t.Run("EmptyData", func(t *testing.T) { + assert.False(t, MatchesSelector("", "0xaabbccdd")) + }) + + t.Run("RawBytes", func(t *testing.T) { + assert.True(t, MatchesSelector([]byte{0xaa, 0xbb, 0xcc, 0xdd, 0x00}, "0xaabbccdd")) + }) + + t.Run("NonStringNonBytes", func(t *testing.T) { + assert.False(t, MatchesSelector(42, "0xaabbccdd")) + }) + + t.Run("NilData", func(t *testing.T) { + assert.False(t, MatchesSelector(nil, "0xaabbccdd")) + }) +} + +func TestIsCustomError(t *testing.T) { + t.Run("NilError", func(t *testing.T) { + assert.False(t, IsCustomError(nil, nil, "Foo")) + }) + + t.Run("NonRPCError", func(t *testing.T) { + assert.False(t, IsCustomError(errors.New("plain"), nil, "Foo")) + }) + + t.Run("NilMetadata", func(t *testing.T) { + err := &rpcDataError{code: 3, msg: "revert", data: "0xaabbccdd"} + assert.False(t, IsCustomError(err, nil, "Foo")) + }) +} + +// TestIsNonceTooLowError pins the substring-match contract used by both the +// claimer and PRT broadcast paths to short-circuit on the JSON-RPC +// "nonce too low" rejection. The classifier must catch the literal anvil/ +// geth wording, the wrapped form (`[nonce too low]` produced when a top-level +// formatter renders a []error), and arbitrary case; it must not match +// unrelated errors. +func TestIsNonceTooLowError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "Nil", err: nil, want: false}, + {name: "LiteralLowercase", err: errors.New("nonce too low"), want: true}, + {name: "MixedCase", err: errors.New("Nonce Too Low"), want: true}, + {name: "BracketWrapped", err: errors.New("[nonce too low]"), want: true}, + { + name: "WrappedWithFmt", + err: fmt.Errorf("send transaction: %w", errors.New("nonce too low")), + want: true, + }, + {name: "UnrelatedError", err: errors.New("connection refused"), want: false}, + {name: "RevertedError", err: errors.New(executionRevertedMessage), want: false}, + { + name: "NonceTooHigh", + err: errors.New("nonce too high"), + want: false, // intentional — different condition, not handled here + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, IsNonceTooLowError(tc.err)) + }) + } +} + +// describeTestMetaData declares a small error ABI to exercise DescribeRevert +// without depending on generated contract bindings. +var describeTestMetaData = &bind.MetaData{ABI: `[ + {"type":"error","name":"InsufficientFunds","inputs":[ + {"name":"value","type":"uint256"},{"name":"balance","type":"uint256"}]}, + {"type":"error","name":"AppReverted","inputs":[ + {"name":"data","type":"bytes"}]}, + {"type":"error","name":"Rejected","inputs":[ + {"name":"reason","type":"string"}]}, + {"type":"error","name":"Closed","inputs":[]} +]`} + +func describeTestRevert(t *testing.T, name string, args ...any) error { + t.Helper() + parsed, err := describeTestMetaData.GetAbi() + require.NoError(t, err) + abiErr := parsed.Errors[name] + packed, err := abiErr.Inputs.Pack(args...) + require.NoError(t, err) + payload := append(append([]byte{}, abiErr.ID[:4]...), packed...) + return &rpcDataError{code: 3, msg: executionRevertedMessage, data: fmt.Sprintf("0x%x", payload)} +} + +func TestDescribeRevert(t *testing.T) { + t.Run("NumericArgs", func(t *testing.T) { + err := describeTestRevert(t, "InsufficientFunds", big.NewInt(5), big.NewInt(3)) + desc, ok := DescribeRevert(err, describeTestMetaData) + assert.True(t, ok) + assert.Equal(t, "InsufficientFunds(value=5, balance=3)", desc) + }) + + t.Run("BytesArgsHexEncoded", func(t *testing.T) { + err := describeTestRevert(t, "AppReverted", []byte{0xde, 0xad}) + desc, ok := DescribeRevert(err, describeTestMetaData) + assert.True(t, ok) + assert.Equal(t, "AppReverted(data=0xdead)", desc) + }) + + t.Run("StringArgsQuoted", func(t *testing.T) { + // Contract-controlled text must reach the output escaped, never raw — + // otherwise a malicious contract could inject terminal escape codes. + err := describeTestRevert(t, "Rejected", "evil\x1b[2Jstring") + desc, ok := DescribeRevert(err, describeTestMetaData) + assert.True(t, ok) + assert.Equal(t, `Rejected(reason="evil\x1b[2Jstring")`, desc) + }) + + t.Run("NoArgs", func(t *testing.T) { + err := describeTestRevert(t, "Closed") + desc, ok := DescribeRevert(err, describeTestMetaData) + assert.True(t, ok) + assert.Equal(t, "Closed()", desc) + }) + + t.Run("SelectorMatchedButArgsUndecodable", func(t *testing.T) { + parsed, err := describeTestMetaData.GetAbi() + require.NoError(t, err) + // Selector of a parameterized error with a truncated body. + id := parsed.Errors["InsufficientFunds"].ID + payload := append([]byte{}, id[:4]...) + e := &rpcDataError{code: 3, msg: executionRevertedMessage, data: fmt.Sprintf("0x%x", payload)} + desc, ok := DescribeRevert(e, describeTestMetaData) + assert.True(t, ok) + assert.Equal(t, "InsufficientFunds", desc, "bare name when args do not decode") + }) + + t.Run("UnknownSelector", func(t *testing.T) { + e := &rpcDataError{code: 3, msg: executionRevertedMessage, data: "0xdeadbeef"} + _, ok := DescribeRevert(e, describeTestMetaData) + assert.False(t, ok) + }) + + t.Run("NoRevertData", func(t *testing.T) { + _, ok := DescribeRevert(errors.New("plain"), describeTestMetaData) + assert.False(t, ok) + }) + + t.Run("NilMetadata", func(t *testing.T) { + err := describeTestRevert(t, "Closed") + _, ok := DescribeRevert(err, nil) + assert.False(t, ok) + }) +} diff --git a/pkg/ethutil/salt.go b/pkg/ethutil/salt.go new file mode 100644 index 000000000..c5385748c --- /dev/null +++ b/pkg/ethutil/salt.go @@ -0,0 +1,36 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +// create a type for salt to print correctly as JSON +type SaltBytes [32]byte + +func ParseSalt(salt string) (SaltBytes, error) { + data, err := hex.DecodeString(TrimHex(salt)) + if err != nil { + return [32]byte{}, err + } + if len(data) != 32 { + return [32]byte{}, fmt.Errorf("invalid salt length(%d) != 32", len(data)) + } + var out [32]byte + copy(out[:], data) + return out, nil +} + +// print it as a single string instead of multiple integer values +func (me *SaltBytes) MarshalJSON() ([]byte, error) { + return json.Marshal(hex.EncodeToString(me[:])) +} + +func (me SaltBytes) String() string { + return common.BytesToHash(me[:]).String() +} diff --git a/pkg/ethutil/search.go b/pkg/ethutil/search.go new file mode 100644 index 000000000..7359c5075 --- /dev/null +++ b/pkg/ethutil/search.go @@ -0,0 +1,122 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "errors" + "fmt" + "math/big" + "slices" +) + +// ErrMonotonicViolation is returned by FindTransitions when the seed prevValue +// is greater than the value observed at startBlock. The queried counter is +// assumed monotonic non-decreasing, so a seed that exceeds the on-chain value +// means the caller's previous value is no longer consistent with the chain. +// Callers can match it with errors.Is to distinguish this corruption signal +// from transient query failures. +var ErrMonotonicViolation = errors.New("monotonic assumption violated") + +// TransitionQueryFn for binary search +type TransitionQueryFn func(ctx context.Context, block uint64) (*big.Int, error) + +type OnHitFn func(block uint64) error + +// sortAndCallOnHit sorts the transition blocks and calls onHit for each in chronological order +func sortAndCallOnHit(transitionBlocks []uint64, onHit OnHitFn) (uint64, error) { + slices.Sort(transitionBlocks) + for _, block := range transitionBlocks { + if err := onHit(block); err != nil { + return 0, err + } + } + return uint64(len(transitionBlocks)), nil +} + +// FindTransitions performs divide-and-conquer search for transitions using oracle +// and calls onHit for each transition block in chronological order. +// The prevValue is optional and used to detect a transition exactly at startBlock. +// It assumes the queried value is monotonic (e.g., increasing counter); if it can revert, +// intermediate transitions may be missed when net change is zero. +func FindTransitions(ctx context.Context, startBlock, endBlock uint64, prevValue *big.Int, + transitionQuery TransitionQueryFn, onHit OnHitFn) (uint64, error) { + if startBlock > endBlock { + return 0, nil + } + + type interval struct { + StartBlock, EndBlock uint64 + StartValue, EndValue *big.Int + } + + startValue, err := transitionQuery(ctx, startBlock) + if err != nil { + return 0, fmt.Errorf("transitionQuery(startBlock=%d): %w", startBlock, err) + } + + var transitionBlocks []uint64 + if prevValue != nil { + comparisonResult := prevValue.Cmp(startValue) + if comparisonResult > 0 { + return 0, fmt.Errorf("%w: prevValue %s > startValue %s at block %d", + ErrMonotonicViolation, prevValue.String(), startValue.String(), startBlock) + } + if comparisonResult < 0 { + transitionBlocks = append(transitionBlocks, startBlock) + } + } + + if startBlock == endBlock { + return sortAndCallOnHit(transitionBlocks, onHit) + } + + endValue, err := transitionQuery(ctx, endBlock) + if err != nil { + return 0, fmt.Errorf("transitionQuery(endBlock=%d): %w", endBlock, err) + } + + if startValue.Cmp(endValue) == 0 { + // No further transitions, but may have added startBlock + return sortAndCallOnHit(transitionBlocks, onHit) + } + + // First phase: collect all transition blocks + stack := []interval{{StartBlock: startBlock, EndBlock: endBlock, StartValue: startValue, EndValue: endValue}} + for len(stack) > 0 { + // Check for context cancellation + select { + case <-ctx.Done(): + return 0, ctx.Err() + default: + } + + // Pop from stack + iv := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + if iv.StartBlock == iv.EndBlock { + // Found a transition block + transitionBlocks = append(transitionBlocks, iv.StartBlock) + continue + } + + midBlock := iv.StartBlock + (iv.EndBlock-iv.StartBlock)/2 //nolint:mnd + midValue, err := transitionQuery(ctx, midBlock) + if err != nil { + return 0, fmt.Errorf("transitionQuery(midBlock=%d): %w", midBlock, err) + } + + // Add new intervals to stack if there are transitions + if midValue.Cmp(iv.EndValue) != 0 { + stack = append(stack, interval{StartBlock: midBlock + 1, EndBlock: iv.EndBlock, StartValue: midValue, EndValue: iv.EndValue}) + } + if iv.StartValue.Cmp(midValue) != 0 { + stack = append(stack, interval{StartBlock: iv.StartBlock, EndBlock: midBlock, StartValue: iv.StartValue, EndValue: midValue}) + } + } + + // Second phase: sort transition blocks and call onHit in chronological order + return sortAndCallOnHit(transitionBlocks, onHit) +} diff --git a/pkg/ethutil/search_test.go b/pkg/ethutil/search_test.go new file mode 100644 index 000000000..41f2f7b5f --- /dev/null +++ b/pkg/ethutil/search_test.go @@ -0,0 +1,246 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/stretchr/testify/suite" +) + +type SearchSuite struct { + suite.Suite + ctx context.Context +} + +func TestSearchSuite(t *testing.T) { + suite.Run(t, new(SearchSuite)) +} + +func (s *SearchSuite) SetupTest() { + s.ctx = context.Background() +} + +// Helper to create a mock transitionQuery from a map +func mockTransitionQuery(values map[uint64]*big.Int) TransitionQueryFn { + return func(_ context.Context, block uint64) (*big.Int, error) { + if val, ok := values[block]; ok { + return val, nil + } + return big.NewInt(0), nil // default + } +} + +// Helper to create an onHit that collects blocks +func collectOnHit(blocks *[]uint64) OnHitFn { + return func(block uint64) error { + *blocks = append(*blocks, block) + return nil + } +} + +// Helper to create an onHit that returns error +func errorOnHit(err error) OnHitFn { + return func(_ uint64) error { + return err + } +} + +// Helper to create a transitionQuery that returns error +func errorTransitionQuery(err error) TransitionQueryFn { + return func(_ context.Context, _ uint64) (*big.Int, error) { + return nil, err + } +} + +func (s *SearchSuite) TestNoTransitionsWhenValuesEqual() { + values := map[uint64]*big.Int{ + 0: big.NewInt(1), + 10: big.NewInt(1), + } + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 10, nil, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Empty(blocks) +} + +func (s *SearchSuite) TestSingleTransition() { + values := map[uint64]*big.Int{ + 0: big.NewInt(1), + 1: big.NewInt(2), + } + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 1, nil, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal([]uint64{1}, blocks) +} + +func (s *SearchSuite) TestMultipleTransitions() { + values := map[uint64]*big.Int{ + 1: big.NewInt(1), + 2: big.NewInt(1), + 3: big.NewInt(2), + 4: big.NewInt(2), + 5: big.NewInt(3), + 6: big.NewInt(3), + } + var blocks []uint64 + count, err := FindTransitions(s.ctx, 1, 6, nil, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal(count, uint64(2)) + s.Equal([]uint64{3, 5}, blocks) + + blocks = []uint64{} + previousValue := big.NewInt(0) + count, err = FindTransitions(s.ctx, 1, 6, previousValue, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal(count, uint64(3)) + s.Equal([]uint64{1, 3, 5}, blocks) +} + +func (s *SearchSuite) TestTransitionAtBoundary() { + values := map[uint64]*big.Int{ + 0: big.NewInt(1), + 1: big.NewInt(2), + } + var blocks []uint64 + count, err := FindTransitions(s.ctx, 0, 1, nil, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal(count, uint64(1)) + s.Equal([]uint64{1}, blocks) + + blocks = []uint64{} + previousValue := big.NewInt(0) + count, err = FindTransitions(s.ctx, 0, 1, previousValue, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal(count, uint64(2)) + s.Equal([]uint64{0, 1}, blocks) + +} + +func (s *SearchSuite) TestStartEqualsEnd() { + values := map[uint64]*big.Int{ + 5: big.NewInt(1), + } + var blocks []uint64 + count, err := FindTransitions(s.ctx, 5, 5, nil, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal(count, uint64(0)) + s.Empty(blocks) + + blocks = []uint64{} + previousValue := big.NewInt(0) + count, err = FindTransitions(s.ctx, 5, 5, previousValue, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal(count, uint64(1)) + s.Equal([]uint64{5}, blocks) +} + +func (s *SearchSuite) TestContextCancellation() { + values := map[uint64]*big.Int{ + 0: big.NewInt(1), + 10: big.NewInt(2), + } + ctx, cancel := context.WithCancel(s.ctx) + cancel() + var blocks []uint64 + _, err := FindTransitions(ctx, 0, 10, nil, mockTransitionQuery(values), collectOnHit(&blocks)) + s.Error(err) + s.Equal(context.Canceled, err) +} + +func (s *SearchSuite) TestTransitionQueryErrorAtStart() { + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 10, nil, errorTransitionQuery(fmt.Errorf("query error")), collectOnHit(&blocks)) + s.Error(err) + s.Contains(err.Error(), "transitionQuery(startBlock=0): query error") + s.Empty(blocks) +} + +func (s *SearchSuite) TestTransitionQueryErrorAtEnd() { + values := map[uint64]*big.Int{ + 0: big.NewInt(1), + } + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 10, nil, func(_ context.Context, block uint64) (*big.Int, error) { + if block == 10 { + return nil, fmt.Errorf("query error at end") + } + return values[block], nil + }, collectOnHit(&blocks)) + s.Error(err) + s.Contains(err.Error(), "transitionQuery(endBlock=10): query error at end") + s.Empty(blocks) +} + +func (s *SearchSuite) TestTransitionQueryErrorAtMid() { + values := map[uint64]*big.Int{ + 0: big.NewInt(1), + 10: big.NewInt(2), + } + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 10, nil, func(_ context.Context, block uint64) (*big.Int, error) { + if block == 5 { + return nil, fmt.Errorf("query error at mid") + } + if val, ok := values[block]; ok { + return val, nil + } + return big.NewInt(0), nil + }, collectOnHit(&blocks)) + s.Error(err) + s.Contains(err.Error(), "transitionQuery(midBlock=5): query error at mid") + s.Empty(blocks) +} + +func (s *SearchSuite) TestOnHitError() { + values := map[uint64]*big.Int{ + 0: big.NewInt(1), + 1: big.NewInt(2), + } + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 1, nil, mockTransitionQuery(values), errorOnHit(fmt.Errorf("onHit error"))) + s.Error(err) + s.Contains(err.Error(), "onHit error") + s.Empty(blocks) +} + +func (s *SearchSuite) TestLargeRange() { + values := make(map[uint64]*big.Int) + for i := uint64(0); i <= 100; i++ { + if i < 50 { + values[i] = big.NewInt(1) + } else { + values[i] = big.NewInt(2) + } + } + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 100, nil, mockTransitionQuery(values), collectOnHit(&blocks)) + s.NoError(err) + s.Equal([]uint64{50}, blocks) +} + +func (s *SearchSuite) TestNoValuesDefined() { + var blocks []uint64 + _, err := FindTransitions(s.ctx, 0, 10, nil, mockTransitionQuery(map[uint64]*big.Int{}), collectOnHit(&blocks)) + s.NoError(err) + s.Empty(blocks) +} + +func (s *SearchSuite) TestMonotonicViolation() { + values := map[uint64]*big.Int{ + 0: big.NewInt(2), + 1: big.NewInt(1), + } + var blocks []uint64 + previousValue := big.NewInt(3) // 3 > 2, violation + _, err := FindTransitions(s.ctx, 0, 1, previousValue, mockTransitionQuery(values), collectOnHit(&blocks)) + s.Error(err) + s.ErrorIs(err, ErrMonotonicViolation) + s.Contains(err.Error(), "monotonic assumption violated") + s.Empty(blocks) +} diff --git a/pkg/ethutil/selfhosted.go b/pkg/ethutil/selfhosted.go new file mode 100644 index 000000000..a95f4c9b4 --- /dev/null +++ b/pkg/ethutil/selfhosted.go @@ -0,0 +1,221 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iauthorityfactory" + "github.com/cartesi/rollups-node/pkg/contracts/iselfhostedapplicationfactory" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type SelfhostedApplicationDeployment struct { + FactoryAddress common.Address `json:"factory_address"` + AuthorityOwnerAddress common.Address `json:"authority_owner"` + TemplateHash common.Hash `json:"template_hash"` + EpochLength uint64 `json:"epoch_length"` + ClaimStagingPeriod uint64 `json:"claim_staging_period"` + WithdrawalConfig iapplicationfactory.WithdrawalConfig `json:"withdrawal_config"` + Salt SaltBytes `json:"salt"` + + InputBoxAddress common.Address `json:"inputbox_address"` + IInputBoxBlock uint64 `json:"inputbox_block"` + + Verbose bool +} + +type SelfhostedApplicationDeploymentResult struct { + Deployment *SelfhostedApplicationDeployment `json:"deployment"` + + ApplicationAddress common.Address `json:"application_address"` + AuthorityAddress common.Address `json:"authority_address"` + + AuthorityFactoryAddress common.Address `json:"authority_factory"` + ApplicationFactoryAddress common.Address `json:"application_factory"` +} + +func (me *SelfhostedApplicationDeployment) String() string { + result := "" + result += "selfhosted deployment:\n" + result += fmt.Sprintf("\tauthority owner: %v\n", me.AuthorityOwnerAddress) + result += fmt.Sprintf("\tinput box address: %v\n", me.InputBoxAddress) + if me.Verbose { + result += fmt.Sprintf("\tfactory address: %v\n", me.FactoryAddress) + result += fmt.Sprintf("\ttemplate hash: %v\n", me.TemplateHash) + result += fmt.Sprintf("\tsalt: %v\n", me.Salt) + result += fmt.Sprintf("\tepoch length: %v\n", me.EpochLength) + result += fmt.Sprintf("\tclaim staging period: %v\n", me.ClaimStagingPeriod) + } + return result +} + +func (me *SelfhostedApplicationDeploymentResult) String() string { + result := "" + result += fmt.Sprintf("\tapplication address: %v\n", me.ApplicationAddress) + result += fmt.Sprintf("\tauthority address: %v\n", me.AuthorityAddress) + if me.Deployment.Verbose { + result += fmt.Sprintf("\tapplication factory: %v\n", me.ApplicationFactoryAddress) + result += fmt.Sprintf("\tauthority factory: %v\n", me.AuthorityFactoryAddress) + } + return result +} + +func (me *SelfhostedApplicationDeployment) Deploy( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, +) (common.Address, IApplicationDeploymentResult, error) { + return me.DeployWithTransaction(ctx, client, txOptsFactory, nil) +} + +// DeployWithTransaction returns the predicted application address and no confirmed +// deployment result when runner does not wait for a receipt. The prediction does +// not prove that the deployment transaction succeeded. +func (me *SelfhostedApplicationDeployment) DeployWithTransaction( + ctx context.Context, + client *ethclient.Client, + txOptsFactory TransactOptsFactory, + runner TransactionRunner, +) (common.Address, IApplicationDeploymentResult, error) { + zero := common.Address{} + result := &SelfhostedApplicationDeploymentResult{} + result.Deployment = me + factory, err := iselfhostedapplicationfactory.NewISelfHostedApplicationFactory(me.FactoryAddress, client) + if err != nil { + return zero, nil, err + } + + if err := ValidateWithdrawalConfig(me.WithdrawalConfig); err != nil { + return zero, nil, err + } + if err := CheckWithdrawalOutputBuilderCode(ctx, client, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + + // The self-hosted factory binding has its own WithdrawalConfig type + // with identical fields; explicit conversion is required. + shWC := iselfhostedapplicationfactory.WithdrawalConfig(me.WithdrawalConfig) + + // check if addresses are available (have no code) + applicationAddress, authorityAddress, err := factory.CalculateAddresses( + &bind.CallOpts{Context: ctx}, + me.AuthorityOwnerAddress, + new(big.Int).SetUint64(me.EpochLength), + new(big.Int).SetUint64(me.ClaimStagingPeriod), + me.TemplateHash, + me.InputBoxAddress, + shWC, + me.Salt, + ) + if err != nil { + return zero, nil, err + } + + applicationCode, err := client.CodeAt(ctx, applicationAddress, nil) + if err != nil { + return zero, nil, err + } + if len(applicationCode) != 0 { + return zero, nil, fmt.Errorf("application with address %v already exists; use a different salt", applicationAddress) + } + + authorityCode, err := client.CodeAt(ctx, authorityAddress, nil) + if err != nil { + return zero, nil, err + } + if len(authorityCode) != 0 { + return zero, nil, fmt.Errorf("authority with address %v already exists; use a different salt", authorityAddress) + } + + var txOpts *bind.TransactOpts + if runner == nil { + // Preserve the existing helper's preparation for non-CLI callers. The + // CLI runner applies its common transaction policy instead. + txOpts, err = _prepareTransaction(ctx, client, txOptsFactory, big.NewInt(0)) + } else { + txOpts, err = txOptsFactory.NewTransactOpts(ctx) + } + if err != nil { + return zero, nil, fmt.Errorf("failed to create transaction options: %w", err) + } + + // deploy the contracts + receipt, err := runDeploymentTransaction( + ctx, client, txOpts, + func(txOpts *bind.TransactOpts) (*types.Transaction, error) { + result.ApplicationFactoryAddress, err = factory.GetApplicationFactory(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, fmt.Errorf("failed to retrieve application factory address: %w", err) + } + result.AuthorityFactoryAddress, err = factory.GetAuthorityFactory(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, fmt.Errorf("failed to retrieve authority factory address: %w", err) + } + return factory.DeployContracts( + txOpts, + me.AuthorityOwnerAddress, + new(big.Int).SetUint64(me.EpochLength), + new(big.Int).SetUint64(me.ClaimStagingPeriod), + me.TemplateHash, + me.InputBoxAddress, + shWC, + me.Salt, + ) + }, + runner, + ) + if err != nil { + return zero, nil, fmt.Errorf("failed to create a self hosted application: %w", err) + } + if receipt == nil { + return applicationAddress, nil, nil + } + + applicationFactory, err := iapplicationfactory.NewIApplicationFactory(result.ApplicationFactoryAddress, client) + if err != nil { + return zero, nil, err + } + + authorityFactory, err := iauthorityfactory.NewIAuthorityFactory(result.AuthorityFactoryAddress, client) + if err != nil { + return zero, nil, err + } + + for _, vLog := range receipt.Logs { + event, err := applicationFactory.ParseApplicationCreated(*vLog) + if err != nil { + continue // Skip logs that don't match + } + result.ApplicationAddress = event.AppContract + goto applicationEventFound + } + return zero, nil, fmt.Errorf("self-hosted deployment receipt has no ApplicationCreated event") + +applicationEventFound: + for _, vLog := range receipt.Logs { + event, err := authorityFactory.ParseAuthorityCreated(*vLog) + if err != nil { + continue // Skip logs that don't match + } + result.AuthorityAddress = event.Authority + goto authorityEventFound + } + return zero, nil, fmt.Errorf("self-hosted deployment receipt has no AuthorityCreated event") + +authorityEventFound: + if err := VerifyDeployedWithdrawalConfig(ctx, client, result.ApplicationAddress, me.WithdrawalConfig); err != nil { + return zero, nil, err + } + return result.ApplicationAddress, result, nil +} + +func (me *SelfhostedApplicationDeployment) GetFactoryAddress() common.Address { + return me.FactoryAddress +} diff --git a/pkg/ethutil/transaction.go b/pkg/ethutil/transaction.go index f84616738..eadc28897 100644 --- a/pkg/ethutil/transaction.go +++ b/pkg/ethutil/transaction.go @@ -16,24 +16,46 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ) -const PollInterval = 100 * time.Millisecond +type TransactOptsFactory interface { + From() common.Address + NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) +} + +type staticTransactOptsFactory struct { + opts *bind.TransactOpts +} + +func (f *staticTransactOptsFactory) From() common.Address { + return f.opts.From +} + +func (f *staticTransactOptsFactory) NewTransactOpts(ctx context.Context) (*bind.TransactOpts, error) { + opts := *f.opts + opts.Context = ctx + return &opts, nil +} + +func NewStaticTransactOptsFactory(txOpts *bind.TransactOpts) TransactOptsFactory { + return &staticTransactOptsFactory{opts: txOpts} +} + +const PollInterval = 500 * time.Millisecond // Prepare the transaction, send it, and wait for the receipt. func sendTransaction( ctx context.Context, client *ethclient.Client, - signer Signer, + txOptsFactory TransactOptsFactory, txValue *big.Int, - gasLimit uint64, doSend func(txOpts *bind.TransactOpts) (*types.Transaction, error), ) (*types.Receipt, error) { - txOpts, err := _prepareTransaction(ctx, client, signer, txValue, gasLimit) + txOpts, err := _prepareTransaction(ctx, client, txOptsFactory, txValue) if err != nil { - return nil, fmt.Errorf("failed to prepare transaction: %v", err) + return nil, fmt.Errorf("failed to prepare transaction: %w", err) } tx, err := doSend(txOpts) if err != nil { - return nil, fmt.Errorf("failed to send dapp address: %v", err) + return nil, fmt.Errorf("failed to send transaction: %w", err) } receipt, err := _waitForTransaction(ctx, client, tx) if err != nil { @@ -46,27 +68,28 @@ func sendTransaction( func _prepareTransaction( ctx context.Context, client *ethclient.Client, - signer Signer, + txOptsFactory TransactOptsFactory, txValue *big.Int, - gasLimit uint64, ) (*bind.TransactOpts, error) { - nonce, err := client.PendingNonceAt(ctx, signer.Account()) + nonce, err := client.PendingNonceAt(ctx, txOptsFactory.From()) if err != nil { - return nil, fmt.Errorf("failed to get nonce: %v", err) + return nil, fmt.Errorf("failed to get nonce: %w", err) } gasPrice, err := client.SuggestGasPrice(ctx) if err != nil { - return nil, fmt.Errorf("failed to get gas price: %v", err) + return nil, fmt.Errorf("failed to get gas price: %w", err) } - tx, err := signer.MakeTransactor() + nonceBigInt := &big.Int{} + nonceBigInt.SetUint64(nonce) + + txOpts, err := txOptsFactory.NewTransactOpts(ctx) if err != nil { - return nil, fmt.Errorf("failed to create transactor: %v", err) + return nil, fmt.Errorf("failed to get transaction options: %w", err) } - tx.Nonce = big.NewInt(int64(nonce)) - tx.Value = txValue - tx.GasLimit = gasLimit - tx.GasPrice = gasPrice - return tx, nil + txOpts.Nonce = nonceBigInt + txOpts.Value = txValue + txOpts.GasPrice = gasPrice + return txOpts, nil } // Wait for transaction to be included in a block. Return the transaction receipt. @@ -78,7 +101,7 @@ func _waitForTransaction( for { _, isPending, err := client.TransactionByHash(ctx, tx.Hash()) if err != nil { - return nil, fmt.Errorf("fail to recover transaction: %v", err) + return nil, fmt.Errorf("fail to recover transaction: %w", err) } if !isPending { break @@ -92,12 +115,12 @@ func _waitForTransaction( } receipt, err := client.TransactionReceipt(ctx, tx.Hash()) if err != nil { - return nil, fmt.Errorf("failed to get receipt: %v", err) + return nil, fmt.Errorf("failed to get receipt: %w", err) } - if receipt.Status == 0 { - reason, err := _traceTransaction(ctx, client, tx.Hash()) + if receipt.Status == types.ReceiptStatusFailed { + reason, err := _traceTransaction(client, tx.Hash()) if err != nil { - return nil, fmt.Errorf("transaction failed; failed to get reason: %v", err) + return nil, fmt.Errorf("transaction failed; failed to get reason: %w", err) } return nil, fmt.Errorf("transaction failed: %v", reason) } @@ -107,7 +130,6 @@ func _waitForTransaction( // Call the Ethereum node using the RPC client directly because the ethclient struct doesn't have a // binding for the trace API. More details in: https://github.com/ethereum/go-ethereum/issues/17341 func _traceTransaction( - ctx context.Context, client *ethclient.Client, hash common.Hash, ) (string, error) { diff --git a/pkg/ethutil/withdrawal_account.go b/pkg/ethutil/withdrawal_account.go new file mode 100644 index 000000000..68e9ad8ff --- /dev/null +++ b/pkg/ethutil/withdrawal_account.go @@ -0,0 +1,151 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" + "github.com/cartesi/rollups-node/pkg/contracts/iusdwithdrawaloutputbuilder" +) + +// usdAccountSize is the exact byte length of the LibUsdAccount encoding consumed +// by every UsdWithdrawalOutputBuilder: +// +// bytes 0..11 uint96 balance, little-endian +// bytes 12..31 20-byte user address +const usdAccountSize = 32 + +// DescribeWithdrawalAccount renders a multi-line human description of the +// `account` bytes consumed by an IApplication.withdraw() call so the +// operator can verify the recipient and amount before signing. +// +// Algorithm: +// +// 1. Call IUsdWithdrawalOutputBuilder.Token() on the on-chain builder. +// A revert here means the builder is not a USD-family builder; the +// caller should fall back to a raw-bytes display. +// 2. Require and split the exact 32-byte record into recipient and balance +// per LibUsdAccount. Any other length is a hard error. +// 3. Best-effort fetch IERC20Metadata.Symbol() and Decimals() on the +// returned token address so the balance can be rendered as a +// fixed-point amount. If either view reverts (broken or non-standard +// ERC-20), the raw uint96 balance is shown unmodified. +// +// Tri-state return: +// +// - (desc, true, nil): builder recognized and account decoded. +// - ("", true, err): builder recognized but the bytes do not match +// the USD encoding — surface to the operator. +// - ("", false, nil): Token() reverted — caller falls back to raw +// bytes and stricter confirmation. +func DescribeWithdrawalAccount( + ctx context.Context, + client *ethclient.Client, + builder common.Address, + account []byte, +) (description string, matched bool, err error) { + b, err := iusdwithdrawaloutputbuilder.NewIUsdWithdrawalOutputBuilder(builder, client) + if err != nil { + return "", false, nil + } + token, err := b.Token(&bind.CallOpts{Context: ctx}) + if err != nil { + return "", false, nil + } + recipient, balance, err := decodeUSDAccount(account) + if err != nil { + return "", true, fmt.Errorf("%w (token %s)", err, token) + } + + symbol, decimals, metaOK := fetchERC20Metadata(ctx, client, token) + tokenLine := fmt.Sprintf(" token: %s", token) + if metaOK { + tokenLine = fmt.Sprintf(" token: %s %s", token, symbol) + } + var amountLine string + if metaOK { + amountLine = fmt.Sprintf( + " amount: %s %s (raw: %s, decimals: %d)", + formatTokenAmount(balance, decimals), symbol, balance.String(), decimals) + } else { + amountLine = fmt.Sprintf( + " amount (raw uint96): %s (token metadata unavailable)", + balance.String()) + } + return fmt.Sprintf( + "USD-style account (recognized via IUsdWithdrawalOutputBuilder.Token)\n"+ + "%s\n recipient: %s\n%s", + tokenLine, recipient, amountLine, + ), true, nil +} + +func decodeUSDAccount(account []byte) (common.Address, *big.Int, error) { + if len(account) != usdAccountSize { + return common.Address{}, nil, fmt.Errorf( + "USD account must be exactly %d bytes, got %d", + usdAccountSize, + len(account), + ) + } + + var balanceBytes [12]byte + for i := range balanceBytes { + balanceBytes[len(balanceBytes)-1-i] = account[i] + } + balance := new(big.Int).SetBytes(balanceBytes[:]) + var recipient common.Address + copy(recipient[:], account[12:usdAccountSize]) + return recipient, balance, nil +} + +// fetchERC20Metadata best-effort-fetches the symbol and decimals of an +// ERC-20 token. Returns ok=false if either view reverts so the caller can +// fall back to a raw integer display rather than guessing. +func fetchERC20Metadata( + ctx context.Context, + client *ethclient.Client, + token common.Address, +) (symbol string, decimals uint8, ok bool) { + md, err := ierc20metadata.NewIERC20Metadata(token, client) + if err != nil { + return "", 0, false + } + opts := &bind.CallOpts{Context: ctx} + symbol, err = md.Symbol(opts) + if err != nil { + return "", 0, false + } + decimals, err = md.Decimals(opts) + if err != nil { + return "", 0, false + } + return symbol, decimals, true +} + +// formatTokenAmount converts a raw integer balance into the conventional +// fixed-point string (e.g. balance=1_500_000, decimals=6 → "1.5"). Trailing +// zeros in the fractional part are trimmed so common round amounts render +// compactly. +func formatTokenAmount(raw *big.Int, decimals uint8) string { + if decimals == 0 { + return raw.String() + } + denom := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + whole, frac := new(big.Int).QuoRem(new(big.Int).Set(raw), denom, new(big.Int)) + if frac.Sign() == 0 { + return whole.String() + } + fracStr := fmt.Sprintf("%0*s", decimals, frac.String()) + for len(fracStr) > 0 && fracStr[len(fracStr)-1] == '0' { + fracStr = fracStr[:len(fracStr)-1] + } + return whole.String() + "." + fracStr +} diff --git a/pkg/ethutil/withdrawal_account_test.go b/pkg/ethutil/withdrawal_account_test.go new file mode 100644 index 000000000..4d4ec5474 --- /dev/null +++ b/pkg/ethutil/withdrawal_account_test.go @@ -0,0 +1,60 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package ethutil + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestFormatTokenAmount(t *testing.T) { + cases := []struct { + raw string + decimals uint8 + want string + }{ + {"0", 0, "0"}, + {"42", 0, "42"}, + {"1500000", 6, "1.5"}, + {"1234567", 6, "1.234567"}, + {"1000000", 6, "1"}, + {"1", 6, "0.000001"}, + {"1000000000000000000", 18, "1"}, + {"1500000000000000000", 18, "1.5"}, + {"999999999", 8, "9.99999999"}, + {"1", 18, "0.000000000000000001"}, + {"79228162514264337593543950335", 6, "79228162514264337593543.950335"}, + } + for _, c := range cases { + raw, ok := new(big.Int).SetString(c.raw, 10) + require.True(t, ok) + before := new(big.Int).Set(raw) + got := formatTokenAmount(raw, c.decimals) + require.Equalf(t, c.want, got, "formatTokenAmount(%s, %d)", c.raw, c.decimals) + require.Zero(t, before.Cmp(raw), "formatTokenAmount mutated its input") + } +} + +func TestDecodeUSDAccountExactUint96Record(t *testing.T) { + recipient := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + account := common.FromHex("0x0102030405060708090a0b0cbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + before := bytes.Clone(account) + + gotRecipient, gotBalance, err := decodeUSDAccount(account) + require.NoError(t, err) + require.Equal(t, recipient, gotRecipient) + require.Equal(t, "3727165692135864801209549313", gotBalance.String()) + require.Equal(t, before, account, "decodeUSDAccount mutated its source bytes") +} + +func TestDecodeUSDAccountRequiresExactSize(t *testing.T) { + for _, size := range []int{0, usdAccountSize - 1, usdAccountSize + 1} { + _, _, err := decodeUSDAccount(make([]byte, size)) + require.ErrorContains(t, err, "exactly 32 bytes") + } +} diff --git a/pkg/ethutil/withdrawal_config.go b/pkg/ethutil/withdrawal_config.go new file mode 100644 index 000000000..57fe4f2aa --- /dev/null +++ b/pkg/ethutil/withdrawal_config.go @@ -0,0 +1,170 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "context" + "fmt" + "math/big" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplication" + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Constants mirror CanonicalMachine.sol. +const ( + log2DataBlockSize = 5 + log2MemorySize = 64 +) + +// ValidateWithdrawalConfig is the Go mirror of LibWithdrawalConfig.isValid in +// src/library/LibWithdrawalConfig.sol. It exists so the CLI can surface a clear +// error before sending a transaction that would revert with the opaque +// InvalidWithdrawalConfig selector. +// +// A zero-valued config (no foreclosure / no withdrawal) is valid. +func ValidateWithdrawalConfig(wc iapplicationfactory.WithdrawalConfig) error { + log2AccountsDriveSize := uint(log2DataBlockSize) + + uint(wc.Log2MaxNumOfAccounts) + + uint(wc.Log2LeavesPerAccount) + + if log2AccountsDriveSize > log2MemorySize { + return fmt.Errorf( + "withdrawal config: accounts drive larger than machine memory: log2(drive) = %d + %d + %d = %d > %d", + log2DataBlockSize, wc.Log2MaxNumOfAccounts, wc.Log2LeavesPerAccount, + log2AccountsDriveSize, log2MemorySize, + ) + } + + endIndex := new(big.Int).SetUint64(wc.AccountsDriveStartIndex) + endIndex.Add(endIndex, big.NewInt(1)) + accountsDriveEnd := new(big.Int).Lsh(endIndex, log2AccountsDriveSize) + memorySize := new(big.Int).Lsh(big.NewInt(1), log2MemorySize) + + if accountsDriveEnd.Cmp(memorySize) > 0 { + return fmt.Errorf( + "withdrawal config: accounts drive ends past machine memory: (start+1=%s) << %d > 2^%d", + endIndex.String(), log2AccountsDriveSize, log2MemorySize, + ) + } + + return nil +} + +// withdrawalConfigArgs is sourced from the abigen-generated +// IApplicationFactory ABI so the encoding stays in lockstep with the +// contract definition. The tuple type is taken from +// `calculateApplicationAddress` (unique signature, no overload ambiguity). +var withdrawalConfigArgs = func() abi.Arguments { + parsed, err := iapplicationfactory.IApplicationFactoryMetaData.GetAbi() + if err != nil { + panic(fmt.Errorf("withdrawal_config: failed to parse iapplicationfactory ABI: %w", err)) + } + method, ok := parsed.Methods["calculateApplicationAddress"] + if !ok { + panic("withdrawal_config: calculateApplicationAddress method not found in ABI") + } + for _, in := range method.Inputs { + if in.Name == "withdrawalConfig" { + return abi.Arguments{{Name: "withdrawalConfig", Type: in.Type}} + } + } + panic("withdrawal_config: withdrawalConfig argument not found in calculateApplicationAddress ABI") +}() + +// EncodeWithdrawalConfig serializes a WithdrawalConfig as the on-chain +// tuple ABI encoding (160 bytes for the all-static field layout). The +// all-zero config encodes to 160 zero bytes — the canonical "no +// foreclosure" representation. +func EncodeWithdrawalConfig(wc iapplicationfactory.WithdrawalConfig) ([]byte, error) { + return withdrawalConfigArgs.Pack(wc) +} + +// GetApplicationWithdrawalConfig reads the WithdrawalConfig struct from a +// deployed IApplication contract via the single getWithdrawalConfig() view. +// Used at registration time when the contract is the source of truth. +// +// abigen emits a distinct WithdrawalConfig struct per contract package, so +// the iapplication-binding result is copied into the iapplicationfactory +// shape callers expect for downstream encoding via EncodeWithdrawalConfig. +func GetApplicationWithdrawalConfig( + ctx context.Context, + client *ethclient.Client, + appAddr common.Address, +) (iapplicationfactory.WithdrawalConfig, error) { + app, err := iapplication.NewIApplication(appAddr, client) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, + fmt.Errorf("withdrawal config: failed to instantiate IApplication binding: %w", err) + } + + wc, err := app.GetWithdrawalConfig(&bind.CallOpts{Context: ctx}) + if err != nil { + return iapplicationfactory.WithdrawalConfig{}, + fmt.Errorf("withdrawal config: getWithdrawalConfig failed: %w", err) + } + return iapplicationfactory.WithdrawalConfig{ + Guardian: wc.Guardian, + Log2LeavesPerAccount: wc.Log2LeavesPerAccount, + Log2MaxNumOfAccounts: wc.Log2MaxNumOfAccounts, + AccountsDriveStartIndex: wc.AccountsDriveStartIndex, + WithdrawalOutputBuilder: wc.WithdrawalOutputBuilder, + }, nil +} + +// VerifyDeployedWithdrawalConfig reads the WithdrawalConfig from the +// just-deployed application contract and asserts field-by-field equality +// against the config the caller passed to the factory. The four binding +// packages (iapplicationfactory, iselfhostedapplicationfactory, +// idaveappfactory, iapplication) each declare their own WithdrawalConfig +// struct; the deploy paths cross between them via Go type conversion, +// which silently masks any abigen field-order drift. This verify catches +// that drift the moment it ships, rather than at some downstream failure +// (claimer reverting, withdrawal output going to the wrong recipient). +func VerifyDeployedWithdrawalConfig( + ctx context.Context, + client *ethclient.Client, + appAddr common.Address, + expected iapplicationfactory.WithdrawalConfig, +) error { + actual, err := GetApplicationWithdrawalConfig(ctx, client, appAddr) + if err != nil { + return fmt.Errorf("verify withdrawal config: %w", err) + } + if actual != expected { + return fmt.Errorf( + "verify withdrawal config: on-chain config at %s does not match factory input\n"+ + " expected: %+v\n"+ + " actual: %+v", + appAddr, expected, actual) + } + return nil +} + +// CheckWithdrawalOutputBuilderCode performs a cheap sanity check on the +// builder address: if non-zero, it must have bytecode on chain. Skips the +// check when WithdrawalOutputBuilder is the zero address (no-foreclosure +// default). +func CheckWithdrawalOutputBuilderCode( + ctx context.Context, + client *ethclient.Client, + wc iapplicationfactory.WithdrawalConfig, +) error { + if wc.WithdrawalOutputBuilder == (common.Address{}) { + return nil + } + code, err := client.CodeAt(ctx, wc.WithdrawalOutputBuilder, nil) + if err != nil { + return fmt.Errorf("withdrawal config: failed to read builder code at %s: %w", + wc.WithdrawalOutputBuilder, err) + } + if len(code) == 0 { + return fmt.Errorf("withdrawal config: builder address %s has no code on chain", + wc.WithdrawalOutputBuilder) + } + return nil +} diff --git a/pkg/ethutil/withdrawal_config_test.go b/pkg/ethutil/withdrawal_config_test.go new file mode 100644 index 000000000..63817a306 --- /dev/null +++ b/pkg/ethutil/withdrawal_config_test.go @@ -0,0 +1,131 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) +package ethutil + +import ( + "bytes" + "testing" + + "github.com/cartesi/rollups-node/pkg/contracts/iapplicationfactory" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestEncodeWithdrawalConfig(t *testing.T) { + cases := []iapplicationfactory.WithdrawalConfig{ + {}, + { + Guardian: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 20, + AccountsDriveStartIndex: 33554432, + WithdrawalOutputBuilder: common.HexToAddress("0x2222222222222222222222222222222222222222"), + }, + { + Guardian: common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + Log2LeavesPerAccount: 7, + Log2MaxNumOfAccounts: 19, + AccountsDriveStartIndex: 12345, + WithdrawalOutputBuilder: common.HexToAddress("0xcafebabecafebabecafebabecafebabecafebabe"), + }, + } + for i, wc := range cases { + b, err := EncodeWithdrawalConfig(wc) + require.NoError(t, err, "case %d encode", i) + require.Equal(t, 160, len(b), "case %d encoded length (5 * 32 = 160 bytes)", i) + + // Round-trip: unpack the encoded bytes and assert field-by-field equality + // with the original. Pinning this protects against an abigen field-order + // shift between the four binding packages that share the WithdrawalConfig + // shape (iapplicationfactory, iselfhostedapplicationfactory, idaveappfactory, + // iapplication) — a silent reorder there would break encoding and the + // length-only check would not catch it. + unpacked, err := withdrawalConfigArgs.Unpack(b) + require.NoError(t, err, "case %d unpack", i) + require.Len(t, unpacked, 1, "case %d unpack arity", i) + got := *abi.ConvertType(unpacked[0], + new(iapplicationfactory.WithdrawalConfig)).(*iapplicationfactory.WithdrawalConfig) + require.Equal(t, wc, got, "case %d round-trip", i) + } + + // Zero-valued config must encode to 160 zero bytes — the canonical + // "no foreclosure" sentinel used as the DEFAULT value in the deploy tx + // ABI and assumed by downstream readers. + zeroBytes, err := EncodeWithdrawalConfig(iapplicationfactory.WithdrawalConfig{}) + require.NoError(t, err) + require.True(t, bytes.Equal(zeroBytes, make([]byte, 160)), + "all-zero config must encode to 160 zero bytes") +} + +func TestValidateWithdrawalConfig(t *testing.T) { + tests := []struct { + name string + wc iapplicationfactory.WithdrawalConfig + wantErr string // substring expected in the error message; "" means no error + }{ + { + name: "all zeros is valid (no foreclosure)", + wc: iapplicationfactory.WithdrawalConfig{}, + }, + { + name: "typical realistic config", + wc: iapplicationfactory.WithdrawalConfig{ + Guardian: common.HexToAddress("0x1"), + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 20, + AccountsDriveStartIndex: 33554432, + WithdrawalOutputBuilder: common.HexToAddress("0x2"), + }, + }, + { + name: "drive size at the memory boundary, start=0 (valid)", + wc: iapplicationfactory.WithdrawalConfig{ + // 5 + 0 + 59 = 64 == log2MemorySize, start=0 -> end = 1 << 64 == 2^64 == memorySize + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 59, + AccountsDriveStartIndex: 0, + }, + }, + { + name: "drive too large (log2 sum > 64)", + wc: iapplicationfactory.WithdrawalConfig{ + Log2LeavesPerAccount: 60, + Log2MaxNumOfAccounts: 60, + }, + wantErr: "larger than machine memory", + }, + { + name: "drive end overflows past memory (start>0 at boundary)", + wc: iapplicationfactory.WithdrawalConfig{ + // 5 + 0 + 59 = 64 == log2MemorySize, start=1 -> end = 2 << 64 > 2^64 + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 59, + AccountsDriveStartIndex: 1, + }, + wantErr: "past machine memory", + }, + { + name: "drive end past machine memory (start non-zero)", + wc: iapplicationfactory.WithdrawalConfig{ + // 5 + 0 + 30 = 35; start = 2^34 -> (start+1) << 35 > 2^64 + Log2LeavesPerAccount: 0, + Log2MaxNumOfAccounts: 30, + AccountsDriveStartIndex: 1 << 34, + }, + wantErr: "past machine memory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateWithdrawalConfig(tc.wc) + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + } + }) + } +} diff --git a/pkg/inspectclient/generated.go b/pkg/inspectclient/generated.go index dd05e73e7..d274ab80a 100644 --- a/pkg/inspectclient/generated.go +++ b/pkg/inspectclient/generated.go @@ -1,6 +1,6 @@ // Package inspectclient provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.2.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.1 DO NOT EDIT. package inspectclient import ( @@ -17,33 +17,71 @@ import ( // Defines values for CompletionStatus. const ( - Accepted CompletionStatus = "Accepted" - CycleLimitExceeded CompletionStatus = "CycleLimitExceeded" - Exception CompletionStatus = "Exception" - MachineHalted CompletionStatus = "MachineHalted" - Rejected CompletionStatus = "Rejected" - TimeLimitExceeded CompletionStatus = "TimeLimitExceeded" + Accepted CompletionStatus = "Accepted" + Exception CompletionStatus = "Exception" + Failed CompletionStatus = "Failed" + MachineHalted CompletionStatus = "MachineHalted" + Rejected CompletionStatus = "Rejected" ) -// CompletionStatus Whether inspection completed or not (and why not) +// Valid indicates whether the value is a known member of the CompletionStatus enum. +func (e CompletionStatus) Valid() bool { + switch e { + case Accepted: + return true + case Exception: + return true + case Failed: + return true + case MachineHalted: + return true + case Rejected: + return true + default: + return false + } +} + +// CompletionStatus How inspection completed. MachineHalted means the temporary inspect +// execution halted; the canonical application machine is unchanged. +// Failed means the node could not complete inspection because of an +// operational limit, cancellation, timeout, protocol error, or internal +// failure. Reports returned with Failed may be partial. +// +// Example: Accepted type CompletionStatus string // Error Detailed error message. +// +// Example: The request could not be understood by the server due to malformed syntax type Error = string // InspectResult defines model for InspectResult. type InspectResult struct { - // ExceptionPayload Payload in the Ethereum hex binary format. - // The first two characters are '0x' followed by pairs of hexadecimal numbers that correspond to one byte. - // For instance, '0xdeadbeef' corresponds to a payload with length 4 and bytes 222, 173, 190, 175. - // An empty payload is represented by the string '0x'. - ExceptionPayload Payload `json:"exception_payload"` + // Error Sanitized error description, present only when status is Exception or Failed + // + // Example: The node could not complete the inspection + Error *string `json:"error,omitempty"` - // ProcessedInputCount Number of processed inputs since genesis - ProcessedInputCount int `json:"processed_input_count"` - Reports []Report `json:"reports"` + // ExceptionData Raw guest-provided CMIO exception payload, present only when status is Exception + ExceptionData *Payload `json:"exception_data,omitempty"` - // Status Whether inspection completed or not (and why not) + // ProcessedInputCount Number of processed inputs since genesis + // + // Example: 0 + ProcessedInputCount int `json:"processed_input_count"` + + // Reports Reports emitted before completion; for Failed, this may be only a partial prefix + Reports []Report `json:"reports"` + + // Status How inspection completed. MachineHalted means the temporary inspect + // execution halted; the canonical application machine is unchanged. + // Failed means the node could not complete inspection because of an + // operational limit, cancellation, timeout, protocol error, or internal + // failure. Reports returned with Failed may be partial. + // + // + // Example: Accepted Status CompletionStatus `json:"status"` } @@ -51,6 +89,8 @@ type InspectResult struct { // The first two characters are '0x' followed by pairs of hexadecimal numbers that correspond to one byte. // For instance, '0xdeadbeef' corresponds to a payload with length 4 and bytes 222, 173, 190, 175. // An empty payload is represented by the string '0x'. +// +// Example: 0xdeadbeef type Payload = string // Report defines model for Report. @@ -59,10 +99,13 @@ type Report struct { // The first two characters are '0x' followed by pairs of hexadecimal numbers that correspond to one byte. // For instance, '0xdeadbeef' corresponds to a payload with length 4 and bytes 222, 173, 190, 175. // An empty payload is represented by the string '0x'. + // + // + // Example: 0xdeadbeef Payload Payload `json:"payload"` } -// RequestEditorFn is the function signature for the RequestEditor callback function +// RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error // Doer performs HTTP requests. @@ -135,27 +178,34 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { - // InspectPostWithBody request with any body - InspectPostWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - // Inspect request - Inspect(ctx context.Context, payload string, reqEditors ...RequestEditorFn) (*http.Response, error) -} -func (c *Client) InspectPostWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewInspectPostRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) + // InspectPostWithBody Inspect DApp state via POST + // + // This POST method sends an inspect-state request to the DApp backend, using the body contents as a binary payload for the inspect method. + // + // The response includes the inspection completion status, reports generated by the DApp backend, and the number of previously processed inputs. The optional `error` field contains a sanitized description when the status is `Exception` or `Failed`. For `Exception`, `exception_data` contains the raw CMIO payload supplied by the guest. + // + // The inspect operation is executed on a temporary fork of the machine created upon request arrival, which is discarded afterward. Note that this method is synchronous and not recommended for resource-intensive operations. + // + // Takes any type of body and a specified content type. + // + // Corresponds with POST inspect/{dapp} (the `InspectPost` operationId). + InspectPostWithBody(ctx context.Context, dapp string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) } -func (c *Client) Inspect(ctx context.Context, payload string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewInspectRequest(c.Server, payload) +// InspectPostWithBody Inspect DApp state via POST +// +// This POST method sends an inspect-state request to the DApp backend, using the body contents as a binary payload for the inspect method. +// +// The response includes the inspection completion status, reports generated by the DApp backend, and the number of previously processed inputs. The optional `error` field contains a sanitized description when the status is `Exception` or `Failed`. For `Exception`, `exception_data` contains the raw CMIO payload supplied by the guest. +// +// The inspect operation is executed on a temporary fork of the machine created upon request arrival, which is discarded afterward. Note that this method is synchronous and not recommended for resource-intensive operations. +// +// Takes any type of body and a specified content type. +// +// Corresponds with POST inspect/{dapp} (the `InspectPost` operationId). +func (c *Client) InspectPostWithBody(ctx context.Context, dapp string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewInspectPostRequestWithBody(c.Server, dapp, contentType, body) if err != nil { return nil, err } @@ -166,42 +216,13 @@ func (c *Client) Inspect(ctx context.Context, payload string, reqEditors ...Requ return c.Client.Do(req) } -// NewInspectPostRequestWithBody generates requests for InspectPost with any type of body -func NewInspectPostRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("inspect") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewInspectRequest generates requests for Inspect -func NewInspectRequest(server string, payload string) (*http.Request, error) { +// NewInspectPostRequestWithBody constructs an http.Request for the InspectPost method, with any body, and a specified content type +func NewInspectPostRequestWithBody(server string, dapp string, contentType string, body io.Reader) (*http.Request, error) { var err error var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "payload", runtime.ParamLocationPath, payload) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "dapp", dapp, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -221,11 +242,13 @@ func NewInspectRequest(server string, payload string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } + req.Header.Add("Content-Type", contentType) + return req, nil } @@ -272,43 +295,40 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { - // InspectPostWithBodyWithResponse request with any body - InspectPostWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*InspectPostResponse, error) - // InspectWithResponse request - InspectWithResponse(ctx context.Context, payload string, reqEditors ...RequestEditorFn) (*InspectResponse, error) + // InspectPostWithBodyWithResponse Inspect DApp state via POST + // + // This POST method sends an inspect-state request to the DApp backend, using the body contents as a binary payload for the inspect method. + // + // The response includes the inspection completion status, reports generated by the DApp backend, and the number of previously processed inputs. The optional `error` field contains a sanitized description when the status is `Exception` or `Failed`. For `Exception`, `exception_data` contains the raw CMIO payload supplied by the guest. + // + // The inspect operation is executed on a temporary fork of the machine created upon request arrival, which is discarded afterward. Note that this method is synchronous and not recommended for resource-intensive operations. + // + // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). + // + // Corresponds with POST inspect/{dapp} (the `InspectPost` operationId). + InspectPostWithBodyWithResponse(ctx context.Context, dapp string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*InspectPostResponse, error) } type InspectPostResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *InspectResult + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *InspectResult } -// Status returns HTTPResponse.Status -func (r InspectPostResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r InspectPostResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r InspectPostResponse) GetJSON200() *InspectResult { + return r.JSON200 } -type InspectResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *InspectResult +// GetBody returns the raw response body bytes +func (r InspectPostResponse) GetBody() []byte { + return r.Body } // Status returns HTTPResponse.Status -func (r InspectResponse) Status() string { +func (r InspectPostResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -316,29 +336,38 @@ func (r InspectResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r InspectResponse) StatusCode() int { +func (r InspectPostResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -// InspectPostWithBodyWithResponse request with arbitrary body returning *InspectPostResponse -func (c *ClientWithResponses) InspectPostWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*InspectPostResponse, error) { - rsp, err := c.InspectPostWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r InspectPostResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") } - return ParseInspectPostResponse(rsp) + return "" } -// InspectWithResponse request returning *InspectResponse -func (c *ClientWithResponses) InspectWithResponse(ctx context.Context, payload string, reqEditors ...RequestEditorFn) (*InspectResponse, error) { - rsp, err := c.Inspect(ctx, payload, reqEditors...) +// InspectPostWithBodyWithResponse Inspect DApp state via POST +// +// This POST method sends an inspect-state request to the DApp backend, using the body contents as a binary payload for the inspect method. +// +// The response includes the inspection completion status, reports generated by the DApp backend, and the number of previously processed inputs. The optional `error` field contains a sanitized description when the status is `Exception` or `Failed`. For `Exception`, `exception_data` contains the raw CMIO payload supplied by the guest. +// +// The inspect operation is executed on a temporary fork of the machine created upon request arrival, which is discarded afterward. Note that this method is synchronous and not recommended for resource-intensive operations. +// +// Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). +// +// Corresponds with POST inspect/{dapp} (the `InspectPost` operationId). +func (c *ClientWithResponses) InspectPostWithBodyWithResponse(ctx context.Context, dapp string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*InspectPostResponse, error) { + rsp, err := c.InspectPostWithBody(ctx, dapp, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseInspectResponse(rsp) + return ParseInspectPostResponse(rsp) } // ParseInspectPostResponse parses an HTTP response from a InspectPostWithResponse call @@ -366,29 +395,3 @@ func ParseInspectPostResponse(rsp *http.Response) (*InspectPostResponse, error) return response, nil } - -// ParseInspectResponse parses an HTTP response from a InspectWithResponse call -func ParseInspectResponse(rsp *http.Response) (*InspectResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &InspectResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest InspectResult - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - } - - return response, nil -} diff --git a/pkg/inspectclient/main.go b/pkg/inspectclient/main.go index a816b2ec8..cd3c84b47 100644 --- a/pkg/inspectclient/main.go +++ b/pkg/inspectclient/main.go @@ -5,4 +5,4 @@ // to query the Rollups GraphQL API package inspectclient -//go:generate go run github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen -generate types,client -o generated.go -package inspectclient ../../api/openapi/inspect.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -generate types,client -o generated.go -package inspectclient ../../api/openapi/inspect.yaml diff --git a/pkg/inspectclient/safe.go b/pkg/inspectclient/safe.go new file mode 100644 index 000000000..8712cb4ae --- /dev/null +++ b/pkg/inspectclient/safe.go @@ -0,0 +1,81 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package inspectclient + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/cartesi/rollups-node/internal/httpclient" + "github.com/cartesi/rollups-node/internal/inspectmessages" +) + +// SafeClient renders errors from request construction and all request editors +// with public endpoint details. Constructor options configure the generated +// client before its transport is protected; it cannot be mutated afterward. +type SafeClient struct { + client *Client + publicServer string +} + +// Endpoint returns the public scheme and host for diagnostics. +func (c *SafeClient) Endpoint() string { return c.publicServer } + +// InspectPostWithBody protects diagnostics from both persistent and per-call +// request editors as well as request construction and transport failures. +func (c *SafeClient) InspectPostWithBody( + ctx context.Context, dapp, contentType string, body io.Reader, editors ...RequestEditorFn, +) (*http.Response, error) { + response, err := c.client.InspectPostWithBody(ctx, dapp, contentType, body, editors...) + if err != nil { + return response, httpclient.Diagnostic(ctx, err, "inspect request", c.publicServer) + } + return response, nil +} + +// NewSafeClient confines endpoint credentials to the HTTP transport. The +// generated client builds requests with a public scheme-and-host URL; the +// transport restores the configured base path, query and authentication. +// WithHTTPClient supports ordinary *http.Client values so their transport can +// be protected while preserving client settings. +func NewSafeClient(server string, opts ...ClientOption) (*SafeClient, error) { + parsed, err := httpclient.Validate(server) + if err != nil { + return nil, err + } + publicServer := (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String() + client := &Client{Server: server} + for _, opt := range opts { + if err := opt(client); err != nil { + return nil, httpclient.Diagnostic(context.Background(), err, "configure inspect client", publicServer) + } + } + var underlying *http.Client + if client.Client != nil { + var ok bool + underlying, ok = client.Client.(*http.Client) + if !ok { + return nil, fmt.Errorf("inspect client at %s requires an *http.Client", publicServer) + } + } + publicServer, protected, err := httpclient.NewClient(client.Server, underlying, httpclient.Options{ + AppendOperation: true, + AllowedErrorMessages: []string{ + inspectmessages.MissingApplicationAddress, inspectmessages.MethodNotAllowed, + inspectmessages.PayloadTooLarge, inspectmessages.BadRequest, + inspectmessages.TerminalApplication, inspectmessages.MachineNotReady, + inspectmessages.ForeclosedApplication, inspectmessages.ApplicationNotFound, + inspectmessages.InspectAtCapacity, + }, + }) + if err != nil { + return nil, err + } + client.Server = publicServer + "/" + client.Client = protected + return &SafeClient{client: client, publicServer: publicServer}, nil +} diff --git a/pkg/inspectclient/safe_test.go b/pkg/inspectclient/safe_test.go new file mode 100644 index 000000000..9a8362d18 --- /dev/null +++ b/pkg/inspectclient/safe_test.go @@ -0,0 +1,237 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package inspectclient + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const inspectSecret = "inspect-0123456789abcdef0123456789abcdef" + +type inspectTransport func(*http.Request) (*http.Response, error) + +func (f inspectTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestSafeClientPreservesInspectBasePathQueryAndAuthentication(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + require.Equal(t, "/provider%2Froute//inspect/echo?key="+inspectSecret+"&key=second+", req.RequestURI) + user, password, ok := req.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, inspectSecret, password) + require.Equal(t, "application/octet-stream", req.Header.Get("Content-Type")) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, "input", string(body)) + _, _ = fmt.Fprint(w, inspectSecret) + })) + defer server.Close() + endpoint, err := url.Parse(server.URL + "/provider%2Froute//?key=" + inspectSecret + "&key=second+") + require.NoError(t, err) + endpoint.User = url.UserPassword("operator", inspectSecret) + c, err := NewSafeClient(endpoint.String()) + require.NoError(t, err) + require.Equal(t, server.URL, c.Endpoint()) + resp, err := c.InspectPostWithBody(t.Context(), "echo", "application/octet-stream", strings.NewReader("input")) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, inspectSecret, string(body), "successful inspect results must not be scrubbed") + require.Equal(t, server.URL+"/inspect/echo", resp.Request.URL.String()) +} + +func TestSafeClientPreservesInjectedClientAndRequestEditors(t *testing.T) { + type contextKey struct{} + ctx := context.WithValue(t.Context(), contextKey{}, "request") + original := &http.Client{Transport: inspectTransport(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "request", req.Context().Value(contextKey{})) + require.Equal(t, "/base/inspect/echo?key="+inspectSecret, req.URL.RequestURI()) + require.Equal(t, "Bearer explicit", req.Header.Get("Authorization")) + require.Equal(t, "edited", req.Header.Get("X-Request")) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{}`))}, nil + })} + c, err := NewSafeClient("http://user:"+inspectSecret+"@example.invalid/base?key="+inspectSecret, + WithHTTPClient(original), WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + require.NotContains(t, req.URL.String(), inspectSecret) + req.Header.Set("Authorization", "Bearer explicit") + req.Header.Set("X-Request", "edited") + return nil + })) + require.NoError(t, err) + resp, err := c.InspectPostWithBody(ctx, "echo", "application/octet-stream", strings.NewReader("input")) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.NotSame(t, original, c.client.Client) + require.Nil(t, original.CheckRedirect) +} + +func TestSafeClientFailuresKeepPublicEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "http://example.invalid/"+inspectSecret+"/%bad") + w.WriteHeader(http.StatusTemporaryRedirect) + _, _ = fmt.Fprint(w, inspectSecret) + })) + defer server.Close() + c, err := NewSafeClient(server.URL + "/" + inspectSecret + "?key=" + inspectSecret) + require.NoError(t, err) + resp, err := c.InspectPostWithBody(t.Context(), "echo", "application/octet-stream", strings.NewReader("input")) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + require.Empty(t, resp.Header.Get("Location")) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NotContains(t, string(body), inspectSecret) + require.Contains(t, resp.Request.URL.String(), server.URL) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = c.InspectPostWithBody(ctx, "echo", "application/octet-stream", strings.NewReader("input")) + require.ErrorIs(t, err, context.Canceled) + require.NotContains(t, err.Error(), inspectSecret) + require.Contains(t, err.Error(), server.URL) +} + +type unsupportedDoer struct{} + +func (unsupportedDoer) Do(*http.Request) (*http.Response, error) { + return nil, errors.New("must not be called") +} + +func TestSafeClientRejectsUnsafeConfigurationAndSanitizesEditorErrors(t *testing.T) { + _, err := NewSafeClient("http://example.invalid/"+inspectSecret, WithHTTPClient(unsupportedDoer{})) + require.Error(t, err) + require.NotContains(t, err.Error(), inspectSecret) + require.Contains(t, err.Error(), "http://example.invalid") + _, err = NewSafeClient("http://user:" + inspectSecret + "@example.invalid:" + inspectSecret) + require.Error(t, err) + require.NotContains(t, err.Error(), inspectSecret) + cause := errors.New(inspectSecret) + c, err := NewSafeClient("http://example.invalid/"+inspectSecret, + WithRequestEditorFn(func(context.Context, *http.Request) error { return cause })) + require.NoError(t, err) + _, err = c.InspectPostWithBody(t.Context(), "echo", "application/octet-stream", strings.NewReader("input")) + require.ErrorIs(t, err, cause) + require.NotContains(t, err.Error(), inspectSecret) + require.Contains(t, err.Error(), "http://example.invalid") + c, err = NewSafeClient("http://example.invalid/" + inspectSecret) + require.NoError(t, err) + _, err = c.InspectPostWithBody(t.Context(), "echo", "application/octet-stream", strings.NewReader("input"), + func(context.Context, *http.Request) error { return cause }) + require.ErrorIs(t, err, cause) + require.NotContains(t, err.Error(), inspectSecret) + require.Contains(t, err.Error(), "http://example.invalid") +} + +func TestSafeClientSupportsBaseURLOptionWithoutLosingQuery(t *testing.T) { + original := &http.Client{Transport: inspectTransport(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "http://replacement.invalid/base/inspect/echo?key="+inspectSecret, req.URL.String()) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{}`))}, nil + })} + c, err := NewSafeClient("http://example.invalid/ignored", WithHTTPClient(original), + WithBaseURL("http://replacement.invalid/base?key="+inspectSecret)) + require.NoError(t, err) + require.Equal(t, "http://replacement.invalid", c.Endpoint()) + resp, err := c.InspectPostWithBody(t.Context(), "echo", "application/octet-stream", strings.NewReader("input")) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) +} + +func TestSafeClientZoneEndpointPreservesCredentialsAndOperation(t *testing.T) { + var calls int + original := &http.Client{Transport: inspectTransport(func(req *http.Request) (*http.Response, error) { + calls++ + require.Equal(t, "[::1%lo0]:1", req.URL.Host) + require.Equal(t, "/provider%2Froute/inspect/echo?key="+inspectSecret, req.URL.RequestURI()) + user, password, ok := req.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, inspectSecret, password) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{}`))}, nil + })} + c, err := NewSafeClient("http://operator:"+inspectSecret+"@[::1%25lo0]:1/provider%2Froute?key="+inspectSecret, + WithHTTPClient(original)) + require.NoError(t, err) + require.Equal(t, "http://[::1%25lo0]:1", c.Endpoint()) + response, err := c.InspectPostWithBody(t.Context(), "echo", "application/octet-stream", strings.NewReader("input")) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Equal(t, 1, calls) +} + +func TestSafeClientConstructorOptionErrorCannotExposeRawEndpoint(t *testing.T) { + endpoint := "http://operator:" + inspectSecret + "@example.invalid/private?key=" + inspectSecret + cause := errors.New(endpoint) + _, err := NewSafeClient(endpoint, func(c *Client) error { + require.Equal(t, endpoint, c.Server) + return cause + }) + require.ErrorIs(t, err, cause) + require.Contains(t, err.Error(), "configure inspect client") + require.Contains(t, err.Error(), "http://example.invalid") + require.NotContains(t, err.Error(), inspectSecret) + require.NotContains(t, err.Error(), "operator") +} + +func TestSafeClientPreservesEscapedBasePath(t *testing.T) { + for _, path := range []string{"/base", "/base/", "/base//", "/provider%2F", "/provider%252F", "/provider%20token"} { + t.Run(path, func(t *testing.T) { + expected := strings.TrimSuffix(path, "/") + "/inspect/echo?key=" + inspectSecret + underlying := &http.Client{Transport: inspectTransport(func(req *http.Request) (*http.Response, error) { + require.Equal(t, expected, req.URL.RequestURI()) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{}`))}, nil + })} + c, err := NewSafeClient("http://example.invalid"+path+"?key="+inspectSecret, WithHTTPClient(underlying)) + require.NoError(t, err) + resp, err := c.InspectPostWithBody(t.Context(), "echo", "application/octet-stream", strings.NewReader("input")) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + }) + } +} + +func TestSafeClientFailureDiagnosticUsesCallerDeadline(t *testing.T) { + for _, editorFailure := range []bool{false, true} { + t.Run(fmt.Sprint("editor=", editorFailure), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + var calls int + transport := &http.Client{Transport: inspectTransport(func(req *http.Request) (*http.Response, error) { + calls++ + <-req.Context().Done() + return nil, fmt.Errorf("%s: %w", inspectSecret, req.Context().Err()) + })} + c, err := NewSafeClient("http://operator:"+inspectSecret+"@example.invalid/base?key="+inspectSecret, + WithHTTPClient(transport)) + require.NoError(t, err) + var editors []RequestEditorFn + if editorFailure { + editors = append(editors, func(ctx context.Context, _ *http.Request) error { + <-ctx.Done() + return fmt.Errorf("%s: %w", inspectSecret, ctx.Err()) + }) + } + _, err = c.InspectPostWithBody(ctx, "echo", "application/octet-stream", strings.NewReader("input"), editors...) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Contains(t, err.Error(), "inspect request failed for http://example.invalid: context deadline exceeded") + require.NotContains(t, err.Error(), inspectSecret) + if editorFailure { + require.Zero(t, calls) + } else { + require.Equal(t, 1, calls) + } + }) + } +} diff --git a/pkg/jsonrpc/client/client.go b/pkg/jsonrpc/client/client.go new file mode 100644 index 000000000..6366c4785 --- /dev/null +++ b/pkg/jsonrpc/client/client.go @@ -0,0 +1,142 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package client provides a minimal JSON-RPC 2.0 transport for the rollups +// node API. Callers invoke methods through the generic Call and decode the +// result themselves; the response envelope and parameter shapes are defined +// by the server's OpenRPC specification (served via rpc.discover). +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync/atomic" + + "github.com/cartesi/rollups-node/internal/httpclient" + "github.com/google/uuid" +) + +// Client is a JSON-RPC 2.0 client over HTTP. +type Client struct { + // URL configures the HTTP(S) endpoint and may contain credentials. Transport + // diagnostics use its public scheme and host. Valid HTTP-200 provider errors + // retain their messages and may reflect credentials supplied to that provider. + URL string + // HTTPClient is the underlying HTTP client. + HTTPClient *http.Client + // idCounter is used to generate unique request IDs. + idCounter uint64 +} + +// NewClient creates an HTTP(S) JSON-RPC client. Requests preserve the configured +// path, query and authentication, and do not follow redirects. Call validates the +// endpoint and emits safe transport diagnostics. Unexpected non-200 statuses are +// errors; non-2xx messages are filtered and other 2xx bodies are omitted. +func NewClient(endpoint string) *Client { + return &Client{ + URL: endpoint, + HTTPClient: http.DefaultClient, + } +} + +func (c *Client) nextID() uint64 { + return atomic.AddUint64(&c.idCounter, 1) +} + +// rpcRequest and rpcResponse define the JSON‑RPC request and response formats. +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params"` + ID uint64 `json:"id"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` + ID uint64 `json:"id"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func (e *rpcError) Error() string { + return fmt.Sprintf("RPC Error %d: %s", e.Code, e.Message) +} + +// Call sends a JSON‑RPC request with the given method and parameters, and +// decodes the first response value into result (if non-nil). Trailing values are +// ignored. Valid HTTP-200 RPC error messages are returned without redaction. +// Requests carry a locally generated X-Request-ID. Failures include that same +// identifier after request construction and preserve the original error cause. +// If the request reached the node, its logs contain that identifier. +func (c *Client) Call(ctx context.Context, method string, params any, result any) (err error) { + endpoint, httpClient, err := httpclient.NewClient(c.URL, c.HTTPClient, httpclient.Options{}) + if err != nil { + return fmt.Errorf("configure JSON-RPC client: %w", err) + } + reqObj := rpcRequest{ + JSONRPC: "2.0", + Method: method, + Params: params, + ID: c.nextID(), + } + reqBody, err := json.Marshal(reqObj) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody)) + if err != nil { + return httpclient.Diagnostic(ctx, err, "create JSON-RPC request", endpoint) + } + requestID := uuid.NewString() + defer func() { + if err != nil { + err = fmt.Errorf("%w (request_id=%s)", err, requestID) + } + }() + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", requestID) + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // The shared transport preserves successful payloads. Unexpected 2xx + // bodies therefore remain untrusted diagnostic input and are omitted. + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return fmt.Errorf("HTTP error from %s: %s", endpoint, resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return httpclient.Diagnostic(ctx, err, "read JSON-RPC error response", endpoint) + } + return fmt.Errorf("HTTP error from %s: %s, body: %s", endpoint, resp.Status, string(body)) + } + + var rpcResp rpcResponse + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + return httpclient.Diagnostic(ctx, err, "decode JSON-RPC response", endpoint) + } + if rpcResp.Error != nil { + return rpcResp.Error + } + if result != nil { + if err := json.Unmarshal(rpcResp.Result, result); err != nil { + return httpclient.Diagnostic(ctx, err, "decode JSON-RPC result", endpoint) + } + } + return nil +} diff --git a/pkg/jsonrpc/client/client_test.go b/pkg/jsonrpc/client/client_test.go new file mode 100644 index 000000000..69128d694 --- /dev/null +++ b/pkg/jsonrpc/client/client_test.go @@ -0,0 +1,232 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +const endpointSecret = "credential-0123456789abcdef0123456789abcdef" //nolint:gosec // Synthetic credential for leak regression tests. + +type testTransport func(*http.Request) (*http.Response, error) + +func (f testTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestClientConfinesEndpointAndPreservesRequests(t *testing.T) { + var ids []uint64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/provider%2Frpc//?key="+endpointSecret+"&key=second+", r.RequestURI) + username, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", username) + require.Equal(t, endpointSecret, password) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + var request rpcRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, "cartesi_getApplication", request.Method) + ids = append(ids, request.ID) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"value":%q}}`, request.ID, endpointSecret) + })) + defer server.Close() + endpoint, err := url.Parse(server.URL + "/provider%2Frpc//?key=" + endpointSecret + "&key=second+") + require.NoError(t, err) + endpoint.User = url.UserPassword("operator", endpointSecret) + c := NewClient(endpoint.String()) + require.Equal(t, endpoint.String(), c.URL, "the public configuration API remains compatible") + for range 2 { + var result map[string]string + require.NoError(t, c.Call(t.Context(), "cartesi_getApplication", map[string]string{"application": "echo"}, &result)) + require.Equal(t, endpointSecret, result["value"], "successful results must remain intact") + } + require.Equal(t, []uint64{1, 2}, ids) +} + +func TestClientSupportsInjectedHTTPClientAndEndpointReplacement(t *testing.T) { + c := NewClient("http://example.invalid/initial?key=" + endpointSecret) + c.URL = "http://replacement.invalid/replacement?key=" + endpointSecret + var seen bool + original := &http.Client{Transport: testTransport(func(req *http.Request) (*http.Response, error) { + seen = true + require.Equal(t, c.URL, req.URL.String()) + require.Equal(t, "replacement.invalid", req.Host) + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":1}`))}, nil + })} + c.HTTPClient = original + var result int + require.NoError(t, c.Call(t.Context(), "test", nil, &result)) + require.Equal(t, 1, result) + require.True(t, seen) + require.Same(t, original, c.HTTPClient) + require.Nil(t, original.CheckRedirect, "protecting requests must not mutate the injected client") +} + +func TestClientCanRemoveSameHostCredentialsThroughURL(t *testing.T) { + c := NewClient("http://operator:" + endpointSecret + "@example.invalid/private?key=" + endpointSecret) + c.URL = "http://example.invalid" + c.HTTPClient = &http.Client{Transport: testTransport(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "http://example.invalid", req.URL.String()) + require.Empty(t, req.Header.Get("Authorization")) + return &http.Response{StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":1}`))}, nil + })} + require.NoError(t, c.Call(t.Context(), "test", nil, nil)) +} + +func TestClientFailureOutputDoesNotDependOnCredentials(t *testing.T) { + outputs := make([]string, 0, 2) + for _, secret := range []string{"secret-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "secret-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"} { + c := NewClient("http://operator:" + secret + "@example.invalid/" + secret + "?key=" + secret) + c.HTTPClient = &http.Client{Transport: testTransport(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("provider failed for %s", req.URL) + })} + err := c.Call(t.Context(), "test", nil, nil) + require.Error(t, err) + require.NotContains(t, err.Error(), secret) + require.Contains(t, err.Error(), "http://example.invalid") + outputs = append(outputs, withoutClientRequestID(t, err.Error())) + } + require.Equal(t, outputs[0], outputs[1]) +} + +func TestClientFailuresHavePublicEndpointAndNoCredentials(t *testing.T) { + for _, status := range []int{http.StatusAccepted, http.StatusBadRequest, http.StatusInternalServerError, http.StatusTemporaryRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "http://example.invalid/"+endpointSecret+"/%bad") + w.WriteHeader(status) + _, _ = fmt.Fprint(w, endpointSecret) + })) + defer server.Close() + c := NewClient(server.URL + "/" + endpointSecret + "?key=" + endpointSecret) + err := c.Call(t.Context(), "test", nil, nil) + require.Error(t, err) + require.NotContains(t, err.Error(), endpointSecret) + require.Contains(t, err.Error(), server.URL) + }) + } + t.Run("invalid endpoint", func(t *testing.T) { + c := NewClient("http://operator:" + endpointSecret + "@example.invalid:" + endpointSecret) + err := c.Call(t.Context(), "test", nil, nil) + require.Error(t, err) + require.NotContains(t, err.Error(), endpointSecret) + }) + t.Run("malformed result", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":1,"result":1234567890123456789012345678901234567890}`) + })) + defer server.Close() + c := NewClient(server.URL + "/?key=1234567890123456789012345678901234567890") + var result int64 + err := c.Call(t.Context(), "test", nil, &result) + require.Error(t, err) + require.NotContains(t, err.Error(), "1234567890123456789012345678901234567890") + require.Contains(t, err.Error(), server.URL) + var typeError *json.UnmarshalTypeError + require.ErrorAs(t, err, &typeError) + }) +} + +func TestClientProtocolUpgradeDoesNotExposeStream(t *testing.T) { + observed := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + observed <- r.RequestURI + user, password, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, endpointSecret, password) + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer conn.Close() + _, _ = fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n%s %s", + r.RequestURI, r.Header.Get("Authorization")) + })) + defer server.Close() + endpoint, err := url.Parse(server.URL + "/" + endpointSecret + "?key=" + endpointSecret) + require.NoError(t, err) + endpoint.User = url.UserPassword("operator", endpointSecret) + err = NewClient(endpoint.String()).Call(t.Context(), "test", nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "101 Switching Protocols") + require.Contains(t, err.Error(), server.URL) + require.NotContains(t, err.Error(), endpointSecret) + require.NotContains(t, err.Error(), "Basic ") + require.Equal(t, "/"+endpointSecret+"?key="+endpointSecret, <-observed) +} + +func TestClientCancellationAndRPCErrorRemainClassifiable(t *testing.T) { + t.Run("cancellation", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + c := NewClient("http://127.0.0.1:1/" + endpointSecret) + err := c.Call(ctx, "test", nil, nil) + require.ErrorIs(t, err, context.Canceled) + require.NotContains(t, err.Error(), endpointSecret) + }) + t.Run("RPC error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"block range","data":"0x1234"}}`) + })) + defer server.Close() + c := NewClient(server.URL + "/?key=3") + err := c.Call(t.Context(), "test", nil, nil) + var rpcErr *rpcError + require.True(t, errors.As(err, &rpcErr)) + require.Equal(t, -32005, rpcErr.Code) + require.Equal(t, "block range", rpcErr.Message) + require.Equal(t, "0x1234", rpcErr.Data) + }) +} + +func TestClientConcurrentRequestsHaveDistinctIDs(t *testing.T) { + const requests = 16 + var mu sync.Mutex + ids := make(map[uint64]bool) + requestIDs := make(map[string]bool) + c := NewClient("http://example.invalid/rpc?key=" + endpointSecret) + c.HTTPClient = &http.Client{Transport: testTransport(func(req *http.Request) (*http.Response, error) { + var request rpcRequest + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return nil, err + } + requestID := req.Header.Get("X-Request-ID") + if _, err := uuid.Parse(requestID); err != nil { + return nil, fmt.Errorf("invalid request ID: %w", err) + } + mu.Lock() + ids[request.ID] = true + requestIDs[requestID] = true + mu.Unlock() + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader( + fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":1}`, request.ID)))}, nil + })} + var wg sync.WaitGroup + failures := make(chan error, requests) + for range requests { + wg.Go(func() { failures <- c.Call(t.Context(), "test", nil, nil) }) + } + wg.Wait() + close(failures) + for err := range failures { + require.NoError(t, err) + } + require.Len(t, ids, requests) + require.Len(t, requestIDs, requests) +} diff --git a/pkg/jsonrpc/client/request_id_test.go b/pkg/jsonrpc/client/request_id_test.go new file mode 100644 index 000000000..dea40c17e --- /dev/null +++ b/pkg/jsonrpc/client/request_id_test.go @@ -0,0 +1,215 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/service" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +var clientRequestIDSuffix = regexp.MustCompile(` \(request_id=([^()]+)\)$`) + +func withoutClientRequestID(t *testing.T, message string) string { + t.Helper() + match := clientRequestIDSuffix.FindStringSubmatch(message) + require.Len(t, match, 2, "failure must include the client-generated request ID") + id, err := uuid.Parse(match[1]) + require.NoError(t, err) + require.Equal(t, id.String(), match[1]) + return strings.TrimSuffix(message, match[0]) +} + +func TestClientFailureRequestIDMatchesNodeAndRejectsResponseID(t *testing.T) { + for _, hostile := range []bool{false, true} { + t.Run(fmt.Sprint("hostile=", hostile), func(t *testing.T) { + observed := make(chan string, 1) + server := httptest.NewServer(service.RequestIDMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := service.RequestIDFromContext(r.Context()) + require.Equal(t, r.Header.Get("X-Request-ID"), id) + _, err := uuid.Parse(id) + require.NoError(t, err) + observed <- id + if hostile { + w.Header().Set("X-Request-ID", endpointSecret) + http.Error(w, "service at capacity (request_id="+endpointSecret+")", http.StatusServiceUnavailable) + return + } + service.WriteInternalError(r.Context(), w, slog.New(slog.NewTextHandler(io.Discard, nil)), errors.New(endpointSecret)) + }))) + defer server.Close() + err := NewClient(server.URL+"/rpc?key="+endpointSecret).Call(t.Context(), "test", nil, nil) + require.Error(t, err) + select { + case id := <-observed: + require.Contains(t, err.Error(), " (request_id="+id+")") + default: + t.Fatal("the node did not receive the request ID") + } + require.NotContains(t, err.Error(), endpointSecret) + require.Contains(t, err.Error(), server.URL) + withoutClientRequestID(t, err.Error()) + }) + } +} + +func TestClientZoneEndpointReachesTransportWithFullCredentials(t *testing.T) { + c := NewClient("http://operator:" + endpointSecret + "@[::1%25lo0]:1/provider%2Frpc?key=" + endpointSecret) + var calls int + c.HTTPClient = &http.Client{Transport: testTransport(func(req *http.Request) (*http.Response, error) { + calls++ + require.Equal(t, "[::1%lo0]:1", req.URL.Host) + require.Equal(t, "/provider%2Frpc?key="+endpointSecret, req.URL.RequestURI()) + user, password, ok := req.BasicAuth() + require.True(t, ok) + require.Equal(t, "operator", user) + require.Equal(t, endpointSecret, password) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":1}`))}, nil + })} + var result int + require.NoError(t, c.Call(t.Context(), "test", nil, &result)) + require.Equal(t, 1, calls) + require.Equal(t, 1, result) +} + +func TestClientEnvelopeDecodeErrorsHaveNoRawEndpoint(t *testing.T) { + for _, body := range []string{ + `{"jsonrpc":1234567890123456789012345678901234567890,"id":1,"result":0}`, + `{"jsonrpc":"2.0","id":1234567890123456789012345678901234567890,"result":0}`, + "", + } { + t.Run(body, func(t *testing.T) { + c := NewClient("http://operator:" + endpointSecret + "@example.invalid/private?key=" + endpointSecret) + c.HTTPClient = &http.Client{Transport: testTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + err := c.Call(t.Context(), "test", nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "decode JSON-RPC response") + require.Contains(t, err.Error(), "http://example.invalid") + require.NotContains(t, err.Error(), endpointSecret) + require.NotContains(t, err.Error(), "1234567890123456789012345678901234567890") + if body == "" { + require.Contains(t, err.Error(), "empty response") + require.ErrorIs(t, err, io.EOF) + } else { + var cause *json.UnmarshalTypeError + require.ErrorAs(t, err, &cause) + } + }) + } +} + +func TestClientLocalFailuresHaveNoRequestIDOrDispatch(t *testing.T) { + for _, test := range []struct { + name, endpoint string + params any + context func() context.Context + }{ + {name: "configuration", endpoint: "http://operator:" + endpointSecret + "@example.invalid:%bad"}, + {name: "marshal", params: make(chan int)}, + {name: "request construction", context: func() context.Context { return nil }}, + } { + t.Run(test.name, func(t *testing.T) { + endpoint := test.endpoint + if endpoint == "" { + endpoint = "http://operator:" + endpointSecret + "@example.invalid/private?key=" + endpointSecret + } + c := NewClient(endpoint) + var calls int + c.HTTPClient = &http.Client{Transport: testTransport(func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("unexpected request") + })} + ctx := t.Context() + if test.context != nil { + ctx = test.context() + } + err := c.Call(ctx, "test", test.params, nil) + require.Error(t, err) + require.Zero(t, calls) + require.NotContains(t, err.Error(), "request_id") + require.NotContains(t, err.Error(), endpointSecret) + if test.name == "marshal" { + var cause *json.UnsupportedTypeError + require.ErrorAs(t, err, &cause) + } + }) + } +} + +type deadlineResponseBody struct{ ctx context.Context } + +func (b deadlineResponseBody) Read([]byte) (int, error) { + <-b.ctx.Done() + return 0, fmt.Errorf("%s: %w", endpointSecret, b.ctx.Err()) +} + +func (deadlineResponseBody) Close() error { return nil } + +type deadlineResult struct{ ctx context.Context } + +func (r deadlineResult) UnmarshalJSON([]byte) error { + <-r.ctx.Done() + return fmt.Errorf("%s: %w", endpointSecret, r.ctx.Err()) +} + +func TestClientDecodeDiagnosticsUseCallerDeadline(t *testing.T) { + for _, decodeResult := range []bool{false, true} { + t.Run(fmt.Sprint("result=", decodeResult), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + c := NewClient("http://operator:" + endpointSecret + "@example.invalid/private?key=" + endpointSecret) + var calls int + c.HTTPClient = &http.Client{Transport: testTransport(func(req *http.Request) (*http.Response, error) { + calls++ + var body io.ReadCloser = deadlineResponseBody{ctx: req.Context()} + if decodeResult { + body = io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":1}`)) + } + return &http.Response{StatusCode: http.StatusOK, Body: body}, nil + })} + var result any + if decodeResult { + result = &deadlineResult{ctx: ctx} + } + err := c.Call(ctx, "test", nil, result) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Equal(t, 1, calls) + operation := "decode JSON-RPC response" + if decodeResult { + operation = "decode JSON-RPC result" + } + require.Contains(t, err.Error(), operation+" failed for http://example.invalid: context deadline exceeded") + require.NotContains(t, err.Error(), endpointSecret) + withoutClientRequestID(t, err.Error()) + }) + } +} + +func TestClientRPCFailuresDoNotRetainInspectMessages(t *testing.T) { + c := NewClient("http://example.invalid/rpc?key=" + endpointSecret) + c.HTTPClient = &http.Client{Transport: testTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, + Body: io.NopCloser(strings.NewReader("Machine not ready\n"))}, nil + })} + err := c.Call(t.Context(), "test", nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "503 Service Unavailable") + require.Contains(t, err.Error(), "HTTP request failed") + require.NotContains(t, err.Error(), "Machine not ready") +} diff --git a/pkg/machine/backend.go b/pkg/machine/backend.go new file mode 100644 index 000000000..fbc12797f --- /dev/null +++ b/pkg/machine/backend.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "encoding/json" + "time" +) + +type BreakReason int32 + +const ( + Failed BreakReason = 0x0 + Halted BreakReason = 0x1 + YieldedManually BreakReason = 0x2 + YieldedAutomatically BreakReason = 0x3 + YieldedSoftly BreakReason = 0x4 + ReachedTargetMcycle BreakReason = 0x5 + McycleOverflow BreakReason = 0x8 +) + +type HashCollectorState struct { + MCycleSamplingPeriod uint64 + MCyclePhase uint64 + Log2BundleMCycleCount int32 + Hashes []Hash + PartialBundle json.RawMessage + ConsoleIOError string +} + +// MemoryProof is the complete Merkle proof returned by the emulator for an +// aligned span of machine memory. TargetAddress and Log2TargetSize identify +// the span. The root metadata lets callers verify that the proof was produced +// for the requested machine state. +type MemoryProof struct { + Log2RootSize int32 + Log2TargetSize int32 + RootHash Hash + Siblings []Hash + TargetAddress uint64 + TargetHash Hash +} + +// Identity contains observations from the loaded machine and its server. +type Identity struct { + MarchID uint64 + UarchStateHash Hash + UarchPristineStateHash Hash +} + +// This Backend interface covers the methods used from the emulator / remote machine server. +// It is to abstract the emulator package and allow for easier testing and mocking in unit tests. +type Backend interface { + Load(dir string, runtimeConfig string, timeout time.Duration) error + HashFunction(timeout time.Duration) (string, error) + MachineIdentity(timeout time.Duration) (Identity, error) + Store(directory string, timeout time.Duration) error + + Run(mcycleEnd uint64, timeout time.Duration) (BreakReason, error) + // RunAndCollectRootHashes may advance the backend before returning an error. + // Callers must discard the backend after an error instead of retrying it with + // the previous HashCollectorState. + RunAndCollectRootHashes(mcycleEnd uint64, state *HashCollectorState, timeout time.Duration, + ) (reason BreakReason, err error) + + IsAtManualYield(timeout time.Duration) (bool, error) + ReadMCycle(timeout time.Duration) (uint64, error) + + SendCmioResponse(reason uint16, data []byte, revertRootHash *Hash, timeout time.Duration) error + ReceiveCmioRequest(timeout time.Duration) (cmd uint8, reason uint16, data []byte, err error) + + WriteMemory(address uint64, data []byte, timeout time.Duration) error + ReadMemory(address uint64, length uint64, timeout time.Duration) ([]byte, error) + + GetRootHash(timeout time.Duration) (Hash, error) + GetProof(address uint64, log2TargetSize, log2RootSize int32, timeout time.Duration) (MemoryProof, error) + + Delete() + ForkServer(timeout time.Duration) (Backend, string, uint32, error) + ShutdownServer(timeout time.Duration) error + + NewMachineRuntimeConfig() (string, error) + CmioRxBufferSize() uint64 +} + +// BackendFactory is a function type that creates a new server instance. +// It takes an address and timeout, and returns a machine Backend, bound address, process ID, and error. +type BackendFactory func(address string, timeout time.Duration) (Backend, string, uint32, error) diff --git a/pkg/machine/backend_test.go b/pkg/machine/backend_test.go new file mode 100644 index 000000000..dd0644b36 --- /dev/null +++ b/pkg/machine/backend_test.go @@ -0,0 +1,258 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "crypto/rand" + "encoding/binary" + "time" + + "github.com/stretchr/testify/mock" +) + +const testMachineAddress = "127.0.0.1:12345" + +// MockBackend is a testify mock implementation of the Backend interface +type MockBackend struct { + mock.Mock +} + +func (m *MockBackend) Load(dir string, runtimeConfig string, timeout time.Duration) error { + args := m.Called(dir, runtimeConfig, timeout) + return args.Error(0) +} + +func (m *MockBackend) HashFunction(timeout time.Duration) (string, error) { + args := m.Called(timeout) + return args.String(0), args.Error(1) +} + +func (m *MockBackend) MachineIdentity(timeout time.Duration) (Identity, error) { + args := m.Called(timeout) + return args.Get(0).(Identity), args.Error(1) +} + +func (m *MockBackend) Store(directory string, timeout time.Duration) error { + args := m.Called(directory, timeout) + return args.Error(0) +} + +func (m *MockBackend) Run(mcycleEnd uint64, timeout time.Duration) (BreakReason, error) { + args := m.Called(mcycleEnd, timeout) + return args.Get(0).(BreakReason), args.Error(1) +} + +func (m *MockBackend) IsAtManualYield(timeout time.Duration) (bool, error) { + args := m.Called(timeout) + return args.Bool(0), args.Error(1) +} + +func (m *MockBackend) ReadMCycle(timeout time.Duration) (uint64, error) { + args := m.Called(timeout) + return args.Get(0).(uint64), args.Error(1) +} + +func (m *MockBackend) SendCmioResponse(reason uint16, data []byte, revertRootHash *Hash, timeout time.Duration) error { + args := mock.Arguments{} + switch reason { + case uint16(AdvanceStateRequest): + // match hash by value on advance + args = m.Called(reason, data, *revertRootHash, timeout) + case uint16(InspectStateRequest): + // nil on inspect + args = m.Called(reason, data, nil, timeout) + } + return args.Error(0) +} + +func (m *MockBackend) ReceiveCmioRequest(timeout time.Duration) (uint8, uint16, []byte, error) { + args := m.Called(timeout) + return args.Get(0).(uint8), args.Get(1).(uint16), args.Get(2).([]byte), args.Error(3) +} + +func (m *MockBackend) GetRootHash(timeout time.Duration) (Hash, error) { + args := m.Called(timeout) + return args.Get(0).(Hash), args.Error(1) +} + +func (m *MockBackend) WriteMemory(address uint64, data []byte, timeout time.Duration) error { + args := m.Called(address, data, timeout) + return args.Error(0) +} + +func (m *MockBackend) ReadMemory(address uint64, length uint64, timeout time.Duration) ([]byte, error) { + args := m.Called(address, length, timeout) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]byte), args.Error(1) +} + +func (m *MockBackend) Delete() { + m.Called() +} + +func (m *MockBackend) ForkServer(timeout time.Duration) (Backend, string, uint32, error) { + args := m.Called(timeout) + return args.Get(0).(Backend), args.String(1), args.Get(2).(uint32), args.Error(3) +} + +func (m *MockBackend) ShutdownServer(timeout time.Duration) error { + args := m.Called(timeout) + return args.Error(0) +} + +func (m *MockBackend) NewMachineRuntimeConfig() (string, error) { + args := m.Called() + return args.String(0), args.Error(1) +} + +func (m *MockBackend) CmioRxBufferSize() uint64 { + args := m.Called() + return args.Get(0).(uint64) +} + +func (m *MockBackend) RunAndCollectRootHashes( + mcycleEnd uint64, + state *HashCollectorState, + timeout time.Duration, +) (reason BreakReason, err error) { + args := m.Called(mcycleEnd, state, timeout) + return args.Get(0).(BreakReason), args.Error(1) +} + +func (m *MockBackend) GetProof( + address uint64, + log2TargetSize, + log2RootSize int32, + timeout time.Duration, +) (MemoryProof, error) { + args := m.Called(address, log2TargetSize, log2RootSize, timeout) + return args.Get(0).(MemoryProof), args.Error(1) +} + +// Helper functions for setting up common mock scenarios + +func randomFakeHash() Hash { + hash := Hash{} + _, _ = rand.Read(hash[:]) + return hash +} + +// SetupAccepted configures the mock for a successful advance/inspect operation +func (m *MockBackend) SetupAccepted(reqType requestType) { + m.SetupRequestYield() + hash := randomFakeHash() + m.On("SendCmioResponse", uint16(reqType), mock.Anything, mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil) + m.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + m.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + if reqType == AdvanceStateRequest { + m.SetupManualYield(ManualYieldReasonAccepted, HashSize) + } else { + m.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(ManualYieldReasonAccepted), hash[:], nil) + } + m.On("CmioRxBufferSize").Return(uint64(1024)) +} + +// SetupRejected configures the mock for a rejected advance/inspect operation +func (m *MockBackend) SetupRejected(reqType requestType) { + m.SetupRequestYield() + hash := randomFakeHash() + m.On("SendCmioResponse", uint16(reqType), mock.Anything, mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil) + m.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + m.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + m.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(ManualYieldReasonRejected), hash[:], nil) + m.On("CmioRxBufferSize").Return(uint64(1024)) + if reqType == AdvanceStateRequest { + m.SetupManualYield(ManualYieldReasonRejected, HashSize) + } +} + +// SetupException configures the mock for an exception during advance/inspect +func (m *MockBackend) SetupException(reqType requestType) { + m.SetupRequestYield() + m.On("SendCmioResponse", uint16(reqType), mock.Anything, mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil) + m.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + m.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + m.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(ManualYieldReasonException), []byte("exception data"), nil) + m.On("CmioRxBufferSize").Return(uint64(1024)) + if reqType == AdvanceStateRequest { + m.SetupManualYield(ManualYieldReasonException, uint32(len("exception data"))) + } +} + +// SetupRequestYield registers the accepted boundary before any completion yield. +func (m *MockBackend) SetupRequestYield() *mock.Call { + m.On("IsAtManualYield", mock.AnythingOfType("time.Duration")).Return(true, nil).Once() + tohost := htifDeviceYield< 1 { + next := make([]Hash, len(level)/2) + for index := range next { + next[index] = crypto.Keccak256Hash(level[2*index][:], level[2*index+1][:]) + } + level = next + } + return level[0] +} + +// collectTestEntries follows sample pushes and bundle completion for fake backends. +func collectTestEntries(state *HashCollectorState, delta uint64, fixed bool, periodic, final Hash) { + period := state.MCycleSamplingPeriod + samples := delta / period + if fixed { + samples = 0 + if delta != 0 { + samples = (delta - 1) / period + } + } + leaves := make([]Hash, samples) + for index := range leaves { + leaves[index] = periodic + } + if fixed { + leaves = append(leaves, final) + } + width := 1 << state.Log2BundleMCycleCount + state.Hashes = nil + state.PartialBundle = nil + state.MCyclePhase = delta % period + for start := 0; start < len(leaves); start += width { + remaining := len(leaves) - start + if remaining < width && !fixed { + state.PartialBundle = []byte(fmt.Sprintf(`{"leaf_count":%d}`, remaining)) + break + } + bundle := make([]Hash, width) + for index := range bundle { + bundle[index] = final + if index < remaining { + bundle[index] = leaves[start+index] + } + } + state.Hashes = append(state.Hashes, denseTestEntry(bundle)) + } + if fixed && width > 1 { + bundle := make([]Hash, width) + for index := range bundle { + bundle[index] = final + } + state.Hashes = append(state.Hashes, denseTestEntry(bundle)) + } +} diff --git a/pkg/machine/computation_hash.go b/pkg/machine/computation_hash.go new file mode 100644 index 000000000..a3f969943 --- /dev/null +++ b/pkg/machine/computation_hash.go @@ -0,0 +1,89 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import "github.com/cartesi/rollups-node/internal/model" + +const ( + // Log2MaxUarchCyclesPerMCycle is the emulator's + // CM_ROLLUP_LOG2_MAX_UARCH_CYCLES_PER_MCYCLE; the Go name omits the C + // ROLLUP_ prefix. The DAVE 3.0-alpha node calls the same dimension + // LOG2_UARCH_SPAN_TO_BARCH. + Log2MaxUarchCyclesPerMCycle uint64 = model.Log2MaxUarchCyclesPerMCycle + + // Log2MaxMCyclesPerAdvanceState is emulator 0.21's + // CM_ROLLUP_LOG2_MAX_MCYCLES_PER_ADVANCE_STATE; the Go name omits the C + // ROLLUP_ prefix. The DAVE 3.0-alpha node calls the same dimension + // LOG2_BARCH_SPAN_TO_INPUT. Keep it aliased to the model definition so + // the machine endpoint and computation-hash dimensions cannot diverge. + Log2MaxMCyclesPerAdvanceState uint64 = model.Log2MaxExecutionCycles + + // Log2MaxAdvanceStatesPerEpoch is emulator 0.21's + // CM_ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH; the Go name omits the C + // ROLLUP_ prefix. The DAVE 3.0-alpha node calls the same dimension + // LOG2_INPUT_SPAN_TO_EPOCH. + Log2MaxAdvanceStatesPerEpoch uint64 = model.Log2MaxAdvanceStatesPerEpoch + + // Log2UarchCycleComputationHashPeriod is the computation-hash sampling + // period expressed in uarch cycles. The DAVE 3.0-alpha node calls it + // LOG2_STRIDE; ArbitrationConstants.log2step(0) selects it. + Log2UarchCycleComputationHashPeriod uint64 = model.Log2RootTournamentStride + + // Log2UarchCyclesPerAdvanceState is the full advance-state window expressed + // in uarch cycles. The DAVE 3.0-alpha node calls it + // LOG2_UARCH_SPAN_TO_INPUT. + Log2UarchCyclesPerAdvanceState uint64 = Log2MaxMCyclesPerAdvanceState + Log2MaxUarchCyclesPerMCycle // 68 + + // MaxUarchCycle is emulator 0.21's UARCH_CYCLE_MAX. The DAVE 3.0-alpha + // node calls the same maximum coordinate UARCH_SPAN_TO_BARCH. + MaxUarchCycle uint64 = (1 << Log2MaxUarchCyclesPerMCycle) - 1 + + // MaxMCycleDeltaPerAdvanceState is the largest endpoint delta in one + // advance-state execution window, not its 2^48-cycle cardinality. The DAVE + // 3.0-alpha node calls it BARCH_SPAN_TO_INPUT. + MaxMCycleDeltaPerAdvanceState uint64 = model.MaxExecutionCycleSpan + + // MaxAdvanceStateIndexPerEpoch is the largest zero-based input-slot index. + // The DAVE 3.0-alpha node calls it INPUT_SPAN_TO_EPOCH. + MaxAdvanceStateIndexPerEpoch uint64 = model.MaxAdvanceStatesPerEpoch - 1 + + // Log2MCycleComputationHashPeriod is the value passed to the emulator's + // log2_mcycle_period CLI parameter, expressed in mcycles. + Log2MCycleComputationHashPeriod uint64 = model.Log2MCycleComputationHashPeriod + + // MCycleComputationHashPeriod is the number of mcycles between state-root + // samples. The DAVE 3.0-alpha node calls it BIG_STEPS_IN_STRIDE. + MCycleComputationHashPeriod uint64 = 1 << Log2MCycleComputationHashPeriod + + // Log2StateHashesPerEntry is the sample-subtree height of each stored entry. + Log2StateHashesPerEntry uint64 = model.Log2StateHashesPerEntry + + // Log2InputEntryCapacity is the emulator's log2_entries_per_input, counting + // entry roots rather than individual state-hash samples. + Log2InputEntryCapacity uint64 = model.Log2InputHashCollectionCapacity + + // InputEntryCapacity is the number of computation-hash entries reserved + // for one input. + InputEntryCapacity uint64 = model.InputHashCollectionCapacity + + // Log2EpochComputationHashLeafCount is the emulator's + // log2_epoch_computation_hash_leaf_count, counting individual samples. + Log2EpochComputationHashLeafCount uint64 = model.Log2EpochComputationHashLeafCount + + // EpochComputationHashLeafCount is the number of leaves in the root epoch + // computation hash. The DAVE 3.0-alpha node calls it + // STRIDE_COUNT_IN_EPOCH. + EpochComputationHashLeafCount uint64 = 1 << Log2EpochComputationHashLeafCount + + // MaxAdvanceStatesPerEpoch is the number of input slots reserved by the + // emulator computation-hash tree. DAVE derives it as + // INPUT_SPAN_TO_EPOCH + 1. + MaxAdvanceStatesPerEpoch uint64 = model.MaxAdvanceStatesPerEpoch + + // Each collection call spans at most 2^8 sampling periods. It can also + // collect a fixed-point sample at its endpoint. This bounds per-call + // hashing work but does not guarantee completion within its deadline. + log2ComputationHashEntriesPerChunk uint64 = 8 + mcycleComputationHashChunkSize uint64 = MCycleComputationHashPeriod << log2ComputationHashEntriesPerChunk +) diff --git a/pkg/machine/computation_hash_test.go b/pkg/machine/computation_hash_test.go new file mode 100644 index 000000000..9a889d957 --- /dev/null +++ b/pkg/machine/computation_hash_test.go @@ -0,0 +1,47 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "testing" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +func TestComputationHashDimensions(t *testing.T) { + require.Equal(t, uint64(20), Log2MaxUarchCyclesPerMCycle) + require.Equal(t, uint64(48), Log2MaxMCyclesPerAdvanceState) + require.Equal(t, uint64(24), Log2MaxAdvanceStatesPerEpoch) + require.Equal(t, uint64(38), Log2UarchCycleComputationHashPeriod) + require.Equal(t, uint64(68), Log2UarchCyclesPerAdvanceState) + require.Equal(t, uint64(1)<<20-1, MaxUarchCycle) + require.Equal(t, uint64(1)<<48-1, MaxMCycleDeltaPerAdvanceState) + require.Equal(t, uint64(1)<<24-1, MaxAdvanceStateIndexPerEpoch) + + require.Equal(t, uint64(18), Log2MCycleComputationHashPeriod) + require.Equal(t, uint64(1)<<18, MCycleComputationHashPeriod) + require.Equal(t, uint64(24), Log2InputEntryCapacity) + require.Equal(t, uint64(1)<<24, InputEntryCapacity) + require.Equal(t, uint64(54), Log2EpochComputationHashLeafCount) + require.Equal(t, uint64(1)<<54, EpochComputationHashLeafCount) + require.Equal(t, uint64(1)<<24, MaxAdvanceStatesPerEpoch) + require.Equal( + t, + model.Log2InputComputationHashLeafCount, + Log2MaxMCyclesPerAdvanceState-Log2MCycleComputationHashPeriod, + "the execution window and sampling period derive the sample count", + ) + require.Equal(t, model.Log2InputHashCollectionCapacity, model.Log2InputComputationHashLeafCount-Log2StateHashesPerEntry) + require.Equal(t, uint64(6), Log2StateHashesPerEntry) +} + +func TestComputationHashCollectionChunkBoundsSamplesPerCall(t *testing.T) { + require.Equal( + t, + MCycleComputationHashPeriod<<8, + mcycleComputationHashChunkSize, + "a collection call spans at most 2^8 sampling periods, plus a possible fixed-point sample", + ) +} diff --git a/pkg/machine/doc.go b/pkg/machine/doc.go new file mode 100644 index 000000000..079361b9e --- /dev/null +++ b/pkg/machine/doc.go @@ -0,0 +1,29 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +// Package machine exposes the rollups-oriented operations of a Cartesi +// machine and translates emulator stops into a small Go contract. +// +// A request that reaches a deterministic guest completion returns a response +// value with CompletionStatusAccepted, CompletionStatusRejected, +// CompletionStatusException, CompletionStatusHalted, +// CompletionStatusOverflow, or CompletionStatusUnexpectedYield. Advance can +// also return CompletionStatusInvalidOutputsRoot when an accepted yield +// declares a root length other than 32 bytes. Anything that +// prevents completion—including deadlines, local resource limits, backend +// failures, and configured cycle exhaustion—returns an error. Advance then +// returns no response; Inspect may return partial reports with +// CompletionStatusUnknown. In short: terminal +// guest outcomes travel as values; incomplete execution travels as an error. +// Callers decide how a completed outcome affects canonical application state. +// +// PRT advances return canonical collections, as defined in model/input_hash_collection.go. +// PaddingRepetitions counts entries. Machine is the sole canonicalizer; +// downstream layers preserve this representation without repair. +// +// A configured cycle span of zero means that no operator cap shortens the +// emulator's fixed window. The resolved endpoint mirrors emulator 0.21's +// saturating imcyclemax arithmetic. ErrMcycleOverflow preserves the distinct +// emulator-reported fact that imcyclemax, rather than a node target, stopped the +// machine. +package machine diff --git a/pkg/machine/emulator_identity_test.go b/pkg/machine/emulator_identity_test.go new file mode 100644 index 000000000..ef789759a --- /dev/null +++ b/pkg/machine/emulator_identity_test.go @@ -0,0 +1,28 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/require" +) + +const ( + testRequiredMarchID = uint64(22) + testOtherMarchID = uint64(21) + identityTimeout = 2 * time.Second +) + +func TestLinkedEmulatorIdentityMatchesPins(t *testing.T) { + identity, err := LinkedEmulatorIdentity() + require.NoError(t, err) + require.Equal(t, testRequiredMarchID, identity.HeaderMarchID) + require.Equal(t, testRequiredMarchID, identity.RuntimeMarchID) + require.Equal(t, model.UarchPristineStateHash(), identity.UarchPristineStateHash) + require.NoError(t, ValidateEmulatorMarchID()) + require.NoError(t, ValidateEmulatorComputationHashLimits()) +} diff --git a/pkg/machine/hash_function_test.go b/pkg/machine/hash_function_test.go new file mode 100644 index 000000000..9089a2ccf --- /dev/null +++ b/pkg/machine/hash_function_test.go @@ -0,0 +1,264 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/emulator" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + testKeccakHashFunction = "keccak256" + testShaHashFunction = "sha256" +) + +func TestLibCartesiBackendHashFunction(t *testing.T) { + const timeout = 2 * time.Second + readErr := errors.New("initial configuration unavailable") + timeoutErr := errors.New("timeout setting failed") + for _, tc := range []struct { + name string + config string + value string + readErr error + timeoutErr error + decodeErr bool + }{ + {name: testKeccakHashFunction, config: `{"hash_tree":{"hash_function":"keccak256"}}`, value: testKeccakHashFunction}, + { + name: testShaHashFunction, + config: `{"hash_tree":{"hash_function":"sha256"},"hash_function":"keccak256"}`, + value: testShaHashFunction, + }, + {name: "missing field", config: `{}`}, + {name: "malformed JSON", config: `{`, decodeErr: true}, + {name: "wrong field type", config: `{"hash_tree":{"hash_function":1}}`, decodeErr: true}, + {name: "read error", readErr: readErr}, + {name: "timeout error", timeoutErr: timeoutErr}, + } { + t.Run(tc.name, func(t *testing.T) { + remote := new(MockRemoteMachine) + timeoutCall := remote.On("SetTimeout", timeout.Milliseconds()).Return(tc.timeoutErr).Once() + if tc.timeoutErr == nil { + remote.On("GetInitialConfig").Return(tc.config, tc.readErr).Once().NotBefore(timeoutCall) + } + backend := &LibCartesiBackend{inner: remote} + value, err := backend.HashFunction(timeout) + switch { + case tc.timeoutErr != nil: + require.ErrorIs(t, err, timeoutErr) + remote.AssertNotCalled(t, "GetInitialConfig") + case tc.readErr != nil: + require.ErrorIs(t, err, readErr) + case tc.decodeErr: + require.ErrorContains(t, err, "decode initial configuration") + default: + require.NoError(t, err) + } + require.Equal(t, tc.value, value) + remote.AssertExpectations(t) + }) + } +} + +func TestLoadChecksHashFunctionBeforeYield(t *testing.T) { + readErr := errors.New("hash function read failed") + shutdownErr := errors.New("shutdown failed") + for _, tc := range []struct { + name string + algorithm string + readErr error + shutdownErr error + }{ + {name: testKeccakHashFunction, algorithm: testKeccakHashFunction}, + {name: testShaHashFunction, algorithm: testShaHashFunction}, + {name: "unknown", algorithm: "other"}, + {name: "missing"}, + {name: "read error", algorithm: testKeccakHashFunction, readErr: readErr}, + {name: "sha256 cleanup error", algorithm: testShaHashFunction, shutdownErr: shutdownErr}, + {name: "read and cleanup errors", readErr: readErr, shutdownErr: shutdownErr}, + } { + t.Run(tc.name, func(t *testing.T) { + backend := NewMockBackend() + config := DefaultConfig("machine-image") + runtimeConfig := `{}` + config.RuntimeConfig = &runtimeConfig + config.BackendFactoryFn = func(_ string, _ time.Duration) (Backend, string, uint32, error) { + return backend, testMachineAddress, 0, nil + } + loadCall := backend.On("Load", config.Path, runtimeConfig, config.ExecutionParameters.LoadDeadline). + Return(nil).Once() + hashCall := backend.On("HashFunction", config.ExecutionParameters.FastDeadline). + Return(tc.algorithm, tc.readErr).Once().NotBefore(loadCall) + // Optional later calls let an incorrect admission fail an ordinary assertion. + yieldCall := backend.On("IsAtManualYield", config.ExecutionParameters.FastDeadline). + Return(true, nil).Maybe().NotBefore(hashCall) + backend.On("ReceiveCmioRequest", config.ExecutionParameters.FastDeadline). + Return(uint8(0), uint16(ManualYieldReasonAccepted), make([]byte, HashSize), nil). + Maybe().NotBefore(yieldCall) + shutdownCall := backend.On("ShutdownServer", config.ExecutionParameters.FastDeadline). + Return(tc.shutdownErr).Once().NotBefore(hashCall) + backend.On("Delete").Return().Once().NotBefore(shutdownCall) + + loaded, err := Load(t.Context(), slog.New(slog.NewTextHandler(io.Discard, nil)), config) + if loaded != nil { + t.Cleanup(func() { require.NoError(t, loaded.Close()) }) + } + switch { + case tc.readErr != nil: + require.Nil(t, loaded) + require.ErrorIs(t, err, ErrMachineInternal) + require.ErrorIs(t, err, readErr) + case tc.algorithm != testKeccakHashFunction: + require.Nil(t, loaded) + require.ErrorIs(t, err, ErrUnsupportedHashFunction) + require.ErrorContains(t, err, "machine hash function") + require.ErrorContains(t, err, `"`+tc.algorithm+`"`) + require.ErrorContains(t, err, "expected keccak256") + default: + require.NoError(t, err) + require.NotNil(t, loaded) + require.NoError(t, loaded.Close()) + backend.AssertNumberOfCalls(t, "IsAtManualYield", 1) + backend.AssertNumberOfCalls(t, "ReceiveCmioRequest", 1) + } + if tc.shutdownErr != nil { + require.ErrorIs(t, err, shutdownErr) + require.ErrorIs(t, err, ErrOrphanServer) + } + if tc.readErr != nil || tc.algorithm != testKeccakHashFunction { + backend.AssertNotCalled(t, "IsAtManualYield", mock.Anything) + backend.AssertNotCalled(t, "ReceiveCmioRequest", mock.Anything) + } + backend.AssertNumberOfCalls(t, "HashFunction", 1) + backend.AssertNumberOfCalls(t, "ShutdownServer", 1) + backend.AssertNumberOfCalls(t, "Delete", 1) + backend.AssertExpectations(t) + }) + } +} + +func TestLoadCancellationBeforeHashFunctionRead(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + backend := NewMockBackend() + config := DefaultConfig("machine-image") + runtimeConfig := `{}` + config.RuntimeConfig = &runtimeConfig + config.BackendFactoryFn = MockBackendFactory(backend) + backend.On("Load", config.Path, runtimeConfig, config.ExecutionParameters.LoadDeadline). + Run(func(mock.Arguments) { cancel() }).Return(nil).Once() + backend.SetupForCleanup() + loaded, err := Load(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)), config) + require.Nil(t, loaded) + require.ErrorIs(t, err, ErrCanceled) + require.NotErrorIs(t, err, ErrMachineInternal) + backend.AssertNotCalled(t, "HashFunction", mock.Anything) + backend.AssertNumberOfCalls(t, "ShutdownServer", 1) + backend.AssertNumberOfCalls(t, "Delete", 1) + backend.AssertExpectations(t) +} + +type hashFunctionCleanupBackend struct { + Backend + reads int + algorithm string + shutdowns int + deletes int +} + +func (b *hashFunctionCleanupBackend) HashFunction(timeout time.Duration) (string, error) { + b.reads++ + name, err := b.Backend.HashFunction(timeout) + b.algorithm = name + return name, err +} + +func (b *hashFunctionCleanupBackend) ShutdownServer(timeout time.Duration) error { + b.shutdowns++ + return b.Backend.ShutdownServer(timeout) +} + +func (b *hashFunctionCleanupBackend) Delete() { + b.deletes++ + b.Backend.Delete() +} + +func TestLoadStoredMachineHashFunction(t *testing.T) { + const deadline = 2 * time.Second + for _, algorithm := range []string{testKeccakHashFunction, testShaHashFunction} { + t.Run(algorithm, func(t *testing.T) { + configJSON := fmt.Sprintf(`{ + "ram":{"length":4096}, + "cmio":{"rx_buffer":{},"tx_buffer":{}}, + "hash_tree":{"hash_function":%q} + }`, algorithm) + local, err := emulator.CreateMachine(configJSON, "", "") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, local.Destroy()) + local.Delete() + }) + for _, register := range []struct { + id emulator.RegID + value uint64 + }{ + {emulator.REG_IFLAGS_Y, 1}, + {emulator.REG_HTIF_TOHOST_DEV, emulator.HtifDeviceYield}, + {emulator.REG_HTIF_TOHOST_CMD, uint64(emulator.YieldManual)}, + {emulator.REG_HTIF_TOHOST_REASON, uint64(emulator.ManualYieldReasonAccepted)}, + {emulator.REG_HTIF_TOHOST_DATA, HashSize}, + } { + require.NoError(t, local.WriteReg(register.id, register.value)) + } + _, err = local.GetRootHash() + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "machine") + require.NoError(t, local.Store(path)) + config := DefaultConfig(path) + config.ExecutionParameters.FastDeadline = deadline + config.ExecutionParameters.LoadDeadline = deadline + var backend *hashFunctionCleanupBackend + config.BackendFactoryFn = func(address string, timeout time.Duration) (Backend, string, uint32, error) { + inner, bound, pid, err := NewLibCartesiBackend(address, timeout) + if err != nil { + return nil, bound, pid, err + } + backend = &hashFunctionCleanupBackend{Backend: inner} + return backend, bound, pid, nil + } + t.Cleanup(func() { + if backend != nil && backend.deletes == 0 { + require.NoError(t, backend.ShutdownServer(deadline)) + backend.Delete() + } + }) + loaded, err := Load(t.Context(), slog.New(slog.NewTextHandler(io.Discard, nil)), config) + if algorithm == testShaHashFunction { + require.Nil(t, loaded) + require.ErrorIs(t, err, ErrUnsupportedHashFunction) + require.ErrorContains(t, err, `"sha256"`) + } else { + require.NoError(t, err) + require.NotNil(t, loaded) + require.NoError(t, loaded.Close()) + } + require.NotNil(t, backend) + require.Equal(t, 1, backend.reads) + require.Equal(t, algorithm, backend.algorithm) + require.Equal(t, 1, backend.shutdowns) + require.Equal(t, 1, backend.deletes) + }) + } +} diff --git a/pkg/machine/implementation.go b/pkg/machine/implementation.go new file mode 100644 index 000000000..b21fe86e4 --- /dev/null +++ b/pkg/machine/implementation.go @@ -0,0 +1,1085 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "math/bits" + "os" + "syscall" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/crypto" +) + +// RequestType represents the type of request to send to the machine +type requestType uint8 + +const ( + AdvanceStateRequest requestType = 0x0 + InspectStateRequest requestType = 0x1 +) + +type runResult struct { + outputs []Output + reports []Report + periodicStateHashes []Hash + paddingRepetitions uint64 + finalStateEntry Hash +} + +type processResult struct { + runResult + completion completionResult +} + +type incrementResult struct { + breakReason BreakReason + currentCycle Cycle +} + +type completionResult struct { + status CompletionStatus + data []byte +} + +// executionBounds is the resolved cycle window for one request. A configured +// span of zero is converted to the fixed machine window exactly once here; +// downstream execution and diagnostics consume the same resolved values. +type executionBounds struct { + start Cycle + limit Cycle + span Cycle + configured bool +} + +func (r requestType) String() string { + switch r { + case AdvanceStateRequest: + return "advance" + case InspectStateRequest: + return "inspect" + default: + return fmt.Sprintf("request_type_%d", r) + } +} + +type automaticYieldReason uint16 + +const ( + AutomaticYieldReasonProgress automaticYieldReason = 0x1 + AutomaticYieldReasonOutput automaticYieldReason = 0x2 + AutomaticYieldReasonReport automaticYieldReason = 0x4 +) + +type manualYieldReason uint16 + +const ( + ManualYieldReasonAccepted manualYieldReason = 0x1 + ManualYieldReasonRejected manualYieldReason = 0x2 + ManualYieldReasonException manualYieldReason = 0x4 +) + +// maxOutputs and maxReports are local operational resource ceilings. They +// bound host memory consumption; exceeding them is an incomplete execution +// failure, never a protocol-level input completion status. +const maxOutputs = 65536 // 2^16 +const maxReports = 65536 // 2^16 + +const ( + // These addresses and hash-tree sizes are defined by the Cartesi Machine + // emulator. + iflagsYAddress uint64 = 0x308 + htifTohostAddress uint64 = 0x330 + TxBufferAddress uint64 = 0x60800000 + HashLog2Size = 5 // 32-byte data block + machineMemoryLog2Size int32 = 64 + memoryProofSiblingCount = int(machineMemoryLog2Size) - HashLog2Size +) + +const ( + // These values encode the HTIF fields proved by the accepted-state check. + htifDeviceYield uint64 = 2 + htifCommandManual uint64 = 1 + htifReasonInputAccepted uint64 = 1 + htifDeviceShift = 56 + htifCommandShift = 48 + htifReasonShift = 32 +) + +// machineImpl implements the Machine interface by wrapping an emulator.RemoteMachine +type machineImpl struct { + backend Backend + + address string // address of the JSON RPC remote cartesi machine server + pid uint32 // process ID of the machine server + params model.ExecutionParameters + logger *slog.Logger +} + +// Fork creates a new machine instance by forking the current one +func (m *machineImpl) Fork(ctx context.Context) (Machine, error) { + if err := checkContext(ctx); err != nil { + return nil, err + } + + // Forks the server. + newServer, address, pid, err := m.backend.ForkServer(m.params.FastDeadline) + if err != nil { + err = fmt.Errorf("could not fork the machine: %w", err) + return nil, errors.Join(ErrMachineInternal, err) + } + + // Create a new machine with the forked server + newMachine := &machineImpl{ + backend: newServer, + address: address, + pid: pid, + params: m.params, + logger: m.logger, + } + + return newMachine, nil +} + +// Hash returns the machine's merkle tree root hash +func (m *machineImpl) Hash(ctx context.Context) (Hash, error) { + if err := checkContext(ctx); err != nil { + return Hash{}, err + } + + hash, err := m.backend.GetRootHash(m.params.LoadDeadline) + if err != nil { + err := fmt.Errorf("could not get the machine's root hash: %w", err) + return hash, errors.Join(ErrMachineInternal, err) + } + + return hash, nil +} + +func (m *machineImpl) StateProof(ctx context.Context) (*StateProof, error) { + if err := checkContext(ctx); err != nil { + return nil, err + } + machineHash, err := m.backend.GetRootHash(m.params.LoadDeadline) + if err != nil { + return nil, errors.Join( + ErrMachineInternal, + fmt.Errorf("could not get the machine root for its validity proof: %w", err), + ) + } + + iflagsYProof, err := m.readLeafProof(ctx, machineHash, iflagsYAddress) + if err != nil { + return nil, fmt.Errorf("could not prove iflags_Y: %w", err) + } + htifTohostProof, err := m.readLeafProof(ctx, machineHash, htifTohostAddress) + if err != nil { + return nil, fmt.Errorf("could not prove HTIF tohost: %w", err) + } + txBufferProof, err := m.readLeafProof(ctx, machineHash, TxBufferAddress) + if err != nil { + return nil, fmt.Errorf("could not prove the CMIO TX buffer: %w", err) + } + + return &StateProof{ + MachineHash: machineHash, + IflagsYProof: iflagsYProof, + HtifTohostProof: htifTohostProof, + TxBufferProof: txBufferProof, + }, nil +} + +// ValidateAcceptedState checks the state semantics required when an epoch +// is published through the released v3 contracts. StateProof itself remains +// generic so the exact post-run proof can also be persisted for terminal +// outcomes. +func ValidateAcceptedState(proof *StateProof) error { + if proof == nil { + return fmt.Errorf("state proof is nil: %w", ErrInvalidMachineProof) + } + if readMachineWord(proof.IflagsYProof.DataBlock, iflagsYAddress) == 0 { + return fmt.Errorf("iflags_Y is zero: %w", ErrInvalidMachineProof) + } + tohost := readMachineWord(proof.HtifTohostProof.DataBlock, htifTohostAddress) + if !isAcceptedManualYield(tohost) { + return fmt.Errorf("HTIF tohost does not signal an accepted manual yield: %w", ErrInvalidMachineProof) + } + return nil +} + +func (m *machineImpl) readLeafProof(ctx context.Context, machineHash Hash, wordAddress uint64) (LeafProof, error) { + if err := checkContext(ctx); err != nil { + return LeafProof{}, err + } + dataBlockAddress := wordAddress &^ ((uint64(1) << HashLog2Size) - 1) + proof, err := m.backend.GetProof( + dataBlockAddress, + int32(HashLog2Size), + machineMemoryLog2Size, + m.params.LoadDeadline, + ) + if err != nil { + return LeafProof{}, errors.Join(ErrMachineInternal, fmt.Errorf("could not get memory proof: %w", err)) + } + data, err := m.backend.ReadMemory( + dataBlockAddress, + uint64(1)<> HashLog2Size + for _, sibling := range proof.Siblings { + if index&1 == 0 { + root = Hash(crypto.Keccak256Hash(root[:], sibling[:])) + } else { + root = Hash(crypto.Keccak256Hash(sibling[:], root[:])) + } + index >>= 1 + } + if root != machineHash { + return LeafProof{}, fmt.Errorf("proof siblings do not reconstruct the current machine root: %w", ErrInvalidMachineProof) + } + + return LeafProof{ + DataBlock: dataBlock, + Siblings: append([]Hash(nil), proof.Siblings...), + }, nil +} + +func readMachineWord(dataBlock Hash, address uint64) uint64 { + offset := int(address & ((uint64(1) << HashLog2Size) - 1)) + return binary.LittleEndian.Uint64(dataBlock[offset : offset+8]) +} + +func isAcceptedManualYield(tohost uint64) bool { + return tohost>>htifDeviceShift == htifDeviceYield && + (tohost>>htifCommandShift)&0xff == htifCommandManual && + (tohost>>htifReasonShift)&0xffff == htifReasonInputAccepted +} + +// Advance sends an input to the machine and processes it +func (m *machineImpl) Advance(ctx context.Context, input []byte, checkpointHash Hash, computeHashes bool) (*AdvanceResponse, error) { + result, err := m.process(ctx, input, AdvanceStateRequest, &checkpointHash, computeHashes) + if err != nil { + return nil, err + } + if !result.completion.status.IsCompleted() { + return nil, fmt.Errorf( + "invalid completed advance status %d: %w", + result.completion.status, + ErrMachineInternal, + ) + } + + resp := &AdvanceResponse{ + Status: result.completion.status, + Outputs: result.outputs, + Reports: result.reports, + PeriodicStateHashes: result.periodicStateHashes, + PaddingRepetitions: result.paddingRepetitions, + FinalStateEntry: result.finalStateEntry, + } + + if resp.Status == CompletionStatusException { + resp.ExceptionData = append([]byte{}, result.completion.data...) + } + + return resp, nil +} + +// Inspect sends a query to the machine and returns the results. +func (m *machineImpl) Inspect(ctx context.Context, query []byte) (*InspectResponse, error) { + // For inspect-state requests, revert_root_hash is not checked and can be NULL/empty + result, err := m.process(ctx, query, InspectStateRequest, nil, false) + response := &InspectResponse{Reports: result.reports} + if err != nil { + return response, err + } + + response.Status = result.completion.status + if !response.Status.IsCompleted() { + return response, fmt.Errorf( + "invalid completed inspect status %d: %w", + result.completion.status, + ErrMachineInternal, + ) + } + if response.Status == CompletionStatusException { + response.ExceptionData = append([]byte{}, result.completion.data...) + } + return response, nil +} + +// Store saves the machine state to the specified path +func (m *machineImpl) Store(ctx context.Context, path string) error { + if err := checkContext(ctx); err != nil { + return err + } + + err := m.backend.Store(path, m.params.StoreDeadline) + if err != nil { + err = fmt.Errorf("could not store the machine state: %w", err) + return errors.Join(ErrMachineInternal, err) + } + + return nil +} + +// Close shuts down the machine and its server +func (m *machineImpl) Close() error { + if m.backend == nil { + return nil + } + + err := m.backend.ShutdownServer(m.params.FastDeadline) + if err != nil { + // ShutdownServer can fail because SIGINT was delivered to the + // entire process group: the child is already shutting down (closing + // sockets) so our RPC gets "connection reset" or "end of stream". + // Wait briefly for the child to finish exiting before reporting it + // as orphaned. + if m.pid != 0 && waitForExit(m.pid, 500*time.Millisecond) { //nolint: mnd + m.backend.Delete() + m.backend = nil + return nil + } + err = fmt.Errorf("could not shut down the server: %w", err) + err = errors.Join(errors.Join(ErrMachineInternal, err), + fmt.Errorf("%w at address %s", ErrOrphanServer, m.address)) + } + m.backend.Delete() + m.backend = nil + return err +} + +// waitForExit polls until the process with the given PID has exited or the +// timeout elapses. Returns true if the process exited within the timeout. +func waitForExit(pid uint32, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + proc, err := os.FindProcess(int(pid)) + if err != nil { + return true + } + // try send no-op signal 0 to check process is still receiving signals. + if proc.Signal(syscall.Signal(0)) != nil { + return true + } + time.Sleep(50 * time.Millisecond) //nolint: mnd + } + return false +} + +// Address returns the address of the machine server +func (m *machineImpl) Address() string { + return m.address +} + +// Helper methods + +// isAtManualYield checks if the machine is at a manual yield point +func (m *machineImpl) isAtManualYield(ctx context.Context) (bool, error) { + if err := checkContext(ctx); err != nil { + return false, err + } + + isAtManualYield, err := m.backend.IsAtManualYield(m.params.FastDeadline) + if err != nil { + err = fmt.Errorf("could not read iflagsY: %w", err) + return false, errors.Join(ErrMachineInternal, err) + } + return isAtManualYield, nil +} + +// checkRequestYield requires the accepted manual yield used as a request boundary. +func (m *machineImpl) checkRequestYield(ctx context.Context) error { + atYield, err := m.isAtManualYield(ctx) + if err != nil { + return err + } + if !atYield { + return errors.Join(ErrNotAtManualYield, ErrMachineInternal) + } + tohost, err := m.readTohost() + if err != nil { + return err + } + if !isAcceptedManualYield(tohost) { + return errors.Join(ErrNotAtManualYield, ErrMachineInternal) + } + return nil +} + +func (m *machineImpl) readTohost() (uint64, error) { + const wordSize uint64 = 8 + data, err := m.backend.ReadMemory(htifTohostAddress, wordSize, m.params.FastDeadline) + if err != nil { + return 0, errors.Join(ErrMachineInternal, fmt.Errorf("could not read HTIF tohost: %w", err)) + } + if len(data) != int(wordSize) { + return 0, fmt.Errorf("HTIF tohost read returned %d bytes, expected %d: %w", len(data), wordSize, ErrMachineInternal) + } + return binary.LittleEndian.Uint64(data), nil +} + +// readManualYieldResult returns the typed completion status and data reported by +// the current manual yield. The machine MUST be at a manual yield when called. +func (m *machineImpl) readManualYieldResult(ctx context.Context) (completionResult, error) { + if err := checkContext(ctx); err != nil { + return completionResult{}, err + } + + _, yieldReason, data, err := m.backend.ReceiveCmioRequest(m.params.FastDeadline) + if err != nil { + return completionResult{}, err + } + + switch manualYieldReason(yieldReason) { + case ManualYieldReasonAccepted: + return completionResult{status: CompletionStatusAccepted, data: data}, nil + case ManualYieldReasonRejected: + return completionResult{status: CompletionStatusRejected, data: data}, nil + case ManualYieldReasonException: + return completionResult{status: CompletionStatusException, data: data}, nil + default: + return completionResult{status: CompletionStatusUnexpectedYield}, nil + } +} + +// readAdvanceYieldResult checks the accepted root declaration before asking the +// emulator to retrieve a guest-sized payload. A malformed length is a completed +// advance, including when it exceeds the TX-buffer capacity. Load and Inspect +// do not use this advance-specific rule. +func (m *machineImpl) readAdvanceYieldResult(ctx context.Context) (completionResult, error) { + if err := checkContext(ctx); err != nil { + return completionResult{}, err + } + tohost, err := m.readTohost() + if err != nil { + return completionResult{}, err + } + if isAcceptedManualYield(tohost) { + // The accepted root is retained by StateProof, not by this response. + // HTIF's low 32 bits declare its byte length. + if tohost&math.MaxUint32 != HashSize { + return completionResult{status: CompletionStatusInvalidOutputsRoot}, nil + } + return completionResult{status: CompletionStatusAccepted}, nil + } + return m.readManualYieldResult(ctx) +} + +// readMCycle reads the current cycle from the machine. +func (m *machineImpl) readMCycle(ctx context.Context) (uint64, error) { + if err := checkContext(ctx); err != nil { + return 0, err + } + + mcycle, err := m.backend.ReadMCycle(m.params.FastDeadline) + if err != nil { + err = fmt.Errorf("could not read the machine's current cycle: %w", err) + return mcycle, errors.Join(ErrMachineInternal, err) + } + return mcycle, nil +} + +// process delivers an advance-state or inspect-state request only at an +// RX-accepted manual yield, and returns its completion and collected responses. +// A completed execution does not imply readiness for another request. +// checkpointHash is nil for inspects. +func (m *machineImpl) process( + ctx context.Context, + request []byte, + reqType requestType, + checkpointHash *Hash, + computeHashes bool, +) (processResult, error) { + if err := checkContext(ctx); err != nil { + return processResult{}, err + } + // An L1 advance arrives as the full EvmAdvance encoding, which the canonical + // InputBox bounds to CanonicalMachine.INPUT_MAX_SIZE = 2^16 bytes (it reverts + // InputTooLarge above that), while the standard CMIO RX buffer holds 2^21 + // bytes. Advance overflow is therefore defensive on the supported path; + // inspect and custom input providers still need the runtime check. + if length, capacity := uint64(len(request)), m.backend.CmioRxBufferSize(); length > capacity { + return processResult{}, fmt.Errorf( + "%s request payload length %d exceeds CMIO receive buffer capacity %d: %w", + reqType, length, capacity, ErrPayloadLengthLimitExceeded, + ) + } + + // Validate the request-specific increment before consulting or changing the + // machine. A zero increment would otherwise make the run loop spin forever. + if err := m.validateExecutionCycleIncrement(reqType); err != nil { + return processResult{}, err + } + // Validate the execution target before sending the request. Sending a CMIO + // response mutates the candidate, so invalid configuration must fail before + // that point. A valid target that exceeds uint64 saturates at MaxUint64, + // matching the emulator's imcyclemax calculation. + bounds, err := m.executionCycleBounds(ctx, reqType) + if err != nil { + return processResult{}, err + } + + if err := m.checkRequestYield(ctx); err != nil { + return processResult{}, err + } + + err = m.backend.SendCmioResponse(uint16(reqType), request, checkpointHash, m.params.FastDeadline) + if err != nil { + return processResult{}, err + } + + execution, err := m.run(ctx, reqType, computeHashes, bounds) + result := processResult{runResult: execution} + switch { + case err == nil: + if reqType == AdvanceStateRequest { + result.completion, err = m.readAdvanceYieldResult(ctx) + return result, err + } + manualResult, err := m.readManualYieldResult(ctx) + result.completion = manualResult + return result, err + case errors.Is(err, ErrMachineInternal): + return result, err + case errors.Is(err, ErrHalted): + result.completion.status = CompletionStatusHalted + return result, nil + case errors.Is(err, ErrMcycleOverflow): + result.completion.status = CompletionStatusOverflow + return result, nil + default: + return result, err + } +} + +// run executes a request whose increment and cycle bounds were validated before +// SendCmioResponse mutated the machine. It returns collected responses and the +// canonical compressed input hash collection. +func (m *machineImpl) run( + ctx context.Context, + reqType requestType, + computeHashes bool, + bounds executionBounds, +) (runResult, error) { + startTime := time.Now() + currentCycle := bounds.start + + stepTimeout := m.params.AdvanceIncDeadline + runTimeout := m.params.AdvanceMaxDeadline + increment := m.executionCycleIncrement(reqType) + if reqType == InspectStateRequest { + stepTimeout = m.params.InspectIncDeadline + runTimeout = m.params.InspectMaxDeadline + } + if computeHashes { + increment = min(increment, mcycleComputationHashChunkSize) + } + + m.logger.Debug("run", + "startingCycle", currentCycle, + "limitCycle", bounds.limit, + "leftover", bounds.limit-currentCycle) + + result := runResult{ + outputs: make([]Output, 0, 16), + reports: make([]Report, 0, 16), + } + + var hashCollectorState *HashCollectorState + if computeHashes { + hashCollectorState = &HashCollectorState{ + MCycleSamplingPeriod: MCycleComputationHashPeriod, + MCyclePhase: 0, + Log2BundleMCycleCount: int32(Log2StateHashesPerEntry), + Hashes: []Hash{}, + } + } + finish := func(runErr error) (runResult, error) { + return finalizeRunResult(result, hashCollectorState, currentCycle-bounds.start, runErr) + } + // Incomplete executions may expose partial outputs and reports, but their PRT + // collections are discarded rather than normalized into persistable evidence. + fail := func(runErr error) (runResult, error) { + return result, runErr + } + + for { + if err := checkContext(ctx); err != nil { + return fail(err) + } + if time.Since(startTime) > runTimeout { + return fail(fmt.Errorf("run operation timed out: %w", ErrDeadlineExceeded)) + } + + hashCountBeforeRun := 0 + if hashCollectorState != nil { + if err := validateHashCollectorProgress(hashCollectorState, currentCycle-bounds.start, false); err != nil { + return fail(invalidInputHashCollectionSpanError(nil, err)) + } + hashCountBeforeRun = len(hashCollectorState.Hashes) + } + incrementResult, err := m.runIncrementInterval( + ctx, + currentCycle, + bounds.limit, + increment, + hashCollectorState, + stepTimeout, + ) + currentCycle = incrementResult.currentCycle + if err != nil { + if errors.Is(err, ErrReachedLimitMcycle) { + return fail(executionLimitError(reqType, bounds, currentCycle, err)) + } + return fail(err) + } + + terminalHashAppended := computeHashes && isFixedPointBreakReason(incrementResult.breakReason) + if terminalHashAppended && len(hashCollectorState.Hashes) <= hashCountBeforeRun { + return fail(fmt.Errorf( + "machine stopped at fixed point %d without appending its state hash: %w", + incrementResult.breakReason, + ErrMachineInternal, + )) + } + + if hashCollectorState != nil && incrementResult.breakReason != Failed { + if err := validateHashCollectorProgress(hashCollectorState, currentCycle-bounds.start, terminalHashAppended); err != nil { + return fail(invalidInputHashCollectionSpanError(nil, err)) + } + } + + switch incrementResult.breakReason { + case YieldedManually: + return finish(nil) + case Halted: + return finish(ErrHalted) + case McycleOverflow: + return finish(ErrMcycleOverflow) + case ReachedTargetMcycle, YieldedSoftly: + continue + case Failed: + return fail(ErrMachineInternal) + case YieldedAutomatically: + // Service the CMIO request below, then continue execution. + default: + return fail(fmt.Errorf( + "invalid break reason: %d: %w", + incrementResult.breakReason, + ErrMachineInternal, + )) + } + + if err := checkContext(ctx); err != nil { + return fail(err) + } + + _, yieldReason, data, err := m.backend.ReceiveCmioRequest(m.params.FastDeadline) + if err != nil { + werr := fmt.Errorf("could not read output/report: %w", err) + return fail(werr) + } + + switch automaticYieldReason(yieldReason) { + case AutomaticYieldReasonProgress: + m.logger.Debug("ignoring yield reason progress", "value", fmt.Sprintf("%v", data)) + case AutomaticYieldReasonOutput: + if len(result.outputs) == maxOutputs { + return fail(executionResponseLimitError( + reqType, "output", len(result.outputs)+1, maxOutputs, ErrOutputsLimitExceeded, + )) + } + result.outputs = append(result.outputs, data) + case AutomaticYieldReasonReport: + if len(result.reports) == maxReports { + return fail(executionResponseLimitError( + reqType, "report", len(result.reports)+1, maxReports, ErrReportsLimitExceeded, + )) + } + result.reports = append(result.reports, data) + default: + err := fmt.Errorf("invalid automatic yield reason: %d: %w", yieldReason, ErrMachineInternal) + return fail(err) + } + } +} + +// finalizeRunResult converts the emulator collector's fixed-point-inclusive +// result into the node's compressed representation. Keeping this at the return +// boundary leaves the execution loop concerned only with execution policy. +func finalizeRunResult( + result runResult, + collector *HashCollectorState, + deltaMCycles uint64, + runErr error, +) (runResult, error) { + if collector == nil { + return result, runErr + } + + kept, padding, final, spanErr := model.CanonicalInputHashCollection(collector.Hashes, deltaMCycles) + if spanErr != nil { + return result, invalidInputHashCollectionSpanError(runErr, spanErr) + } + + // The emulator returns an entry of the fixed-point root for padding. + // Keep entries containing periodic samples and fold final-only entries + // into that padding, preserving the same computation-hash leaf sequence. + result.periodicStateHashes = kept + result.paddingRepetitions = padding + result.finalStateEntry = final + return result, runErr +} + +// invalidInputHashCollectionSpanError makes collection integrity authoritative +// over a terminal execution status. The status remains useful context, but is +// deliberately not wrapped: callers must not persist a malformed collection as +// a completed exception or halt. +func invalidInputHashCollectionSpanError(runErr, spanErr error) error { + err := errors.Join( + ErrMachineInternal, + fmt.Errorf("invalid collected state hash span; mismatch is deterministic for the execution increment "+ + "and points to the emulator; re-enabling repeats the input and this failure: %w", spanErr), + ) + if runErr != nil { + err = fmt.Errorf("execution ended with %v and invalid hash span: %w", runErr, err) + } + return err +} + +func executionResponseLimitError( + reqType requestType, + responseKind string, + count int, + capacity int, + limitErr error, +) error { + return fmt.Errorf( + "%s %s count %d exceeds local operational capacity %d: %w", + reqType, responseKind, count, capacity, limitErr, + ) +} + +// validateHashCollectorProgress checks the retained collector before delivery +// and each successful return against the cycles elapsed since the input began. +func validateHashCollectorProgress(state *HashCollectorState, deltaMCycles uint64, fixedPoint bool) error { + partial := bytes.TrimSpace(state.PartialBundle) + present := len(partial) != 0 && !bytes.Equal(partial, []byte("null")) + if fixedPoint { + if present { + return errors.New("partial bundle remains at a fixed point") + } + return nil + } + log2Period := uint64(bits.TrailingZeros64(state.MCycleSamplingPeriod)) //nolint:gosec + log2PerEntry := state.Log2BundleMCycleCount + if log2PerEntry < 0 || log2Period > model.Log2MCyclesPerStateHashEntry || + uint64(log2PerEntry) != model.Log2MCyclesPerStateHashEntry-log2Period { + return fmt.Errorf("invalid collector period %d and entry height %d", log2Period, log2PerEntry) + } + samples := deltaMCycles >> log2Period + entries, partialSamples := samples>>log2PerEntry, samples&((uint64(1)< model.MaxExecutionCycleSpan { + return executionBounds{}, fmt.Errorf( + "%s execution configured cycle span exceeds hard maximum: requested_span=%d maximum_span=%d: %w", + reqType, executionCycleSpan, model.MaxExecutionCycleSpan, ErrReachedLimitMcycle, + ) + } + + currentCycle, err := m.readMCycle(ctx) + if err != nil { + return executionBounds{}, err + } + + configured := executionCycleSpan != 0 + if executionCycleSpan == 0 { + // Emulator 0.22 arms imcyclemax = mcycle + 2^48 - 1 (saturating) + // at input delivery. A pending halt or manual yield takes precedence + // over overflow at that endpoint. Match its complete cycle window. + executionCycleSpan = model.MaxExecutionCycleSpan + } + limit := ^uint64(0) + if currentCycle <= ^uint64(0)-executionCycleSpan { + limit = currentCycle + executionCycleSpan + } + return executionBounds{ + start: currentCycle, limit: limit, span: executionCycleSpan, configured: configured, + }, nil +} + +// validateExecutionCycleIncrement is a runtime defense for values loaded from +// persistent storage or supplied through a custom MachineConfig. process calls +// it before SendCmioResponse changes the candidate machine. +func (m *machineImpl) validateExecutionCycleIncrement(reqType requestType) error { + if m.executionCycleIncrement(reqType) == 0 { + return fmt.Errorf("%s execution increment must be greater than zero: %w", reqType, ErrMachineInternal) + } + return nil +} + +func (m *machineImpl) configuredCycleSpan(reqType requestType) uint64 { + if reqType == InspectStateRequest { + return m.params.InspectMaxCycles + } + return m.params.AdvanceMaxCycles +} + +func (m *machineImpl) executionCycleIncrement(reqType requestType) uint64 { + if reqType == InspectStateRequest { + return m.params.InspectIncCycles + } + return m.params.AdvanceIncCycles +} + +// executionLimitError formats the resolved request window while preserving the +// emulator's typed imcyclemax origin when it—not a node target—stopped the run. +func executionLimitError( + reqType requestType, + bounds executionBounds, + current uint64, + origin error, +) error { + targetSpan := bounds.limit - bounds.start + executedCycles := uint64(0) + if current >= bounds.start { + executedCycles = current - bounds.start + } + source := "fixed" + if bounds.configured { + source = "configured" + } + progress := fmt.Sprintf( + "start=%d requested_span=%d target_span=%d executed_cycles=%d", + bounds.start, bounds.span, targetSpan, executedCycles, + ) + + // A machine overflow may stop execution before its local target. This most + // commonly happens because an inspect does not re-arm imcyclemax and inherits + // the ceiling from the preceding advance. Preserve the machine-origin + // sentinel, but do not claim that a local span was exhausted. + if current < bounds.limit { + return fmt.Errorf( + "%s execution stopped before %s cycle limit: %s: %w", + reqType, source, progress, origin, + ) + } + if errors.Is(origin, ErrMcycleOverflow) && bounds.configured { + // imcyclemax has precedence over reaching the requested run target. Even + // when both cycles are equal, the machine-enforced ceiling—not the local + // configured cap—stopped execution, so raising that cap cannot help. + return fmt.Errorf( + "%s execution stopped at machine imcyclemax coincident with configured target: %s: %w", + reqType, progress, origin, + ) + } + + if errors.Is(origin, ErrMcycleOverflow) { + source += " (machine imcyclemax)" + } + return fmt.Errorf( + "%s execution reached %s cycle limit without completing: %s: %w", + reqType, source, progress, origin, + ) +} + +// runIncrementInterval runs the machine for at most incrementLimit mcycles (or +// the distance left to limitCycle) and preserves the emulator's typed break +// reason for the request loop to classify. +func (m *machineImpl) runIncrementInterval(ctx context.Context, + currentCycle Cycle, + limitCycle Cycle, + incrementLimit Cycle, + hashCollectorState *HashCollectorState, + timeout time.Duration, +) (incrementResult, error) { + startingCycle := currentCycle + + // Return without calling the backend after an ordinary local target has + // already been reached. MaxUint64 is different: saturating arithmetic can + // make both the node target and the emulator's imcyclemax equal the current + // cycle. Calling the emulator once at that fixed point preserves its + // authoritative fixed-point reason instead of mislabeling the stop as an + // operator-cap failure. + atSaturatedEndpoint := currentCycle == ^uint64(0) && limitCycle == ^uint64(0) + if currentCycle >= limitCycle && !atSaturatedEndpoint { + return incrementResult{currentCycle: currentCycle}, ErrReachedLimitMcycle + } + + // Calculates the increment. + increment := min(incrementLimit, limitCycle-currentCycle) + + m.logger.Debug("machine step before run", "currentCycle", currentCycle, "increment", increment) + + // Runs the machine. + breakReason, err := m.backendRun(currentCycle+increment, hashCollectorState, timeout) + if err != nil { + return incrementResult{currentCycle: currentCycle}, err + } + if hashCollectorState != nil && hashCollectorState.ConsoleIOError != "" { + m.logger.Warn( + "machine console I/O error while collecting computation-hash entries", + "error", hashCollectorState.ConsoleIOError, + ) + hashCollectorState.ConsoleIOError = "" + } + + // Gets the current cycle. + currentCycle, err = m.readMCycle(ctx) + if err != nil { + return incrementResult{}, err + } + + m.logger.Debug("machine step after run", + "startingCycle", startingCycle, + "increment", increment, + "currentCycle", currentCycle, + "leftover", limitCycle-currentCycle, + "breakReason", breakReason) + + if atSaturatedEndpoint && !isFixedPointBreakReason(breakReason) { + // Emulator 0.22 reports a pending halt or manual yield before overflow + // at imcyclemax. Every permitted endpoint result is a fixed point. + return incrementResult{breakReason: breakReason, currentCycle: currentCycle}, fmt.Errorf( + "machine returned break reason %d instead of a fixed point at MaxUint64: %w", + breakReason, ErrMachineInternal, + ) + } + + switch breakReason { + case YieldedManually, + YieldedAutomatically, + YieldedSoftly, + ReachedTargetMcycle, + McycleOverflow, + Halted, + Failed: + return incrementResult{breakReason: breakReason, currentCycle: currentCycle}, nil + default: + err := fmt.Errorf("invalid break reason: %d: %w", breakReason, ErrMachineInternal) + return incrementResult{breakReason: breakReason, currentCycle: currentCycle}, err + } +} + +func (m *machineImpl) backendRun(mcycleEnd uint64, hashCollectorState *HashCollectorState, timeout time.Duration) (BreakReason, error) { + if hashCollectorState != nil { + m.logger.Debug("Running with root hash collection") + return m.backend.RunAndCollectRootHashes(mcycleEnd, hashCollectorState, timeout) + } + return m.backend.Run(mcycleEnd, timeout) +} + +func isFixedPointBreakReason(reason BreakReason) bool { + return reason == YieldedManually || reason == Halted || reason == McycleOverflow +} + +// Helper functions + +func checkContext(ctx context.Context) error { + if ctx == nil { + return nil + } + err := ctx.Err() + switch { + case errors.Is(err, context.DeadlineExceeded): + return ErrDeadlineExceeded + case errors.Is(err, context.Canceled): + return ErrCanceled + default: + return err + } +} diff --git a/pkg/machine/implementation_test.go b/pkg/machine/implementation_test.go new file mode 100644 index 000000000..287378901 --- /dev/null +++ b/pkg/machine/implementation_test.go @@ -0,0 +1,2681 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "log/slog" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +func TestImplementation(t *testing.T) { + suite.Run(t, new(ImplementationSuite)) +} + +type ImplementationSuite struct { + suite.Suite + logger *slog.Logger +} + +const ( + stateProofDataBlockSize uint64 = 1 << HashLog2Size + stateProofDataBlockMask = stateProofDataBlockSize - 1 +) + +func testExecutionBounds(start, limit uint64) executionBounds { + return executionBounds{ + start: start, limit: limit, span: limit - start, configured: true, + } +} + +func (s *ImplementationSuite) SetupSuite() { + s.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// Test Fork method +func (s *ImplementationSuite) TestFork() { + require := s.Require() + ctx := context.Background() + + // Test successful fork + mockBackend := NewMockBackend() + forkedBackend := NewMockBackend() + mockBackend.On("ForkServer", mock.AnythingOfType("time.Duration")).Return( + forkedBackend, "127.0.0.1:54321", uint32(54321), nil) + + machine := &machineImpl{ + backend: mockBackend, + address: testMachineAddress, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + + forkedMachine, err := machine.Fork(ctx) + require.NoError(err) + require.NotNil(forkedMachine) + require.Equal("127.0.0.1:54321", forkedMachine.Address()) + mockBackend.AssertExpectations(s.T()) + + // Test fork with backend error + mockBackend2 := NewMockBackend() + mockBackend2.On("ForkServer", mock.AnythingOfType("time.Duration")).Return( + (*MockBackend)(nil), "", uint32(0), errors.New("fork failed")) + + machine2 := &machineImpl{ + backend: mockBackend2, + address: testMachineAddress, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + + _, err = machine2.Fork(ctx) + require.Error(err) + require.ErrorIs(err, ErrMachineInternal) + require.Contains(err.Error(), "could not fork the machine") + + // Test fork with canceled context + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + _, err = machine.Fork(canceledCtx) + require.ErrorIs(err, ErrCanceled) + mockBackend2.AssertExpectations(s.T()) +} + +// Test Hash method +func (s *ImplementationSuite) TestHash() { + require := s.Require() + ctx := context.Background() + + // Test successful hash retrieval + mockBackend := NewMockBackend() + expectedHash := randomFakeHash() + mockBackend.On("GetRootHash", mock.AnythingOfType("time.Duration")).Return(expectedHash, nil) + + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + LoadDeadline: time.Second * 5, + }, + } + + hash, err := machine.Hash(ctx) + require.NoError(err) + require.Equal(expectedHash, hash) + mockBackend.AssertExpectations(s.T()) + + // Test hash with backend error + mockBackend2 := NewMockBackend() + mockBackend2.On("GetRootHash", mock.AnythingOfType("time.Duration")).Return((Hash)(Hash{}), errors.New("hash failed")) + machine2 := &machineImpl{ + backend: mockBackend2, + logger: s.logger, + params: model.ExecutionParameters{ + LoadDeadline: time.Second * 5, + }, + } + _, err = machine2.Hash(ctx) + require.Error(err) + require.ErrorIs(err, ErrMachineInternal) + require.Contains(err.Error(), "could not get the machine's root hash") + mockBackend2.AssertExpectations(s.T()) + + // Test hash with canceled context + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + _, err = machine.Hash(canceledCtx) + require.ErrorIs(err, ErrCanceled) +} + +func (s *ImplementationSuite) TestStateProof() { + blocks := acceptedStateTestBlocks(1, acceptedTohostTestValue()) + machine, expected := s.machineWithMemoryProofs(blocks) + + actual, err := machine.StateProof(context.Background()) + s.Require().NoError(err) + s.Require().Equal(expected, actual) + machine.backend.(*MockBackend).AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestValidateAcceptedState() { + for _, test := range []struct { + name string + iflags uint64 + tohost uint64 + valid bool + }{ + {name: "accepted", iflags: 1, tohost: acceptedTohostTestValue(), valid: true}, + {name: "iflags_Y is zero", iflags: 0, tohost: acceptedTohostTestValue()}, + {name: "wrong HTIF device", iflags: 1, tohost: uint64(3)<<56 | uint64(1)<<48 | uint64(1)<<32}, + {name: "wrong HTIF command", iflags: 1, tohost: uint64(2)<<56 | uint64(2)<<48 | uint64(1)<<32}, + {name: "tohost is rejected", iflags: 1, tohost: uint64(2)<<56 | uint64(1)<<48 | uint64(2)<<32}, + } { + s.Run(test.name, func() { + blocks := acceptedStateTestBlocks(test.iflags, test.tohost) + machine, _ := s.machineWithMemoryProofs(blocks) + + proof, err := machine.StateProof(context.Background()) + s.Require().NoError(err) + err = ValidateAcceptedState(proof) + if test.valid { + s.Require().NoError(err) + } else { + s.Require().ErrorIs(err, ErrInvalidMachineProof) + } + machine.backend.(*MockBackend).AssertExpectations(s.T()) + }) + } + s.Require().ErrorIs(ValidateAcceptedState(nil), ErrInvalidMachineProof) +} + +func (s *ImplementationSuite) TestStateProofHonorsCanceledContext() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + machine := &machineImpl{backend: NewMockBackend()} + + proof, err := machine.StateProof(ctx) + s.Require().Nil(proof) + s.Require().ErrorIs(err, ErrCanceled) +} + +func (s *ImplementationSuite) TestVerifyMemoryProofRejectsMalformedProof() { + address := TxBufferAddress + block := randomFakeHash() + root, proofs := buildTestMemoryProofs(map[uint64]Hash{address: block}) + data := append([]byte(nil), block[:]...) + + for _, test := range []struct { + name string + mutate func(*MemoryProof, *[]byte) + }{ + {name: "root size", mutate: func(p *MemoryProof, _ *[]byte) { p.Log2RootSize-- }}, + {name: "target size", mutate: func(p *MemoryProof, _ *[]byte) { p.Log2TargetSize-- }}, + {name: "target address", mutate: func(p *MemoryProof, _ *[]byte) { p.TargetAddress += stateProofDataBlockSize }}, + {name: "reported root", mutate: func(p *MemoryProof, _ *[]byte) { p.RootHash[0] ^= 0xff }}, + {name: "sibling count", mutate: func(p *MemoryProof, _ *[]byte) { p.Siblings = p.Siblings[:len(p.Siblings)-1] }}, + {name: "data length", mutate: func(_ *MemoryProof, data *[]byte) { *data = (*data)[:len(*data)-1] }}, + {name: "target hash", mutate: func(p *MemoryProof, _ *[]byte) { p.TargetHash[0] ^= 0xff }}, + {name: "sibling path", mutate: func(p *MemoryProof, _ *[]byte) { p.Siblings[0][0] ^= 0xff }}, + } { + s.Run(test.name, func() { + proof := proofs[address] + proof.Siblings = append([]Hash(nil), proof.Siblings...) + proofData := append([]byte(nil), data...) + test.mutate(&proof, &proofData) + + leaf, err := verifyMemoryProof(root, address, proof, proofData) + s.Require().Equal(LeafProof{}, leaf) + s.Require().ErrorIs(err, ErrInvalidMachineProof) + }) + } +} + +func (s *ImplementationSuite) machineWithMemoryProofs(blocks map[uint64]Hash) (*machineImpl, *StateProof) { + root, proofs := buildTestMemoryProofs(blocks) + backend := NewMockBackend() + backend.On("GetRootHash", mock.AnythingOfType("time.Duration")).Return(root, nil).Once() + for _, address := range []uint64{ + iflagsYAddress &^ stateProofDataBlockMask, + htifTohostAddress &^ stateProofDataBlockMask, + TxBufferAddress, + } { + block := blocks[address] + backend.On( + "GetProof", + address, + int32(HashLog2Size), + machineMemoryLog2Size, + mock.AnythingOfType("time.Duration"), + ). + Return(proofs[address], nil).Once() + backend.On("ReadMemory", address, stateProofDataBlockSize, mock.AnythingOfType("time.Duration")). + Return(append([]byte(nil), block[:]...), nil).Once() + } + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + LoadDeadline: 5 * time.Second, + }, + } + expected := &StateProof{ + MachineHash: root, + IflagsYProof: LeafProof{DataBlock: blocks[iflagsYAddress&^stateProofDataBlockMask], Siblings: proofs[iflagsYAddress&^stateProofDataBlockMask].Siblings}, + HtifTohostProof: LeafProof{DataBlock: blocks[htifTohostAddress&^stateProofDataBlockMask], Siblings: proofs[htifTohostAddress&^stateProofDataBlockMask].Siblings}, + TxBufferProof: LeafProof{DataBlock: blocks[TxBufferAddress], Siblings: proofs[TxBufferAddress].Siblings}, + } + return machine, expected +} + +func acceptedStateTestBlocks(iflags, tohost uint64) map[uint64]Hash { + var iflagsBlock Hash + binary.LittleEndian.PutUint64(iflagsBlock[iflagsYAddress&stateProofDataBlockMask:], iflags) + var tohostBlock Hash + binary.LittleEndian.PutUint64(tohostBlock[htifTohostAddress&stateProofDataBlockMask:], tohost) + return map[uint64]Hash{ + iflagsYAddress &^ stateProofDataBlockMask: iflagsBlock, + htifTohostAddress &^ stateProofDataBlockMask: tohostBlock, + TxBufferAddress: randomFakeHash(), + } +} + +func acceptedTohostTestValue() uint64 { + return uint64(2)<<56 | uint64(1)<<48 | uint64(1)<<32 +} + +func buildTestMemoryProofs(blocks map[uint64]Hash) (Hash, map[uint64]MemoryProof) { + const depth = memoryProofSiblingCount + defaultHashes := make([]Hash, depth+1) + defaultHashes[0] = Hash(crypto.Keccak256Hash(make([]byte, stateProofDataBlockSize))) + for level := 1; level <= depth; level++ { + defaultHashes[level] = Hash(crypto.Keccak256Hash( + defaultHashes[level-1][:], + defaultHashes[level-1][:], + )) + } + + current := make(map[uint64]Hash, len(blocks)) + indexes := make(map[uint64]uint64, len(blocks)) + siblings := make(map[uint64][]Hash, len(blocks)) + for address, block := range blocks { + index := address >> HashLog2Size + indexes[address] = index + current[index] = Hash(crypto.Keccak256Hash(block[:])) + siblings[address] = make([]Hash, 0, depth) + } + + for level := 0; level < depth; level++ { + for address, originalIndex := range indexes { + index := originalIndex >> level + sibling, ok := current[index^1] + if !ok { + sibling = defaultHashes[level] + } + siblings[address] = append(siblings[address], sibling) + } + + parents := make(map[uint64]struct{}, len(current)) + for index := range current { + parents[index>>1] = struct{}{} + } + next := make(map[uint64]Hash, len(parents)) + for parent := range parents { + left, ok := current[parent<<1] + if !ok { + left = defaultHashes[level] + } + right, ok := current[parent<<1|1] + if !ok { + right = defaultHashes[level] + } + next[parent] = Hash(crypto.Keccak256Hash(left[:], right[:])) + } + current = next + } + + root := current[0] + proofs := make(map[uint64]MemoryProof, len(blocks)) + for address, block := range blocks { + proofs[address] = MemoryProof{ + Log2RootSize: machineMemoryLog2Size, + Log2TargetSize: int32(HashLog2Size), + RootHash: root, + Siblings: siblings[address], + TargetAddress: address, + TargetHash: Hash(crypto.Keccak256Hash(block[:])), + } + } + return root, proofs +} + +// Test Advance method +func (s *ImplementationSuite) TestAdvance() { + require := s.Require() + ctx := context.Background() + expectedHash := randomFakeHash() + + // Test successful advance (accepted) + mockBackend := NewMockBackend() + mockBackend.SetupAccepted(AdvanceStateRequest) + + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + input := []byte("test input") + resp, err := machine.Advance(ctx, input, expectedHash, false) + require.NoError(err) + require.NotNil(resp) + require.Equal(CompletionStatusAccepted, resp.Status) + require.Zero(resp.FinalStateEntry) + require.Empty(resp.Outputs) + require.Empty(resp.Reports) + mockBackend.AssertExpectations(s.T()) + + // Test advance with rejection + mockBackend2 := NewMockBackend() + mockBackend2.SetupRejected(AdvanceStateRequest) + machine2 := &machineImpl{ + backend: mockBackend2, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + resp, err = machine2.Advance(ctx, input, expectedHash, false) + require.NoError(err) + require.NotNil(resp) + require.Equal(CompletionStatusRejected, resp.Status) + require.Empty(resp.Outputs) + require.Empty(resp.Reports) + mockBackend2.AssertExpectations(s.T()) + + // Test advance with exception + mockBackend3 := NewMockBackend() + mockBackend3.SetupException(AdvanceStateRequest) + machine3 := &machineImpl{ + backend: mockBackend3, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + resp, err = machine3.Advance(ctx, input, expectedHash, false) + require.NoError(err) + require.NotNil(resp) + require.Equal(CompletionStatusException, resp.Status) + require.Equal([]byte("exception data"), resp.ExceptionData) + mockBackend3.AssertExpectations(s.T()) + + // Halting is also a completed deterministic status and retains + // the PRT input hash collection produced before the halt. + expectedHaltHash := randomFakeHash() + mockBackendHalted := NewMockBackend() + mockBackendHalted.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackendHalted.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil).Once() + mockBackendHalted.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(100), nil).Once() + mockBackendHalted.SetupRequestYield() + mockBackendHalted.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), mock.Anything, mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + mockBackendHalted.On( + "RunAndCollectRootHashes", + mock.AnythingOfType("uint64"), + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, 100, true, expectedHaltHash, expectedHaltHash) + }).Return(Halted, nil) + machineHalted := &machineImpl{ + backend: mockBackendHalted, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second * 10, + }, + } + resp, err = machineHalted.Advance(ctx, input, expectedHash, true) + require.NoError(err) + require.NotNil(resp) + require.Equal(CompletionStatusHalted, resp.Status) + require.Equal(Hash(model.FinalStateEntry(expectedHaltHash)), resp.FinalStateEntry) + require.Empty(resp.PeriodicStateHashes) + require.Equal(InputEntryCapacity, resp.PaddingRepetitions) + mockBackendHalted.AssertExpectations(s.T()) + + // A halt between sampling boundaries still supplies its final entry. + offBoundaryHaltHash := randomFakeHash() + mockBackendHaltedOff := NewMockBackend() + mockBackendHaltedOff.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackendHaltedOff.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil).Once() + mockBackendHaltedOff.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(101), nil).Once() + mockBackendHaltedOff.SetupRequestYield() + mockBackendHaltedOff.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), mock.Anything, mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + mockBackendHaltedOff.On( + "RunAndCollectRootHashes", + mock.AnythingOfType("uint64"), + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, 101, true, offBoundaryHaltHash, offBoundaryHaltHash) + }).Return(Halted, nil) + machineHaltedOff := &machineImpl{ + backend: mockBackendHaltedOff, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second * 10, + }, + } + resp, err = machineHaltedOff.Advance(ctx, input, expectedHash, true) + require.NoError(err) + require.NotNil(resp) + require.Equal(CompletionStatusHalted, resp.Status) + require.Empty(resp.PeriodicStateHashes) + require.Equal(InputEntryCapacity, resp.PaddingRepetitions) + mockBackendHaltedOff.AssertExpectations(s.T()) + + // Test advance with payload too large + mockBackend4 := NewMockBackend() + mockBackend4.On("CmioRxBufferSize").Return(uint64(5)) + machine4 := &machineImpl{ + backend: mockBackend4, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + largeInput := make([]byte, 10) + resp, err = machine4.Advance(ctx, largeInput, expectedHash, false) + require.ErrorIs(err, ErrPayloadLengthLimitExceeded) + require.Nil(resp) + mockBackend4.AssertExpectations(s.T()) + + // A configured target beyond uint64 saturates at MaxUint64, just like the + // emulator's imcyclemax. The emulator's overflow break reason completes the + // run even when the saturated local target is the same cycle. + mockBackendOverflow := NewMockBackend() + mockBackendOverflow.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackendOverflow.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(^uint64(0)-uint64(999), nil).Once() + mockBackendOverflow.SetupRequestYield() + mockBackendOverflow.On("SendCmioResponse", uint16(AdvanceStateRequest), input, expectedHash, + mock.AnythingOfType("time.Duration")).Return(nil).Once() + mockBackendOverflow.On("Run", ^uint64(0), mock.AnythingOfType("time.Duration")). + Return(McycleOverflow, nil).Once() + mockBackendOverflow.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(^uint64(0), nil).Once() + machineOverflow := &machineImpl{ + backend: mockBackendOverflow, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: ^uint64(0), + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + AdvanceMaxCycles: 1000, + }, + } + resp, err = machineOverflow.Advance(ctx, input, expectedHash, false) + require.NoError(err) + require.NotNil(resp) + require.Equal(CompletionStatusOverflow, resp.Status) + mockBackendOverflow.AssertExpectations(s.T()) + + // Test advance with invalid hash length + mockBackend5 := NewMockBackend() + mockBackend5.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackend5.SetupRequestYield() + mockBackend5.On( + "SendCmioResponse", uint16(AdvanceStateRequest), mock.Anything, + expectedHash, mock.AnythingOfType("time.Duration"), + ).Return(nil) + mockBackend5.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend5.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + mockBackend5.SetupManualYield(ManualYieldReasonAccepted, 16) + machine5 := &machineImpl{ + backend: mockBackend5, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + resp, err = machine5.Advance(ctx, input, expectedHash, false) + require.NoError(err) + require.NotNil(resp) + require.Equal(CompletionStatusInvalidOutputsRoot, resp.Status) + mockBackend5.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestCompletedAdvanceIsIndependentOfAdequateIncrementSettings() { + expectedOutputsHash := randomFakeHash() + expectedHashes := []Hash{randomFakeHash(), randomFakeHash()} + configs := []model.ExecutionParameters{ + { + FastDeadline: time.Second, + AdvanceIncCycles: 2, + AdvanceIncDeadline: 10 * time.Millisecond, + AdvanceMaxDeadline: time.Second, + }, + { + FastDeadline: 2 * time.Second, + AdvanceIncCycles: 4, + AdvanceIncDeadline: 100 * time.Millisecond, + AdvanceMaxDeadline: 2 * time.Second, + }, + { + FastDeadline: 3 * time.Second, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: 3 * time.Second, + }, + } + expectedTargets := [][]uint64{ + {2, 4, 5, 7, 8, 10}, + {4, 7, 10}, + {100, 103, 106}, + } + + var baseline *AdvanceResponse + for i, params := range configs { + s.Run(fmt.Sprintf("configuration-%d", i), func() { + backend := &statefulAdvanceBackend{ + outputsHash: expectedOutputsHash, + hashes: expectedHashes, + } + machine := &machineImpl{backend: backend, logger: s.logger, params: params} + + result, err := machine.Advance(context.Background(), []byte("stable-input"), Hash{}, true) + s.Require().NoError(err) + s.Require().NotNil(result) + s.Require().Equal(expectedTargets[i], backend.runTargets) + if baseline == nil { + baseline = result + } else { + s.Require().Equal(baseline, result) + } + }) + } +} + +func (s *ImplementationSuite) TestPRTHashCollectionIsIndependentOfConfiguredCycleCap() { + const configuredCap uint64 = 10 + expectedOutputsHash := randomFakeHash() + expectedHashes := []Hash{randomFakeHash(), randomFakeHash()} + + runAdvance := func(maxCycles uint64) (*AdvanceResponse, []uint64) { + backend := &statefulAdvanceBackend{ + outputsHash: expectedOutputsHash, + hashes: expectedHashes, + } + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + AdvanceMaxCycles: maxCycles, + }, + } + + result, err := machine.Advance(context.Background(), []byte("stable-input"), Hash{}, true) + s.Require().NoError(err) + s.Require().NotNil(result) + return result, backend.runTargets + } + + uncapped, uncappedTargets := runAdvance(0) + capped, cappedTargets := runAdvance(configuredCap) + maximumCap, maximumCapTargets := runAdvance(model.MaxExecutionCycleSpan) + + s.Equal([]uint64{100, 103, 106}, uncappedTargets) + s.Equal([]uint64{configuredCap, configuredCap, configuredCap}, cappedTargets) + s.NotEqual(uncappedTargets, cappedTargets, "configured cap must alter the backend run targets") + s.Equal(uncapped, capped) + s.Equal(uncapped.PeriodicStateHashes, capped.PeriodicStateHashes) + s.Equal(uncapped.PaddingRepetitions, capped.PaddingRepetitions) + expected := &HashCollectorState{MCycleSamplingPeriod: MCycleComputationHashPeriod, + Log2BundleMCycleCount: int32(Log2StateHashesPerEntry)} + collectTestEntries(expected, 9, true, expectedHashes[0], Hash{0xF1, 0xED}) + kept, padding, final, err := model.CanonicalInputHashCollection(expected.Hashes, 9) + s.Require().NoError(err) + s.Equal(kept, capped.PeriodicStateHashes) + s.Equal(padding, capped.PaddingRepetitions) + s.Equal(final, capped.FinalStateEntry) + s.Equal(uncappedTargets, maximumCapTargets) + s.Equal(uncapped, maximumCap, + "the largest valid configured span must be canonically identical to no operator cap") +} + +// statefulAdvanceBackend models a guest that produces an output at mcycle 3, +// a report at mcycle 6, and its final accepted yield at mcycle 9. Small +// increments reach intermediate targets; large increments stop early at the +// same guest events. This makes the scheduling-invariance test exercise the +// real run loop rather than preprogramming one yield per call. +type statefulAdvanceBackend struct { + delivered bool + cycle uint64 + runTargets []uint64 + outputsHash Hash + hashes []Hash +} + +func (b *statefulAdvanceBackend) run(mcycleEnd uint64) (BreakReason, error) { + b.runTargets = append(b.runTargets, mcycleEnd) + if mcycleEnd <= b.cycle { + return Failed, fmt.Errorf("run target %d is not ahead of cycle %d", mcycleEnd, b.cycle) + } + var eventCycle uint64 + var reason BreakReason + switch { + case b.cycle < 3: + eventCycle, reason = 3, YieldedAutomatically + case b.cycle < 6: + eventCycle, reason = 6, YieldedAutomatically + case b.cycle < 9: + eventCycle, reason = 9, YieldedManually + default: + return Failed, errors.New("scripted advance already completed") + } + if mcycleEnd < eventCycle { + b.cycle = mcycleEnd + return ReachedTargetMcycle, nil + } + b.cycle = eventCycle + return reason, nil +} + +func (b *statefulAdvanceBackend) Load(string, string, time.Duration) error { return nil } +func (b *statefulAdvanceBackend) HashFunction(time.Duration) (string, error) { + return testKeccakHashFunction, nil +} +func (b *statefulAdvanceBackend) MachineIdentity(time.Duration) (Identity, error) { + pristine := Hash(model.UarchPristineStateHash()) + return Identity{MarchID: model.CartesiMachineMarchID, UarchStateHash: pristine, UarchPristineStateHash: pristine}, nil +} +func (b *statefulAdvanceBackend) Store(string, time.Duration) error { return nil } +func (b *statefulAdvanceBackend) Run(end uint64, _ time.Duration) (BreakReason, error) { + return b.run(end) +} +func (b *statefulAdvanceBackend) RunAndCollectRootHashes( + end uint64, + state *HashCollectorState, + _ time.Duration, +) (BreakReason, error) { + reason, err := b.run(end) + if err == nil { + collectTestEntries(state, b.cycle, isFixedPointBreakReason(reason), b.hashes[0], Hash{0xF1, 0xED}) + } + return reason, err +} +func (b *statefulAdvanceBackend) IsAtManualYield(time.Duration) (bool, error) { + return !b.delivered || b.cycle == 9, nil +} +func (b *statefulAdvanceBackend) ReadMCycle(time.Duration) (uint64, error) { return b.cycle, nil } +func (b *statefulAdvanceBackend) SendCmioResponse(reason uint16, data []byte, _ *Hash, _ time.Duration) error { + if reason != uint16(AdvanceStateRequest) || string(data) != "stable-input" { + return fmt.Errorf("unexpected CMIO response reason=%d data_len=%d", reason, len(data)) + } + b.delivered = true + return nil +} +func (b *statefulAdvanceBackend) ReceiveCmioRequest(time.Duration) (uint8, uint16, []byte, error) { + switch b.cycle { + case 3: + return 0, uint16(AutomaticYieldReasonOutput), []byte("stable-output"), nil + case 6: + return 0, uint16(AutomaticYieldReasonReport), []byte("stable-report"), nil + case 9: + return 0, uint16(ManualYieldReasonAccepted), b.outputsHash[:], nil + default: + return 0, 0, nil, fmt.Errorf("no CMIO request at cycle %d", b.cycle) + } +} +func (b *statefulAdvanceBackend) WriteMemory(uint64, []byte, time.Duration) error { return nil } +func (b *statefulAdvanceBackend) ReadMemory(address, length uint64, _ time.Duration) ([]byte, error) { + if address != htifTohostAddress || length != 8 || (b.delivered && b.cycle != 9) { + return nil, errors.New("unexpected scripted memory read") + } + return binary.LittleEndian.AppendUint64(nil, acceptedTohostTestValue()|HashSize), nil +} +func (b *statefulAdvanceBackend) GetRootHash(time.Duration) (Hash, error) { return Hash{}, nil } +func (b *statefulAdvanceBackend) GetProof(uint64, int32, int32, time.Duration) (MemoryProof, error) { + return MemoryProof{}, nil +} +func (b *statefulAdvanceBackend) Delete() {} +func (b *statefulAdvanceBackend) ForkServer(time.Duration) (Backend, string, uint32, error) { + return nil, "", 0, errors.New("not implemented") +} +func (b *statefulAdvanceBackend) ShutdownServer(time.Duration) error { return nil } +func (b *statefulAdvanceBackend) NewMachineRuntimeConfig() (string, error) { + return "{}", nil +} +func (b *statefulAdvanceBackend) CmioRxBufferSize() uint64 { return 1024 } + +func (s *ImplementationSuite) TestInterruptedAdvanceReturnsNilAndCanBeRetried() { + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), + []byte("retry-input"), + mock.Anything, + mock.AnythingOfType("time.Duration"), + ).Return(nil) + backend.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")). + Return(YieldedManually, nil) + backend.SetupManualYield(ManualYieldReasonAccepted, HashSize) + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result, err := machine.Advance(ctx, []byte("retry-input"), Hash{}, false) + s.Require().ErrorIs(err, ErrCanceled) + s.Require().Nil(result) + + result, err = machine.Advance(context.Background(), []byte("retry-input"), Hash{}, false) + s.Require().NoError(err) + s.Require().NotNil(result) + s.Require().Equal(CompletionStatusAccepted, result.Status) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestRunUsesHardExecutionSpanWhenMaximumIsZero() { + const startCycle uint64 = 7 + // The fixed window mirrors the emulator's imcyclemax: 2^48 - 1 ahead. + const executionCycleSpan uint64 = (1 << Log2MaxMCyclesPerAdvanceState) - 1 + + tests := []struct { + name string + reqType requestType + params model.ExecutionParameters + }{ + { + name: "advance", + reqType: AdvanceStateRequest, + params: model.ExecutionParameters{ + AdvanceIncCycles: ^uint64(0), + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + }, + }, + { + name: "inspect", + reqType: InspectStateRequest, + params: model.ExecutionParameters{ + InspectIncCycles: ^uint64(0), + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second, + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + mockBackend := NewMockBackend() + mockBackend.On("Run", startCycle+executionCycleSpan, mock.AnythingOfType("time.Duration")). + Return(McycleOverflow, nil) + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(startCycle+executionCycleSpan, nil).Once() + + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: tt.params, + } + + result, err := machine.run( + context.Background(), tt.reqType, false, + executionBounds{ + start: startCycle, limit: startCycle + executionCycleSpan, + span: executionCycleSpan, + }, + ) + s.Require().ErrorIs(err, ErrMcycleOverflow) + s.Empty(result.outputs) + s.Empty(result.reports) + mockBackend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestExecutionCycleBoundsAcceptsMaxSafeStart() { + const maxSafeStart uint64 = ^uint64(0) - model.MaxExecutionCycleSpan + + mockBackend := NewMockBackend() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(maxSafeStart, nil) + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second, + AdvanceIncCycles: 1, + }, + } + + bounds, err := machine.executionCycleBounds(context.Background(), AdvanceStateRequest) + s.Require().NoError(err) + s.Equal(maxSafeStart, bounds.start) + s.Equal(maxSafeStart+model.MaxExecutionCycleSpan, bounds.limit) + s.Equal(model.MaxExecutionCycleSpan, bounds.span) + s.False(bounds.configured) + mockBackend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestExecutionCycleBoundsUsesRequestSpecificMaximum() { + const start uint64 = 17 + for _, test := range []struct { + name string + reqType requestType + span uint64 + params model.ExecutionParameters + }{ + { + name: "advance configured", reqType: AdvanceStateRequest, span: 101, + params: model.ExecutionParameters{AdvanceIncCycles: 1, AdvanceMaxCycles: 101}, + }, + { + name: "inspect configured", reqType: InspectStateRequest, span: 202, + params: model.ExecutionParameters{InspectIncCycles: 1, InspectMaxCycles: 202}, + }, + { + name: "advance zero uses fixed", reqType: AdvanceStateRequest, span: model.MaxExecutionCycleSpan, + params: model.ExecutionParameters{AdvanceIncCycles: 1}, + }, + { + name: "inspect zero uses fixed", reqType: InspectStateRequest, span: model.MaxExecutionCycleSpan, + params: model.ExecutionParameters{InspectIncCycles: 1}, + }, + } { + s.Run(test.name, func() { + backend := NewMockBackend() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil) + machine := &machineImpl{backend: backend, logger: s.logger, params: test.params} + + bounds, err := machine.executionCycleBounds(context.Background(), test.reqType) + s.Require().NoError(err) + s.Equal(start, bounds.start) + s.Equal(start+test.span, bounds.limit) + s.Equal(test.span, bounds.span) + if test.params.AdvanceMaxCycles != 0 || test.params.InspectMaxCycles != 0 { + s.True(bounds.configured) + } else { + s.False(bounds.configured) + } + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestExecutionCycleBoundsRejectsMaximumAboveHardLimit() { + for _, reqType := range []requestType{AdvanceStateRequest, InspectStateRequest} { + s.Run(reqType.String(), func() { + backend := NewMockBackend() + params := model.ExecutionParameters{ + AdvanceIncCycles: 1, + AdvanceMaxCycles: model.MaxExecutionCycles, + InspectIncCycles: 1, + } + if reqType == InspectStateRequest { + params.AdvanceMaxCycles = 0 + params.InspectMaxCycles = model.MaxExecutionCycles + } + machine := &machineImpl{backend: backend, logger: s.logger, params: params} + + _, err := machine.executionCycleBounds(context.Background(), reqType) + s.Require().ErrorIs(err, ErrReachedLimitMcycle) + s.Contains(err.Error(), reqType.String()) + s.Contains(err.Error(), fmt.Sprint(model.MaxExecutionCycles)) + backend.AssertNotCalled(s.T(), "ReadMCycle", mock.Anything) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestProcessRejectsInvalidMaximumBeforeCMIO() { + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + AdvanceIncCycles: 1, + AdvanceMaxCycles: model.MaxExecutionCycles, + FastDeadline: time.Second, + }, + } + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + s.Require().Nil(response) + s.Require().ErrorIs(err, ErrReachedLimitMcycle) + backend.AssertNotCalled(s.T(), "ReadMCycle", mock.Anything) + backend.AssertNotCalled(s.T(), "SendCmioResponse", + mock.Anything, mock.Anything, mock.Anything, mock.Anything) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestProcessRejectsZeroIncrementBeforeCMIO() { + for _, test := range []struct { + name string + reqType requestType + invoke func(*machineImpl) error + }{ + { + name: "advance", + reqType: AdvanceStateRequest, + invoke: func(machine *machineImpl) error { + _, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + return err + }, + }, + { + name: "inspect", + reqType: InspectStateRequest, + invoke: func(machine *machineImpl) error { + _, err := machine.Inspect(context.Background(), []byte("query")) + return err + }, + }, + } { + s.Run(test.name, func() { + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)).Maybe() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(1), nil).Maybe() + params := model.ExecutionParameters{ + FastDeadline: time.Second, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second, + } + machine := &machineImpl{backend: backend, logger: s.logger, params: params} + + err := test.invoke(machine) + s.Require().Error(err) + s.Contains(err.Error(), test.reqType.String()) + s.Contains(err.Error(), "increment") + backend.AssertNotCalled(s.T(), "SendCmioResponse", + mock.Anything, mock.Anything, mock.Anything, mock.Anything) + backend.AssertNotCalled(s.T(), "Run", mock.Anything, mock.Anything) + backend.AssertNotCalled(s.T(), "RunAndCollectRootHashes", mock.Anything, mock.Anything, mock.Anything) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestConfiguredCycleEndpointCompletionSucceeds() { + const start, span = uint64(10), uint64(5) + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), []byte("input"), mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + backend.On("Run", start+span, mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start+span, nil).Once() + backend.SetupManualYield(ManualYieldReasonAccepted, HashSize) + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: span + 100, AdvanceMaxCycles: span, + AdvanceIncDeadline: time.Second, AdvanceMaxDeadline: time.Second, + FastDeadline: time.Second, + }} + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + s.Require().NoError(err) + s.Require().Equal(CompletionStatusAccepted, response.Status) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestConfiguredCycleEndpointHaltSucceeds() { + const start, span = uint64(20), uint64(7) + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), []byte("input"), mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + backend.On("Run", start+span, mock.AnythingOfType("time.Duration")).Return(Halted, nil) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start+span, nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: span + 100, AdvanceMaxCycles: span, + AdvanceIncDeadline: time.Second, AdvanceMaxDeadline: time.Second, + FastDeadline: time.Second, + }} + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + s.Require().NoError(err) + s.Require().Equal(CompletionStatusHalted, response.Status) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestConfiguredCycleExhaustionFails() { + const start, span = uint64(10), uint64(5) + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(InspectStateRequest), []byte("query"), mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + backend.On("Run", start+span, mock.AnythingOfType("time.Duration")).Return(ReachedTargetMcycle, nil) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start+span, nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + InspectIncCycles: span + 100, InspectMaxCycles: span, + InspectIncDeadline: time.Second, InspectMaxDeadline: time.Second, + FastDeadline: time.Second, + }} + + response, err := machine.Inspect(context.Background(), []byte("query")) + s.Require().NotNil(response) + s.Require().Equal(CompletionStatusUnknown, response.Status) + s.Require().Empty(response.Reports) + s.Require().ErrorIs(err, ErrReachedLimitMcycle) + s.Contains(err.Error(), "inspect execution reached configured cycle limit") + s.Contains(err.Error(), "start=10 requested_span=5") + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestAdvanceConfiguredCycleExhaustionReturnsNoResult() { + const start, span = uint64(30), uint64(9) + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), []byte("input"), mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + backend.On("Run", start+span, mock.AnythingOfType("time.Duration")).Return(ReachedTargetMcycle, nil) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start+span, nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: span + 100, AdvanceMaxCycles: span, + AdvanceIncDeadline: time.Second, AdvanceMaxDeadline: time.Second, + FastDeadline: time.Second, + }} + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + s.Require().Nil(response) + s.Require().ErrorIs(err, ErrReachedLimitMcycle) + s.Contains(err.Error(), "advance execution reached configured cycle limit") + s.Contains(err.Error(), "start=30 requested_span=9") + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestAdvanceMachineOverflowCompletesRun() { + const start = uint64(30) + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), []byte("input"), mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + // With no configured cap, the emulator enforces the span itself and reports + // the mcycle-overflow fixed point as a break reason. + backend.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")). + Return(McycleOverflow, nil) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(start+model.MaxExecutionCycleSpan, nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: ^uint64(0), + AdvanceIncDeadline: time.Second, AdvanceMaxDeadline: time.Second, + FastDeadline: time.Second, + }} + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Equal(CompletionStatusOverflow, response.Status) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestAdvanceConfiguredLimitTieCompletesWithMachineOverflow() { + const start = uint64(30) + configuredSpan := model.MaxExecutionCycleSpan + limit := start + configuredSpan + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), []byte("input"), mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil).Once() + backend.On("Run", limit, mock.AnythingOfType("time.Duration")).Return(McycleOverflow, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(limit, nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: ^uint64(0), + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + AdvanceMaxCycles: configuredSpan, + FastDeadline: time.Second, + }} + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Equal(CompletionStatusOverflow, response.Status) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestAdvanceStartingAtMachineMaximumCompletesWithOverflow() { + for _, test := range []struct { + name string + configuredMax uint64 + }{ + {name: "no operator cap", configuredMax: 0}, + {name: "configured cap also saturates", configuredMax: 1}, + } { + s.Run(test.name, func() { + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(^uint64(0), nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), []byte("input"), mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil).Once() + backend.On("Run", ^uint64(0), mock.AnythingOfType("time.Duration")). + Return(McycleOverflow, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(^uint64(0), nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: 1, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + AdvanceMaxCycles: test.configuredMax, + FastDeadline: time.Second, + }} + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, false) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Equal(CompletionStatusOverflow, response.Status) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestRunRejectsNonFixedPointReasonAtMachineMaximum() { + for _, breakReason := range []BreakReason{ReachedTargetMcycle, YieldedSoftly} { + s.Run(fmt.Sprintf("break reason %d", breakReason), func() { + backend := NewMockBackend() + backend.On("Run", ^uint64(0), mock.AnythingOfType("time.Duration")). + Return(breakReason, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(^uint64(0), nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + FastDeadline: time.Second, + }} + + result, err := machine.runIncrementInterval( + context.Background(), ^uint64(0), ^uint64(0), 1, nil, time.Second, + ) + + s.Equal(breakReason, result.breakReason) + s.Equal(^uint64(0), result.currentCycle) + s.Require().ErrorIs(err, ErrMachineInternal) + s.Contains(err.Error(), "instead of a fixed point at MaxUint64") + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestRunAcceptsFixedPointReasonAtMachineMaximum() { + for _, reason := range []BreakReason{Halted, YieldedManually, McycleOverflow} { + s.Run(fmt.Sprintf("break reason %d", reason), func() { + backend := NewMockBackend() + backend.On("Run", ^uint64(0), mock.AnythingOfType("time.Duration")).Return(reason, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(^uint64(0), nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger} + result, err := machine.runIncrementInterval(context.Background(), ^uint64(0), ^uint64(0), 1, nil, time.Second) + s.Require().NoError(err) + s.Equal(reason, result.breakReason) + s.Equal(^uint64(0), result.currentCycle) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestInspectInheritedMcycleOverflowCompletesRun() { + const start = uint64(100) + + for _, test := range []struct { + name string + configuredMax uint64 + requestedSpan uint64 + executedCycles uint64 + }{ + { + name: "before configured limit", + configuredMax: 50, + requestedSpan: 50, + executedCycles: 7, + }, + { + name: "at configured limit", + configuredMax: 7, + requestedSpan: 7, + executedCycles: 7, + }, + { + name: "before zero default fixed limit", + requestedSpan: model.MaxExecutionCycleSpan, + executedCycles: 7, + }, + } { + s.Run(test.name, func() { + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(InspectStateRequest), []byte("query"), nil, mock.AnythingOfType("time.Duration"), + ).Return(nil).Once() + backend.On("Run", start+test.requestedSpan, mock.AnythingOfType("time.Duration")). + Return(McycleOverflow, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(start+test.executedCycles, nil).Once() + + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + FastDeadline: time.Second, + InspectIncCycles: ^uint64(0), + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second, + InspectMaxCycles: test.configuredMax, + }} + + response, err := machine.Inspect(context.Background(), []byte("query")) + s.Require().NotNil(response) + s.Equal(CompletionStatusOverflow, response.Status) + s.Empty(response.Reports) + s.Require().NoError(err) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestExecutionCycleBoundsSaturatesAtMachineMaximum() { + for _, test := range []struct { + name string + start uint64 + configured uint64 + }{ + { + name: "configured cap beyond representable range", + start: ^uint64(0) - 10, configured: 11, + }, + { + name: "zero cap", + start: ^uint64(0) - model.MaxExecutionCycleSpan + 1, configured: 0, + }, + { + name: "maximum cap", + start: ^uint64(0) - model.MaxExecutionCycleSpan + 1, + configured: model.MaxExecutionCycleSpan, + }, + } { + s.Run(test.name, func() { + backend := NewMockBackend() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(test.start, nil) + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: 1, + AdvanceMaxCycles: test.configured, + }} + + bounds, err := machine.executionCycleBounds(context.Background(), AdvanceStateRequest) + s.Require().NoError(err) + s.Equal(test.start, bounds.start) + s.Equal(^uint64(0), bounds.limit) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestExecutionCycleBoundsUsesFullWindowWhenUncapped() { + + s.Run("window endpoint is start plus 2^48 minus 1", func() { + const start = uint64(1000) + backend := NewMockBackend() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(start, nil) + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + AdvanceIncCycles: 1, + }} + + bounds, err := machine.executionCycleBounds(context.Background(), AdvanceStateRequest) + s.Require().NoError(err) + s.Equal(start, bounds.start) + s.Equal(start+model.MaxExecutionCycleSpan, bounds.limit) + backend.AssertExpectations(s.T()) + }) +} + +func (s *ImplementationSuite) TestExecutionResponseLimitErrorsExposeOnlyCounts() { + for _, test := range []struct { + kind string + count int + capacity int + sentinel error + }{ + {kind: "output", count: maxOutputs + 1, capacity: maxOutputs, sentinel: ErrOutputsLimitExceeded}, + {kind: "report", count: maxReports + 1, capacity: maxReports, sentinel: ErrReportsLimitExceeded}, + } { + err := executionResponseLimitError( + AdvanceStateRequest, test.kind, test.count, test.capacity, test.sentinel, + ) + s.Require().ErrorIs(err, test.sentinel) + s.Contains(err.Error(), fmt.Sprintf("advance %s count %d", test.kind, test.count)) + s.Contains(err.Error(), fmt.Sprintf("capacity %d", test.capacity)) + } +} + +func (s *ImplementationSuite) TestInspectUsesConfiguredCycleIncrement() { + require := s.Require() + const startCycle uint64 = 7 + const inspectIncCycles uint64 = 137 + + mockBackend := NewMockBackend() + mockBackend.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(startCycle, nil).Once() + mockBackend.SetupRequestYield() + mockBackend.On( + "SendCmioResponse", + uint16(InspectStateRequest), mock.Anything, mock.Anything, mock.AnythingOfType("time.Duration"), + ).Return(nil) + mockBackend.On("Run", startCycle+inspectIncCycles, mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(startCycle+inspectIncCycles, nil).Once() + mockBackend.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(ManualYieldReasonAccepted), []byte(nil), nil) + + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second, + InspectIncCycles: inspectIncCycles, + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second, + }, + } + + response, err := machine.Inspect(context.Background(), []byte("query")) + require.NoError(err) + require.Equal(CompletionStatusAccepted, response.Status) + mockBackend.AssertExpectations(s.T()) +} + +// Test Inspect method +func (s *ImplementationSuite) TestInspect() { + require := s.Require() + ctx := context.Background() + + // Test successful inspect (accepted) + mockBackend := NewMockBackend() + mockBackend.SetupAccepted(InspectStateRequest) + + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + InspectIncCycles: 100, + InspectIncDeadline: time.Second * 1, + InspectMaxDeadline: time.Second * 10, + }, + } + + query := []byte("test query") + response, err := machine.Inspect(ctx, query) + require.NoError(err) + require.Equal(CompletionStatusAccepted, response.Status) + require.Empty(response.Reports) + mockBackend.AssertExpectations(s.T()) + + // Test inspect with rejection + mockBackend2 := NewMockBackend() + mockBackend2.SetupRejected(InspectStateRequest) + machine2 := &machineImpl{ + backend: mockBackend2, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + InspectIncCycles: 100, + InspectIncDeadline: time.Second * 1, + InspectMaxDeadline: time.Second * 10, + }, + } + response, err = machine2.Inspect(ctx, query) + require.NoError(err) + require.Equal(CompletionStatusRejected, response.Status) + require.Empty(response.Reports) + mockBackend2.AssertExpectations(s.T()) + + // Test inspect with exception + mockBackend3 := NewMockBackend() + mockBackend3.SetupException(InspectStateRequest) + machine3 := &machineImpl{ + backend: mockBackend3, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + InspectIncCycles: 100, + InspectIncDeadline: time.Second * 1, + InspectMaxDeadline: time.Second * 10, + }, + } + response, err = machine3.Inspect(ctx, query) + require.NoError(err) + require.Equal(CompletionStatusException, response.Status) + require.Equal([]byte("exception data"), response.ExceptionData) + require.Empty(response.Reports) + mockBackend3.AssertExpectations(s.T()) + + // A halt is a completed guest outcome. Reports emitted before the halt are + // part of that completed inspect response. + mockBackendHalted := NewMockBackend() + mockBackendHalted.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackendHalted.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil).Once() + mockBackendHalted.SetupRequestYield() + mockBackendHalted.On("SendCmioResponse", uint16(InspectStateRequest), query, nil, + mock.AnythingOfType("time.Duration")).Return(nil).Once() + mockBackendHalted.On("Run", uint64(100), mock.AnythingOfType("time.Duration")). + Return(YieldedAutomatically, nil).Once() + mockBackendHalted.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(50), nil).Once() + mockBackendHalted.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")). + Return(uint8(0), uint16(AutomaticYieldReasonReport), []byte("before halt"), nil).Once() + mockBackendHalted.On("Run", uint64(150), mock.AnythingOfType("time.Duration")). + Return(Halted, nil).Once() + mockBackendHalted.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(75), nil).Once() + machineHalted := &machineImpl{ + backend: mockBackendHalted, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + InspectIncCycles: 100, + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second * 10, + }, + } + response, err = machineHalted.Inspect(ctx, query) + require.NoError(err) + require.Equal(CompletionStatusHalted, response.Status) + require.Equal([]Report{[]byte("before halt")}, response.Reports) + mockBackendHalted.AssertExpectations(s.T()) + + // An internal stop is not a completed guest outcome. It returns Unknown + // with the partial report prefix and a typed execution error. + mockBackendFailed := NewMockBackend() + mockBackendFailed.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackendFailed.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil).Once() + mockBackendFailed.SetupRequestYield() + mockBackendFailed.On("SendCmioResponse", uint16(InspectStateRequest), query, nil, + mock.AnythingOfType("time.Duration")).Return(nil).Once() + mockBackendFailed.On("Run", uint64(100), mock.AnythingOfType("time.Duration")). + Return(YieldedAutomatically, nil).Once() + mockBackendFailed.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(50), nil).Once() + mockBackendFailed.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")). + Return(uint8(0), uint16(AutomaticYieldReasonReport), []byte("partial"), nil).Once() + mockBackendFailed.On("Run", uint64(150), mock.AnythingOfType("time.Duration")). + Return(Failed, nil).Once() + mockBackendFailed.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(75), nil).Once() + machineFailed := &machineImpl{ + backend: mockBackendFailed, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + InspectIncCycles: 100, + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second * 10, + }, + } + response, err = machineFailed.Inspect(ctx, query) + require.ErrorIs(err, ErrMachineInternal) + require.Equal(CompletionStatusUnknown, response.Status) + require.Equal([]Report{[]byte("partial")}, response.Reports) + mockBackendFailed.AssertExpectations(s.T()) + + // Test inspect with payload too large + mockBackend4 := NewMockBackend() + mockBackend4.On("CmioRxBufferSize").Return(uint64(5)) + machine4 := &machineImpl{ + backend: mockBackend4, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + InspectIncCycles: 100, + InspectIncDeadline: time.Second * 1, + InspectMaxDeadline: time.Second * 10, + }, + } + largeQuery := make([]byte, 10) + response, err = machine4.Inspect(ctx, largeQuery) + require.NotNil(response) + require.Equal(CompletionStatusUnknown, response.Status) + require.ErrorIs(err, ErrPayloadLengthLimitExceeded) + mockBackend4.AssertExpectations(s.T()) + + // Inspect uses the same saturating target as advance. The emulator's overflow + // break reason completes the run even though this inspect fork is disposable. + mockBackendOverflow := NewMockBackend() + mockBackendOverflow.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackendOverflow.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(^uint64(0)-uint64(999), nil).Once() + mockBackendOverflow.SetupRequestYield() + mockBackendOverflow.On("SendCmioResponse", uint16(InspectStateRequest), query, nil, + mock.AnythingOfType("time.Duration")).Return(nil).Once() + mockBackendOverflow.On("Run", ^uint64(0), mock.AnythingOfType("time.Duration")). + Return(McycleOverflow, nil).Once() + mockBackendOverflow.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(^uint64(0), nil).Once() + machineOverflow := &machineImpl{ + backend: mockBackendOverflow, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second, + InspectIncCycles: ^uint64(0), + InspectIncDeadline: time.Second, + InspectMaxDeadline: time.Second, + InspectMaxCycles: 1000, + }, + } + response, err = machineOverflow.Inspect(ctx, query) + require.NotNil(response) + require.Equal(CompletionStatusOverflow, response.Status) + require.Empty(response.Reports) + require.NoError(err) + mockBackendOverflow.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestAdvanceCanonicalizesTerminalBoundaryHash() { + for _, test := range []struct { + name string + yieldReason manualYieldReason + status CompletionStatus + terminalAtBoundary bool + sameRunPriorBoundary bool + immediateYield bool + }{ + { + name: "accepted at first boundary", + yieldReason: ManualYieldReasonAccepted, + status: CompletionStatusAccepted, + terminalAtBoundary: true, + }, + { + name: "rejected at first boundary", + yieldReason: ManualYieldReasonRejected, + status: CompletionStatusRejected, + terminalAtBoundary: true, + }, + { + name: "exception at first boundary", + yieldReason: ManualYieldReasonException, + status: CompletionStatusException, + terminalAtBoundary: true, + }, + { + name: "unexpected yield at first boundary", + yieldReason: manualYieldReason(0x7f), + status: CompletionStatusUnexpectedYield, + terminalAtBoundary: true, + }, + { + name: "off-boundary terminal keeps prior boundary", + yieldReason: ManualYieldReasonAccepted, + status: CompletionStatusAccepted, + terminalAtBoundary: false, + }, + { + name: "same run prior boundary survives off-boundary terminal", + yieldReason: ManualYieldReasonAccepted, + status: CompletionStatusAccepted, + sameRunPriorBoundary: true, + }, + { + name: "immediate off-boundary yield drops its fixed-point sample", + yieldReason: ManualYieldReasonAccepted, + status: CompletionStatusAccepted, + immediateYield: true, + }, + } { + s.Run(test.name, func() { + collectedHash := randomFakeHash() + terminalSample := randomFakeHash() + outputsHash := randomFakeHash() + + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(uint64(0), nil).Once() + backend.SetupRequestYield() + backend.On( + "SendCmioResponse", + uint16(AdvanceStateRequest), []byte("input"), mock.Anything, mock.AnythingOfType("time.Duration"), + ). + Return(nil).Once() + switch { + case test.terminalAtBoundary: + backend.On( + "RunAndCollectRootHashes", + MCycleComputationHashPeriod, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, MCycleComputationHashPeriod, true, collectedHash, terminalSample) + }).Return(YieldedManually, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(MCycleComputationHashPeriod, nil).Once() + case test.sameRunPriorBoundary: + // The same call crosses a sampling boundary and reaches a fixed point. + backend.On( + "RunAndCollectRootHashes", + 2*MCycleComputationHashPeriod, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, MCycleComputationHashPeriod+1, true, collectedHash, terminalSample) + }).Return(YieldedManually, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(MCycleComputationHashPeriod+1, nil).Once() + case test.immediateYield: + // A yield before the first periodic sample still produces final padding. + backend.On( + "RunAndCollectRootHashes", + MCycleComputationHashPeriod, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, 1, true, collectedHash, terminalSample) + }).Return(YieldedManually, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(uint64(1), nil).Once() + default: + // The first call reaches and records a real prior boundary. + backend.On( + "RunAndCollectRootHashes", + MCycleComputationHashPeriod, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, MCycleComputationHashPeriod, false, collectedHash, terminalSample) + }).Return(ReachedTargetMcycle, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(MCycleComputationHashPeriod, nil).Once() + // A terminal call completes the retained partial bundle. + backend.On( + "RunAndCollectRootHashes", + 2*MCycleComputationHashPeriod, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, MCycleComputationHashPeriod+1, true, collectedHash, terminalSample) + }).Return(YieldedManually, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(MCycleComputationHashPeriod+1, nil).Once() + } + backend.SetupManualYield(test.yieldReason, HashSize) + if test.yieldReason != ManualYieldReasonAccepted { + backend.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")). + Return(uint8(0), uint16(test.yieldReason), outputsHash[:], nil).Once() + } + + advanceIncrement := MCycleComputationHashPeriod + if test.sameRunPriorBoundary { + advanceIncrement = 2 * MCycleComputationHashPeriod + } + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second, + AdvanceIncCycles: advanceIncrement, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + }, + } + + response, err := machine.Advance(context.Background(), []byte("input"), Hash{}, true) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Equal(test.status, response.Status) + delta := MCycleComputationHashPeriod + 1 + if test.terminalAtBoundary { + delta = MCycleComputationHashPeriod + } else if test.immediateYield { + delta = 1 + } + expected := &HashCollectorState{MCycleSamplingPeriod: MCycleComputationHashPeriod, + Log2BundleMCycleCount: int32(Log2StateHashesPerEntry)} + collectTestEntries(expected, delta, true, collectedHash, terminalSample) + kept, padding, final, err := model.CanonicalInputHashCollection(expected.Hashes, delta) + s.Require().NoError(err) + s.Equal(kept, response.PeriodicStateHashes) + s.Equal(padding, response.PaddingRepetitions) + s.Equal(final, response.FinalStateEntry) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestRunCanonicalizesOverflowCollection() { + const fixedEndpoint uint64 = model.MaxExecutionCycleSpan + const startCycle uint64 = fixedEndpoint - mcycleComputationHashChunkSize + terminalSample := randomFakeHash() + backend := NewMockBackend() + backend.On( + "RunAndCollectRootHashes", + fixedEndpoint, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + collectTestEntries(state, fixedEndpoint-startCycle, true, terminalSample, terminalSample) + }).Return(McycleOverflow, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(fixedEndpoint, nil).Once() + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + AdvanceIncCycles: fixedEndpoint, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + }, + } + + result, err := machine.run( + context.Background(), AdvanceStateRequest, true, testExecutionBounds(startCycle, fixedEndpoint), + ) + + s.Require().ErrorIs(err, ErrMcycleOverflow) + s.Empty(result.outputs) + s.Empty(result.reports) + expected := &HashCollectorState{MCycleSamplingPeriod: MCycleComputationHashPeriod, + Log2BundleMCycleCount: int32(Log2StateHashesPerEntry)} + collectTestEntries(expected, fixedEndpoint-startCycle, true, terminalSample, terminalSample) + kept, padding, final, spanErr := model.CanonicalInputHashCollection(expected.Hashes, fixedEndpoint-startCycle) + s.Require().NoError(spanErr) + s.Equal(kept, result.periodicStateHashes) + s.Equal(padding, result.paddingRepetitions) + s.Equal(final, result.finalStateEntry) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestRunCapsCollectorChunksAndPreservesCollectionState() { + firstPeriodicHash := randomFakeHash() + terminalHash := randomFakeHash() + backend := NewMockBackend() + backend.On( + "RunAndCollectRootHashes", + mcycleComputationHashChunkSize, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + s.Zero(state.MCyclePhase) + s.Empty(state.Hashes) + collectTestEntries(state, mcycleComputationHashChunkSize, false, firstPeriodicHash, terminalHash) + }).Return(ReachedTargetMcycle, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(mcycleComputationHashChunkSize, nil).Once() + backend.On( + "RunAndCollectRootHashes", + 2*mcycleComputationHashChunkSize, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + s.Zero(state.MCyclePhase) + // Each stored entry covers 2^24 mcycles. The chunk is an exact multiple. + s.Equal(mcycleComputationHashChunkSize/(uint64(1)<<24), uint64(len(state.Hashes))) + collectTestEntries(state, 2*mcycleComputationHashChunkSize, true, firstPeriodicHash, terminalHash) + }).Return(YieldedManually, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(2*mcycleComputationHashChunkSize, nil).Once() + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + AdvanceIncCycles: 3 * mcycleComputationHashChunkSize, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + }, + } + + result, err := machine.run( + context.Background(), AdvanceStateRequest, true, + testExecutionBounds(0, 3*mcycleComputationHashChunkSize), + ) + + s.Require().NoError(err) + expected := &HashCollectorState{MCycleSamplingPeriod: MCycleComputationHashPeriod, + Log2BundleMCycleCount: int32(Log2StateHashesPerEntry)} + collectTestEntries(expected, 2*mcycleComputationHashChunkSize, true, firstPeriodicHash, terminalHash) + kept, padding, final, spanErr := model.CanonicalInputHashCollection(expected.Hashes, 2*mcycleComputationHashChunkSize) + s.Require().NoError(spanErr) + s.Equal(kept, result.periodicStateHashes) + s.Equal(padding, result.paddingRepetitions) + s.Equal(final, result.finalStateEntry) + backend.AssertExpectations(s.T()) +} + +func (s *ImplementationSuite) TestRunRejectsFixedPointWithoutAppendedHash() { + backend := NewMockBackend() + backend.On( + "RunAndCollectRootHashes", + MCycleComputationHashPeriod, + mock.AnythingOfType("*machine.HashCollectorState"), + mock.AnythingOfType("time.Duration"), + ).Run(func(args mock.Arguments) { + state := args.Get(1).(*HashCollectorState) + state.PartialBundle = []byte(`{"leaf_count":1}`) + }).Return(YieldedManually, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(MCycleComputationHashPeriod, nil).Once() + machine := &machineImpl{ + backend: backend, + logger: s.logger, + params: model.ExecutionParameters{ + AdvanceIncCycles: MCycleComputationHashPeriod, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: time.Second, + }, + } + + _, err := machine.run( + context.Background(), AdvanceStateRequest, true, + testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + + s.Require().ErrorIs(err, ErrMachineInternal) + s.Contains(err.Error(), "without appending its state hash") + backend.AssertExpectations(s.T()) +} + +// Test Store method +func (s *ImplementationSuite) TestStore() { + require := s.Require() + ctx := context.Background() + + // Test successful store + mockBackend := NewMockBackend() + mockBackend.On("Store", "/tmp/test", mock.AnythingOfType("time.Duration")).Return(nil) + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + StoreDeadline: time.Second * 5, + }, + } + + err := machine.Store(ctx, "/tmp/test") + require.NoError(err) + mockBackend.AssertExpectations(s.T()) + + // Test store with backend error + mockBackend2 := NewMockBackend() + mockBackend2.On("Store", "/tmp/test", mock.AnythingOfType("time.Duration")).Return(errors.New("store failed")) + machine2 := &machineImpl{ + backend: mockBackend2, + logger: s.logger, + params: model.ExecutionParameters{ + StoreDeadline: time.Second * 5, + }, + } + err = machine2.Store(ctx, "/tmp/test") + require.Error(err) + require.ErrorIs(err, ErrMachineInternal) + require.Contains(err.Error(), "could not store the machine state") + + // Test store with canceled context + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + err = machine.Store(canceledCtx, "/tmp/test") + require.ErrorIs(err, ErrCanceled) + mockBackend2.AssertExpectations(s.T()) +} + +// Test Close method +func (s *ImplementationSuite) TestClose() { + require := s.Require() + + // Test successful close + mockBackend := NewMockBackend() + mockBackend.On("ShutdownServer", mock.AnythingOfType("time.Duration")).Return(nil) + mockBackend.On("Delete").Return() + machine := &machineImpl{ + backend: mockBackend, + address: testMachineAddress, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + + err := machine.Close() + require.NoError(err) + require.Nil(machine.backend) + mockBackend.AssertExpectations(s.T()) + + // Test close with nil backend + err = machine.Close() + require.NoError(err) + + // Test close with shutdown error + mockBackend2 := NewMockBackend() + mockBackend2.On("ShutdownServer", mock.AnythingOfType("time.Duration")).Return(errors.New("shutdown failed")) + mockBackend2.On("Delete").Return() + machine2 := &machineImpl{ + backend: mockBackend2, + address: testMachineAddress, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + + err = machine2.Close() + require.Error(err) + require.ErrorIs(err, ErrMachineInternal) + require.ErrorIs(err, ErrOrphanServer) + require.Nil(machine2.backend) + mockBackend2.AssertExpectations(s.T()) +} + +// Test Address method +func (s *ImplementationSuite) TestAddress() { + require := s.Require() + + machine := &machineImpl{ + address: testMachineAddress, + } + + address := machine.Address() + require.Equal(testMachineAddress, address) +} + +// Test helper methods +func (s *ImplementationSuite) TestHelperMethods() { + require := s.Require() + ctx := context.Background() + + // Test isAtManualYield + mockBackend := NewMockBackend() + mockBackend.On("IsAtManualYield", mock.AnythingOfType("time.Duration")).Return(true, nil) + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + + isAtYield, err := machine.isAtManualYield(ctx) + require.NoError(err) + require.True(isAtYield) + mockBackend.AssertExpectations(s.T()) + + mockBackend2 := NewMockBackend() + mockBackend2.On("IsAtManualYield", mock.AnythingOfType("time.Duration")).Return(false, errors.New("yield check failed")) + machine2 := &machineImpl{ + backend: mockBackend2, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + _, err = machine2.isAtManualYield(ctx) + require.Error(err) + require.ErrorIs(err, ErrMachineInternal) + mockBackend2.AssertExpectations(s.T()) + + // Test readManualYieldResult + mockBackend3 := NewMockBackend() + expectedHash3 := randomFakeHash() + mockBackend3.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(ManualYieldReasonAccepted), expectedHash3[:], nil) + machine3 := &machineImpl{ + backend: mockBackend3, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + manualResult, err := machine3.readManualYieldResult(ctx) + require.NoError(err) + require.Equal(CompletionStatusAccepted, manualResult.status) + require.NotNil(manualResult.data) + mockBackend3.AssertExpectations(s.T()) + + mockBackend4 := NewMockBackend() + expectedHash4 := randomFakeHash() + mockBackend4.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(ManualYieldReasonRejected), expectedHash4[:], nil) + machine4 := &machineImpl{ + backend: mockBackend4, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + manualResult, err = machine4.readManualYieldResult(ctx) + require.NoError(err) + require.Equal(CompletionStatusRejected, manualResult.status) + require.NotNil(manualResult.data) + mockBackend4.AssertExpectations(s.T()) + + mockBackend5 := NewMockBackend() + mockBackend5.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(ManualYieldReasonException), []byte("exception data"), nil) + machine5 := &machineImpl{ + backend: mockBackend5, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + manualResult, err = machine5.readManualYieldResult(ctx) + require.NoError(err) + require.Equal(CompletionStatusException, manualResult.status) + require.NotNil(manualResult.data) + mockBackend5.AssertExpectations(s.T()) + + mockUnexpected := NewMockBackend() + mockUnexpected.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(0x7f), []byte("unexpected"), nil) + machineUnexpected := &machineImpl{ + backend: mockUnexpected, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + manualResult, err = machineUnexpected.readManualYieldResult(ctx) + require.NoError(err) + require.Equal(CompletionStatusUnexpectedYield, manualResult.status) + require.Nil(manualResult.data) + mockUnexpected.AssertExpectations(s.T()) + + // Test readMCycle + mockBackend6 := NewMockBackend() + mockBackend6.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(12345), nil) + machine6 := &machineImpl{ + backend: mockBackend6, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + cycle, err := machine6.readMCycle(ctx) + require.NoError(err) + require.Equal(uint64(12345), cycle) + mockBackend6.AssertExpectations(s.T()) + + mockBackend7 := NewMockBackend() + mockBackend7.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), errors.New("read cycle failed")) + machine7 := &machineImpl{ + backend: mockBackend7, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + }, + } + _, err = machine7.readMCycle(ctx) + require.Error(err) + require.ErrorIs(err, ErrMachineInternal) + mockBackend7.AssertExpectations(s.T()) + + // Test with canceled context + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + + _, err = machine.isAtManualYield(canceledCtx) + require.ErrorIs(err, ErrCanceled) + + _, err = machine3.readManualYieldResult(canceledCtx) + require.ErrorIs(err, ErrCanceled) + + _, err = machine6.readMCycle(canceledCtx) + require.ErrorIs(err, ErrCanceled) +} + +// Test run method with various scenarios +func (s *ImplementationSuite) TestRun() { + require := s.Require() + ctx := context.Background() + + // Test run with manual yield (no outputs/reports) + mockBackend := NewMockBackend() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + result, err := machine.run( + ctx, AdvanceStateRequest, false, testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + require.NoError(err) + require.Empty(result.outputs) + require.Empty(result.reports) + mockBackend.AssertExpectations(s.T()) + + // Test run with read cycle error + mockBackend2 := NewMockBackend() + mockBackend2.On("Run", uint64(100), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + mockBackend2.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), errors.New("read cycle failed")) + machine2 := &machineImpl{ + backend: mockBackend2, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + _, err = machine2.run( + ctx, AdvanceStateRequest, false, testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + require.Error(err) + require.Contains(err.Error(), "read cycle failed") + mockBackend2.AssertExpectations(s.T()) + + // Test run with multiple steps + mockBackend3 := NewMockBackend() + mockBackend3.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend3.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(ReachedTargetMcycle, nil).Once() + mockBackend3.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil).Once() + + machine3 := &machineImpl{ + backend: mockBackend3, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + _, err = machine3.run( + ctx, AdvanceStateRequest, false, testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + require.NoError(err) + mockBackend3.AssertExpectations(s.T()) + + // Test run with ReceiveCmioRequest error during automatic yield + mockBackend4 := NewMockBackend() + mockBackend4.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend4.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedAutomatically, nil) + mockBackend4.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(0), []byte(nil), errors.New("cmio request failed")) + + machine4 := &machineImpl{ + backend: mockBackend4, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + _, err = machine4.run( + ctx, AdvanceStateRequest, false, testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + require.Error(err) + require.Contains(err.Error(), "could not read output/report") + require.Contains(err.Error(), "cmio request failed") + mockBackend4.AssertExpectations(s.T()) + + // Test run with automatic yield producing output then manual yield + mockBackend5 := NewMockBackend() + mockBackend5.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend5.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")). + Return(YieldedAutomatically, nil).Once() + mockBackend5.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")). + Return(YieldedManually, nil).Once() + mockBackend5.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(AutomaticYieldReasonOutput), []byte("output data"), nil).Once() + + machine5 := &machineImpl{ + backend: mockBackend5, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + result5, err := machine5.run( + ctx, AdvanceStateRequest, false, testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + require.NoError(err) + require.Len(result5.outputs, 1) + require.Equal([]byte("output data"), []byte(result5.outputs[0])) + require.Empty(result5.reports) + mockBackend5.AssertExpectations(s.T()) + + // Test run with automatic yield producing report then manual yield + mockBackend6 := NewMockBackend() + mockBackend6.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend6.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")). + Return(YieldedAutomatically, nil).Once() + mockBackend6.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")). + Return(YieldedManually, nil).Once() + mockBackend6.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(AutomaticYieldReasonReport), []byte("report data"), nil).Once() + + machine6 := &machineImpl{ + backend: mockBackend6, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + result6, err := machine6.run( + ctx, AdvanceStateRequest, false, testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + require.NoError(err) + require.Empty(result6.outputs) + require.Len(result6.reports, 1) + require.Equal([]byte("report data"), []byte(result6.reports[0])) + mockBackend6.AssertExpectations(s.T()) + +} + +func (s *ImplementationSuite) TestRunIncrementIntervalPreservesBreakReason() { + for _, test := range []struct { + name string + breakReason BreakReason + currentCycle uint64 + }{ + {"manual yield", YieldedManually, 150}, + {"automatic yield", YieldedAutomatically, 200}, + {"soft yield", YieldedSoftly, 150}, + {"target", ReachedTargetMcycle, 200}, + {"mcycle overflow", McycleOverflow, 199}, + {"halt", Halted, 175}, + {"failure", Failed, 160}, + } { + s.Run(test.name, func() { + backend := NewMockBackend() + backend.On("Run", uint64(200), mock.AnythingOfType("time.Duration")). + Return(test.breakReason, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(test.currentCycle, nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger} + + result, err := machine.runIncrementInterval( + context.Background(), 100, 1000, 100, nil, time.Second, + ) + + s.Require().NoError(err) + s.Equal(test.breakReason, result.breakReason) + s.Equal(test.currentCycle, result.currentCycle) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestRunIncrementIntervalErrors() { + s.Run("already at limit", func() { + machine := &machineImpl{backend: NewMockBackend(), logger: s.logger} + result, err := machine.runIncrementInterval( + context.Background(), 1000, 1000, 100, nil, time.Second, + ) + s.Require().ErrorIs(err, ErrReachedLimitMcycle) + s.Equal(uint64(1000), result.currentCycle) + }) + + s.Run("backend error", func() { + backend := NewMockBackend() + backend.On("Run", uint64(200), mock.AnythingOfType("time.Duration")). + Return(Failed, errors.New("run failed")).Once() + machine := &machineImpl{backend: backend, logger: s.logger} + _, err := machine.runIncrementInterval( + context.Background(), 100, 1000, 100, nil, time.Second, + ) + s.Require().ErrorContains(err, "run failed") + backend.AssertExpectations(s.T()) + }) + + s.Run("read cycle error", func() { + backend := NewMockBackend() + backend.On("Run", uint64(200), mock.AnythingOfType("time.Duration")). + Return(YieldedManually, nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(uint64(0), errors.New("read cycle failed")).Once() + machine := &machineImpl{backend: backend, logger: s.logger} + _, err := machine.runIncrementInterval( + context.Background(), 100, 1000, 100, nil, time.Second, + ) + s.Require().ErrorContains(err, "read cycle failed") + backend.AssertExpectations(s.T()) + }) + + s.Run("unknown break reason", func() { + backend := NewMockBackend() + backend.On("Run", uint64(200), mock.AnythingOfType("time.Duration")). + Return(BreakReason(99), nil).Once() + backend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(uint64(150), nil).Once() + machine := &machineImpl{backend: backend, logger: s.logger} + result, err := machine.runIncrementInterval( + context.Background(), 100, 1000, 100, nil, time.Second, + ) + s.Require().ErrorIs(err, ErrMachineInternal) + s.Equal(BreakReason(99), result.breakReason) + backend.AssertExpectations(s.T()) + }) +} + +// Test process method +func (s *ImplementationSuite) TestProcess() { + require := s.Require() + ctx := context.Background() + expectedHash := randomFakeHash() + + // Test successful process + mockBackend := NewMockBackend() + mockBackend.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackend.SetupRequestYield() + mockBackend.On( + "SendCmioResponse", mock.AnythingOfType("uint16"), mock.Anything, + expectedHash, mock.AnythingOfType("time.Duration"), + ).Return(nil) + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend.On("Run", mock.AnythingOfType("uint64"), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil) + mockBackend.SetupManualYield(ManualYieldReasonAccepted, HashSize) + + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + input := []byte("test input") + result, err := machine.process(ctx, input, AdvanceStateRequest, &expectedHash, false) + require.NoError(err) + require.Equal(CompletionStatusAccepted, result.completion.status) + require.Empty(result.outputs) + require.Empty(result.reports) + require.Nil(result.completion.data, "the root comes from the state proof, not a copied accepted payload") + mockBackend.AssertExpectations(s.T()) + + // A halt completes the request without producing a CMIO manual yield. + haltedBackend := NewMockBackend() + haltedBackend.On("CmioRxBufferSize").Return(uint64(1024)) + haltedBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(uint64(0), nil).Once() + haltedBackend.SetupRequestYield() + haltedBackend.On( + "SendCmioResponse", + mock.AnythingOfType("uint16"), mock.Anything, expectedHash, + mock.AnythingOfType("time.Duration"), + ).Return(nil).Once() + haltedBackend.On("Run", uint64(100), mock.AnythingOfType("time.Duration")). + Return(Halted, nil).Once() + haltedBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")). + Return(uint64(100), nil).Once() + haltedMachine := &machineImpl{ + backend: haltedBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: 5 * time.Second, + AdvanceMaxCycles: 1000, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second, + AdvanceMaxDeadline: 10 * time.Second, + }, + } + result, err = haltedMachine.process( + ctx, input, AdvanceStateRequest, &expectedHash, false, + ) + require.NoError(err) + require.Equal(CompletionStatusHalted, result.completion.status) + require.Nil(result.completion.data) + haltedBackend.AssertExpectations(s.T()) + + // Test process with payload too large + mockBackend2 := NewMockBackend() + mockBackend2.On("CmioRxBufferSize").Return(uint64(5)) + machine2 := &machineImpl{ + backend: mockBackend2, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + _, err = machine2.process(ctx, input, AdvanceStateRequest, &expectedHash, false) + require.ErrorIs(err, ErrPayloadLengthLimitExceeded) + require.Contains(err.Error(), "advance request payload length 10") + require.Contains(err.Error(), "capacity 5") + require.NotContains(err.Error(), string(input)) + mockBackend2.AssertExpectations(s.T()) + + // Test process with send error + mockBackend3 := NewMockBackend() + mockBackend3.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackend3.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), nil) + mockBackend3.SetupRequestYield() + mockBackend3.On("SendCmioResponse", + mock.AnythingOfType("uint16"), + mock.Anything, + expectedHash, + mock.AnythingOfType("time.Duration"), + ).Return(errors.New("send failed")) + machine3 := &machineImpl{ + backend: mockBackend3, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + _, err = machine3.process(ctx, input, AdvanceStateRequest, &expectedHash, false) + require.Error(err) + require.Contains(err.Error(), "send failed") + mockBackend3.AssertExpectations(s.T()) + + // Test process with run error + mockBackend4 := NewMockBackend() + mockBackend4.On("CmioRxBufferSize").Return(uint64(1024)) + mockBackend4.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(0), errors.New("read cycle failed")) + machine4 := &machineImpl{ + backend: mockBackend4, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + _, err = machine4.process(ctx, input, AdvanceStateRequest, &expectedHash, false) + require.Error(err) + require.Contains(err.Error(), "read cycle failed") + mockBackend4.AssertNotCalled(s.T(), "SendCmioResponse", mock.Anything, mock.Anything, mock.Anything) + mockBackend4.AssertExpectations(s.T()) +} + +// Test multiple automatic yields in sequence +func (s *ImplementationSuite) TestMultipleAutomaticYields() { + require := s.Require() + ctx := context.Background() + + mockBackend := NewMockBackend() + machine := &machineImpl{ + backend: mockBackend, + logger: s.logger, + params: model.ExecutionParameters{ + FastDeadline: time.Second * 5, + AdvanceIncCycles: 100, + AdvanceIncDeadline: time.Second * 1, + AdvanceMaxDeadline: time.Second * 10, + }, + } + + // Setup for multiple automatic yields followed by manual yield + // First automatic yield with output + mockBackend.On("Run", uint64(100), mock.AnythingOfType("time.Duration")).Return(YieldedAutomatically, nil).Once() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(10), nil).Once() + mockBackend.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(AutomaticYieldReasonOutput), []byte("output1"), nil).Once() + + // Second automatic yield with output + mockBackend.On("Run", uint64(110), mock.AnythingOfType("time.Duration")).Return(YieldedAutomatically, nil).Once() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(20), nil).Once() + mockBackend.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(AutomaticYieldReasonOutput), []byte(""), nil).Once() + + // Third automatic yield with report + mockBackend.On("Run", uint64(120), mock.AnythingOfType("time.Duration")).Return(YieldedAutomatically, nil).Once() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(30), nil).Once() + mockBackend.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(AutomaticYieldReasonReport), []byte("output2"), nil).Once() + + // Fourth automatic yield with report + mockBackend.On("Run", uint64(130), mock.AnythingOfType("time.Duration")).Return(YieldedAutomatically, nil).Once() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(40), nil).Once() + mockBackend.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(AutomaticYieldReasonReport), []byte(""), nil).Once() + + // Fifth automatic yield with progress (ignored) + mockBackend.On("Run", uint64(140), mock.AnythingOfType("time.Duration")).Return(YieldedAutomatically, nil).Once() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(50), nil).Once() + mockBackend.On("ReceiveCmioRequest", mock.AnythingOfType("time.Duration")).Return( + uint8(0), uint16(AutomaticYieldReasonProgress), []byte("progress"), nil).Once() + + // Final manual yield + mockBackend.On("Run", uint64(150), mock.AnythingOfType("time.Duration")).Return(YieldedManually, nil).Once() + mockBackend.On("ReadMCycle", mock.AnythingOfType("time.Duration")).Return(uint64(60), nil).Once() + + result, err := machine.run( + ctx, AdvanceStateRequest, false, testExecutionBounds(0, model.MaxExecutionCycleSpan), + ) + require.NoError(err) + + require.Len(result.outputs, 2) + require.Equal([]byte("output1"), result.outputs[0]) + require.Equal([]byte(""), result.outputs[1]) + + require.Len(result.reports, 2) + require.Equal([]byte("output2"), result.reports[0]) + require.Equal([]byte(""), result.reports[1]) + + mockBackend.AssertExpectations(s.T()) +} + +// Test checkContext function +func (s *ImplementationSuite) TestCheckContext() { + require := s.Require() + + // Test normal context + ctx := context.Background() + err := checkContext(ctx) + require.NoError(err) + + // Test canceled context + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + err = checkContext(canceledCtx) + require.ErrorIs(err, ErrCanceled) + + // Test deadline exceeded context — use a deadline in the past so it's + // already expired without relying on sleep timing. + expiredCtx, cancel := context.WithDeadline(ctx, time.Now().Add(-time.Second)) + defer cancel() + err = checkContext(expiredCtx) + require.ErrorIs(err, ErrDeadlineExceeded) + + // Test nil context (should not panic) + err = checkContext(nil) //nolint:staticcheck // The nil-context contract is intentional. + require.NoError(err) // nil context is valid in Go +} + +func (s *ImplementationSuite) TestInvalidInputHashCollectionSpanError() { + require := s.Require() + + // A malformed collection is an internal failure even when execution also + // reached a completed terminal state. The terminal status is context only. + err := invalidInputHashCollectionSpanError(ErrHalted, errors.New("overcollection")) + require.ErrorIs(err, ErrMachineInternal) + require.NotErrorIs(err, ErrHalted) + require.ErrorContains(err, ErrHalted.Error()) +} diff --git a/pkg/machine/invalid_outputs_root_test.go b/pkg/machine/invalid_outputs_root_test.go new file mode 100644 index 000000000..f61c2e8b2 --- /dev/null +++ b/pkg/machine/invalid_outputs_root_test.go @@ -0,0 +1,143 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/stretchr/testify/mock" +) + +func (s *ImplementationSuite) TestAdvanceOutputsRootLength() { + // The standard CMIO TX buffer holds 2^21 bytes. Test the declaration, + // including lengths that ReceiveCmioRequest would reject before retrieval. + for _, length := range []uint32{0, 31, HashSize, 33, 1 << 21, 1<<21 + 1, math.MaxUint32} { + for _, collect := range []bool{false, true} { + s.Run(fmt.Sprintf("length=%d/collect=%t", length, collect), func() { + backend := NewMockBackend() + backend.On("CmioRxBufferSize").Return(uint64(1024)) + backend.SetupRequestYield() + backend.On("SendCmioResponse", uint16(AdvanceStateRequest), []byte("input"), Hash{}, mock.Anything). + Return(nil).Once() + backend.On("ReadMCycle", mock.Anything).Return(uint64(0), nil).Once() + backend.On("ReadMCycle", mock.Anything).Return(uint64(1), nil).Once() + periodic := Hash{0x11} + if collect { + backend.On("RunAndCollectRootHashes", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + collector := args.Get(1).(*HashCollectorState) + collectTestEntries(collector, 1, true, periodic, Hash{0x22}) + }).Return(YieldedManually, nil).Once() + } else { + backend.On("Run", mock.Anything, mock.Anything).Return(YieldedManually, nil).Once() + } + backend.SetupManualYield(ManualYieldReasonAccepted, length) + machine := &machineImpl{backend: backend, logger: s.logger, params: model.ExecutionParameters{ + FastDeadline: time.Second, AdvanceIncCycles: 10, + AdvanceIncDeadline: time.Second, AdvanceMaxDeadline: time.Second, + }} + + result, err := machine.Advance(context.Background(), []byte("input"), Hash{}, collect) + s.Require().NoError(err) + s.Require().NotNil(result) + want := CompletionStatusInvalidOutputsRoot + if length == HashSize { + want = CompletionStatusAccepted + } + s.Equal(want, result.Status) + s.Nil(result.ExceptionData) + if collect { + s.Empty(result.PeriodicStateHashes) + s.Equal(InputEntryCapacity, result.PaddingRepetitions) + s.Equal(Hash(model.FinalStateEntry(Hash{0x22})), result.FinalStateEntry) + } else { + s.Empty(result.PeriodicStateHashes) + s.Zero(result.PaddingRepetitions) + } + backend.AssertNotCalled(s.T(), "ReceiveCmioRequest", mock.Anything) + backend.AssertExpectations(s.T()) + }) + } + } +} + +func (s *ImplementationSuite) TestAdvanceOutputsRootReadErrorsStayOperational() { + readErr := errors.New("machine memory read failed") + for _, test := range []struct { + name string + data []byte + err error + }{ + {name: "read failed", err: readErr}, + {name: "short read", data: make([]byte, 7)}, + {name: "long read", data: make([]byte, 9)}, + } { + s.Run(test.name, func() { + backend := NewMockBackend() + backend.On("ReadMemory", htifTohostAddress, uint64(8), mock.Anything). + Return(test.data, test.err).Once() + machine := &machineImpl{backend: backend, logger: s.logger} + result, err := machine.readAdvanceYieldResult(context.Background()) + s.Require().ErrorIs(err, ErrMachineInternal) + if test.err != nil { + s.ErrorIs(err, readErr) + } + s.Equal(CompletionStatusUnknown, result.status) + backend.AssertNotCalled(s.T(), "ReceiveCmioRequest", mock.Anything) + backend.AssertExpectations(s.T()) + }) + } +} + +func (s *ImplementationSuite) TestAdvanceRootRuleRequiresAcceptedManualYield() { + for _, test := range []struct { + name string + tohost uint64 + }{ + {"wrong device", uint64(3)<= state.MCycleSamplingPeriod { + return Failed, fmt.Errorf( + "phase must be less than period, got phase %v and period %v", + state.MCyclePhase, + state.MCycleSamplingPeriod, + ) + } + if err := e.inner.SetTimeout(timeout.Milliseconds()); err != nil { + return Failed, fmt.Errorf("failed to set operation timeout: %w", err) + } + + rawResult, err := e.inner.CollectMCycleRootHashes( + mcycleEnd, + log2Period, + state.MCyclePhase, + state.Log2BundleMCycleCount, + state.PartialBundle, + ) + if err != nil { + return Failed, err + } + result := struct { + RootHashes []string `json:"hashes"` + MCyclePhase uint64 `json:"mcycle_phase"` + BreakReason string `json:"break_reason"` + PartialBundle json.RawMessage `json:"partial_bundle,omitempty"` + ConsoleIOError string `json:"console_io_error,omitempty"` + }{} + err = json.Unmarshal(rawResult, &result) + if err != nil { + return Failed, fmt.Errorf("failed to unmarshal CollectMCycleRootHashes result: %w", err) + } + reason, err = decodeBreakReason(result.BreakReason) + if err != nil { + return Failed, fmt.Errorf("invalid CollectMCycleRootHashes result: %w", err) + } + if result.MCyclePhase >= state.MCycleSamplingPeriod { + return Failed, fmt.Errorf( + "invalid CollectMCycleRootHashes result: phase %v must be less than period %v", + result.MCyclePhase, + state.MCycleSamplingPeriod, + ) + } + + decodedHashes := make([]Hash, len(result.RootHashes)) + for i, base64Hash := range result.RootHashes { + if err := decodeB64To32(&decodedHashes[i], base64Hash); err != nil { + return Failed, fmt.Errorf("invalid collected hash at index %v: %w", i, err) + } + } + + state.Hashes = append(state.Hashes, decodedHashes...) + state.MCyclePhase = result.MCyclePhase + state.PartialBundle = result.PartialBundle + state.ConsoleIOError = result.ConsoleIOError + + return reason, nil +} diff --git a/pkg/machine/libcartesi_test.go b/pkg/machine/libcartesi_test.go new file mode 100644 index 000000000..2909b30ea --- /dev/null +++ b/pkg/machine/libcartesi_test.go @@ -0,0 +1,865 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/emulator" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +func TestLibCartesi(t *testing.T) { + suite.Run(t, new(LibCartesiSuite)) +} + +func TestValidateEmulatorComputationHashLimits(t *testing.T) { + require.NoError(t, ValidateEmulatorComputationHashLimits()) + require.NoError(t, validateEmulatorComputationHashLimit("LOG2_MAX_MCYCLES_PER_ADVANCE_STATE", 48, 48)) + + err := validateEmulatorComputationHashLimit("LOG2_MAX_MCYCLES_PER_ADVANCE_STATE", 48, 47) + require.ErrorIs(t, err, ErrMachineInternal) + require.ErrorContains(t, err, "node computation-hash dimension 2^48") + require.ErrorContains(t, err, "CM_ROLLUP_LOG2_MAX_MCYCLES_PER_ADVANCE_STATE=47") +} + +func TestStateProofConstantsMatchEmulator(t *testing.T) { + require.EqualValues(t, emulator.CmioTxBufferStart, TxBufferAddress) + require.EqualValues(t, emulator.HashTreeLog2WordSize, HashLog2Size) + require.EqualValues(t, emulator.HashTreeLog2RootSize, machineMemoryLog2Size) + require.EqualValues(t, emulator.HtifDeviceYield, htifDeviceYield) + require.EqualValues(t, emulator.YieldManual, htifCommandManual) + require.EqualValues(t, emulator.ManualYieldReasonAccepted, htifReasonInputAccepted) + require.EqualValues(t, emulator.HtifDeviceShift, htifDeviceShift) + require.EqualValues(t, emulator.HtifCommandShift, htifCommandShift) + require.EqualValues(t, emulator.HtifReasonShift, htifReasonShift) + + // cm_get_reg_address accepts a nil local machine and returns the static + // address defined by the linked emulator. + var localMachine emulator.Machine + actualIflagsYAddress, err := localMachine.GetRegAddress(emulator.REG_IFLAGS_Y) + require.NoError(t, err) + require.Equal(t, iflagsYAddress, actualIflagsYAddress) + actualHtifTohostAddress, err := localMachine.GetRegAddress(emulator.REG_HTIF_TOHOST) + require.NoError(t, err) + require.Equal(t, htifTohostAddress, actualHtifTohostAddress) +} + +type LibCartesiSuite struct { + suite.Suite + mockRemoteMachine *MockRemoteMachine + backend *LibCartesiBackend +} + +func (s *LibCartesiSuite) SetupTest() { + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} +} + +func (s *LibCartesiSuite) TestLoad() { + require := s.Require() + + // Test successful load + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("Load", "/test/dir", "config").Return(nil) + + err := s.backend.Load("/test/dir", "config", 5*time.Second) + require.NoError(err) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test load with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + err = s.backend.Load("/test/dir", "config", 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test load with load error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("Load", "/test/dir", "config").Return(errors.New("load error")) + + err = s.backend.Load("/test/dir", "config", 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "load error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRun() { + require := s.Require() + + // Test successful run + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("Run", uint64(1000)).Return(emulator.BreakReason(YieldedManually), nil) + + breakReason, err := s.backend.Run(1000, 5*time.Second) + require.NoError(err) + require.Equal(YieldedManually, breakReason) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test run with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + _, err = s.backend.Run(1000, 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test run with run error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("Run", uint64(1000)).Return(emulator.BreakReason(Failed), errors.New("run error")) + + _, err = s.backend.Run(1000, 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "run error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunMcycleOverflow() { + require := s.Require() + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("Run", uint64(1000)).Return(emulator.BreakReasonMcycleOverflow, nil) + + breakReason, err := s.backend.Run(1000, 5*time.Second) + require.NoError(err) + require.Equal(McycleOverflow, breakReason) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunAndCollectRootHashesMcycleOverflow() { + require := s.Require() + state := &HashCollectorState{MCycleSamplingPeriod: 1} + result := []byte(`{"hashes":[],"mcycle_phase":0,"break_reason":"mcycle_overflow"}`) + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On( + "CollectMCycleRootHashes", + uint64(1000), + uint64(0), + uint64(0), + int32(0), + json.RawMessage(nil), + ).Return(result, nil) + + breakReason, err := s.backend.RunAndCollectRootHashes(1000, state, 5*time.Second) + require.NoError(err) + require.Equal(McycleOverflow, breakReason) + require.Nil(state.PartialBundle) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunAndCollectRootHashesContinuesPartialBundle() { + require := s.Require() + previousPartialBundle := json.RawMessage( + `{"log2_max_leaves":2,"hash_function":"keccak256","leaf_count":1,"context":["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]}`, + ) + nextPartialBundle := json.RawMessage( + `{"log2_max_leaves":2,"hash_function":"keccak256","leaf_count":2,"context":["AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]}`, + ) + previousHash := Hash{0x11} + collectedHash := Hash{0x22} + state := &HashCollectorState{ + MCycleSamplingPeriod: 4, + MCyclePhase: 3, + Log2BundleMCycleCount: 2, + Hashes: []Hash{previousHash}, + PartialBundle: previousPartialBundle, + } + result, err := json.Marshal(struct { + Hashes []string `json:"hashes"` + MCyclePhase uint64 `json:"mcycle_phase"` + BreakReason string `json:"break_reason"` + PartialBundle json.RawMessage `json:"partial_bundle"` + ConsoleIOError string `json:"console_io_error"` + }{ + Hashes: []string{base64.StdEncoding.EncodeToString(collectedHash[:])}, + MCyclePhase: 1, + BreakReason: "reached_target_mcycle", + PartialBundle: nextPartialBundle, + ConsoleIOError: "console write failed", + }) + require.NoError(err) + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On( + "CollectMCycleRootHashes", + uint64(1000), + uint64(2), + uint64(3), + int32(2), + previousPartialBundle, + ).Return(result, nil) + + breakReason, err := s.backend.RunAndCollectRootHashes(1000, state, 5*time.Second) + require.NoError(err) + require.Equal(ReachedTargetMcycle, breakReason) + require.Equal(uint64(1), state.MCyclePhase) + require.Equal([]Hash{previousHash, collectedHash}, state.Hashes) + require.Equal(nextPartialBundle, state.PartialBundle) + require.Equal("console write failed", state.ConsoleIOError) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunAndCollectRootHashesClearsPartialBundleAtFixedPoint() { + require := s.Require() + previousPartialBundle := json.RawMessage( + `{"log2_max_leaves":2,"hash_function":"keccak256","leaf_count":3,"context":["AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]}`, + ) + state := &HashCollectorState{ + MCycleSamplingPeriod: 4, + MCyclePhase: 2, + Log2BundleMCycleCount: 2, + PartialBundle: previousPartialBundle, + } + result := []byte(`{"hashes":[],"mcycle_phase":2,"break_reason":"halted"}`) + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On( + "CollectMCycleRootHashes", + uint64(1000), + uint64(2), + uint64(2), + int32(2), + previousPartialBundle, + ).Return(result, nil) + + breakReason, err := s.backend.RunAndCollectRootHashes(1000, state, 5*time.Second) + require.NoError(err) + require.Equal(Halted, breakReason) + require.Nil(state.PartialBundle) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunAndCollectRootHashesInvalidHashLeavesStateUnchanged() { + require := s.Require() + previousPartialBundle := json.RawMessage( + `{"log2_max_leaves":2,"hash_function":"keccak256","leaf_count":1,"context":["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]}`, + ) + nextPartialBundle := json.RawMessage( + `{"log2_max_leaves":2,"hash_function":"keccak256","leaf_count":2,"context":["AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]}`, + ) + previousHash := Hash{0x11} + validHash := Hash{0x22} + state := &HashCollectorState{ + MCycleSamplingPeriod: 4, + MCyclePhase: 3, + Log2BundleMCycleCount: 2, + Hashes: []Hash{previousHash}, + PartialBundle: previousPartialBundle, + } + originalState := *state + originalState.Hashes = append([]Hash(nil), state.Hashes...) + originalState.PartialBundle = append(json.RawMessage(nil), state.PartialBundle...) + result, err := json.Marshal(struct { + Hashes []string `json:"hashes"` + MCyclePhase uint64 `json:"mcycle_phase"` + BreakReason string `json:"break_reason"` + PartialBundle json.RawMessage `json:"partial_bundle"` + }{ + Hashes: []string{ + base64.StdEncoding.EncodeToString(validHash[:]), + "not-base64", + }, + MCyclePhase: 1, + BreakReason: "reached_target_mcycle", + PartialBundle: nextPartialBundle, + }) + require.NoError(err) + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On( + "CollectMCycleRootHashes", + uint64(1000), + uint64(2), + uint64(3), + int32(2), + previousPartialBundle, + ).Return(result, nil) + + breakReason, err := s.backend.RunAndCollectRootHashes(1000, state, 5*time.Second) + require.Error(err) + require.Equal(Failed, breakReason) + require.Equal(originalState, *state) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunAndCollectRootHashesInvalidBreakReasonLeavesStateUnchanged() { + require := s.Require() + previousHash := Hash{0x11} + state := &HashCollectorState{ + MCycleSamplingPeriod: 4, + MCyclePhase: 3, + Hashes: []Hash{previousHash}, + } + originalState := *state + originalState.Hashes = append([]Hash(nil), state.Hashes...) + result := []byte(`{"hashes":[],"mcycle_phase":1,"break_reason":"future_reason"}`) + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On( + "CollectMCycleRootHashes", + uint64(1000), + uint64(2), + uint64(3), + int32(0), + json.RawMessage(nil), + ).Return(result, nil) + + breakReason, err := s.backend.RunAndCollectRootHashes(1000, state, 5*time.Second) + require.ErrorContains(err, "unknown break reason") + require.Equal(Failed, breakReason) + require.Equal(originalState, *state) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunAndCollectRootHashesInvalidResultPhaseLeavesStateUnchanged() { + require := s.Require() + state := &HashCollectorState{MCycleSamplingPeriod: 4, MCyclePhase: 3} + originalState := *state + result := []byte(`{"hashes":[],"mcycle_phase":4,"break_reason":"reached_target_mcycle"}`) + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On( + "CollectMCycleRootHashes", + uint64(1000), + uint64(2), + uint64(3), + int32(0), + json.RawMessage(nil), + ).Return(result, nil) + + breakReason, err := s.backend.RunAndCollectRootHashes(1000, state, 5*time.Second) + require.ErrorContains(err, "phase 4 must be less than period 4") + require.Equal(Failed, breakReason) + require.Equal(originalState, *state) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestRunAndCollectRootHashesRejectsInvalidInputPhase() { + require := s.Require() + state := &HashCollectorState{MCycleSamplingPeriod: 4, MCyclePhase: 4} + + breakReason, err := s.backend.RunAndCollectRootHashes(1000, state, 5*time.Second) + require.ErrorContains(err, "phase must be less than period") + require.Equal(Failed, breakReason) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestGetRootHash() { + require := s.Require() + + expectedHash := randomFakeHash() + + // Test successful get root hash + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("GetRootHash").Return(expectedHash, nil) + + hash, err := s.backend.GetRootHash(5 * time.Second) + require.NoError(err) + require.Equal(expectedHash, hash) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + _, err = s.backend.GetRootHash(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with get hash error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("GetRootHash").Return(Hash{}, errors.New("hash error")) + + _, err = s.backend.GetRootHash(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "hash error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestGetProofPreservesEmulatorMetadata() { + require := s.Require() + expected := MemoryProof{ + Log2RootSize: 64, + Log2TargetSize: 5, + RootHash: randomFakeHash(), + Siblings: []Hash{randomFakeHash(), randomFakeHash()}, + TargetAddress: 0x60800000, + TargetHash: randomFakeHash(), + } + siblingHashes := make([]string, len(expected.Siblings)) + for i := range expected.Siblings { + siblingHashes[i] = base64.StdEncoding.EncodeToString(expected.Siblings[i][:]) + } + encoded, err := json.Marshal(map[string]any{ + "log2_root_size": expected.Log2RootSize, + "log2_target_size": expected.Log2TargetSize, + "root_hash": base64.StdEncoding.EncodeToString(expected.RootHash[:]), + "sibling_hashes": siblingHashes, + "target_address": expected.TargetAddress, + "target_hash": base64.StdEncoding.EncodeToString(expected.TargetHash[:]), + }) + require.NoError(err) + + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("GetProof", expected.TargetAddress, expected.Log2TargetSize, expected.Log2RootSize). + Return(string(encoded), nil) + + actual, err := s.backend.GetProof( + expected.TargetAddress, + expected.Log2TargetSize, + expected.Log2RootSize, + 5*time.Second, + ) + require.NoError(err) + require.Equal(expected, actual) + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestReadMemory() { + require := s.Require() + expected := []byte{0xde, 0xad, 0xbe, 0xef} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReadMemory", uint64(0x300), uint64(len(expected))). + Return(expected, nil) + + actual, err := s.backend.ReadMemory(0x300, uint64(len(expected)), 5*time.Second) + require.NoError(err) + require.Equal(expected, actual) + s.mockRemoteMachine.AssertExpectations(s.T()) + + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + actual, err = s.backend.ReadMemory(0x300, uint64(len(expected)), 5*time.Second) + require.Nil(actual) + require.ErrorContains(err, "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReadMemory", uint64(0x300), uint64(len(expected))). + Return([]byte(nil), errors.New("read error")) + actual, err = s.backend.ReadMemory(0x300, uint64(len(expected)), 5*time.Second) + require.Nil(actual) + require.ErrorContains(err, "read error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestIsAtManualYield() { + require := s.Require() + + // Any nonzero flag denotes a manual yield; its value is not a CMIO reason. + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReadReg", emulator.REG_IFLAGS_Y).Return(uint64(2), nil) + + isAtYield, err := s.backend.IsAtManualYield(5 * time.Second) + require.NoError(err) + require.True(isAtYield) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test manual yield false + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReadReg", emulator.REG_IFLAGS_Y).Return(uint64(0), nil) + + isAtYield, err = s.backend.IsAtManualYield(5 * time.Second) + require.NoError(err) + require.False(isAtYield) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + _, err = s.backend.IsAtManualYield(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with read register error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReadReg", emulator.REG_IFLAGS_Y).Return(uint64(0), errors.New("read error")) + + _, err = s.backend.IsAtManualYield(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "read error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestReadMCycle() { + require := s.Require() + + // Test successful read + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReadReg", emulator.REG_MCYCLE).Return(uint64(12345), nil) + + cycle, err := s.backend.ReadMCycle(5 * time.Second) + require.NoError(err) + require.Equal(uint64(12345), cycle) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + _, err = s.backend.ReadMCycle(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with read error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReadReg", emulator.REG_MCYCLE).Return(uint64(0), errors.New("read error")) + + _, err = s.backend.ReadMCycle(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "read error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestSendCmioResponse() { + require := s.Require() + + data := []byte("test data") + expectedHash := randomFakeHash() + + // Test successful send + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("SendCmioResponse", uint16(1), data, &expectedHash).Return(nil) + + err := s.backend.SendCmioResponse(1, data, &expectedHash, 5*time.Second) + require.NoError(err) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + err = s.backend.SendCmioResponse(1, data, &expectedHash, 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with send error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("SendCmioResponse", uint16(1), data, &expectedHash).Return(errors.New("send error")) + + err = s.backend.SendCmioResponse(1, data, &expectedHash, 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "send error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestReceiveCmioRequest() { + require := s.Require() + + expectedData := []byte("response data") + + // Test successful receive + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReceiveCmioRequest").Return(uint8(1), uint16(2), expectedData, nil) + + cmd, reason, data, err := s.backend.ReceiveCmioRequest(5 * time.Second) + require.NoError(err) + require.Equal(uint8(1), cmd) + require.Equal(uint16(2), reason) + require.Equal(expectedData, data) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + _, _, _, err = s.backend.ReceiveCmioRequest(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with receive error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ReceiveCmioRequest").Return(uint8(0), uint16(0), []byte(nil), errors.New("receive error")) + + _, _, _, err = s.backend.ReceiveCmioRequest(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "receive error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestStore() { + require := s.Require() + + // Test successful store + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("Store", "/test/dir").Return(nil) + + err := s.backend.Store("/test/dir", 5*time.Second) + require.NoError(err) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + err = s.backend.Store("/test/dir", 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with store error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("Store", "/test/dir").Return(errors.New("store error")) + + err = s.backend.Store("/test/dir", 5*time.Second) + require.Error(err) + require.Contains(err.Error(), "store error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestDelete() { + s.mockRemoteMachine.On("Delete").Return() + + s.backend.Delete() + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestForkServer() { + require := s.Require() + + forkedMachine := new(emulator.RemoteMachine) + + // Test successful fork + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ForkServer").Return(forkedMachine, "127.0.0.1:54321", uint32(54321), nil) + + backend, address, pid, err := s.backend.ForkServer(5 * time.Second) + require.NoError(err) + require.NotNil(backend) + require.Equal("127.0.0.1:54321", address) + require.Equal(uint32(54321), pid) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + _, _, _, err = s.backend.ForkServer(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with fork error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ForkServer").Return((*emulator.RemoteMachine)(nil), "", uint32(0), errors.New("fork error")) + + _, _, _, err = s.backend.ForkServer(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "fork error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestShutdownServer() { + require := s.Require() + + // Test successful shutdown + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ShutdownServer").Return(nil) + + err := s.backend.ShutdownServer(5 * time.Second) + require.NoError(err) + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with timeout error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(errors.New("timeout error")) + + err = s.backend.ShutdownServer(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "failed to set operation timeout") + s.mockRemoteMachine.AssertExpectations(s.T()) + + // Test with shutdown error + s.mockRemoteMachine = new(MockRemoteMachine) + s.backend = &LibCartesiBackend{inner: s.mockRemoteMachine} + s.mockRemoteMachine.On("SetTimeout", int64(5000)).Return(nil) + s.mockRemoteMachine.On("ShutdownServer").Return(errors.New("shutdown error")) + + err = s.backend.ShutdownServer(5 * time.Second) + require.Error(err) + require.Contains(err.Error(), "shutdown error") + s.mockRemoteMachine.AssertExpectations(s.T()) +} + +func (s *LibCartesiSuite) TestNewMachineRuntimeConfig() { + require := s.Require() + + config, err := s.backend.NewMachineRuntimeConfig() + require.NoError(err) + machine, err := emulator.CreateMachine(`{"ram":{"length":4096}}`, config, "") + require.NoError(err) + s.T().Cleanup(func() { + require.NoError(machine.Destroy()) + machine.Delete() + }) + applied, err := machine.GetRuntimeConfig() + require.NoError(err) + var sent, kept map[string]any + require.NoError(json.Unmarshal([]byte(config), &sent)) + require.NoError(json.Unmarshal([]byte(applied), &kept)) + require.Subset(kept, sent) +} + +func (s *LibCartesiSuite) TestCmioRxBufferSize() { + require := s.Require() + + size := s.backend.CmioRxBufferSize() + require.Equal(uint64(1<= 8 { + binary.LittleEndian.PutUint64(data, test.tohost) + } + backend.On("ReadMemory", htifTohostAddress, uint64(8), mock.Anything). + Return(data, test.memoryErr).Once() + } + machine := &machineImpl{backend: backend, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + params: model.ExecutionParameters{FastDeadline: time.Second, AdvanceIncCycles: 10, InspectIncCycles: 10}} + var err error + if reqType == AdvanceStateRequest { + var response *AdvanceResponse + response, err = machine.Advance(t.Context(), []byte("input"), Hash{}, false) + require.Nil(t, response) + } else { + _, err = machine.Inspect(t.Context(), []byte("query")) + } + require.ErrorIs(t, err, ErrMachineInternal) + if test.refused { + require.ErrorIs(t, err, ErrNotAtManualYield) + } + if test.yieldErr != nil || test.memoryErr != nil { + require.ErrorIs(t, err, readErr) + } + backend.AssertNotCalled(t, "SendCmioResponse", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + backend.AssertNotCalled(t, "Run", mock.Anything, mock.Anything) + backend.AssertExpectations(t) + }) + } + } +} + +type admissionCountingBackend struct { + Backend + sends int +} + +func (b *admissionCountingBackend) SendCmioResponse(reason uint16, data []byte, checkpoint *Hash, timeout time.Duration) error { + b.sends++ + return b.Backend.SendCmioResponse(reason, data, checkpoint, timeout) +} + +func TestRejectedYieldRefusesAdvanceAndInspectWithoutMutation(t *testing.T) { + machine := loadEndpointGuest(t, 0, emulator.ManualYieldReasonRejected, false) + impl := machine.(*machineImpl) + backend := &admissionCountingBackend{Backend: impl.backend} + impl.backend = backend + checkpoint, err := machine.Hash(t.Context()) + require.NoError(t, err) + response, err := machine.Advance(t.Context(), []byte("first input"), checkpoint, false) + require.NoError(t, err) + require.Equal(t, CompletionStatusRejected, response.Status) + require.Equal(t, 1, backend.sends) + boundary, err := machine.Hash(t.Context()) + require.NoError(t, err) + backend.sends = 0 + + t.Run("advance", func(t *testing.T) { + _, err := machine.Advance(t.Context(), []byte("second input"), boundary, false) + require.ErrorIs(t, err, ErrNotAtManualYield) + require.ErrorIs(t, err, ErrMachineInternal) + require.Zero(t, backend.sends) + after, err := machine.Hash(t.Context()) + require.NoError(t, err) + require.Equal(t, boundary, after) + }) + t.Run("inspect", func(t *testing.T) { + _, err := machine.Inspect(t.Context(), []byte("query")) + require.ErrorIs(t, err, ErrNotAtManualYield) + require.ErrorIs(t, err, ErrMachineInternal) + require.Zero(t, backend.sends) + after, err := machine.Hash(t.Context()) + require.NoError(t, err) + require.Equal(t, boundary, after) + }) +} + +func TestHaltedAcceptedYieldStillDeliversRequest(t *testing.T) { + for _, reqType := range []requestType{AdvanceStateRequest, InspectStateRequest} { + t.Run(reqType.String(), func(t *testing.T) { + machine := loadEndpointGuest(t, 0, emulator.ManualYieldReasonAccepted, true) + impl := machine.(*machineImpl) + backend := &admissionCountingBackend{Backend: impl.backend} + impl.backend = backend + checkpoint, err := machine.Hash(t.Context()) + require.NoError(t, err) + if reqType == AdvanceStateRequest { + response, err := machine.Advance(t.Context(), []byte("input"), checkpoint, false) + require.NoError(t, err) + require.Equal(t, CompletionStatusHalted, response.Status) + } else { + response, err := machine.Inspect(t.Context(), []byte("query")) + require.NoError(t, err) + require.Equal(t, CompletionStatusHalted, response.Status) + } + require.Equal(t, 1, backend.sends) + after, err := machine.Hash(t.Context()) + require.NoError(t, err) + require.NotEqual(t, checkpoint, after) + }) + } +} diff --git a/pkg/machine/saturated_endpoint_test.go b/pkg/machine/saturated_endpoint_test.go new file mode 100644 index 000000000..5f62bbd9a --- /dev/null +++ b/pkg/machine/saturated_endpoint_test.go @@ -0,0 +1,130 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package machine + +import ( + "encoding/binary" + "encoding/json" + "io" + "log/slog" + "math" + "path/filepath" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/pkg/emulator" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const endpointProgramCycles = uint64(3) + +func loadEndpointGuest(t *testing.T, start uint64, reason emulator.CmioYieldReason, halted bool) Machine { + t.Helper() + const ( + ramStart = uint64(0x80000000) + ramLength = uint64(4096) + instructionBytes = 4 + loadHTIF = uint32(0x400082b7) // lui t0,0x40008 + clearFromhost = uint32(0x0002b423) // sd zero,8(t0) + yieldPrepared = uint32(0x0132b023) // sd x19,0(t0) + operationDeadline = 2 * time.Second + ) + config, err := json.Marshal(map[string]any{ + "processor": map[string]any{"registers": map[string]any{"pc": ramStart}}, + "ram": map[string]any{"length": ramLength}, + "cmio": map[string]any{"rx_buffer": map[string]any{}, "tx_buffer": map[string]any{}}, + }) + require.NoError(t, err) + local, err := emulator.CreateMachine(string(config), "", "") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, local.Destroy()) + local.Delete() + }) + instructions := []uint32{loadHTIF, clearFromhost, yieldPrepared} + program := make([]byte, instructionBytes*len(instructions)) + for index, instruction := range instructions { + binary.LittleEndian.PutUint32(program[instructionBytes*index:], instruction) + } + require.NoError(t, local.WriteMemory(ramStart, program)) + length := uint64(0) + if reason == emulator.ManualYieldReasonAccepted { + length = HashSize + } + preparedYield := emulator.HtifDeviceYield<)\n", no per-request log +// (logging every rejection would amplify a flood). On admit: defers +// [SemaphoreAdmission.Release] so panics in downstream handlers still +// free the permit. +func AdmissionMiddleware(ac *SemaphoreAdmission) func(http.Handler) http.Handler { + if ac == nil { + return func(next http.Handler) http.Handler { return next } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !ac.TryAcquire() { + reqID := RequestIDFromContext(r.Context()) + jitter := retryAfterMin + rand.IntN(retryAfterMax-retryAfterMin+1) //nolint:gosec // G404: jitter, not security + w.Header().Set("Retry-After", strconv.Itoa(jitter)) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusServiceUnavailable) + // gosec G705 is a false positive: text/plain with nosniff, + // and reqID is validated upstream by RequestIDMiddleware + // against a safe charset, so no taint reaches a browser. + _, _ = fmt.Fprintf(w, "service at capacity (request_id=%s)\n", reqID) //nolint:gosec // G705 + return + } + defer ac.Release() + next.ServeHTTP(w, r) + }) + } +} diff --git a/pkg/service/http_admission_test.go b/pkg/service/http_admission_test.go new file mode 100644 index 000000000..8e97ef7ca --- /dev/null +++ b/pkg/service/http_admission_test.go @@ -0,0 +1,228 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// ----------------------------------------------------------------------------- +// SemaphoreAdmission +// ----------------------------------------------------------------------------- + +func TestSemaphoreAdmission_BasicAcquireRelease(t *testing.T) { + a := NewSemaphoreAdmission(2) + + require.True(t, a.TryAcquire()) + require.True(t, a.TryAcquire()) + require.False(t, a.TryAcquire(), "third should be rejected at limit 2") + + a.Release() + require.True(t, a.TryAcquire(), "after one release a new permit should be available") +} + +func TestSemaphoreAdmission_RejectedCounter(t *testing.T) { + a := NewSemaphoreAdmission(1) + + require.True(t, a.TryAcquire()) + for range 4 { + require.False(t, a.TryAcquire()) + } + require.Equal(t, uint64(4), a.Rejected()) + + a.Release() + require.True(t, a.TryAcquire()) + require.Equal(t, uint64(4), a.Rejected(), "successful acquire must not bump counter") +} + +func TestSemaphoreAdmission_ZeroLimit(t *testing.T) { + // A zero-capacity semaphore naturally rejects every TryAcquire. + a := NewSemaphoreAdmission(0) + + for range 3 { + require.False(t, a.TryAcquire()) + } + require.Equal(t, uint64(3), a.Rejected()) +} + +// ----------------------------------------------------------------------------- +// AdmissionMiddleware +// ----------------------------------------------------------------------------- + +func TestAdmissionMiddleware_NilIsPassthrough(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + mw := AdmissionMiddleware(nil) + + rr := httptest.NewRecorder() + mw(handler).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "ok", rr.Body.String()) +} + +func TestAdmissionMiddleware_AdmitsBelowLimit(t *testing.T) { + ac := NewSemaphoreAdmission(2) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mw := AdmissionMiddleware(ac) + + for range 5 { + rr := httptest.NewRecorder() + mw(handler).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusOK, rr.Code) + } + require.Equal(t, uint64(0), ac.Rejected()) +} + +func TestAdmissionMiddleware_RejectsAtLimit(t *testing.T) { + ac := NewSemaphoreAdmission(1) + + // A handler that signals when it has been entered (i.e. the permit + // has been acquired by AdmissionMiddleware) and then blocks until + // we release it. This gives the test a deterministic point to fire + // the second request without any time-based polling. + entered := make(chan struct{}) + block := make(chan struct{}) + done := make(chan struct{}) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(entered) + <-block + w.WriteHeader(http.StatusOK) + }) + + wrapped := AdmissionMiddleware(ac)(handler) + + // Fire the first request in a goroutine; it will hold the permit + // until we close(block). + go func() { + rr := httptest.NewRecorder() + wrapped.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + close(done) + }() + + <-entered // first request has the permit + + // Second request — must be rejected immediately. + rr := httptest.NewRecorder() + wrapped.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + require.Equal(t, http.StatusServiceUnavailable, rr.Code) + retryAfter, err := strconv.Atoi(rr.Header().Get("Retry-After")) + require.NoError(t, err) + require.GreaterOrEqual(t, retryAfter, 1) + require.LessOrEqual(t, retryAfter, 3) + require.Contains(t, rr.Body.String(), "service at capacity") + require.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options")) + require.Equal(t, uint64(1), ac.Rejected()) + + close(block) + <-done +} + +func TestAdmissionMiddleware_ReleasesOnSuccess(t *testing.T) { + ac := NewSemaphoreAdmission(1) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mw := AdmissionMiddleware(ac) + + for range 3 { + rr := httptest.NewRecorder() + mw(handler).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusOK, rr.Code) + } + // The permit should always be back after each sequential request. + require.Equal(t, uint64(0), ac.Rejected()) +} + +// TestAdmissionReleaseOnPanic pins the "permit is released even when a +// downstream handler panics" invariant. This must work under the real +// middleware chain — RecoverMiddleware wraps AdmissionMiddleware and +// catches the panic, but only after AdmissionMiddleware's deferred +// Release has run. +func TestAdmissionReleaseOnPanic(t *testing.T) { + ac := NewSemaphoreAdmission(2) + + panicking := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic(errors.New("kaboom")) + }) + + // Chain: Recover(Admission(panicking)). Admission defers its + // Release(), then the handler panics, then the defer runs (releasing + // the permit), then Recover catches the panic and writes a 500. + var buf bytes.Buffer + chain := RecoverMiddleware(captureLogger(&buf))(AdmissionMiddleware(ac)(panicking)) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + chain.ServeHTTP(rr, req) + + require.Equal(t, http.StatusInternalServerError, rr.Code) + + // Both permits should be available: verify by acquiring both in a row. + require.True(t, ac.TryAcquire(), "permit 1 must be available after panic") + require.True(t, ac.TryAcquire(), "permit 2 must be available after panic") + ac.Release() + ac.Release() +} + +func TestAdmissionMiddleware_NoLogSpam(t *testing.T) { + ac := NewSemaphoreAdmission(1) + ac.TryAcquire() // pre-fill the single permit so all subsequent acquires fail + handler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + + // No logger is wired into AdmissionMiddleware itself, so rejection + // must produce no log output regardless of what wraps it. Verify + // that wrapping in RecoverMiddleware with a capture logger doesn't + // pick up any rejection logs. + var buf bytes.Buffer + chain := RecoverMiddleware(captureLogger(&buf))(AdmissionMiddleware(ac)(handler)) + + for range 10 { + rr := httptest.NewRecorder() + chain.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusServiceUnavailable, rr.Code) + } + require.Empty(t, buf.String(), "rejection must not log per-request") +} + +func TestAdmissionMiddleware_ConcurrentStress(t *testing.T) { + ac := NewSemaphoreAdmission(4) + + // Handler that yields briefly so concurrent requests have a chance + // to collide on the semaphore. + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mw := AdmissionMiddleware(ac) + wrapped := mw(handler) + + var wg sync.WaitGroup + for range 100 { + wg.Add(1) + go func() { + defer wg.Done() + rr := httptest.NewRecorder() + wrapped.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + }() + } + wg.Wait() + + // After everything settles, all permits must be back. + for range 4 { + require.True(t, ac.TryAcquire()) + } +} diff --git a/pkg/service/http_cors.go b/pkg/service/http_cors.go new file mode 100644 index 000000000..3198640f0 --- /dev/null +++ b/pkg/service/http_cors.go @@ -0,0 +1,207 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "bufio" + "log/slog" + "net" + "net/http" + "strings" +) + +// CORSConfig holds the pre-validated, pre-joined CORS policy for a single +// HTTP surface (JSON-RPC or inspect). When the allowedOrigins map is empty +// the config is disabled and the middleware is a no-op pass-through. +type CORSConfig struct { + allowedOrigins map[string]struct{} // lowercased at construction + methods string // pre-joined for header value + headers string // pre-joined for header value + maxAge string // "3600" +} + +// Enabled reports whether the config has at least one valid allowed origin. +func (c CORSConfig) Enabled() bool { + return len(c.allowedOrigins) > 0 +} + +// ParseCORSConfig splits origins on comma, validates each entry, and returns +// a ready-to-use [CORSConfig]. Invalid entries are logged as warnings and +// skipped. If no valid origins remain, the returned config is disabled +// ([CORSConfig.Enabled] returns false). +// +// Validation rules per origin: +// - Empty/whitespace-only entries are silently skipped. +// - "null" is rejected (sandboxed iframe attack vector). +// - Entries with paths, query strings, or fragments are rejected. +// - A single trailing slash is stripped (bare origin normalization). +// - The entire origin is lowercased per RFC 6454. +func ParseCORSConfig(logger *slog.Logger, origins string, methods []string, headers []string) CORSConfig { + cfg := CORSConfig{ + allowedOrigins: make(map[string]struct{}), + methods: strings.Join(methods, ", "), + headers: strings.Join(headers, ", "), + maxAge: "3600", + } + + for raw := range strings.SplitSeq(origins, ",") { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + + if strings.EqualFold(entry, "null") { + logger.Warn("CORS origin \"null\" rejected (sandboxed iframe attack vector)", + "origin", entry, + ) + continue + } + + entry = strings.TrimSuffix(entry, "/") + + if scheme, rest, ok := strings.Cut(entry, "://"); ok { + _ = scheme + if strings.ContainsAny(rest, "/?#") { + logger.Warn("CORS origin rejected: must not contain path, query, or fragment", + "origin", raw, + ) + continue + } + } else if strings.ContainsAny(entry, "/?#") { + logger.Warn("CORS origin rejected: must not contain path, query, or fragment", + "origin", raw, + ) + continue + } + + lower := strings.ToLower(entry) + cfg.allowedOrigins[lower] = struct{}{} + } + + if len(cfg.allowedOrigins) == 0 { + cfg.allowedOrigins = nil + } + return cfg +} + +// corsWriter wraps an [http.ResponseWriter] and injects CORS headers at +// WriteHeader time, after the handler has had a chance to set (or +// overwrite) response headers. This ensures CORS headers are authoritative +// regardless of what downstream handlers do — including setting their own +// Access-Control-Allow-Origin, which the wrapper strips and replaces. +type corsWriter struct { + http.ResponseWriter + origin string + cfg CORSConfig + written bool +} + +func (cw *corsWriter) setCORSHeaders() { + h := cw.Header() + h.Add("Vary", "Origin") + h.Del("Access-Control-Allow-Origin") + h.Set("Access-Control-Allow-Origin", cw.origin) + h.Set("Access-Control-Allow-Methods", cw.cfg.methods) + h.Set("Access-Control-Allow-Headers", cw.cfg.headers) +} + +func (cw *corsWriter) WriteHeader(code int) { + if !cw.written { + cw.written = true + cw.setCORSHeaders() + } + cw.ResponseWriter.WriteHeader(code) +} + +func (cw *corsWriter) Write(b []byte) (int, error) { + if !cw.written { + cw.written = true + cw.setCORSHeaders() + } + return cw.ResponseWriter.Write(b) +} + +func (cw *corsWriter) Unwrap() http.ResponseWriter { return cw.ResponseWriter } + +func (cw *corsWriter) Flush() { + if !cw.written { + cw.written = true + cw.setCORSHeaders() + } + if f, ok := cw.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (cw *corsWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := cw.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// CORSMiddleware returns a middleware that enforces the given [CORSConfig]. +// When the config is disabled ([CORSConfig.Enabled] returns false), the +// returned middleware is a no-op identity wrapper — the same pattern used +// by [AdmissionMiddleware] when its controller is nil. +// +// Preflight requests (OPTIONS with Access-Control-Request-Method) from +// allowed origins are short-circuited with a 204 before downstream +// handlers (and therefore before admission control) run. +func CORSMiddleware(cfg CORSConfig) func(http.Handler) http.Handler { + if !cfg.Enabled() { + return func(next http.Handler) http.Handler { return next } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + next.ServeHTTP(w, r) + return + } + + // Short-circuit ALL preflights before admission. Allowed + // origins get CORS grant headers; disallowed origins get + // Vary only. Either way, downstream handlers (and their + // admission permits) are never touched. + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + w.Header().Add("Vary", "Origin") + w.Header().Add("Vary", "Access-Control-Request-Method") + w.Header().Add("Vary", "Access-Control-Request-Headers") + lower := strings.ToLower(origin) + if _, ok := cfg.allowedOrigins[lower]; ok { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", cfg.methods) + w.Header().Set("Access-Control-Allow-Headers", cfg.headers) + w.Header().Set("Access-Control-Max-Age", cfg.maxAge) + } + w.WriteHeader(http.StatusNoContent) + return + } + + lower := strings.ToLower(origin) + if _, ok := cfg.allowedOrigins[lower]; !ok { + // Disallowed origin: set Vary for caching correctness + // but emit no CORS grant headers. + w.Header().Add("Vary", "Origin") + next.ServeHTTP(w, r) + return + } + + // Non-preflight allowed origin: wrap the response writer so + // CORS headers (including Vary: Origin) are injected at + // WriteHeader time, after the handler has had a chance to + // set (and potentially overwrite) headers. + cw := &corsWriter{ResponseWriter: w, origin: origin, cfg: cfg} + next.ServeHTTP(cw, r) + + // If the handler returned without calling WriteHeader or + // Write (e.g., an empty 200), inject CORS headers now so + // they are present when the stdlib flushes. + if !cw.written { + cw.setCORSHeaders() + } + }) + } +} diff --git a/pkg/service/http_cors_test.go b/pkg/service/http_cors_test.go new file mode 100644 index 000000000..d4fbb2cb6 --- /dev/null +++ b/pkg/service/http_cors_test.go @@ -0,0 +1,359 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// ParseCORSConfig — constructor tests +// --------------------------------------------------------------------------- + +func TestParseCORSConfig_EmptyString(t *testing.T) { + cfg := ParseCORSConfig(discardLogger(), "", []string{"POST"}, []string{"Content-Type"}) + require.False(t, cfg.Enabled()) +} + +func TestParseCORSConfig_WhitespaceOnly(t *testing.T) { + cfg := ParseCORSConfig(discardLogger(), " , , ", []string{"POST"}, []string{"Content-Type"}) + require.False(t, cfg.Enabled()) +} + +func TestParseCORSConfig_NullRejected(t *testing.T) { + var buf bytes.Buffer + cfg := ParseCORSConfig(captureLogger(&buf), "null", []string{"POST"}, []string{"Content-Type"}) + require.False(t, cfg.Enabled()) + require.Contains(t, buf.String(), "null") + require.Contains(t, buf.String(), "level=WARN") +} + +func TestParseCORSConfig_NullAmongValid(t *testing.T) { + var buf bytes.Buffer + cfg := ParseCORSConfig(captureLogger(&buf), "http://ok.com,null", []string{"POST"}, []string{"Content-Type"}) + require.True(t, cfg.Enabled()) + _, ok := cfg.allowedOrigins["http://ok.com"] + require.True(t, ok) + _, ok = cfg.allowedOrigins["null"] + require.False(t, ok) +} + +func TestParseCORSConfig_TrailingSlashStripped(t *testing.T) { + cfg := ParseCORSConfig(discardLogger(), "http://example.com/", []string{"POST"}, []string{"Content-Type"}) + require.True(t, cfg.Enabled()) + _, ok := cfg.allowedOrigins["http://example.com"] + require.True(t, ok) +} + +func TestParseCORSConfig_PathRejected(t *testing.T) { + var buf bytes.Buffer + cfg := ParseCORSConfig(captureLogger(&buf), "http://example.com/foo", []string{"POST"}, []string{"Content-Type"}) + require.False(t, cfg.Enabled()) + require.Contains(t, buf.String(), "level=WARN") +} + +func TestParseCORSConfig_QueryRejected(t *testing.T) { + var buf bytes.Buffer + cfg := ParseCORSConfig(captureLogger(&buf), "http://example.com?x=1", []string{"POST"}, []string{"Content-Type"}) + require.False(t, cfg.Enabled()) + require.Contains(t, buf.String(), "level=WARN") +} + +func TestParseCORSConfig_FragmentRejected(t *testing.T) { + var buf bytes.Buffer + cfg := ParseCORSConfig(captureLogger(&buf), "http://example.com#f", []string{"POST"}, []string{"Content-Type"}) + require.False(t, cfg.Enabled()) + require.Contains(t, buf.String(), "level=WARN") +} + +func TestParseCORSConfig_Lowercased(t *testing.T) { + cfg := ParseCORSConfig(discardLogger(), "HTTP://EXAMPLE.COM", []string{"POST"}, []string{"Content-Type"}) + require.True(t, cfg.Enabled()) + _, ok := cfg.allowedOrigins["http://example.com"] + require.True(t, ok) +} + +func TestParseCORSConfig_ValidOrigins(t *testing.T) { + cfg := ParseCORSConfig( + discardLogger(), + "http://localhost:3000, https://app.example.com", + []string{"POST", "OPTIONS"}, + []string{"Content-Type"}, + ) + require.True(t, cfg.Enabled()) + _, ok := cfg.allowedOrigins["http://localhost:3000"] + require.True(t, ok) + _, ok = cfg.allowedOrigins["https://app.example.com"] + require.True(t, ok) +} + +// --------------------------------------------------------------------------- +// CORSMiddleware — runtime tests +// --------------------------------------------------------------------------- + +// echoHandler writes a 200 and records that it was called via the pointed bool. +func echoHandler(called *bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + *called = true + w.WriteHeader(http.StatusOK) + }) +} + +func enabledCORSConfig() CORSConfig { + return ParseCORSConfig( + discardLogger(), + "http://example.com, https://other.example.com", + []string{"POST", "OPTIONS"}, + []string{"Content-Type"}, + ) +} + +func TestCORSMiddleware_Disabled(t *testing.T) { + cfg := ParseCORSConfig(discardLogger(), "", []string{"POST"}, []string{"Content-Type"}) + var called bool + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.True(t, called) + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Empty(t, rr.Header().Get("Vary")) +} + +func TestCORSMiddleware_NoOriginHeader(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.True(t, called) + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Empty(t, rr.Header().Get("Vary")) +} + +func TestCORSMiddleware_AllowedOrigin(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.True(t, called) + require.Equal(t, "http://example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") + require.Equal(t, "POST, OPTIONS", rr.Header().Get("Access-Control-Allow-Methods")) + require.Equal(t, "Content-Type", rr.Header().Get("Access-Control-Allow-Headers")) +} + +func TestCORSMiddleware_DisallowedOrigin(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "http://evil.com") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.True(t, called) + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") +} + +func TestCORSMiddleware_CaseInsensitive(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "HTTP://EXAMPLE.COM") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.True(t, called) + require.Equal(t, "HTTP://EXAMPLE.COM", rr.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORSMiddleware_PreflightAllowed(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "http://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.False(t, called) + require.Equal(t, http.StatusNoContent, rr.Code) + require.Equal(t, "http://example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "POST, OPTIONS", rr.Header().Get("Access-Control-Allow-Methods")) + require.Equal(t, "Content-Type", rr.Header().Get("Access-Control-Allow-Headers")) + require.Equal(t, "3600", rr.Header().Get("Access-Control-Max-Age")) + vary := rr.Header().Values("Vary") + require.Contains(t, vary, "Origin") + require.Contains(t, vary, "Access-Control-Request-Method") + require.Contains(t, vary, "Access-Control-Request-Headers") +} + +func TestCORSMiddleware_PreflightDisallowed(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "http://evil.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.False(t, called, "disallowed preflight must not reach downstream handler") + require.Equal(t, http.StatusNoContent, rr.Code) + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") + require.Empty(t, rr.Header().Get("Access-Control-Max-Age")) +} + +func TestCORSMiddleware_OptionsWithoutRequestMethod(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.True(t, called) + require.Equal(t, "http://example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Empty(t, rr.Header().Get("Access-Control-Max-Age")) +} + +func TestCORSMiddleware_CredentialsNeverSet(t *testing.T) { + cfg := enabledCORSConfig() + var called bool + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), echoHandler(&called), req) + + require.True(t, called) + require.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) +} + +func TestCORSMiddleware_UpstreamHeadersStripped(t *testing.T) { + cfg := enabledCORSConfig() + upstream := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "upstream") + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), upstream, req) + + require.Equal(t, "http://example.com", rr.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORSMiddleware_VaryUsesAdd(t *testing.T) { + cfg := enabledCORSConfig() + upstream := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Vary", "Accept-Encoding") + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), upstream, req) + + vary := rr.Header().Values("Vary") + require.Contains(t, vary, "Origin") + require.Contains(t, vary, "Accept-Encoding") +} + +func TestCORSMiddleware_ErrorResponseGetsCORSHeaders(t *testing.T) { + cfg := enabledCORSConfig() + errorHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), errorHandler, req) + + require.Equal(t, http.StatusInternalServerError, rr.Code) + require.Equal(t, "http://example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Values("Vary"), "Origin") +} + +func TestCorsWriter_Unwrap(t *testing.T) { + inner := httptest.NewRecorder() + cw := &corsWriter{ResponseWriter: inner, origin: "http://example.com", cfg: enabledCORSConfig()} + + require.Same(t, http.ResponseWriter(inner), cw.Unwrap()) +} + +func TestCORSMiddleware_MaxBytesReaderUnwrapChain(t *testing.T) { + cfg := enabledCORSConfig() + + // Handler uses MaxBytesReader to enforce a 16-byte body limit. + // MaxBytesReader walks the Unwrap() chain to reach the real + // *http.response so it can force-close the connection after 413. + // Without Unwrap on corsWriter, the walk would stop at the + // embedded ResponseWriter interface and the close would silently + // fail. + maxBodyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 16) + _, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + + oversizedBody := strings.Repeat("X", 1024) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(oversizedBody)) + req.Header.Set("Origin", "http://example.com") + rr := runMiddleware(t, CORSMiddleware(cfg), maxBodyHandler, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code) + require.Equal(t, "http://example.com", rr.Header().Get("Access-Control-Allow-Origin"), + "CORS headers must be present on 413 responses when corsWriter wraps the response writer") + require.Contains(t, rr.Header().Values("Vary"), "Origin") +} + +// TestCORSMiddleware_Admission503GetsCORSHeaders verifies that when admission +// control rejects a request with 503, the response still carries CORS headers. +// The middleware chain is CORS -> Admission -> handler. The corsWriter wraps +// the response writer before admission runs, so WriteHeader(503) triggers CORS +// header injection. A middleware reorder would silently break this invariant. +func TestCORSMiddleware_Admission503GetsCORSHeaders(t *testing.T) { + cfg := enabledCORSConfig() + + // Create an admission controller with limit 1 and immediately fill it + // so every subsequent request is rejected with 503. + admission := NewSemaphoreAdmission(1) + acquired := admission.TryAcquire() + require.True(t, acquired, "pre-fill must succeed on a fresh semaphore") + + // The inner handler must never be reached — admission rejects first. + var handlerCalled bool + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + }) + + // Build the middleware chain: CORS(Admission(handler)). + chain := CORSMiddleware(cfg)(AdmissionMiddleware(admission)(handler)) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Origin", "http://example.com") + rr := httptest.NewRecorder() + chain.ServeHTTP(rr, req) + + require.False(t, handlerCalled, "handler must not be called when admission rejects") + require.Equal(t, http.StatusServiceUnavailable, rr.Code) + require.Equal(t, "http://example.com", rr.Header().Get("Access-Control-Allow-Origin"), + "CORS headers must be present on admission 503 responses") + require.Contains(t, rr.Header().Values("Vary"), "Origin") + require.Contains(t, rr.Body.String(), "service at capacity") + + retryAfter := rr.Header().Get("Retry-After") + require.NotEmpty(t, retryAfter, "Retry-After header must be present on 503") + retryVal, err := strconv.Atoi(retryAfter) + require.NoError(t, err, "Retry-After must be a valid integer") + require.GreaterOrEqual(t, retryVal, 1) + require.LessOrEqual(t, retryVal, 3) +} diff --git a/pkg/service/http_middleware.go b/pkg/service/http_middleware.go new file mode 100644 index 000000000..a4f5275bb --- /dev/null +++ b/pkg/service/http_middleware.go @@ -0,0 +1,162 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "net" + "net/http" + "regexp" + "runtime/debug" + + "github.com/google/uuid" +) + +const ( + requestIDHeader = "X-Request-ID" +) + +// requestIDPattern defines the accepted charset and length for +// upstream-supplied request ids. Anything outside this set is treated as +// untrusted and a fresh UUID is generated instead. +// +// The charset is deliberately chosen to cover the ID formats emitted by +// common reverse proxies and tracing systems while remaining safe to log +// and echo in response headers: +// +// - envoy uses `.` and `:` +// - AWS ALB and X-Ray use `-` and `=` +// - GCP Cloud Trace uses `/` +// - `+` appears in some base64-style correlation IDs +// +// Explicitly excluded: `\r`, `\n`, space, `<`, `>`, `"`, `'`, backtick, +// and all other control characters — these would enable log-injection or +// header-splitting if echoed back verbatim. The `{1,128}` quantifier is +// the single source of truth for the length bound. +var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._:=/+-]{1,128}$`) + +// RequestIDMiddleware reads the X-Request-ID request header and trusts it +// only when it matches ^[A-Za-z0-9._:=/+-]{1,128}$. Otherwise a fresh +// UUIDv4 is generated. The chosen id is placed on the request context +// under ctxKeyRequestID{} and echoed on the response as X-Request-ID. +func RequestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(requestIDHeader) + if !requestIDPattern.MatchString(id) { + id = uuid.NewString() + } + w.Header().Set(requestIDHeader, id) + ctx := context.WithValue(r.Context(), ctxKeyRequestID{}, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// responseWriterTap wraps an http.ResponseWriter and records whether any +// header or body bytes have been sent. It implements Unwrap so that helpers +// such as http.MaxBytesReader and http.ResponseController can walk the +// wrapper chain to reach the underlying *response and access its internal +// interfaces (for example, to force a connection close after 413). +// +// Without Unwrap, oversized POST bodies still return 413 but the underlying +// connection is not force-closed and clients may see protocol confusion on a +// subsequent pipelined request. Do not remove Unwrap. +type responseWriterTap struct { + http.ResponseWriter + wroteHeader bool +} + +func (t *responseWriterTap) WriteHeader(code int) { + t.wroteHeader = true + t.ResponseWriter.WriteHeader(code) +} + +func (t *responseWriterTap) Write(b []byte) (int, error) { + t.wroteHeader = true + return t.ResponseWriter.Write(b) +} + +func (t *responseWriterTap) Unwrap() http.ResponseWriter { + return t.ResponseWriter +} + +// Flush forwards to the wrapped writer's Flusher implementation if it +// supports streaming. Because responseWriterTap embeds the +// http.ResponseWriter interface (not a concrete type), Go's method +// promotion only surfaces Header/Write/WriteHeader — optional interfaces +// like http.Flusher are not promoted and a direct +// w.(http.Flusher).Flush() on the tap would fail the type assertion even +// when the underlying writer supports it. Forward explicitly so handlers +// can stream through RecoverMiddleware. +// +// http.NewResponseController(w).Flush() also works for the same purpose +// because it walks Unwrap() — use that path when you can't rely on a +// type assertion. +func (t *responseWriterTap) Flush() { + if f, ok := t.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Hijack forwards to the wrapped writer's Hijacker implementation if any. +// Required for any future websocket or connection-takeover use case; +// today no inspect or jsonrpc handler hijacks, but having the forwarding +// in place avoids a silent failure the moment one is added. Returns +// http.ErrNotSupported when the underlying writer does not implement +// http.Hijacker (for example, httptest.ResponseRecorder). +func (t *responseWriterTap) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := t.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// RecoverMiddleware catches panics from downstream handlers. If nothing has +// been written yet it emits a generic 500 via WriteInternalError. If bytes +// are already on the wire it re-panics with http.ErrAbortHandler to drop the +// connection silently — stitching a 500 onto a partial 200 would produce a +// corrupt response and a "superfluous WriteHeader" warning. +// +// Panics whose value is http.ErrAbortHandler are re-panicked unchanged so +// that the stdlib server preserves its special (non-logged) semantics. +// +// When RecoverMiddleware wraps RequestIDMiddleware (Recover outermost), the +// deferred recovery cannot read the request id from r.Context() because r is +// the pre-wrap request from this closure — downstream middleware creates a +// new *http.Request via WithContext and passes it to its next.ServeHTTP, but +// the outer r is untouched. The request id is instead read from the tap's +// response header, which RequestIDMiddleware populates on the shared +// ResponseWriter before calling next, so it is always set by the time a +// downstream panic reaches the defer. +func RecoverMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tap := &responseWriterTap{ResponseWriter: w} + defer func() { + rec := recover() + if rec == nil { + return + } + if rec == http.ErrAbortHandler { + panic(rec) + } + reqID := tap.Header().Get(requestIDHeader) + logger.Error("http handler panic", + "panic", rec, + "stack", string(debug.Stack()), + "request_id", reqID, + ) + if !tap.wroteHeader { + ctx := context.WithValue(r.Context(), ctxKeyRequestID{}, reqID) + WriteInternalError(ctx, tap, logger, fmt.Errorf("panic in handler: %v", rec)) + return + } + panic(http.ErrAbortHandler) + }() + next.ServeHTTP(tap, r) + }) + } +} diff --git a/pkg/service/http_middleware_test.go b/pkg/service/http_middleware_test.go new file mode 100644 index 000000000..0225c2e1b --- /dev/null +++ b/pkg/service/http_middleware_test.go @@ -0,0 +1,396 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package service + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// runMiddleware wires a middleware in front of a handler and records the +// response via httptest.NewRecorder. +func runMiddleware(t *testing.T, mw func(http.Handler) http.Handler, h http.Handler, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + mw(h).ServeHTTP(rr, req) + return rr +} + +// ----------------------------------------------------------------------------- +// RequestIDMiddleware +// ----------------------------------------------------------------------------- + +func TestRequestIDMiddleware_GeneratesWhenMissing(t *testing.T) { + var capturedID string + h := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedID = RequestIDFromContext(r.Context()) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := runMiddleware(t, RequestIDMiddleware, h, req) + + require.NotEmpty(t, capturedID) + require.Equal(t, capturedID, rr.Header().Get("X-Request-ID")) + + _, err := uuid.Parse(capturedID) + require.NoError(t, err, "generated id should parse as UUID") +} + +func TestRequestIDMiddleware_AcceptsValid(t *testing.T) { + // Pin the full accepted charset. Each entry represents a real-world + // upstream format we must preserve end-to-end for correlation: + // - "abc_123-xyz" — legacy underscore/hyphen + // - "abc.def.123" — envoy-style dotted id + // - "a:b:c" — envoy host:port:id + // - "trace=1-2-3" — AWS X-Ray style with '=' + // - "projects/foo/traces/bar" — GCP Cloud Trace path + // - "trace+span" — base64-ish '+' + cases := []string{ + "abc_123-xyz", + "abc.def.123", + "a:b:c", + "trace=1-2-3", + "projects/foo/traces/bar", + "trace+span", + } + for _, valid := range cases { + t.Run(valid, func(t *testing.T) { + var capturedID string + h := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedID = RequestIDFromContext(r.Context()) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Request-ID", valid) + rr := runMiddleware(t, RequestIDMiddleware, h, req) + + require.Equal(t, valid, capturedID) + require.Equal(t, valid, rr.Header().Get("X-Request-ID")) + }) + } +} + +func TestRequestIDMiddleware_RejectsTooLong(t *testing.T) { + long := strings.Repeat("a", 129) + var capturedID string + h := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedID = RequestIDFromContext(r.Context()) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Request-ID", long) + runMiddleware(t, RequestIDMiddleware, h, req) + + require.NotEqual(t, long, capturedID) + _, err := uuid.Parse(capturedID) + require.NoError(t, err, "regenerated id should parse as UUID") +} + +func TestRequestIDMiddleware_RejectsBadChars(t *testing.T) { + // Each case must be regenerated as a fresh UUID. Keep the charset + // exclusion list tight: anything that could enable log-injection, + // header-splitting, or HTML/JS smuggling when echoed back in logs or + // on the X-Request-ID response header. + cases := map[string]string{ + "semicolon": "foo;bar", + "space": "id with space", + "newline": "id\nnewline", + "carriage": "id\rcr", + "tab": "foo\tbar", + "angle": "